Why this matters
Integers are the most common values in any program: counts, indexes, sizes, IDs, times. Rust makes you choose how big each integer is and whether it can be negative, and it treats a number too large for its type as an error rather than quietly producing garbage. To make those choices well, and to understand messages like "attempt to add with overflow", you need a clear picture of how integers are stored as bits. That picture also carries straight over to C and to anything involving files, networks or hardware.
Bits and bytes
Memory is a long row of bytes, each made of 8 bits, each bit 0 or 1. A group of n bits can represent 2^n different patterns. One byte: 256 patterns. Four bytes (32 bits): about 4.3 billion.
A number is stored in binary, base 2. Each bit has a weight that is a power of two:
bit weight: 128 64 32 16 8 4 2 1
pattern: 0 0 1 0 1 0 1 0 = 32 + 8 + 2 = 42
You can see this from Rust:
fn main() {
let n: u8 = 42;
println!("{n} = {n:08b} = {n:#x}");
}
42 = 00101010 = 0x2a
{n:08b} prints binary padded to 8 digits; hexadecimal (base 16) is a compact way to write bits, one hex digit per 4 bits (2a is 0010 1010).
Rust's integer types
| bits | signed | unsigned | signed range |
|---|---|---|---|
| 8 | i8 | u8 | -128 to 127 |
| 16 | i16 | u16 | -32768 to 32767 |
| 32 | i32 | u32 | about plus or minus 2.1 billion |
| 64 | i64 | u64 | about plus or minus 9.2 * 10^18 |
| 128 | i128 | u128 | about plus or minus 1.7 * 10^38 |
| pointer size | isize | usize | 64 bits on a 64-bit machine |
i means signed (can be negative), u unsigned. Unsigned types hold 0 to 2^n - 1: u8 is 0 to 255, u32 is 0 to 4,294,967,295. Every type knows its limits:
fn main() {
println!("u8: {} to {}", u8::MIN, u8::MAX);
println!("i8: {} to {}", i8::MIN, i8::MAX);
println!("i32: {} to {}", i32::MIN, i32::MAX);
println!("u64 max: {}", u64::MAX);
println!("usize is {} bytes", std::mem::size_of::<usize>());
}
u8: 0 to 255
i8: -128 to 127
i32: -2147483648 to 2147483647
u64 max: 18446744073709551615
usize is 8 bytes
std::mem::size_of::<T>() asks the compiler how many bytes a type occupies. usize is the type Rust uses for sizes and indexes: it is as wide as a memory address on the machine, so it can count every byte of memory.
Which to use? i32 is the default when you write a plain literal like 5, and a sensible choice for general numbers. Use usize for indexes and lengths (Rust requires it there), u8 for raw bytes, i64/u64 when values can exceed two billion (file sizes, milliseconds since 1970).
Literals can carry their type as a suffix and use _ as a digit separator: 255u8, 1_000_000, 0xff, 0o17, 0b1010_1010.
Negative numbers: two's complement
How does an i8 store -1 in 8 bits? Every modern CPU uses two's complement: the top bit has a negative weight.
bit weight: -128 64 32 16 8 4 2 1
42: 0 0 1 0 1 0 1 0 = 42
-1: 1 1 1 1 1 1 1 1 = -128 + 127 = -1
-128: 1 0 0 0 0 0 0 0 = -128
127: 0 1 1 1 1 1 1 1 = 127
So the same bit pattern means different numbers depending on the type:
fn main() {
let bits: u8 = 0b1111_1111;
println!("as u8: {}", bits);
println!("as i8: {}", bits as i8);
let minus_two: i8 = -2;
println!("-2 as bits: {:08b}", minus_two);
}
as u8: 255
as i8: -1
-2 as bits: 11111110
as converts between number types; between same-sized integers it keeps the bits and changes how they are read. The beauty of two's complement is that addition works identically for signed and unsigned values, so the CPU needs only one adder. The cost is the lopsided range: there is one more negative value than positive.
Arithmetic
fn main() {
let a = 17;
let b = 5;
println!("{} {} {} {} {}", a + b, a - b, a * b, a / b, a % b);
println!("{} {}", -17 / 5, -17 % 5);
println!("{}", 2_i32.pow(10));
}
22 12 85 3 2
-3 -2
1024
Integer division / discards the fraction, truncating toward zero: 17 / 5 is 3, -17 / 5 is -3. % is the remainder, with the sign of the left side. Division by zero stops the program with a panic ("attempt to divide by zero"); if the divisor is a literal 0, the compiler refuses outright.
Both sides of an operator must have the same type. let x: i32 = 5; let y: i64 = 6; x + y is an error; convert one side explicitly with as or (better, module 10) i64::from(x).
Overflow
What happens when a result does not fit? Here u8 holds at most 255:
fn main() {
let level: u8 = 250;
let bonus: u8 = "10".parse().unwrap();
let total = level + bonus;
println!("{total}");
}
thread 'main' panicked at main.rs:4:17:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This is a panic: the program stopped deliberately with a message, instead of continuing with a wrong value. ("thread 'main' panicked at" names the source location; the exit status is 101.) I parsed the 10 from text only to hide it from the compiler: with two literal values, rustc spots the overflow while compiling and refuses with error: this arithmetic operation will overflow.
In a release build (-O, cargo build --release) overflow checks are off by default for speed, and the result wraps around: 250 + 10 becomes 4, exactly like a car odometer rolling over. So overflow is never undefined behavior in Rust (in C, signed overflow is), but it is a bug either way. When wrapping or limits are what you want, say so with explicit methods:
fn main() {
let level: u8 = 250;
println!("{}", level.wrapping_add(10));
println!("{:?}", level.checked_add(10));
println!("{:?}", level.checked_add(5));
println!("{}", level.saturating_add(10));
println!("{:?}", level.overflowing_add(10));
}
4
None
Some(255)
255
(4, true)
wrapping_add: wrap around, always (useful for hashes and checksums).checked_add:Some(result)if it fits,Noneif not. This is theOptiontype (module 8); you can already print it with{:?}.saturating_add: stick at the maximum or minimum (volume controls, health points).overflowing_add: the wrapped result plus a flag saying whether it overflowed.
Each exists for sub, mul, div, pow and more.
Common mistakes
Mixing integer types:
fn main() {
let count: i32 = 3;
let big: i64 = 10;
let sum = count + big;
}
error[E0308]: mismatched types
--> main.rs:4:23
|
4 | let sum = count + big;
| ^^^ expected `i32`, found `i64`
error[E0277]: cannot add `i64` to `i32`
--> main.rs:4:21
|
4 | let sum = count + big;
| ^ no implementation for `i32 + i64`
|
= help: the trait `Add<i64>` is not implemented for `i32`
= help: the following other types implement trait `Add<Rhs>`:
`&i32` implements `Add<i32>`
`&i32` implements `Add`
`i32` implements `Add<&i32>`
`i32` implements `Add`
Two errors for one mistake: the types do not match, and there is no + defined between an i32 and an i64. Convert explicitly: i64::from(count) + big or count as i64 + big. Widening an i32 to i64 can never lose information. Going the other way (big as i32) can silently cut off the high bits, which is exactly why the compiler will not pick a conversion for you.
A literal that does not fit:
fn main() {
let x: u8 = 256;
println!("{x}");
}
error: literal out of range for `u8`
--> main.rs:2:17
|
2 | let x: u8 = 256;
| ^^^
|
= note: the literal `256` does not fit into the type `u8` whose range is `0..=255`
= note: `#[deny(overflowing_literals)]` on by default
Negative values in unsigned types. let n: u32 = -1; is a compile error ("cannot apply unary operator - to type u32"), and 0u32 - 1 at run time is an overflow panic. If a quantity can go below zero, even temporarily, use a signed type.
Expecting a fraction from integer division. 7 / 2 is 3. If you want 3.5, use floats (next lesson): 7.0 / 2.0.
Recap
- An n-bit integer has 2^n patterns. Unsigned: 0 to 2^n - 1. Signed (two's complement): -2^(n-1) to 2^(n-1) - 1.
- Types:
i8..i128,u8..u128,isize/usize. Defaulti32; indexes and lengths areusize. T::MIN,T::MAX,std::mem::size_of::<T>()tell you a type's limits and size.- Integer
/truncates toward zero;%has the sign of the left operand. - Overflow panics in debug builds and wraps in release; use
wrapping_,checked_,saturating_oroverflowing_methods to choose the behavior explicitly. - Operands must have the same type; convert explicitly.
Going further: print -1i32 as u32 and 300i32 as u8 and explain the results from the bits. i32::MAX.checked_mul(2) and i64::MAX.count_ones() are also worth a try. The integer methods are listed in the docs for any type, e.g. doc.rust-lang.org/std/primitive.u8.html.