</>PatchNote
Back to posts

2024-07-07

HSPACE Rust Lectures #2 — Fundamental Types

RustHSPACE 2024Type System

This session walks through Rust's fundamental types. It sounds like a dull topic, but it's where you find out why things that C would have let slide are made explicit in Rust.

Fixed-size numeric types

The size is in the name.

KindTypes
Unsigned integersu8, u16, u32, u64, u128, usize
Signed integersi8, i16, i32, i64, i128, isize
Floating pointf32, f64

usize / isize match the pointer width (64 bits on a 64-bit target). Array indices and collection lengths are all usize. Unlike C's int, whose width varies by platform, i32 is 32 bits everywhere — a difference you feel immediately when cross-compiling.

Literals can carry a type suffix (42u8, 1729_isize) and underscores for readability (1_000_000).

Conversion is always explicit

An as cast truncates out-of-range values modulo 2^N.

assert_eq!(10_i8 as u16, 10_u16);           // in range
assert_eq!(-1_i16 as i32, -1_i32);          // sign-extended
assert_eq!(65535_u16 as i32, 65535_i32);    // zero-extended

// out of range → modulo 2^N for an N-bit destination ("truncation")
assert_eq!(1000_i16 as u8, 232_u8);
assert_eq!(65535_u32 as i16, -1_i16);
assert_eq!(-1_i8 as u8, 255_u8);
assert_eq!(255_u8 as i8, -1_i8);

The behavior matches C, but the decisive difference is that it never happens implicitly. To get -1_i8 as u8 == 255, I have to type the as myself. Given how many C bugs come from this conversion happening silently, a few extra characters is a bargain.

Integer methods

Methods hang off the types.

assert_eq!(2_u16.pow(4), 16);               // exponentiation
assert_eq!((-4_i32).abs(), 4);              // absolute value
assert_eq!(0b101101_u8.count_ones(), 4);    // population count

// println!("{}", (-4).abs());   // error: ambiguous numeric type
println!("{}", (-4_i32).abs());
println!("{}", i32::abs(-4));

(-4).abs() being an error surprised me. The literal -4 is known to be an integer but not which integer type, and abs() is defined per type, so there's nothing to resolve. When context pins the type down you can skip the suffix; in isolation you can't.

Four ways to handle overflow

This was the core of the session. Rust panics on integer overflow in debug builds. Release builds wrap for performance, but either way "a silently wrong value flows onward" is not the default. And you can choose the overflow policy per operation.

1. checked_* — None on failure

assert_eq!(10_u8.checked_add(20), Some(30));
assert_eq!(100_u8.checked_add(200), None);

// panic on overflow
let x = i8::MAX;
let _ = x.checked_add(1).unwrap();

// Signed division can overflow too, in one particular case:
// an n-bit signed type can represent -2^(n-1) but not 2^(n-1).
assert_eq!((-128_i8).checked_div(-1), None);

That last line is the fun one. -128_i8 / -1 should be 128, but i8 maxes out at 127, so it isn't representable. In C, that's undefined behavior.

2. wrapping_* — wrap modulo 2^N

assert_eq!(100_u16.wrapping_mul(200), 20000);
assert_eq!(500_u16.wrapping_mul(500), 53392);   // 250000 mod 2^16
assert_eq!(500_i16.wrapping_mul(500), -12144);  // signed types can wrap negative

// Shift distance wraps to the type's width: a 17-bit shift of a 16-bit type is a 1-bit shift
assert_eq!(5_i16.wrapping_shl(17), 10);

Use it where wrapping is intended, like hash functions.

3. saturating_* — clamp at the boundary

assert_eq!(32760_i16.saturating_add(10), 32767);
assert_eq!((-32760_i16).saturating_sub(10), -32768);

For domains like audio samples or pixel values, where clamping is the right answer.

4. overflowing_* — result plus an overflow flag

assert_eq!(255_u8.overflowing_sub(2), (253, false));
assert_eq!(255_u8.overflowing_add(2), (1, true));
assert_eq!(5_u16.overflowing_shl(17), (10, true));

You get a (value, overflowed) tuple.

There are four policies hiding behind one +, and Rust makes you write down which one you want. In effect the language keeps asking: have you thought about what overflow should do here?

bool, char, tuples

bool is only true / false — 0/1 don't silently become truth values as in C. It occupies one byte.

char is one Unicode scalar value, four bytes wide. It shares a name with C's one-byte char and nothing else.

assert_eq!('*' as i32, 42);
assert_eq!('ಠ' as u16, 0xca0);
assert_eq!('ಠ' as i8, -0x60);   // U+0CA0 truncated to eight bits, signed

assert_eq!('*'.is_alphabetic(), false);
assert_eq!('β'.is_alphabetic(), true);
assert_eq!('8'.to_digit(10), Some(8));
assert_eq!('ಠ'.len_utf8(), 3);
assert_eq!(std::char::from_digit(2, 10), Some('2'));

'8'.to_digit(10) returning Some(8) is very Rust: a non-digit yields None, so the possibility of failure is right there in the return type.

Tuples group values of different types, and destructuring pulls them apart.

let text = "I see the eigenvalue in thine eye";
let (head, tail) = text.split_at(21);

assert_eq!(head, "I see the eigenvalue ");
assert_eq!(tail, "in thine eye");

Equivalent to reading temp.0 and temp.1, but far more legible — and this is the standard way for a function to return several values.

