4 min read
On this page

Structs & impl

Structs are how Rust organizes data. They are not classes — there is no inheritance, no constructors with special syntax, no this pointer that changes meaning depending on context. A struct is data. An impl block attaches behavior to that data. The separation is clean and intentional.

Defining Structs

struct User {
    name: String,
    email: String,
    active: bool,
    login_count: u64,
}

fn main() {
    let user = User {
        name: String::from("Alice"),
        email: String::from("alice@example.com"),
        active: true,
        login_count: 1,
    };

    println!("{} ({}) - logins: {}", user.name, user.email, user.login_count);
}
Alice (alice@example.com) - logins: 1

All fields must be initialized. There are no default values unless you implement Default. This eliminates the "partially initialized object" bugs that plague languages with constructors.

Field Access & Mutation

Structs are immutable by default, like everything in Rust. Mutability applies to the entire struct — you cannot make individual fields mutable.

struct Point {
    x: f64,
    y: f64,
}

fn main() {
    let mut p = Point { x: 1.0, y: 2.0 };
    p.x = 3.0;
    println!("({}, {})", p.x, p.y);

    // Struct update syntax: create a new struct from an existing one
    let p2 = Point { x: 10.0, ..p };
    println!("({}, {})", p2.x, p2.y);
}
(3.0, 2.0)
(10.0, 2.0)

The ..p syntax copies remaining fields from p. For types that implement Copy, this copies. For heap types, it moves — so p might be partially invalidated after the update.

Methods with impl

Methods are defined in impl blocks. The first parameter is always some form of self:

struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    // &self: borrows the struct immutably (most common)
    fn area(&self) -> f64 {
        self.width * self.height
    }

    // &mut self: borrows the struct mutably
    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }

    // self: takes ownership (consumes the struct)
    fn into_square(self) -> Rectangle {
        let side = self.width.max(self.height);
        Rectangle { width: side, height: side }
    }
}

fn main() {
    let mut rect = Rectangle { width: 10.0, height: 5.0 };
    println!("Area: {}", rect.area());

    rect.scale(2.0);
    println!("Scaled area: {}", rect.area());

    let square = rect.into_square();
    // rect is consumed — cannot use it anymore
    println!("Square area: {}", square.area());
}
Area: 50
Scaled area: 200
Square area: 400

The self parameter type tells you everything about what the method does to the struct:

  • &self — reads the struct
  • &mut self — modifies the struct
  • self — consumes the struct

Associated Functions (Constructors)

Functions in an impl block that do not take self are called associated functions. They are accessed with :: syntax and commonly serve as constructors:

struct Color {
    r: u8,
    g: u8,
    b: u8,
}

impl Color {
    fn new(r: u8, g: u8, b: u8) -> Self {
        Color { r, g, b }
    }

    fn red() -> Self {
        Color { r: 255, g: 0, b: 0 }
    }

    fn white() -> Self {
        Color { r: 255, g: 255, b: 255 }
    }

    fn hex(&self) -> String {
        format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
    }
}

fn main() {
    let c1 = Color::new(100, 149, 237);
    let c2 = Color::red();
    println!("{} {}", c1.hex(), c2.hex());
}
#6495ed #ff0000

Self is an alias for the struct type within the impl block. Color::new is the conventional name for the primary constructor.

The Builder Pattern

When structs have many optional fields, the builder pattern provides a clean API:

struct ServerConfig {
    host: String,
    port: u16,
    max_connections: u32,
    timeout_secs: u64,
    tls_enabled: bool,
}

struct ServerConfigBuilder {
    host: String,
    port: u16,
    max_connections: u32,
    timeout_secs: u64,
    tls_enabled: bool,
}

impl ServerConfigBuilder {
    fn new(host: &str, port: u16) -> Self {
        ServerConfigBuilder {
            host: host.to_string(),
            port,
            max_connections: 100,
            timeout_secs: 30,
            tls_enabled: false,
        }
    }

    fn max_connections(mut self, n: u32) -> Self {
        self.max_connections = n;
        self
    }

    fn timeout(mut self, secs: u64) -> Self {
        self.timeout_secs = secs;
        self
    }

    fn tls(mut self, enabled: bool) -> Self {
        self.tls_enabled = enabled;
        self
    }

    fn build(self) -> ServerConfig {
        ServerConfig {
            host: self.host,
            port: self.port,
            max_connections: self.max_connections,
            timeout_secs: self.timeout_secs,
            tls_enabled: self.tls_enabled,
        }
    }
}

