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:
Run:
Output:
What's new¶
letcreates a variable. (Unlikevarin Java orx = ...in Python - Rust requireslet.)name = "Alice"- assigns the string"Alice".- No explicit type - Rust infers it.
"Alice"is a&str(string slice; we'll explain shortly);30isi32(32-bit signed integer).
The format string:
{} 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:
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:
In Rust, variables can't change by default. To make a variable changeable, mark it mut:
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:
The : u32 between name and value declares the type. Useful when inference can't figure it out (often after parse):
(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:
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:
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:
- Has a variable for your name (
&str). - Has a variable for your favorite number (
i32). - Has a variable for whether it's morning (
bool). - Has a variable for
pi(f64). - Prints a multi-line message using
println!macros: For thepiline, 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 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.