Skip to content

02 - First Real Program

What this session is

About 45 minutes. You'll learn variables, mutability (Rust's signature variable design), primitive types, two string types (&str and String - a Rust thing), and the println! format syntax.

A small program

Inside your hello project, replace src/main.rs:

fn main() {
    let name = "Alice";
    let age = 30;
    println!("{} is {} years old", name, age);
}

Run:

cargo run

Output:

Alice is 30 years old

What's new

let name = "Alice";
let age = 30;
  • let creates a variable. (Unlike var in Java or x = ... in Python - Rust requires let.)
  • name = "Alice" - assigns the string "Alice".
  • No explicit type - Rust infers it. "Alice" is a &str (string slice; we'll explain shortly); 30 is i32 (32-bit signed integer).

The format string:

println!("{} is {} years old", name, age);

{} is a positional placeholder. Each {} consumes one argument from the list, in order. There's also named-argument and indexed-argument syntax; positional is the most common.

You can name them:

println!("{name} is {age} years old");

This is Rust's f-string equivalent - works since 2021. The variable name in {} matches one in scope; no need to pass it explicitly.

Mutability: variables are immutable by default

This is the first surprise:

let x = 5;
x = 10;        // COMPILE ERROR: cannot assign twice to immutable variable `x`

In Rust, variables can't change by default. To make a variable changeable, mark it mut:

let mut x = 5;
x = 10;        // OK now
println!("{x}");   // 10

Why? Two reasons: 1. Code is easier to reason about. Reading code where most variables don't change tells you what's stable and what changes. 2. The compiler can optimize harder. Immutability enables aggressive optimizations.

You'll often write let x = ... once and never need to change it. When you do need mutation, the mut is right there making it explicit.

Shadowing: a different kind of "rebind"

Even without mut, you can declare a new variable with the same name:

let x = 5;
println!("{x}");        // 5

let x = x + 1;          // new variable x, "shadows" the old one
println!("{x}");        // 6

let x = "hello";        // shadowing can change the type
println!("{x}");        // hello

Shadowing creates a new variable; it doesn't mutate the old. Useful for transforming a value through stages. Not the same as mut.

Types: the primitives

Rust's basic types:

Type What it holds Example
i32 32-bit signed integer 42, -7
i64 64-bit signed integer 1_000_000_000_000
u32 32-bit unsigned integer (non-negative) 42
u64 64-bit unsigned integer 1u64, 42_000_000_000
usize platform-sized unsigned int (usually 64-bit) array indices
f64 64-bit floating-point number 3.14
bool true or false true
char one Unicode scalar value 'A', '🦀'
&str string slice (borrowed) "hello"
String owned, growable string String::from("hello")

The default integer is i32. The default float is f64. Use those unless you need something specific.

You can suffix literals to pick a type: 42_u8, 3.14_f32. Underscores anywhere in numbers are ignored, used for readability: 1_000_000.

Type annotations

Type inference is good, but you can annotate explicitly:

let count: u32 = 42;
let pi: f64 = 3.14159;
let active: bool = true;

The : u32 between name and value declares the type. Useful when inference can't figure it out (often after parse):

let n: i32 = "42".parse().unwrap();

(We'll meet parse and unwrap in page 08. For now, recognize that the : i32 tells parse which type to return.)

Two string types: &str and String

This trips up everyone learning Rust. There are two main string types:

  • &str ("string slice") - a borrowed reference to text that lives somewhere else. String literals like "hello" are &strs. Immutable. Lightweight (just a pointer + length).
  • String - an owned, growable string on the heap. Mutable. Heavier (allocated buffer with capacity).
let literal: &str = "hello";        // &str - points into the program's data
let owned: String = String::from("hello");   // String - allocated
let owned2 = "hello".to_string();   // same thing, alternate syntax

When you need to: - Print or read text → either works (use &str if you can, it's cheaper). - Build a string with +, .push_str, .push → use String (the mutable one). - Parameters that accept either → take &str (a String auto-converts to &str when needed).

fn greet(name: &str) {
    println!("Hello, {name}");
}

greet("Alice");                        // works - literal is &str
greet(&String::from("Alice"));         // works - String coerces to &str

The full story (ownership, borrowing, slicing) lands in page 06. For now: literals are &str, owned strings you build are String.

Arithmetic

let x = 10;
let y = 3;
println!("{}", x + y);    // 13
println!("{}", x - y);    // 7
println!("{}", x * y);    // 30
println!("{}", x / y);    // 3   - integer division (both operands are integers)
println!("{}", x % y);    // 1   - remainder

Integer division drops the remainder. For decimals, use floats:

let q: f64 = 10.0 / 3.0;
println!("{}", q);    // 3.3333333333333335

Rust does not implicitly convert between numeric types. You can't let x: i64 = some_i32. You have to cast explicitly:

let small: i32 = 100;
let big: i64 = small as i64;     // explicit cast
let pi_int: i32 = 3.14_f64 as i32;   // truncates to 3

This strictness catches bugs (silent narrowing in C is a common source of issues). It's also occasionally tedious.

Building strings: format!

println! prints; format! returns a String:

let name = "Alice";
let age = 30;
let msg: String = format!("{name} is {age}");
println!("{msg}");

Same syntax, same placeholders. Use format! when you want the string for later use.

Format-spec essentials

Spec Meaning Example
{} default display 42, "hello"
{:?} debug display (more raw) useful for printing structs
{:.2} 2 decimal places 3.14 from 3.14159
{:5} min width 5, right-aligned 42
{:<5} min width 5, left-aligned 42
{:0>3} pad with zeros, width 3 042

You don't need to memorize them; look up when needed. {} and {:?} are the two you'll use 95% of the time.

Reading input from stdin (briefly)

use std::io;

fn main() {
    let mut input = String::new();
    println!("What's your name?");
    io::stdin().read_line(&mut input).expect("failed to read line");
    let name = input.trim();
    println!("Hello, {name}");
}

New things: - use std::io; - bring the io module into scope. - let mut input = String::new(); - empty mutable String. - io::stdin().read_line(&mut input) - read from stdin, append to input. The &mut is a mutable reference - we'll explain in page 06. - .expect("...") - for now, "crash with this message if it fails." We'll do proper error handling in page 08. - input.trim() - strips trailing newline.

Lots of new syntax. Don't try to absorb it all - just recognize the pattern when you need stdin.

Exercise

In your hello project, replace src/main.rs:

Write a program that:

  1. Has a variable for your name (&str).
  2. Has a variable for your favorite number (i32).
  3. Has a variable for whether it's morning (bool).
  4. Has a variable for pi (f64).
  5. Prints a multi-line message using println! macros:
    Hi, I'm Victor.
    My favorite number is 7.
    Is it morning? true
    Pi is approximately 3.14
    
    For the pi line, use {:.2} to format to 2 decimals.

Run with cargo run.

Stretch: make name a String instead of &str. Pass it to a function fn greet(name: &str). Test that both name (a String) and a literal like "world" work as arguments.

What you might wonder

"Why is everything immutable by default?" Reading code where variables don't change is easier - you know what's stable. mut makes mutation explicit and grep-able. Most variables in real Rust code don't need mut.

"Why two string types?" Performance + ownership. &str is a borrowed view - cheap, immutable. String is owned - can grow, must be allocated and freed. The distinction matters when we get to ownership (page 06). For now: literals are &str, owned strings are String.

"Why explicit casts for numbers?" Implicit numeric conversion is a common bug source in C (silent overflow, sign confusion). Rust forces you to acknowledge what's happening. Slightly more typing; fewer bugs.

"Is mut "global" or per variable?" Per variable. let mut x = 5; let y = 10; - x is mutable, y is not.

"What if I want a constant?" Use const:

const PI: f64 = 3.14159;
const is immutable, must have a type annotation, available throughout the file's scope.

Done

You can: - Use let and let mut correctly. - Recognize the primitive types and pick the right one. - Distinguish &str from String. - Use println! / format! with positional, named, and formatted placeholders. - Cast between numeric types explicitly.

Next page: making your program decide and repeat.

Next: Decisions and loops →

Comments