Variables, Types and Functions · about 40 min

Integers: Bits, Sizes and Overflow

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

bitssignedunsignedsigned range
8i8u8-128 to 127
16i16u16-32768 to 32767
32i32u32about plus or minus 2.1 billion
64i64u64about plus or minus 9.2 * 10^18
128i128u128about plus or minus 1.7 * 10^38
pointer sizeisizeusize64 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)

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

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.

Exercises

Exercise 1 · quiz

How far does a u8 go?

What is the largest value a u8 can hold? Type the number.

Answer

A u8 has 8 bits, so 2^8 = 256 different patterns, from 00000000 to 11111111. Unsigned types use all of them for non-negative numbers: 0 through 255. An i8 uses the same 256 patterns for -128 through 127. In general an n-bit unsigned type holds 0 to 2^n - 1, and an n-bit signed type holds -2^(n-1) to 2^(n-1) - 1.

Exercise 2 · quiz

Reading two's complement

In two's complement, what value does the 8-bit pattern 11111110 have as an i8?

Answer
  • -2

As a u8 the pattern is 254. As an i8, the top bit has weight -128 instead of +128: -128 + 64 + 32 + 16 + 8 + 4 + 2 = -2. Quick check: adding 1 gives 11111111, which is -1, and adding 1 again wraps to 00000000, which is 0. That is why two's complement is used: the same adder circuit works for signed and unsigned numbers.

Exercise 3 · write code

Seconds to h:m:s

Given let total: u32 = 45296; seconds, use integer division / and remainder % to print it as hours, minutes and seconds, in exactly this form (minutes and seconds always two digits):

12:34:56

Compute the three parts from total; do not type them.

File: main.rs

Starting point

fn main() {
    let total: u32 = 45296;
}

Example

expected output

12:34:56
Hint 1

Hours are total / 3600. What is left over is total % 3600.

Hint 2

{minutes:02} pads to two digits with a zero.

Reference solution
fn main() {
    let total: u32 = 45296;
    let hours = total / 3600;
    let minutes = total % 3600 / 60;
    let seconds = total % 60;
    println!("{hours}:{minutes:02}:{seconds:02}");
}

Exercise 4 · write code

Choosing what overflow means

The value let level: u8 = 250; needs 10 added to it. Print what each of these methods returns, one per line, exactly like this:

wrapping: 4
checked: None
saturating: 255

Use level.wrapping_add(10), level.checked_add(10) and level.saturating_add(10). The checked result is printed with {:?}.

File: main.rs

Starting point

fn main() {
    let level: u8 = 250;
    let plain = level + 10;
    println!("{plain}");
}

Example

expected output

wrapping: 4
checked: None
saturating: 255
Hint 1

The starter fails because 250 + 10 overflows a u8. Remove that line.

Hint 2

checked_add returns an Option<u8>, which has no {} form; print it with {:?}.

Reference solution
fn main() {
    let level: u8 = 250;
    println!("wrapping: {}", level.wrapping_add(10));
    println!("checked: {:?}", level.checked_add(10));
    println!("saturating: {}", level.saturating_add(10));
}

Exercise 5 · quiz

Integer division

What does println!("{} {}", -7 / 2, -7 % 2); print?

Answer
  • -3 -1

Integer division truncates toward zero: -3.5 becomes -3. The remainder takes the sign of the left operand, so that (a / b) * b + a % b == a holds: (-3) * 2 + (-1) = -7. If you need a remainder that is never negative (for example, a day of the week), use (-7_i32).rem_euclid(2), which gives 1.