Types and Variables
Intuition
Section titled “Intuition”Rust’s type system is its core safety mechanism. Every value has a definite type known at compile time, enabling the compiler to verify memory safety without runtime checks. Ownership and borrowing replace garbage collection by tracking which part of the code is responsible for each piece of data. Move semantics prevent accidental sharing, and the borrow checker ensures references never outlive the data they point to, eliminating use-after-free bugs at compile time.
Integer Types
Section titled “Integer Types”Rust provides signed and unsigned integers at every power-of-two width from 8 to 128 bits, plus Platform-dependent isize and usize:
| Type | Size (bytes) | Range (signed) | Range (unsigned) |
|---|---|---|---|
i8 / u8 | 1 | -128 to 127 | 0 to 255 |
i16 / u16 | 2 | -32,768 to 32,767 | 0 to 65,535 |
i32 / u32 | 4 | -2,147,483,648 to 2,147,483,647 | 0 to 4,294,967,295 |
i64 / u64 | 8 | -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 | 0 to 18,446,744,073,709,551,615 |
i128 / u128 | 16 | -170,141,183,460,469,231,731,687,303,715,884,105,728 to 170,141,183,460,469,231,731,687,303,715,884,105,727 | 0 to 340,282,366,920,938,463,463,374,607,431,768,211,455 |
isize / usize | 4 or 8 | Pointer-sized | Pointer-sized |
The default integer type is i32. This is not an arbitrary choice — on x86-64, i32 operations are As fast as any smaller integer width, and using i32 avoids the implicit sign-extension or Zero-extension overhead that i8/u8 incur in many contexts.
Integer Literals
Section titled “Integer Literals”let decimal = 1_000_000;let hex = 0xFF_FF;let octal = 0o777;let binary = 0b1111_0000;let byte = b"A"; // u8 onlyThe underscore separator is valid anywhere within a numeric literal for readability. It is ignored By the compiler.
Integer Overflow
Section titled “Integer Overflow”In debug mode, integer overflow panics. In release mode, it wraps using two’s complement arithmetic. This is a deliberate design choice: debug builds catch bugs, release builds avoid runtime checks.
let x: u8 = 255;let y = x + 1; // debug: panic, release: y == 0 (wraps)To explicitly control overflow behavior:
let x: u8 = 255;let y = x.wrapping_add(1); // 0 (wraps)let y = x.saturating_add(1); // 255 (clamps at max)let y = x.checked_add(1); // Nonelet y = x.overflowing_add(1); // (0, true) — value + overflow flagSizing and Alignment
Section titled “Sizing and Alignment”Each type has a size and an alignment requirement. You can query these at compile time:
assert_eq!(std::mem::size_of::<u64>(), 8);assert_eq!(std::mem::align_of::<u64>(), 8);assert_eq!(std::mem::size_of::<u8>(), 1);assert_eq!(std::mem::size_of::<bool>(), 1);Alignment determines the memory address at which a value must be stored. A u64 with alignment 8 Must be placed at an address divisible by 8. Misaligned access on x86-64 works but may be slower; on ARM without unaligned access support, it traps.
Floating-Point Types
Section titled “Floating-Point Types”Rust has two floating-point types conforming to IEEE 754-2008:
| Type | Size | Precision | Range (approximate) |
|---|---|---|---|
f32 | 4 bytes | ~6-7 decimal digits | plus/minus 3.4e38 |
f64 | 8 bytes | ~15-16 decimal digits | plus/minus 1.8e308 |
The default is f64. On modern x86-64 hardware, f64 operations are as fast as f32 — there is no Performance penalty for using the larger type. Use f32 only when you need to reduce memory Bandwidth (e.g., GPU shaders, large arrays of floats in ML workloads).
let x = 2.0; // f64let y: f32 = 3.14; // f32Floating-Point Gotchas
Section titled “Floating-Point Gotchas”assert!(0.1 + 0.2 != 0.3); // true — IEEE 754 representation errorassert!((0.1_f64 + 0.2_f64 - 0.3_f64).abs() < f64::EPSILON);f32::NAN and f64::NAN exist. NaN does not compare equal to anything, including itself:
let nan = f64::NAN;assert!(nan != nan); // trueassert!(!nan.is_nan()); // false — use is_nan() for the checkbool is one byte, not one bit. This is because every byte in memory must be addressable, and a Single-bit bool would require bit-packing overhead for every access.
let x: bool = true;assert_eq!(std::mem::size_of::<bool>(), 1);bool implements Copy``Clone``Debug``DisplayAnd the bitwise operators (!``&``| ^) via the BitAnd``BitOr``BitXor``Not traits.
Character Type
Section titled “Character Type”Rust’s char is a Unicode scalar value, not a byte and not a code point. It is always 4 bytes and Represents any Unicode code point from U+0000 to U+10FFFF, excluding surrogate code points (U+D800–U+DFFF).
let c: char = 'z';let emoji: char = '🦀';let unicode: char = '\u{1F980}';assert_eq!(std::mem::size_of::<char>(), 4);A u8 holds a byte value (0–255). A char holds a Unicode scalar value (0–1,114,111, excluding Surrogates). Converting between them is explicit and fallible:
let byte: u8 = 97;let c = byte as char; // 'a' — always valid (u8 is a subset of Unicode)assert_eq!(c, 'a');
let c: char = '€';let byte = c as u8; // truncates to lower 8 bits — 172 (0xAC)// This is almost certainly not what you want for encoding purposes.// Use .encode_utf8() instead:let mut buf = [0u8; 4];let encoded = c.encode_utf8(&mut buf); // [0xE2, 0x82, 0xAC]assert_eq!(encoded.len(), 3); // '€' is 3 bytes in UTF-8Tuples
Section titled “Tuples”Tuples are fixed-size heterogeneous collections. Maximum arity is 12 (without nesting).
let tuple: (i32, f64, &str) = (1, 3.14, "hello");let (x, y, z) = tuple; // destructuringassert_eq!(x, 1);assert_eq!(tuple.0, 1); // index access (0-based)assert_eq!(tuple.2, "hello");Unit Tuple
Section titled “Unit Tuple”() (the empty tuple, pronounced “unit”) is Rust’s void type. Functions that return nothing Implicitly return (). It has size 0 and alignment 1.
assert_eq!(std::mem::size_of::<()>(), 0);fn do_nothing() {}fn explicit_unit() -> () {}The zero-sized type (ZST) property of () is important for generic programming. In Result<T, ()> The error variant carries no payload overhead since () is a ZST. Additionally, if T has a niche (e.g., Option<T> where None is represented by a sentinel value), the compiler can eliminate the Discriminant entirely through niche optimization.
Tuple Structs
Section titled “Tuple Structs”struct Point(f64, f64, f64);
let p = Point(1.0, 2.0, 3.0);let Point(x, y, z) = p;Tuple structs are named tuples. They are structurally similar to plain tuples but have a distinct Type, which is critical for type safety — a Point(f64, f64) and a Vector(f64, f64) are different Types even though they have the same layout.
Arrays
Section titled “Arrays”Arrays are fixed-size, stack-allocated, homogeneous collections. The size is part of the type.
let arr: [i32; 5] = [1, 2, 3, 4, 5];let zeros = [0u8; 1024]; // [expr; N] — repeat N timeslet first = arr[0]; // 1let slice: &[i32] = &arr[1..3]; // [2, 3]Array Bounds
Section titled “Array Bounds”Out-of-bounds access panics in safe Rust. The compiler inserts bounds checks at runtime. In release Mode with --releaseBounds checks are still present by default (they are only elided when the Compiler can prove the index is in bounds statically).
let arr = [1, 2, 3];// let x = arr[10]; // panic: index out of boundsTo access without bounds checking (when you have already verified bounds), use get_unchecked:
let arr = [1, 2, 3];let idx = 1;if idx < arr.len() { let x = unsafe { *arr.get_unchecked(idx) }; // no bounds check}Array Coercion to Slices
Section titled “Array Coercion to Slices”Arrays coerce to slices (&[T] or &mut [T]). This is how most standard library APIs accept Arrays:
fn sum(data: &[i32]) -> i32 { data.iter().sum()}
let arr = [1, 2, 3];assert_eq!(sum(&arr), 6);Const Generics and Arrays
Section titled “Const Generics and Arrays”Since Rust 1.51, arrays support const generics for the length parameter:
fn first<T, const N: usize>(arr: &[T; N]) -> Option<&T> { arr.first()}
let arr = [10, 20, 30];assert_eq!(first(&arr), Some(&10));This is a significant improvement over the pre-const-generics era where you had to work with slices And lose the compile-time size information.
Vectors
Section titled “Vectors”Vec<T> is Rust’s growable heap-allocated array. It is a triple of (pointer, length, capacity):
┌──────────────────────────────────────┐│ Vec<T> ││ ┌──────────┬────────┬──────────┐ ││ │ ptr │ len │ capacity │ ││ └────┼─────┴────────┴──────────┘ ││ │ ││ ▼ ││ ┌───┬───┬───┬───┬───┬───┬───┬───┐ ││ │ 0 │ 1 │ 2 │ 3 │ │ │ │ │ ││ └───┴───┴───┴───┴───┴───┴───┴───┘ ││ ←──── len ────→ ││ ←──────── capacity ──────────────→ │└──────────────────────────────────────┘let mut v: Vec<i32> = Vec::new();v.push(1);v.push(2);v.push(3);
let v = vec![1, 2, 3]; // macro with inferred typelet v = Vec::with_capacity(100); // pre-allocate, no reallocation until 101 elementsCapacity and Reallocation
Section titled “Capacity and Reallocation”When push would exceed capacity, the vector allocates a new buffer with capacity * 2 (or a Platform-specific growth factor) and copies all elements. This is amortized O(1) per push, but a Single push can be O(n) when reallocation occurs.
let mut v = Vec::with_capacity(4);assert_eq!(v.capacity(), 4);for i in 0..4 { v.push(i);}assert_eq!(v.capacity(), 4); // no reallocationv.push(4);assert!(v.capacity() > 4); // reallocatedDrain and Retain
Section titled “Drain and Retain”let mut v = vec![1, 2, 3, 4, 5];let drained: Vec<i32> = v.drain(1..3).collect();// v == [1, 4, 5], drained == [2, 3]
v.retain(|&x| x > 3);// v == [4, 5]drain removes a range of elements and returns an iterator that yields ownership of them. retain Removes elements that do not satisfy the predicate in-place.
String vs &str
Section titled “String vs &str”This is one of the most common sources of confusion for new Rust programmers. Rust has two string Types:
| Type | Ownership | Location | Size | Growable |
|---|---|---|---|---|
String | Owned | Heap | 24 bytes (ptr + len + cap on 64-bit) | Yes |
&str | Borrowed | Anywhere (stack, heap, static) | 16 bytes (ptr + len) | No |
String is a Vec<u8> guaranteed to hold valid UTF-8. &str is a slice of UTF-8 bytes. Every String can be dereferenced to &strBut not vice versa.
let s: String = String::from("hello");let slice: &str = &s;let literal: &str = "world"; // stored in the binary's .rodata section
let mut owned = String::from("hello");owned.push_str(" world");owned.push('!');// owned == "hello world!"Rust strings are always UTF-8. This is not configurable. If you need non-UTF-8 byte sequences, use Vec<u8> or &[u8].
let s = "hello";assert_eq!(s.len(), 5); // byte lengthassert_eq!(s.chars().count(), 5); // character count (same for ASCII)
let s = "こんにちは";assert_eq!(s.len(), 15); // 5 chars × 3 bytes eachassert_eq!(s.chars().count(), 5); // 5 charactersIndexing into a string with s[0] is not valid because the index is byte-based, and a byte index May fall in the middle of a multi-byte character:
let s = "こんにちは";// let c = s[0]; // compile error: &str cannot be indexed by usizelet bytes: &[u8] = s.as_bytes();// bytes[0..3] is the first character 'こ'Common String Operations
Section titled “Common String Operations”let s = String::from("hello, world!");
let slice = &s[0..5]; // "hello"let slice = &s[7..12]; // "world"
let trimmed = " hello ".trim(); // "hello"let replaced = "hello".replace("l", "r"); // "herro"let parts: Vec<&str> = "a,b,c".split(',').collect();
let mut s = String::new();s.push_str("hello");s.push(' ');
let combined = format!("{} {}", "hello", "world"); // "hello world"String Interning and &'static str
Section titled “String Interning and &'static str”String literals are &'static str — they live for the entire duration of the program. They are Embedded in the binary’s read-only data section.
let s: &'static str = "hello"; // lives foreverFor interning strings at runtime (deduplicating identical strings), use the string_interner or internment crate.
Type Inference
Section titled “Type Inference”Rust’s type inference is local and flow-insensitive. The compiler infers types from usage within a Single function body but does not perform interprocedural type inference. This is a deliberate Design choice with two concrete benefits. First, because each function body is type-checked independently, compilation scales linearly with the number of functions rather than Combinatorially — the compiler never needs to resolve types across function boundaries. Second, error messages are localised to the function where the type mismatch occurs, so the compiler can Point to the exact line rather than tracing through an interprocedural call graph.
let x = 42; // i32 (default integer type)let y = 3.14; // f64 (default float type)let v = vec![1, 2, 3]; // Vec<i32>let s = "hello"; // &str
let x: u64 = 42; // explicit type annotationlet y = 42u64; // suffix annotationlet z = 42_i32; // underscore-separated suffixInference Limitations
Section titled “Inference Limitations”Type inference does not work across function boundaries:
// This does NOT compile — the compiler cannot infer the type of `x`// because it has no usage context at the call site:fn make_vec() -> Vec<_> { // ERROR: type annotations needed Vec::new()}
// Fix: annotate the return type or use a turbofishfn make_vec() -> Vec<i32> { vec![1, 2, 3]}The _ Placeholder
Section titled “The _ Placeholder”The underscore type placeholder tells the compiler “infer this, I don’t care about the exact type”:
let x: _ = 42; // inferred as i32let y: Vec<_> = vec![1, 2, 3]; // Vec<i32>In patterns, _ is a wildcard that discards the value:
let (x, _) = (1, 2); // x = 1, second element discardedlet _ = expensive_call(); // call evaluated, result discardedShadowing
Section titled “Shadowing”Rust allows you to declare a new variable with the same name as an existing one. The new variable Shadows the previous one. This is not mutation — the old variable still exists but is no longer Accessible by name.
let x = 5;let x = x + 1; // new binding, x is now 6let x = "hello"; // new binding, different type — x is now &strShadowing is distinct from let mut:
let x = 5;let x = x + 1; // shadowing — x is immutable, but we create a new binding
let mut y = 5;y = y + 1; // mutation — same binding, modified in placeShadowing is useful for type transformations and for reusing a name after a value is no longer Needed (e.g., after moving it):
let s = String::from("hello");let s = s.len(); // s is borrowed by .len(), then the original String is dropped; the new s is a usizeMutability
Section titled “Mutability”Variables are immutable by default. You must explicitly opt into mutability with let mut:
let x = 5;// x = 6; // ERROR: cannot assign twice to immutable variable
let mut y = 5;y = 6; // OKInterior Mutability vs Variable Mutability
Section titled “Interior Mutability vs Variable Mutability”Variable mutability (let mut) controls whether you can reassign the binding. Interior mutability (Cell, RefCell, Mutex) controls whether you can modify the value through a shared reference. These Are orthogonal concepts.
let x = Cell::new(5); // x is immutablex.set(42); // but the value inside is mutableThis distinction is explored in detail in the ownership and borrowing section.
Partial Mutability with Struct Fields
Section titled “Partial Mutability with Struct Fields”You can make individual fields mutable:
struct Point { x: f64, y: f64,}
let mut p = Point { x: 1.0, y: 2.0 };p.x = 3.0; // OK because p is mut
let p = Point { x: 1.0, y: 2.0 };// p.x = 3.0; // ERROR: p is immutableThere is no let p = Point { mut x: 1.0, y: 2.0 } syntax. Mutability is on the binding, not on the Field.
Constants vs Static
Section titled “Constants vs Static”Rust has two ways to define global values:
const MAX_SIZE: usize = 1024;const PI: f64 = 3.141592653589793;const BUFFER: [u8; 3] = [0xAA, 0xBB, 0xCC];- Inlined at every use site (no address, no memory location)
- Must be evaluatable at compile time (const context)
- Can be of any type that supports const construction
- No fixed memory address — each use may be a separate copy
static
Section titled “static”static GLOBAL_COUNTER: AtomicUsize = AtomicUsize::new(0);static GREETING: &str = "hello";- Has a single memory address for the entire program
- Lives for
'staticlifetime - Must be
Sync(accessible from multiple threads) - Can be mutated with interior mutability (
AtomicUsize``Mutex``OnceLock)
use std::sync::atomic::{AtomicUsize, Ordering};
static CALL_COUNT: AtomicUsize = AtomicUsize::new(0);
fn increment() { CALL_COUNT.fetch_add(1, Ordering::Relaxed);}Type aliases create an alias for an existing type. They do not create a new type — the alias is Interchangeable with the original.
type Kilometers = i32;type Thunk = Box<dyn Fn() -> i32>;
let distance: Kilometers = 42;let x: i32 = distance; // OK — Kilometers IS i32For creating a newtype (a distinct type with the same representation), use a tuple struct:
struct Kilometers(i32);
let distance = Kilometers(42);// let x: i32 = distance; // ERROR — Kilometers is not i32let x: i32 = distance.0; // OK — explicit field accessThe Never Type !
Section titled “The Never Type !”The never type ! represents computations that never return. It has no values and cannot be Instantiated. It coerces to any type, which is why diverging functions can be used in expression Context:
fn diverge() -> ! { panic!("this function never returns");}
let x: i32 = { let y = 5; if y > 3 { 10 } else { diverge() // ! coerces to i32 }};Functions that return ! are called “diverging functions.” Common examples:
panic!()— terminates the program (in non-abort mode, unwinds the stack)std::process::exit()— terminates the process immediately- Infinite loops (
loop { ... }) — if the loop has no break
! is not yet a stable type in Rust’s type system (it is a compiler-internal concept), but you can Use it in function return types. RFC 2361 tracks its stabilization.
! in Match Arms
Section titled “! in Match Arms”In a match expression, a diverging arm does not need to return the same type as other arms:
let x: i32 = match some_condition() { true => 42, false => panic!("cannot happen"), // false arm returns !, which coerces to i32};Destructuring
Section titled “Destructuring”Rust supports destructuring for tuples, arrays, structs, and enums:
// Tuple destructuringlet (a, b, c) = (1, 2.0, "three");
// Array destructuringlet [first, second, ..] = [1, 2, 3, 4, 5];
// Struct destructuringstruct Point { x: f64, y: f64 }let p = Point { x: 1.0, y: 2.0 };let Point { x, y } = p;
// Struct destructuring with renamelet Point { x: a, y: b } = p;
// Struct destructuring with ignorelet Point { x, .. } = p;
// Enum destructuringenum Shape { Circle(f64), Rectangle { width: f64, height: f64 }, Point,}let shape = Shape::Rectangle { width: 10.0, height: 20.0 };if let Shape::Rectangle { width, height } = shape { println!("{} x {}", width, height);}Destructuring in Function Parameters
Section titled “Destructuring in Function Parameters”fn swap((mut x, mut y): (i32, i32)) -> (i32, i32) { std::mem::swap(&mut x, &mut y); (x, y)}Destructuring with ref and ref mut
Section titled “Destructuring with ref and ref mut”By default, destructuring moves values. Use ref to borrow and ref mut to borrow mutably:
struct Point { x: i32, y: i32 }let p = Point { x: 1, y: 2 };
let Point { x, y } = p; // moves x and y out of p// println!("{}", p.x); // ERROR: p partially moved
let p = Point { x: 1, y: 2 };let Point { ref x, ref y } = p; // borrows x and yprintln!("{} {}", x, y); // OKprintln!("{} {}", p.x, p.y); // OK — p is not movedNumeric Conversions
Section titled “Numeric Conversions”Rust does not perform implicit numeric conversions. Every conversion between numeric types is Explicit:
let x: i32 = 42;let y: i64 = x as i64; // explicit castlet z: u8 = x as u8; // truncates (42 fits in u8)let w: u8 = 300u16 as u8; // 44 (300 mod 256)as Cast Semantics
Section titled “as Cast Semantics”The as keyword performs a bitcast/truncation without checking for overflow:
let x: i32 = -1;let y: u32 = x as u32; // 4294967295 (two's complement reinterpretation)let z: i8 = 128i32 as i8; // -128 (wraps)For fallible conversions that return Result:
// TryFrom and TryInto are in the prelude since Rust 1.76 — no explicit use neededuse std::convert::TryFrom;use std::convert::TryInto;
let x: u8 = 255u32.try_into().unwrap(); // OKlet y: Result<u8, _> = 256u32.try_into(); // Err(TryFromIntError)Raw Pointers
Section titled “Raw Pointers”Raw pointers (*const T``*mut T) exist but are restricted to unsafe blocks:
let x = 42;let raw: *const i32 = &x;let mut y = 42;let raw_mut: *mut i32 = &mut y;
unsafe { println!("{}", *raw); *raw_mut = 43;}Raw pointers can be null, misaligned, or dangling — the compiler does not check them. They are Necessary for FFI and for implementing safe abstractions over unsafe memory operations.
Visibility of Primitive Types
Section titled “Visibility of Primitive Types”All primitive types are in the prelude and are always in scope. You do not need to import them. The Following is an approximate list of commonly used prelude items (see the standard library prelude for the authoritative List): Option``Result``Vec``String``Box``Drop``Clone``Copy``Deref``DerefMut AsRef``AsMut``From``Into``IntoIterator``Fn``FnMut``FnOnce``Send``Sync``Unpin Sized``Debug``Display``Iterator``Extend``PartialEq``PartialOrd``Eq``Ord``Hash Default``ToString``println``eprintln``format``vec``drop.
Common Pitfalls
Section titled “Common Pitfalls”Assuming
charis a byte.charis always 4 bytes and represents a Unicode scalar value. For byte-level text processing, useu8or&[u8]. For ASCII-only text,charworks but wastes 3 bytes per character.Using
Stringwhen&strsuffices. If a function only needs to read a string, take&stras the parameter type. This allows the caller to pass both&String(auto-deref) and string literals without allocation.Integer overflow in release mode. The compiler does not check for overflow in release builds. Use
wrapping_*``saturating_*``checked_*Oroverflowing_*methods explicitly when overflow is possible and wrapping is not desired.Floating-point equality. Never use
==to compare floats for equality. Use an epsilon comparison or theapproxcrate.f32andf64do not implementEqprecisely because of this.Indexing strings with byte offsets.
s[i]whereiis a byte offset will panic if the index falls on a non-ASCII character boundary. Uses.chars().nth(i)for character indexing (O(n)) ors.char_indices()for byte-safe iteration.Confusing
constandstatic.constvalues are inlined. They have no memory address. Usestaticwhen you need a single address (e.g., for a global counter, FFI callback, orLazyLock).Shadowing vs mutation. Shadowing creates a new binding; it does not modify the old one. If you need to observe the change through a reference or a closure, use
let mutinstead.ascasts silently truncating.300i32 as u8becomes 44 without any warning. UseTryInto::try_into()for fallible conversions that propagate errors.Vecvs array misuse. Arrays are stack-allocated with a compile-time known size.Vecis heap-allocated with a runtime-determined size. If the size is known at compile time and small, prefer arrays — they avoid heap allocation and have better cache locality.Ignoring alignment. On some platforms (notably ARM), misaligned access to multi-byte integers causes a hardware trap (SIGBUS). Even on x86-64, misaligned access can be significantly slower. Use
#[repr(C)]or#[repr(align(N))]when interfacing with C or when alignment matters.
Summary
Section titled “Summary”This topic covers the core concepts of types and variables, including underlying theory, practical implementation, and key applications.
Key concepts include:
- core concepts and terminology
- algorithms and computational thinking
- practical implementation
- security and ethical considerations
- applications in the real world
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Cross-References
Section titled “Cross-References”- Control Flow and Pattern Matching: Shows how to use variables and types in conditional expressions and pattern matching constructs.
- Ownership and Borrowing: Explains how variable ownership and borrowing rules enforce memory safety at compile time.
- Structs and Enums: Demonstrates how to create custom types using the primitive types and variables covered here.
- Traits and Generics: Extends the type system with polymorphism and shared behaviour across different types.