Variables, Types and Mutability
let bindings that are immutable by default, mut and shadowing, Rust's integer, float, bool and char types, integer overflow that panics in debug builds, safe conversions instead of silent casts, and money as integers.
let bindings
A let binding gives a value a
name: let kwh = 350;. Rust calls it a
binding rather than an assignment because, by default, the
name is bound to that value for good. Trying to change it is a
compile error:
fn main() {
let readings = [350, 412, 120];
let total = 0;
for kwh in readings {
total += kwh;
}
println!("total: {total} kWh");
}
example.rs:5:9: error[E0384]: cannot assign twice to immutable variable `total`: cannot assign twice to immutable variable
The full message points at both places and suggests the fix:
error[E0384]: cannot assign twice to immutable variable `total`
--> example.rs:5:9
|
3 | let total = 0;
| ----- first assignment to `total`
4 | for kwh in readings {
5 | total += kwh;
| ^^^^^^^^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
3 | let mut total = 0;
| +++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0384`.
The +++ under mut marks text the compiler
proposes to add. A binding may also be declared first and given its
value later, once: let total; … total = 350; is fine,
and the compiler refuses any use before that first value, so a
variable can never be read uninitialized.
Mutability with mut
The mut keyword makes a binding mutable: its value may be replaced, and the value may be changed through it, such as adding elements to a list:
fn main() {
let readings = [350, 412, 120];
let mut total = 0;
for kwh in readings {
total += kwh;
}
println!("total: {total} kWh");
let mut queue = Vec::new(); // an empty, growable list
queue.push("h0017");
queue.push("h0042");
println!("{queue:?}");
}
total: 882 kWh ["h0017", "h0042"]
mut belongs to the binding, not to the type: the same
list is read-only through a plain let and changeable
through let mut. The compiler also notices the opposite
mistake, a mut that is never used. Normally that is a
warning; the examples on these pages are compiled with
-D warnings, which turns warnings into errors, as many
teams do in their builds:
fn main() {
let mut meter = "M-30004";
println!("meter: {meter}");
}
example.rs:2:9: error: variable does not need to be mutable
warning: variable does not need to be mutable --> example.rs:2:9 | 2 | let mut meter = "M-30004"; | ----^^^^^ | | | help: remove this `mut` | = note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default warning: 1 warning emitted
Unused variables get a similar warning. If a name is unused on
purpose, start it with an underscore (_meter) and the
compiler stays quiet.
Constants and statics
A const is a
constant
the compiler knows in full: its type must be written out, its value
must be computable while compiling, and the compiler copies the
value to every place it is used. A static is a single
value at a fixed place in memory for the whole run of the program.
Both may be declared outside functions, and both are named in
UPPER_SNAKE_CASE (other names give a warning):
// Fixed tariff in ten-thousandths of a euro per kWh: 2700 = €0.2700
const FIXED_PRICE_PER_KWH: u64 = 2_700;
const HOURS_PER_YEAR: u64 = 365 * 24; // computed by the compiler
static SUPPLIER: &str = "Kestrel Energy";
fn main() {
println!("{SUPPLIER}: {FIXED_PRICE_PER_KWH} per kWh, {HOURS_PER_YEAR} hours a year");
}
Kestrel Energy: 2700 per kWh, 8760 hours a year
Use const for fixed numbers and settings; you rarely
need static. A static mut, a global
variable anyone may change, exists but can only be used inside
unsafe code, and since edition 2024 even taking a
reference to one is an error by default (the lint
static_mut_refs). Older material uses it for counters
and caches; modern code uses the safe types from
Concurrency instead.
Shadowing
A new let with a name that already exists creates a
second variable that hides the first from then on. This is
shadowing, and in Rust it is a
normal tool, not a mistake. Its typical use is a value that passes
through several forms, such as text read from input that becomes a
number:
use std::io;
fn main() {
let mut line = String::new();
io::stdin().read_line(&mut line).expect("could not read standard input");
let kwh = line.trim(); // text without the newline
let kwh: u32 = kwh.parse().expect("not a whole number"); // now a number
let kwh = kwh * 1000; // in watt-hours
println!("{kwh} Wh");
}
350000 Wh
The three kwh variables are separate: the first is a
piece of text (&str), the second and third are
numbers (u32). Each one is immutable; no variable was
ever changed. With mut you could not do this, because a
mutable variable keeps its type. Without shadowing you would need
names like kwh_text and kwh_number.
.expect(…) stops the program with that message if the
input cannot be read or is not a number; better ways to handle that
are on Error Handling.
A shadowing variable lives until the end of its block, like any variable. Inside an inner block it hides the outer one only there:
fn main() {
let tariff = "fixed";
{
let tariff = "dynamic"; // a new variable, only in this block
println!("inside the block: {tariff}");
}
println!("after the block: {tariff}"); // the outer one was never changed
}
inside the block: dynamic after the block: fixed
Integers, floats, bool and char
Rust's simple types each hold a single value. The integer types
carry their size and sign in their name: i for signed
(negative values allowed), u for unsigned, followed by
the number of bits.
| Type | Size | Range or meaning |
|---|---|---|
i8 / u8 |
1 byte | −128 to 127 / 0 to 255 |
i16 / u16 |
2 bytes | −32,768 to 32,767 / 0 to 65,535 |
i32 / u32 |
4 bytes | about ±2.1 billion / 0 to about 4.3 billion |
i64 / u64 |
8 bytes | about ±9.2 × 1018 / 0 to about 1.8 × 1019 |
i128 / u128 |
16 bytes | about ±1.7 × 1038 / 0 to about 3.4 × 1038 |
isize / usize |
pointer size | 8 bytes on 64-bit systems, 4 on 32-bit |
f32 / f64 |
4 / 8 bytes | IEEE 754 floating-point numbers |
bool |
1 byte | true or false |
char |
4 bytes |
One Unicode character, such as 'K' or
'€'
|
The fixed sizes mean the same thing on every platform, unlike C's
int and long (compare
fixed-width integers
in C). The exception is
usize, the unsigned integer as wide as
a memory address on the target platform. It is the type of every
length, size and index: readings.len() returns a
usize, and an index into an array or list must be one.
Its signed partner isize is rarely needed. The sizes
below were measured on a 64-bit Linux machine (ARM64); on a 32-bit
microcontroller usize is 4 bytes.
fn main() {
println!("u8 {} byte", size_of::<u8>());
println!("i32 {} bytes", size_of::<i32>());
println!("u64 {} bytes", size_of::<u64>());
println!("i128 {} bytes", size_of::<i128>());
println!("usize {} bytes", size_of::<usize>());
println!("f64 {} bytes", size_of::<f64>());
println!("bool {} byte", size_of::<bool>());
println!("char {} bytes", size_of::<char>());
println!("u32 from {} to {}", u32::MIN, u32::MAX);
println!("i64 from {} to {}", i64::MIN, i64::MAX);
}
u8 1 byte i32 4 bytes u64 8 bytes i128 16 bytes usize 8 bytes f64 8 bytes bool 1 byte char 4 bytes u32 from 0 to 4294967295 i64 from -9223372036854775808 to 9223372036854775807
A char is four bytes because it holds any Unicode
code point
except the surrogates (a "Unicode scalar value"), not a byte. Text
is not an array of char: a String stores
UTF-8
bytes, so "Größe".len() is 7, its length in bytes (see
Collections and Strings).
Type inference and literals
You rarely have to write types for local variables: the compiler
infers them from how the value is used (see
type inference). When nothing narrows it down, a whole number becomes an
i32 and a decimal number an f64:
use std::any::type_name_of_val;
fn main() {
let meters = 400_000; // no suffix, no annotation
let price = 0.27;
let initial = 'K';
let pilot = true;
println!("{meters}: {}", type_name_of_val(&meters));
println!("{price}: {}", type_name_of_val(&price));
println!("{initial}: {}", type_name_of_val(&initial));
println!("{pilot}: {}", type_name_of_val(&pilot));
}
400000: i32 0.27: f64 K: char true: bool
Underscores make long literals readable (400_000), and
a suffix fixes the type (400_000u64,
2.5f32). Hexadecimal, octal and binary literals start
with 0x, 0o and 0b:
0xF1F4, 0b1010_0001. Function parameters,
return types, const and static always need
their types written out; inference only works inside function
bodies.
Tuples and arrays
Two built-in types group values. A tuple is a fixed
group of values that may have different types, such as a meter and
its reading; an array is a fixed number of values
of one type, written [u32; 7] for seven
u32 values:
fn main() {
let reading: (&str, u32) = ("M-30004", 350); // a tuple: fixed set, mixed types
let (meter, kwh) = reading; // take it apart
println!("{meter} used {kwh} kWh ({} kWh)", reading.1);
let week: [u32; 7] = [11, 9, 12, 10, 13, 15, 14]; // an array: fixed length, one type
println!("{} days, first {}, last {}", week.len(), week[0], week[6]);
println!("{week:?}");
}
M-30004 used 350 kWh (350 kWh) 7 days, first 11, last 14 [11, 9, 12, 10, 13, 15, 14]
Tuple fields are numbered from 0 (reading.1). The
length of an array is part of its type and can never change; for a
list that grows, use a Vec (see
Collections and Strings).
Every index is checked while the program runs.
get returns an Option for an index that
may be missing; plain indexing panics:
fn main() {
let week: [u32; 7] = [11, 9, 12, 10, 13, 15, 14];
let day = std::env::args().count() + 6; // 7: imagine it came from user input
println!("{:?}", week.get(day)); // safe: None instead of a crash
println!("{}", week[day]); // checked: panics
}
None thread 'main' (48213) panicked at example.rs:5:20: index out of bounds: the len is 7 but the index is 7
std::env::args().count() is the number of command-line
arguments including the program's own name, so 1 here. The detour
keeps the compiler from seeing the bad index in advance: with a
plain week[7] it would refuse to compile the program at
all.
Integer overflow
A fixed-size integer can
overflow:
the true result of a calculation does not fit. Kestrel has about
400,000 meters; one reading per meter per hour for a year is 400,000
× 8,760 = 3,504,000,000, more than the 2,147,483,647 an
i32 can hold. In a debug build, Rust inserts an
integer overflow check
into every arithmetic operation, and the program panics instead of
continuing with a wrong number:
// The number of meters, as it would come from a database at run time
fn meter_count() -> i32 {
std::env::args().count() as i32 * 400_000 // 400000 when run without arguments
}
fn main() {
let meters = meter_count();
println!("meters: {meters}");
let meter_hours = meters * 8_760; // one reading per meter per hour, for a year
println!("meter-hours per year: {meter_hours}");
}
meters: 400000 thread 'main' (48213) panicked at example.rs:9:23: attempt to multiply with overflow
A release build (cargo build --release, or
rustc -O as here) leaves the checks out for speed. The
result then wraps around in
two's complement, and the same program prints a negative number of meter-hours:
// The number of meters, as it would come from a database at run time
fn meter_count() -> i32 {
std::env::args().count() as i32 * 400_000 // 400000 when run without arguments
}
fn main() {
let meters = meter_count();
println!("meters: {meters}");
let meter_hours = meters * 8_760; // one reading per meter per hour, for a year
println!("meter-hours per year: {meter_hours}");
}
meters: 400000 meter-hours per year: -790967296
Both behaviors are defined by the language: overflow is a bug in
your program, but it is never
undefined behavior
as signed overflow is in C, where the compiler may assume it never
happens (compare
signed overflow in C). The debug panic is there to find the bug during testing. Two
operations are checked in every build: division by zero and
i32::MIN / -1 always panic.
Saying what should happen
Every integer type has methods that make the overflow behavior part of the code, in every build. They are Rust's version of C23's checked integer arithmetic:
fn main() {
let level: u8 = 250;
println!("checked: {:?}", level.checked_add(10)); // None: did not fit
println!("checked: {:?}", level.checked_add(5)); // Some(255)
println!("wrapping: {}", level.wrapping_add(10)); // 260 - 256
println!("saturating: {}", level.saturating_add(10)); // stops at the maximum
println!("overflowing: {:?}", level.overflowing_add(10)); // result and a flag
println!("saturating: {}", 0u8.saturating_sub(1)); // stops at the minimum
}
checked: None checked: Some(255) wrapping: 4 saturating: 255 overflowing: (4, true) saturating: 0
checked_* returns an Option: the result in
Some, or None if it did not fit, which the
caller must handle. Applied to the Kestrel numbers, it shows which
type is big enough:
fn main() {
let meters: u32 = 400_000;
println!("{:?}", 400_000_i32.checked_mul(8_760)); // too big for i32
println!("{:?}", meters.checked_mul(8_760)); // fits in u32
println!("{:?}", meters.checked_mul(8_760 * 1_000)); // in watt-hours: too big for u32
let meters = u64::from(meters);
println!("{}", meters * 8_760 * 1_000); // u64 has room
}
None Some(3504000000) None 3504000000000
The meter-hours fit in a u32 (at most 4,294,967,295),
but not once they are multiplied by 1,000 for watt-hours; a
u64 holds that easily. u64::from is a safe
conversion, explained below.
Overflow the compiler can see
When the values are known while compiling, the compiler refuses the overflow outright:
fn main() {
let level: u8 = 255 + 1;
println!("{level}");
}
example.rs:2:21: error: this arithmetic operation will overflow: attempt to compute `u8::MAX + 1_u8`, which would overflow
A literal that does not fit its type, such as
let b: u8 = 256;, is refused in the same way ("literal
out of range for `u8`"). That is why the runtime examples above take
their numbers from std::env::args(): with plain
constants, the program would never compile.
Conversions
Rust converts no number from one type to another by itself, not even to a wider type. Where C silently promotes and converts (see implicit conversions in C), Rust stops:
fn main() {
let kwh: u32 = 350;
let price_per_kwh: u64 = 2_700; // ten-thousandths of a euro
let cost = kwh * price_per_kwh;
println!("{cost}");
}
example.rs:4:22: error[E0308]: mismatched types: expected `u32`, found `u64`
There are two ways to convert. The
as cast (value as u64)
converts between number types and always succeeds, which means it
silently changes values that do not fit:
fn main() {
let big: i32 = 300;
let negative: i32 = -1;
let kwh: f64 = 3.99;
println!("{}", big as u8); // keeps the lowest 8 bits
println!("{}", negative as u32); // same bits, read as unsigned
println!("{}", kwh as i32); // drops the fraction
println!("{}", 1e10 as i32); // float to int saturates at i32::MAX
println!("{}", f64::NAN as i32); // NaN becomes 0
}
44 4294967295 3 2147483647 0
None of these lines gives an error or a warning. The traits
From and TryFrom are the safe alternative:
From exists only for conversions that always fit (u8
to u16, u32 to u64), and
TryFrom returns a Result that is an error
when the value does not fit:
fn main() {
let byte: u8 = 0xB9;
let wide = u16::from(byte); // always fits: From
println!("{wide:04X}");
println!("{:?}", u8::try_from(200_i32)); // fits
println!("{:?}", u8::try_from(300_i32)); // does not fit: an error, not 44
match u8::try_from(-1_i32) {
Ok(level) => println!("level {level}"),
Err(e) => println!("refused: {e}"),
}
}
00B9 Ok(200) Err(TryFromIntError(PosOverflow)) refused: number too small to fit in target type
So the fix for the mismatched types above is
u64::from(kwh) * price_per_kwh. Text becomes a number
with parse, which also returns a Result:
"350".parse::<u32>() is Ok(350), and
"35O" (with a letter O) is an error you must handle.
Floats and money
f32 and f64 are
floating-point numbers
with the usual binary rounding: most decimal fractions, like 0.1,
cannot be stored exactly. Rust behaves exactly like C and Python
here:
fn main() {
println!("{}", 0.1 + 0.2);
println!("{}", 0.1 + 0.2 == 0.3);
println!("{:.2}", 2.675); // rounds the binary value, 2.67499999…
println!("{}", (0.29_f64 * 100.0) as i32); // 28, not 29: as cuts off 28.999999…
// Money as whole numbers: the price in ten-thousandths of a euro
const PRICE_PER_KWH: u64 = 2_700; // €0.2700 per kWh
let kwh: u64 = 350;
let cents = (kwh * PRICE_PER_KWH + 50) / 100; // + 50: round to whole cents
println!("€{}.{:02}", cents / 100, cents % 100);
}
0.30000000000000004 false 2.67 28 €94.50
For amounts of money, use integers: whole cents, or smaller units
when prices have more decimals. Kestrel's fixed tariff of €0.2700
per kWh becomes 2,700 ten-thousandths of a euro, and 350 kWh costs
exactly 945,000 ten-thousandths, rounded to 9,450 cents: €94.50. The
u64 type leaves room for totals across all customers.
The background is on
rounding errors
and the C version on
floating point and money.
Formatting output
println!, print! and
format! (which returns a String instead of
printing) share one formatting language. Each {} takes
the next argument, or names a variable directly; after a colon come
options for width, alignment, number base and precision:
fn main() {
let meter = "M-30004";
let kwh = 412.338;
let crc: u16 = 0xF1F4;
let readings = [350, 412, 120];
println!("{meter}: {kwh} kWh"); // a variable name inside the braces
println!("{}: {:.1} kWh", meter, kwh); // positional, one decimal
println!("[{meter:>10}] [{meter:<10}]"); // right- and left-aligned in 10 characters
println!("CRC {crc:04X}, {crc:#06x}, {crc}"); // hexadecimal and decimal
println!("{readings:?}"); // Debug format, for developers
println!("{:?}", "Größe"); // Debug shows the quotes
}
M-30004: 412.338 kWh M-30004: 412.3 kWh [ M-30004] [M-30004 ] CRC F1F4, 0xf1f4, 61940 [350, 412, 120] "Größe"
-
{}uses a type's Display form, meant for users. Numbers and text have one; arrays and lists do not. -
{:?}uses the Debug form, meant for developers: arrays, tuples,Optionvalues and quoted strings. Your own types get one with#[derive(Debug)](see Structs, Enums and Methods). -
Names inside the braces (
{meter}) have worked since Rust 1.58 (January 2022); older code writesprintln!("{}", meter). Only plain variable names are allowed there, not expressions such as{a + b}. -
The compiler checks the format string against the arguments: a
missing or extra argument is a compile error, not a crash or
garbage output as with C's
printf.