Welcome to the second part of our Rust Basics series! In this article, we’ll dive deep into Rust’s type system and common data structures. Understanding these concepts is fundamental to writing efficient and safe Rust code.
Slices
Slices in Rust represent a view into a contiguous sequence of elements in memory. Slices are special as they don’t own the data they point to - they’re a reference to a portion of an array or collection.
Slices are represented as fat pointers, containing both:
- A pointer to the data
- The length of the slice
This makes slices particularly useful for working with portions of data without copying it.
fn main() {
// An array (fixed size, allocated on stack)
let numbers = [1, 2, 3, 4, 5];
// A slice referencing part of the array
let slice = &numbers[1..4]; // Contains [2, 3, 4]
println!("Slice length: {}", slice.len());
println!("First element: {}", slice[0]); // 2
}
Regarding memory layout, Rust’s standard data types are designed with performance and safety in mind:
- Primitive types (i32, f64, bool, etc.): Fixed size, stack-allocated
- Arrays ([T; N]): Fixed size, contiguous memory, stack-allocated when size is known at compile time
- Vectors (Vec
): Dynamically sized, heap-allocated with metadata on the stack - Strings (String): Heap-allocated UTF-8 encoded text with metadata on the stack
- Structs: Memory layout is field-by-field in declaration order (with potential padding for alignment)
Fat / Thin pointers
Rust has two kinds of pointers that represent fundamentally different things:
Thin pointers are just like pointers in C - a single memory address. They have a size of one machine word (e.g., 8 bytes on 64-bit systems). Examples include:
- References to sized types:
&i32
,&bool
,&String
- Raw pointers to sized types:
*const T
,*mut T
where T is sized
Fat pointers contain additional metadata beyond just the memory address. They’re twice the size of thin pointers (two machine words). Examples include:
- Slices:
&[T]
(contains pointer + length) - Trait objects:
&dyn Trait
(contains pointer + vtable pointer) - String slices:
&str
(contains pointer + length)
fn main() {
// Thin pointer - just an address
let x = 42;
let thin_ptr = &x;
// Fat pointer - address + length
let numbers = vec![1, 2, 3, 4, 5];
let slice = &numbers[..];
println!("Size of thin pointer: {} bytes", std::mem::size_of_val(&thin_ptr));
println!("Size of fat pointer: {} bytes", std::mem::size_of_val(&slice));
}
&str and String types
Rust has two string types for flexibility and performance reasons:
String is:
- Owned, heap-allocated, and mutable
- Can grow or shrink in size
- Implemented as a wrapper around Vec
that guarantees valid UTF-8 - Analogous to vector ownership (
Vec<T>
)
&str (string slice) is:
- A reference/view into string data
- Immutable by default
- A fat pointer containing address and length
- More efficient for passing strings around (no copying)
- Can refer to either heap or stack data
- Analogous to slice references (
&[T]
)
fn main() {
// String - owned, heap-allocated
let mut owned_string = String::from("Hello");
owned_string.push_str(", world!"); // Can modify
// &str - borrowed reference to any string data
let string_slice = &owned_string[0..5]; // Contains "Hello"
// String literals are &str slices into static memory
let literal: &str = "I'm a string slice";
// Converting between them
let owned_from_slice = string_slice.to_string();
let slice_from_owned = owned_string.as_str();
}
When to use each:
- Use String when you need to own and potentially modify string data
- Use &str for function parameters when you don’t need ownership
- Use &str for string literals
- Use String when constructing strings from parts or at runtime
Collections
Collections are data structures that can hold multiple values:
- Store data in memory with various organization strategies
- Examples: vectors, hashmaps, sets, etc.
- Focus on storage and access patterns
Iterators are abstractions for sequential access to elements:
- Don’t store data themselves, but provide a way to traverse data
- Lazy by default - elements are processed only when requested
- Allow for elegant, functional-style processing with methods like
map()
,filter()
, andfold()
The key relationship is that collections provide iterators to access their elements.
fn main() {
// A collection (vector)
let numbers = vec![1, 2, 3, 4, 5];
// Creating an iterator from the collection
let mut iter = numbers.iter();
// Using the iterator directly
assert_eq!(iter.next(), Some(&1));
assert_eq!(iter.next(), Some(&2));
// Using iterator adapters for functional operations
let sum: i32 = numbers.iter().sum();
let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
let even: Vec<&i32> = numbers.iter().filter(|x| *x % 2 == 0).collect();
println!("Sum: {}", sum);
println!("Doubled: {:?}", doubled);
println!("Even numbers: {:?}", even);
}
Iterators shine in chained operations that process data while avoiding intermediate allocations:
fn main() {
let text = "1,2,3,4,5";
// Chain iterator operations without creating intermediate collections
let sum: i32 = text.split(',')
.map(|s| s.trim().parse::<i32>().unwrap())
.filter(|&x| x > 2)
.sum();
println!("Sum of numbers > 2: {}", sum); // 12 (3+4+5)
}
Traits
Traits in Rust define shared behavior that types can implement. They’re similar to interfaces in other languages but with some key differences and additional capabilities.
Traits:
- Define a set of method signatures
- Can provide default implementations
- Enable polymorphism and code reuse
- Support bounded parametric polymorphism (generic constraints)
- Allow for operator overloading
// Defining a trait
trait Printable {
fn format(&self) -> String;
// Default implementation
fn print(&self) {
println!("{}", self.format());
}
}
// Implementing for various types
struct Person {
name: String,
age: u32,
}
impl Printable for Person {
fn format(&self) -> String {
format!("Person {{ name: {}, age: {} }}", self.name, self.age)
}
}
struct Point {
x: i32,
y: i32,
}
impl Printable for Point {
fn format(&self) -> String {
format!("Point({}, {})", self.x, self.y)
}
// Override the default implementation
fn print(&self) {
println!("Coordinates: {}", self.format());
}
}
// Trait as a parameter - dynamic dispatch
fn print_it(item: &dyn Printable) {
item.print();
}
// Trait bound - static dispatch
fn print_it_generic<T: Printable>(item: &T) {
item.print();
}
fn main() {
let person = Person { name: "Alice".to_string(), age: 30 };
let point = Point { x: 5, y: 10 };
person.print();
point.print();
print_it(&person);
print_it(&point);
print_it_generic(&person);
print_it_generic(&point);
}
Comparison with interfaces:
- Traits can be implemented for any type, including types you don’t own
- Traits can have default implementations
- Traits can be used as bounds on generic parameters
- Traits support multiple dispatch through trait objects
- Traits can be used for operator overloading
- Traits don’t support inheritance (Rust has no inheritance)
Auto Traits, Blanket Implementations, and Marker Traits
Auto traits are traits that are automatically implemented for types that satisfy certain conditions. They don’t have any methods and are used to indicate certain properties.
Examples include:
Send
: Indicates a type can be safely transferred between threadsSync
: Indicates a type can be safely shared between threadsSized
: Indicates a type has a size known at compile time
Blanket implementations are implementations of traits for a broad range of types that match a pattern, typically using generics.
// Blanket implementation example
trait AsJson {
fn as_json(&self) -> String;
}
// Implement AsJson for any type that implements Display
impl<T: std::fmt::Display> AsJson for T {
fn as_json(&self) -> String {
format!("\"{}\"", self)
}
}
fn main() {
// These types now implement AsJson even though we didn't
// explicitly implement it for them
println!("{}", 42.as_json());
println!("{}", "hello".as_json());
println!("{}", true.as_json());
}
Uncovered types refer to types that don’t implement a specific trait, either because they can’t satisfy the requirements or because an implementation hasn’t been provided.
Marker traits are traits that don’t have any methods but are used to “mark” types as having certain properties. These are often used in type constraints.
Examples:
Copy
: Indicates a type can be duplicated by simple bit-wise copyingSend
: Indicates a type can be safely transferred between threadsSync
: Indicates a type can be safely shared between threads
// Custom marker trait
trait Serializable {}
// Implement for specific types
impl Serializable for String {}
impl Serializable for i32 {}
// Function that only accepts Serializable types
fn save<T: Serializable>(value: T) {
// Implementation details...
println!("Saved to database");
}
fn main() {
save("hello".to_string()); // Works
save(42); // Works
// save(std::rc::Rc::new(42)); // Error! Rc<T> doesn't implement Serializable
}
Generics and Polymorphism
Generics are a way to write code that works with multiple different types. Parametric polymorphism is the technical term for this capability, allowing a single piece of code to handle values of different types uniformly.
In Rust, generics are implemented using type parameters, typically denoted with angle brackets <T>
.
Problems solved by generics:
- Code duplication: Without generics, you’d need separate implementations for each type
- Type safety: Generics maintain full type checking
- Abstraction: Allow focusing on algorithms rather than specific types
- Performance: Through monomorphization (more on this later), Rust generates optimized code for each concrete type
// Generic function
fn first<T>(list: &[T]) -> Option<&T> {
if list.is_empty() {
None
} else {
Some(&list[0])
}
}
// Generic struct
struct Pair<T, U> {
first: T,
second: U,
}
// Generic implementation
impl<T, U> Pair<T, U> {
fn new(first: T, second: U) -> Self {
Pair { first, second }
}
}
fn main() {
// Using generic function with different types
let numbers = vec![1, 2, 3];
let first_number = first(&numbers);
let names = vec!["Alice", "Bob", "Charlie"];
let first_name = first(&names);
// Using generic struct with different type combinations
let pair1 = Pair::new(42, "answer");
let pair2 = Pair::new("hello", 3.14);
}
Static and Dynamic Dispatch
Rust supports two main forms of polymorphism:
Static dispatch (compile-time polymorphism):
- Uses generics and trait bounds
- The compiler generates specialized code for each concrete type
- Has zero runtime cost
- Results in larger binary size due to code duplication
- All type checking is done at compile time
This approach is what gives Rust its “zero-cost abstractions” - you can write generic, abstract code without paying a runtime performance penalty.
Dynamic dispatch (runtime polymorphism):
- Uses trait objects (
dyn Trait
) - Types are resolved at runtime through a virtual method table (vtable)
- Has a small runtime cost for method lookup
- Results in smaller binary size
- Some type checking is deferred to runtime
trait Animal {
fn make_sound(&self) -> String;
}
struct Dog;
struct Cat;
impl Animal for Dog {
fn make_sound(&self) -> String {
"Woof!".to_string()
}
}
impl Animal for Cat {
fn make_sound(&self) -> String {
"Meow!".to_string()
}
}
// Static dispatch - resolved at compile time
fn animal_sound_static<T: Animal>(animal: &T) -> String {
animal.make_sound()
}
// Dynamic dispatch - resolved at runtime
fn animal_sound_dynamic(animal: &dyn Animal) -> String {
animal.make_sound()
}
fn main() {
let dog = Dog;
let cat = Cat;
// Static dispatch
println!("Static dog: {}", animal_sound_static(&dog));
println!("Static cat: {}", animal_sound_static(&cat));
// Dynamic dispatch
println!("Dynamic dog: {}", animal_sound_dynamic(&dog));
println!("Dynamic cat: {}", animal_sound_dynamic(&cat));
// With dynamic dispatch, we can have heterogeneous collections
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog),
Box::new(Cat),
Box::new(Dog),
];
for animal in &animals {
println!("Sound: {}", animal.make_sound());
}
}
When to use static dispatch:
- When performance is critical
- When the set of types is known at compile time
- When the binary size isn’t a concern
When to use dynamic dispatch:
- When you need heterogeneous collections
- When you want plugins or extensibility
- When binary size is a concern
- When the set of types isn’t known at compile time
Monomorphization and Type Specialization
Monomorphization is the process by which Rust transforms generic code into specific, concrete implementations for each type it’s used with. This happens at compile time and is what enables Rust’s zero-cost abstractions.
It applies to static dispatch with generics, not to dynamic dispatch with trait objects. When using dynamic dispatch (dyn Trait
), the concrete type information is erased at runtime, and method calls are resolved through a vtable lookup - there’s no opportunity for the compiler to create type-specialized versions. This is why dynamic dispatch has a small runtime overhead compared to static dispatch.
The tradeoff is clear: static dispatch through monomorphization gives you maximum performance but potentially larger binary size, while dynamic dispatch gives you more flexibility and smaller code size but with a slight performance cost.
// A generic function
fn process<T: std::fmt::Display>(value: T) {
println!("Processing: {}", value);
}
fn main() {
// When we call this with different types
process(42); // i32
process("hello"); // &str
process(3.14); // f64
// The compiler creates specialized versions, similar to:
// fn process_i32(value: i32) { println!("Processing: {}", value); }
// fn process_str(value: &str) { println!("Processing: {}", value); }
// fn process_f64(value: f64) { println!("Processing: {}", value); }
}
Nominative Typing vs Structural Typing
Nominative typing (which Rust uses) means that types are compatible based on their declared names and explicit relationships, not their structure. Two types with identical fields but different names are considered distinct.
Structural typing (which Rust doesn’t use) means that types are compatible if they have the same structure, regardless of their names.
The key differences:
- In nominative typing, type identity is based on names and explicit declarations
- In structural typing, type identity is based on structure and capabilities
// Nominative typing in Rust
struct Point {
x: f32,
y: f32,
}
struct Coordinate {
x: f32,
y: f32,
}
fn distance_from_origin(point: Point) -> f32 {
(point.x.powi(2) + point.y.powi(2)).sqrt()
}
fn main() {
let point = Point { x: 3.0, y: 4.0 };
let coord = Coordinate { x: 3.0, y: 4.0 };
distance_from_origin(point); // Works fine
// distance_from_origin(coord); // Error! Types don't match
// Even though Point and Coordinate have identical structure,
// they're considered completely different types
}
Rust does support a form of structural matching through traits, which allow types to be matched by behavior rather than name. However, this is still based on explicit implementation rather than pure structural typing.