fn main() {
    let config = ServerConfigBuilder::new("0.0.0.0", 8080)
        .max_connections(500)
        .tls(true)
        .timeout(60)
        .build();

    println!("{}:{} (max: {}, tls: {}, timeout: {}s)",
        config.host, config.port, config.max_connections,
        config.tls_enabled, config.timeout_secs);
}
0.0.0.0:8080 (max: 500, tls: true, timeout: 60s)

Each builder method takes mut self (consuming and returning the builder) enabling method chaining. Default values are set in new, and callers override only what they need.

derive: Your Best Friend

Rust can automatically implement common traits with #[derive]:

#[derive(Debug, Clone, PartialEq)]
struct Coordinate {
    lat: f64,
    lon: f64,
}

fn main() {
    let home = Coordinate { lat: 40.7128, lon: -74.0060 };
    let work = Coordinate { lat: 40.7580, lon: -73.9855 };

    // Debug: enables {:?} formatting
    println!("{:?}", home);

    // Clone: explicit deep copy
    let saved = home.clone();

    // PartialEq: enables == comparison
    println!("Same location? {}", home == saved);
    println!("Home is work? {}", home == work);
}
Coordinate { lat: 40.7128, lon: -74.006 }
Same location? true
Home is work? false

The most commonly derived traits:

Trait What It Does
Debug Enables {:?} formatting for printing
Clone Enables .clone() for explicit copies
PartialEq Enables == and != comparison
Eq Marks total equality (all values comparable)
Hash Enables use as HashMap keys
Default Provides a default value via Type::default()
PartialOrd / Ord Enables <, >, sorting

Derive them liberally. There is no runtime cost — the compiler generates the implementation at compile time.

Multiple impl Blocks

You can split methods across multiple impl blocks. This is useful for organizing code or conditional compilation:

#[derive(Debug)]
struct Database {
    url: String,
    connected: bool,
}

impl Database {
    fn new(url: &str) -> Self {
        Database {
            url: url.to_string(),
            connected: false,
        }
    }
}

impl Database {
    fn connect(&mut self) {
        self.connected = true;
        println!("Connected to {}", self.url);
    }

    fn is_connected(&self) -> bool {
        self.connected
    }
}

fn main() {
    let mut db = Database::new("postgres://localhost/mydb");
    println!("Connected: {}", db.is_connected());
    db.connect();
    println!("Connected: {}", db.is_connected());
}
Connected: false
Connected to postgres://localhost/mydb
Connected: true

Tuple Structs & Unit Structs

Tuple structs have unnamed fields. Useful for newtype wrappers:

struct Meters(f64);
struct Seconds(f64);

impl Meters {
    fn new(value: f64) -> Self {
        Meters(value)
    }
}

impl Seconds {
    fn new(value: f64) -> Self {
        Seconds(value)
    }
}

fn speed(distance: &Meters, time: &Seconds) -> f64 {
    distance.0 / time.0
}

fn main() {
    let d = Meters::new(100.0);
    let t = Seconds::new(9.58);
    println!("Speed: {:.2} m/s", speed(&d, &t));

    // This won't compile — types are distinct even though both wrap f64:
    // speed(&t, &d); // error: expected Meters, found Seconds
}
Speed: 10.44 m/s

The newtype pattern uses the type system to prevent mixing up values that have the same underlying type. Meters and Seconds are both f64, but the compiler treats them as different types.

Unit structs have no fields at all. They are used as markers:

struct Production;
struct Development;

// Used as type-level markers for configuration

Common Pitfalls

  • Making every struct public with all fields public. Expose the minimum necessary. Keep fields private and provide methods. This lets you change the internal representation later without breaking callers.
  • Not deriving Debug. Every struct should derive Debug unless you have a specific reason not to. You will need it for printing during development.
  • Using inheritance patterns. Rust does not have inheritance. If you want shared behavior, use traits. If you want shared data, use composition (a struct containing another struct).
  • Forgetting that self methods consume the struct. If a method takes self (not &self), the struct is gone after the call. This is intentional for transformation methods but surprising if you are not expecting it.
  • Not using the builder pattern when constructors get complex. If a struct has more than 3-4 fields with sensible defaults, a builder is cleaner than a constructor with many parameters.
  • Implementing Display but not Debug. Derive Debug for developer output, implement Display manually for user-facing output. They serve different purposes.

Key Takeaways

  • Structs hold data. impl blocks attach behavior. The separation is intentional and clean.
  • &self, &mut self, and self communicate intent: reading, modifying, or consuming.
  • Associated functions (no self parameter) serve as constructors. Type::new() is the convention.
  • The builder pattern handles complex construction with many optional fields.
  • #[derive(Debug, Clone, PartialEq)] gives you common functionality for free. Use it.
  • Composition, not inheritance. Structs containing other structs. Traits for shared behavior.