On this page
Variables, mutability, and primitive types
Variables in Rust: immutable by default
One of the first surprises for developers coming from other languages is that variables are immutable by default in Rust. When you declare let x = 5, the value of x cannot change.
This is not a limitation — it is a deliberate design feature. Default immutability helps the compiler make optimizations, makes it easier to reason about code, and prevents a whole category of bugs where a value changes unexpectedly.
fn main() {
let x = 5;
x = 6; // Error: cannot assign twice to immutable variable `x`
}The Rust compiler produces a clear error message:
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:3:5
|
2 | let x = 5;
| - first assignment to `x`
3 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut x = 5;
| +++Note how the compiler even suggests the solution.
Mutable variables with `mut`
When you need a variable to change, add the mut keyword:
fn main() {
let mut temperature = 20.0_f64;
println!("Initial temperature: {temperature}°C");
temperature += 5.0;
println!("Temperature increased: {temperature}°C");
temperature = temperature * 1.8 + 32.0;
println!("In Fahrenheit: {temperature}°F");
}Mutability is explicit and intentional. When you read Rust code and see let mut, you immediately know that variable will change. When you see just let, you know it won't — which is valuable information for understanding program flow.
Constants with `const`
Constants are declared with const and always require an explicit type annotation. Additionally, they can only contain values computed at compile time:
// Convention: SCREAMING_SNAKE_CASE for constants
const SPEED_OF_LIGHT: u64 = 299_792_458; // meters per second
const PI: f64 = 3.14159265358979323846;
const APP_NAME: &str = "MyApplication";
fn main() {
let radius = 5.0_f64;
let area = PI * radius * radius;
println!("Circle area: {area:.2}");
}Constants are different from immutable variables:
- They exist for the entire duration of the program (they don't have a scope like
let) - They can be declared in global scope (outside functions)
- Their value must be computable at compile time
Shadowing: re-declaring variables
Rust allows re-declaring a variable with let in the same scope. The new declaration "shadows" the previous one:
fn main() {
let x = 5;
// The second `let x` shadows the first
let x = x + 1;
{
// This shadowing only exists in this block
let x = x * 2;
println!("x in inner block: {x}"); // 12
}
println!("x in outer scope: {x}"); // 6
}The crucial difference between shadowing and mut is that shadowing can change the type:
fn main() {
// First it's a text string
let value = "42";
println!("As text: {value}");
// Now it's a number — same name, different type
let value: i32 = value.parse().expect("Not a number");
println!("As number: {value}");
// Now it's a boolean
let value = value > 0;
println!("Is positive: {value}");
}With mut this would be impossible — you cannot change the type of a mutable variable.
Scalar types in Rust
Integers
Rust has signed (i) and unsigned (u) integers of various sizes:
| Type | Range |
|---|---|
i8 |
-128 to 127 |
i16 |
-32,768 to 32,767 |
i32 |
-2,147,483,648 to 2,147,483,647 (default) |
i64 |
-9.2 × 10¹⁸ to 9.2 × 10¹⁸ |
i128 |
Enormous |
isize |
Architecture-dependent (32 or 64 bits) |
u8 |
0 to 255 |
u32 |
0 to 4,294,967,295 |
u64 |
0 to 18.4 × 10¹⁸ |
usize |
For indices and collection sizes |
fn main() {
let signed: i32 = -42;
let unsigned: u32 = 42;
let index: usize = 0; // For indexing arrays/vectors
// Literals in different bases
let decimal = 1_000_000;
let hexadecimal = 0xFF; // 255
let octal = 0o77; // 63
let binary = 0b1111_0000; // 240
let byte: u8 = b'A'; // 65 (ASCII code)
println!("{decimal} {hexadecimal} {octal} {binary} {byte}");
}Floating point
fn main() {
let f32_val: f32 = 3.14; // 32-bit, less precision
let f64_val: f64 = 3.14159265358979; // 64-bit, default
// Scientific notation
let avogadro = 6.022e23_f64;
let electron = 1.6e-19_f64;
println!("{f32_val:.2}");
println!("{f64_val:.10}");
}Booleans
fn main() {
let is_rust_great: bool = true;
let has_gc: bool = false;
println!("Is Rust great? {is_rust_great}");
println!("Has GC? {has_gc}");
// Booleans occupy 1 byte in memory
println!("Size of bool: {} byte(s)", std::mem::size_of::<bool>());
}Characters
The char type in Rust represents a complete Unicode code point (4 bytes), not just ASCII:
fn main() {
let letter: char = 'A';
let emoji: char = '🦀'; // The Rust crab
let chinese: char = '中';
let accented: char = 'é';
println!("{letter} {emoji} {chinese} {accented}");
println!("Size of char: {} bytes", std::mem::size_of::<char>());
}&str vs String
This distinction is fundamental in Rust and deserves special attention:
&str (string slice): A reference to text data owned by someone else. The text usually lives in the program binary or in some String on the heap. It is immutable and its size is known at compile time (or at runtime).
String: A dynamically-sized text type that owns its data on the heap. It is mutable and can grow or shrink.
fn main() {
// &str — string literal, in the binary's data segment
let greeting: &str = "Hello, Rust";
// String — on the heap, owned and mutable
let mut name = String::from("Rustacean");
name.push_str(" the Crab");
name.push('!');
println!("{greeting}, {name}");
// Conversions
let s: String = greeting.to_string();
let r: &str = &name; // String -> &str with &
// len() on &str is bytes, not Unicode characters
let text = "café";
println!("Bytes: {}", text.len()); // 5 (é is 2 bytes in UTF-8)
println!("Chars: {}", text.chars().count()); // 4
}Type inference
Rust has sophisticated type inference. In most cases you don't need to annotate types explicitly:
fn main() {
let x = 42; // i32 by default
let y = 3.14; // f64 by default
let z = true; // bool
let s = "hello"; // &str
// When context requires it, annotate the type
let numbers: Vec<i32> = Vec::new();
let parsed: u64 = "100".parse().unwrap();
// Or with turbofish when calling generic functions
let parsed2 = "100".parse::<u64>().unwrap();
println!("{x} {y} {z} {s} {parsed} {parsed2}");
}With a solid grasp of variables and primitive types, you are ready to explore functions and compound types, which will let you structure more complex programs.
fn main() {
// Default: immutable
let x = 5;
println!("x = {x}");
// x = 6; // Compile error
// With mut: mutable
let mut counter = 0;
counter += 1;
counter += 1;
println!("counter = {counter}"); // 2
// Shadowing: re-declare with let
let y = 10;
let y = y * 2; // shadows the previous
let y = y + 3; // shadows again
println!("y = {y}"); // 23
// Shadowing allows changing the type
let spaces = " "; // &str
let spaces = spaces.len(); // usize
println!("spaces = {spaces}");
// Constants: always immutable, require type
const MAX_POINTS: u32 = 100_000;
println!("maximum = {MAX_POINTS}");
}
Sign in to track your progress