Pointer types

Rust has three kinds of pointer:

  • References &T / &mut T — the safe default. Always point at a valid value, never null, watched by the borrow checker.
  • Boxes Box<T> — an owning heap pointer, the analogue of C++'s unique_ptr.
  • Raw pointers *const T / *mut T — C pointers. Dereferencing them requires an unsafe block.

The approach isn't "forbid dangerous things" but "mark the dangerous parts unsafe and fence them in." Kernel-level code that touches hardware directly does end up using unsafe, and when it does, the region you have to audit is exactly those blocks.

Arrays, vectors, slices

They look similar, but play different roles.

Array [T; N] — the length is part of the type. Fixed at compile time, stored on the stack.

let lazy_caterer: [u32; 6] = [1, 2, 4, 7, 11, 16];
let taxonomy = ["Animalia", "Arthropoda", "Insecta"];

assert_eq!(lazy_caterer[3], 7);
assert_eq!(taxonomy.len(), 3);

// Sieve of Eratosthenes
let mut sieve = [true; 10000];

for i in 2..100 {
    if sieve[i] {
        let mut j = i * i;
        while j < 10000 {
            sieve[j] = false;
            j += i;
        }
    }
}

assert!(sieve[211]);
assert!(!sieve[9876]);

Note the [true; 10000] syntax for "this value, N times."

Vector Vec<T> — a heap-allocated growable array, three words wide: pointer, length, capacity.

let mut primes = vec![2, 3, 5, 7];
assert_eq!(primes.iter().product::<i32>(), 210);

primes.push(11);
primes.push(13);
assert_eq!(primes.iter().product::<i32>(), 30030);

let v: Vec<i32> = (0..5).collect();
assert_eq!(v, [0, 1, 2, 3, 4]);

The turbofish in product::<i32>() tells the iterator what type to accumulate into. collect() works the same way — the target type decides what you get.

Slice &[T] — a view of a contiguous run, whether it belongs to an array or a vector. A two-word fat pointer: pointer and length.

let v: Vec<f64> = vec![0.0, 0.707, 1.0, 0.707];
let a: [f64; 4] = [0.0, -0.707, -1.0, -0.707];

fn print(n: &[f64]) {
    for elt in n {
        println!("{}", elt);
    }
}

print(&a);          // works on arrays
print(&v);          // works on vectors

print(&v[0..2]);    // first two elements of v
print(&a[2..]);     // a[2] onward

This is the payoff: write your function against &[f64] and it accepts arrays, vectors, and sub-ranges alike. The C convention of passing (pointer, length) separately is folded into a single type, and since the length travels with the pointer, the two can't get out of sync.

String types

Rust strings are always UTF-8.

String literals &'static str

let speech = "\"Ouch!\" said the well.\n";

// may span multiple lines
println!("In the room the women come and go,
    Singing of Mount Abora");

// a trailing backslash drops the newline and the next line's leading whitespace
println!("It was a bright, cold day in April, and \
    there were four of us—\
    more or less.");

// raw strings: no escapes
let path = r"C:\Program Files\Gorillas";
println!(r###"a raw string that ends only at a '"' followed by ###"###);

r"..." is a real quality-of-life win for Windows paths and regexes. If the content itself contains quotes, add # signs: r#"..."#.

Byte strings &[u8; N]

let method = b"GET";
assert_eq!(method, &[b'G', b'E', b'T']);

For protocol headers and anything else where you want bytes, not UTF-8.

String — an owned heap string: a Vec<u8> with a UTF-8 guarantee on top.

let error_message = "too many pets".to_string();

assert_eq!(format!("{}°{:02}′{:02}″N", 24, 5, 23), "24°05′23″N".to_string());

let bits = vec!["veni", "vidi", "vici"];
assert_eq!(bits.concat(), "venividivici");
assert_eq!(bits.join(", "), "veni, vidi, vici");

assert!("ONE".to_lowercase() == "one");
assert!("peanut".contains("nut"));
assert_eq!("ಠ_ಠ".replace("ಠ", "■"), "■_■");
assert_eq!(" clean\n".trim(), "clean");

for word in "veni, vidi, vici".split(", ") {
    assert!(word.starts_with("v"));
}

String is to &str exactly what Vec<T> is to &[T]: owner versus view. So function parameters should generally take &str — callers holding a String get deref coercion for free.

One consequence of UTF-8 worth stating plainly: you cannot index by byte. s[0] doesn't compile. A character is 1–4 bytes, so you iterate with s.chars() or look at raw bytes with s.bytes(). It felt awkward at first, but without it, slicing through the middle of a multi-byte character and producing invalid UTF-8 would be trivially easy.

Type aliases

Give a long type a shorter name.

type Bytes = Vec<u8>;

fn decode(data: &Bytes) {
    // ...
}

It's an alias, not a new type, so it stays fully interchangeable with Vec<u8>. The error-handling session puts this to very good use with type Result<T> = std::result::Result<T, MyError>;.

Takeaways

For a session on basic types, what I actually learned was that Rust leaves no ambiguous corners:

  • integer widths don't shift under you per platform
  • conversions are always explicit
  • the overflow policy is chosen per operation
  • slices fold the (pointer, length) convention into a type
  • strings are guaranteed UTF-8, which is why byte indexing is forbidden

Next: ownership and moves — going properly into the ownership rules that session one only skimmed.