2024-07-07
HSPACE Rust Lectures #2 — Fundamental Types
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.
| Kind | Types |
|---|---|
| Unsigned integers | u8, u16, u32, u64, u128, usize |
| Signed integers | i8, i16, i32, i64, i128, isize |
| Floating point | f32, 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++'sunique_ptr. - Raw pointers
*const T/*mut T— C pointers. Dereferencing them requires anunsafeblock.
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.