Skip to content
Go back

Rust Basics - Types and Data Structures

Published:  at  12:21 AM

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:

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:

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:

Fat pointers contain additional metadata beyond just the memory address. They’re twice the size of thin pointers (two machine words). Examples include:

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:

&str (string slice) is:

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:

Collections

Collections are data structures that can hold multiple values:

Iterators are abstractions for sequential access to elements:

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:

// 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:

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:

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:

// 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:

  1. Code duplication: Without generics, you’d need separate implementations for each type
  2. Type safety: Generics maintain full type checking
  3. Abstraction: Allow focusing on algorithms rather than specific types
  4. 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):

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):

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 to use dynamic dispatch:

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:

// 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.



Next Post
Rust Basics - Memory