Rust from the ground up

Values, types and printing

Variables that cannot be reassigned by accident, the handful of types you will actually use, and how to put values into text without guessing.

Part 2 of 12

Beginner14 min readBack to Courses

let, and the safety catch

let makes a variable, and by default the value cannot be reassigned. Adding mut says you mean it: let mut count = 0; gives you a number you can change, and count += 1; is then allowed. Forgetting mut is not a style slip — the compiler refuses to build the program and tells you where the assignment is.

That single rule explains a lot of Rust code you will read. A binding without mut is a promise that the value behind the name does not change, and readers — and the compiler's optimiser — can rely on it.

The types you will see every day

Whole numbers have a size and a sign in their name: i32 is the usual choice, u32 cannot be negative, usize is the one you use for positions and lengths. Decimals are f64. Booleans are bool, a text character is char, and text is either String — owned, growable — or &str, a borrowed view of text that already exists.

Most of the time the type is inferred from the value: let year = 2015; is an i32 without being told. When something needs to change type — you cannot add an i32 to an f64 — say so with as: let half = count as f64 / 2.0;.

An annotation does more than label the binding: it tells the compiler what every part of the expression should be. let n: u8 = 250; makes the literal a u8, and let half: f64 = 1 / 2; is a type error rather than a silent zero, because / is being asked for a float from two integer literals. Writing the type you mean is often enough to turn a wrong program into one that does not compile.

Shadowing, and putting values into text

A second let with the same name is allowed, and it makes a new variable that hides the old one. That is useful when a value changes shape on the way in: read a line as text, then let line = line.trim();, then let n: i32 = line.parse().unwrap();. Each step reuses the name, and nothing is quietly mutated.

{} is the everyday blank in a println!. {:?} is the debug blank, for values whose ordinary form is not meant for readers — an array or a tuple — and it prints in the shape the value is written in. {:.2} keeps two decimal places on a float.

Types in one place

fn main() {
    let mut visits = 0;          // i32, and allowed to change
    visits += 1;

    let price: f64 = 19.5;
    let tax = price * 0.2;

    let name = "Ada";            // &str, borrowed text
    let mut greeting = String::from("Hello, "); // String, owned
    greeting.push_str(name);

    println!("{}, visits today: {}", greeting, visits);
    println!("Total with tax: {:.2}", price + tax);
    println!("Debug view: {:?}", (visits, tax));
}

A converter that keeps its types straight

Run it to see the conversion. Then add a second temperature of your own, and change the output line to show both with one decimal place.

Output
Click "Run" to execute your code

Rust will not mix number types for you. Adding i32 to f64 is a compile error, and the fix is always an explicit as f64 or as i32 — the point is that the round or the truncation is a decision you made, not one the compiler guessed.

Whole numbers have a size

An integer type has a fixed number of bits, so it has a fixed range: u8 holds 0 to 255, and i32 roughly plus or minus two billion. Choose the smallest type that honestly fits the values; then the range is part of the type, not a comment.

Rust checks overflow in a debug build and panics rather than wrapping silently, so a u8 at 255 that adds one stops the program. When the value comes from outside — a count, a size, a score — checked_add returns an Option, and saturating_add stops at the maximum. The type is the promise; these methods keep it when the input is not yours.

Text has two forms

A String owns its characters and can grow; a &str is a borrowed view of text that lives somewhere else. String::from("ada") allocates, while "ada" is a literal stored in the binary, and a function that only reads text should take &str so it can accept both.

len() counts bytes, not characters, because bytes are what memory costs. For "café" the byte count is five and the character count is four, since é takes two bytes in UTF-8. Use chars().count() when the reader will count what they see, and len() when you are sizing a buffer. Neither is wrong — they answer different questions.

Three numeric slips are worth naming. Integer division truncates — 7 / 2 is 3 — so divide in f64 when you mean a fraction. as truncates rather than rounds (3.9 as i32 is 3), and narrowing an integer to a smaller type keeps only the low bits. And parse() needs a type annotation, because "12" could become any number.

Try these next

  • Store a price in cents as u32, then print it with {}.{:02} and check the cents keep two digits.
  • Count the characters in a word with an accent, then its bytes, and explain the difference.
  • Make a u8 reach 256 and read the panic, then try checked_add and print the Option it gives back.

A till that counts in cents

Run it, then change the tax rate to 0.08 and the quantity to 7. Add a five percent discount before tax, and print the discount as a whole number of cents.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What happens if you assign to a variable that was made with plain let?