Learning Rust · Part III · Chapter 06

Structs, your own types

In Go you reach for struct the moment two values belong together. Rust gives you the same tool with sharper edges: fields it owns, methods that say how they borrow, and a one-line derive that writes the boilerplate you would otherwise type by hand.

01

Values that belong together

You have hit this in every language you have written. A function needs to pass back a width and a height, or a host and a port, or a name and an age, and the two values clearly belong together. In Go you stop and declare a struct so they travel as one thing with named parts. Rust has the same construct, spelled the same way, and you reach for it for the same reason: when several values form one idea, you give that idea a type of its own. Read this, then press Run:

a_rectangle.rs
// A rectangle is two numbers that belong together; a struct says so by name.
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    // Build one by naming every field. Order does not matter, names do.
    let card = Rectangle { width: 30, height: 50 };

    // Reach a field with a dot, the same as a Go struct field.
    let area = card.width * card.height;
    println!("a {} by {} card covers {area} square units", card.width, card.height);
}

The struct Rectangle block declares a new type with two named fields, width and height, each a u32. That declaration creates no values; it only describes their shape. The let card = Rectangle { width: 30, height: 50 } line builds an actual value by naming every field and giving it a number. Then card.width and card.height read the fields back with a dot, exactly as you would in Go. A Rust struct is, at this level, the Go struct you already know.

One detail is worth pinning down now. When you build a struct you must give every field a value, and you may write them in any order, because Rust matches by name and not by position. There is no zero-value default filled in behind your back the way Go zeroes an omitted field. If Rectangle has a width and a height, a value of that type has both, always, with no half-built state for a later line to trip over.

A struct is a named bundle of fields

struct Name { field: Type, ... } declares a type whose values carry those fields. You build one by naming every field, and you read a field with value.field. This much is the Go struct, with the one difference that Rust never fills in a field you left out: a struct value always has all of its fields.

02

Shorthand and the update syntax

Naming every field every time gets repetitive fast, especially inside a constructor where the local variables already carry the field names. Rust has two small conveniences for this, and you will see both in real code constantly. The first is field-init shorthand: when a local variable has the same name as the field, you write the name once instead of field: field. The second is the struct update syntax, ..other, which fills every field you did not mention from another value of the same type. Run this:

shorthand_and_update.rs
// Field-init shorthand and struct update syntax: two small conveniences.
#[derive(Debug)]
struct Server {
    host: String,
    port: u16,
    tls: bool,
    workers: u32,
}

// When a local variable already has the field's name, you write it once.
fn server(host: String, port: u16) -> Server {
    Server {
        host,            // shorthand for host: host
        port,            // shorthand for port: port
        tls: true,
        workers: 4,
    }
}

fn main() {
    let base = server(String::from("api.internal"), 8080);
    println!("base talks to {}:{}", base.host, base.port);

    // Struct update syntax: take every unmentioned field from `base`.
    let staging = Server {
        host: String::from("api.staging"),
        ..base
    };
    println!("staging: {staging:?}");
    println!("staging keeps {} workers and tls={}", staging.workers, staging.tls);
}

Inside server, the parameters are already named host and port, so the struct literal writes them bare: host is shorthand for host: host, and port for port: port. The two fields that do not come from a parameter, tls and workers, are written out in full. The shorthand is purely cosmetic: it expands to the long form and means exactly the same thing.

The staging value shows the update syntax. You set the one field that differs, host, and then write ..base to say take every other field from base. So staging gets a fresh host but the same port, tls, and worker count as base. The ..other always comes last, and it covers exactly the fields you did not list yourself. It is the tidy way to make a near-copy that differs in a field or two.

..other can move fields out of the source

The update syntax does not magically copy; it moves the fields it pulls from the source, just like an ordinary assignment would. Here base.port, base.tls, and base.workers are small Copy numbers and booleans, so base stays fully usable. But if ..base had pulled a non-Copy field such as a String, that field would move out of base, and base could no longer be used as a whole. This is ordinary move semantics from III.03 reaching into struct fields, not a new rule.

03

Methods: behaviour with a receiver

A struct so far is only data. The behaviour that goes with it lives in an impl block, short for implementation: a block that attaches functions to the type. A function in an impl block whose first parameter is some form of self is a method, and you call it with a dot, the way you called a method on a string in earlier chapters. The form of that self is the whole story, and it is the same three-way choice you met for references in III.03. Run this and watch a rectangle get read, then changed, then consumed:

methods_and_receivers.rs
// An impl block bolts behaviour onto a struct. Methods take a receiver:
// &self to read, &mut self to change, self to consume.
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // &self borrows the rectangle to read it; the caller keeps ownership.
    fn area(&self) -> u32 {
        self.width * self.height
    }

    // &mut self borrows it mutably so the method can change a field.
    fn scale(&mut self, factor: u32) {
        self.width *= factor;
        self.height *= factor;
    }

    // self by value consumes the rectangle: after this call it is gone.
    fn into_square_side(self) -> u32 {
        self.width.max(self.height)
    }
}

fn main() {
    let mut card = Rectangle { width: 30, height: 50 };
    println!("area before: {}", card.area());

    card.scale(2);
    println!("area after scaling: {} ({card:?})", card.area());

    let side = card.into_square_side();
    println!("a square that fits it has side {side}");
    // card is moved away now; touching it here would not compile.
}

Read the three receivers in order, because they are the heart of this chapter. area(&self) takes a shared reference to the rectangle: it borrows the value to read its fields and gives it straight back, so you can call area as many times as you like and still own the rectangle afterward. scale(&mut self) takes a mutable reference: it borrows the value exclusively so it can change width and height in place, which is why card has to be a mut binding for the call to be allowed. into_square_side(self) takes the value by ownership: it consumes the rectangle, so after that call card is gone and any later use of it would not compile.

That is the same & / &mut / by-value distinction from III.03, now sitting in the receiver slot. The receiver is just the first argument, written in shorthand: &self is shorthand for self: &Self, where Self is the type the impl block is for. So a method is an ordinary function whose first parameter is the value it operates on, and the form of that parameter tells the caller, right in the signature, whether the method reads, mutates, or consumes.

The receiver is the whole contract

&self reads and gives the value back. &mut self borrows it exclusively to change it, so the caller needs a mut binding. self by value consumes it, so the caller cannot use the value afterward. You pick the least powerful receiver that does the job: &self when you can, &mut self when you must change, plain self only when the method genuinely uses up the value.

04

Associated functions and the new convention

Not every function in an impl block takes a self. A function with no self parameter is an associated function: it is attached to the type but not to any one value, so you call it on the type itself with Type::function(...) rather than on a value with a dot. The most common use is a constructor, a function that builds and returns a value of the type. Rust has no special constructor syntax and no new keyword; by strong convention the basic constructor is just an associated function named new. Run this:

the_new_convention.rs
// An associated function takes no self. The `new` convention is just one:
// a function on the type that builds a value of the type.
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // No self: you call it on the type, Rectangle::new(...), not on a value.
    fn new(width: u32, height: u32) -> Rectangle {
        Rectangle { width, height }
    }

    // Another associated function: a named constructor for a common case.
    fn square(side: u32) -> Rectangle {
        Rectangle { width: side, height: side }
    }
}

// A second impl block on the same type is allowed and often tidy.
impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let r = Rectangle::new(30, 50);
    let s = Rectangle::square(20);
    println!("{r:?} has area {}", r.area());
    println!("{s:?} has area {}", s.area());
}

Rectangle::new(30, 50) calls the associated function new on the type and gets back a Rectangle. Notice the path syntax: Type::function with the double colon, the same :: you used for String::from in earlier chapters. That is exactly what from is, an associated function on String. There is nothing magic about new: it is an ordinary function that happens to return Self, and square right below it is a second constructor for the common case of equal sides. A type can have as many named constructors as it needs.

This example also shows that a type may have more than one impl block. The constructors live in the first block and area lives in a second, and the two are merged by the compiler as if you had written one block. Nothing forces you to split them up here; it is a tool for keeping related groups of methods together, and it becomes genuinely useful once generics and traits enter the picture later in Part III.

new is a convention, not a keyword

If you come from a language with new ClassName(), new there is built into the language and allocates an object. In Rust new is just a name the community agreed on for the default constructor. The compiler does not know or care about it. You could call your constructor make or build and it would work the same; new is what the next reader expects to find, so use it for the obvious primary constructor and a descriptive name (square, with_capacity) for the others.

05

derive: the impl blocks you do not write

Some behaviour is so routine that writing it by hand would be pure boilerplate: printing a struct for debugging, making a copy of it, comparing two of them for equality. In Go you might reach for reflect or write the comparison by hand. Rust generates this code for you from a one-line list of trait names above the struct, #[derive(...)]. A trait is a named set of behaviour a type can have (the full story is later in Part III); for now, read derive as please write the standard version of these impls for me. Run this:

derive_does_the_chores.rs
// derive writes whole impl blocks for you from a short list of trait names.
// Debug formats with {:?}, Clone makes a copy, PartialEq compares with ==.
#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let origin = Point { x: 0, y: 0 };

    // Clone: an independent copy you can change without touching the original.
    let mut shifted = origin.clone();
    shifted.x = 5;

    // Debug: {:?} prints the struct, because derive wrote the impl.
    println!("origin:  {origin:?}");
    println!("shifted: {shifted:?}");

    // PartialEq: == and != work, comparing field by field.
    println!("origin == shifted? {}", origin == shifted);
    println!("origin == origin.clone()? {}", origin == origin.clone());
}

The single line #[derive(Debug, Clone, PartialEq)] above Point hands you three abilities. Debug is what lets {:?} print the struct, field by field, which is why every earlier example could print itself. Clone gives you the .clone() method that makes an independent copy, so changing shifted.x leaves origin untouched. PartialEq gives you == and !=, comparing two points field by field. None of that code appears in the file, yet all three work, because derive wrote the obvious implementation for each.

The catch worth knowing is that derive works field by field, so every field must itself support the trait. You can derive(PartialEq) for Point only because i32 is already comparable; a struct holding a field whose type is not comparable cannot derive PartialEq, and the compiler will say so. When the automatic version is wrong for your type, you write the impl by hand instead, which is exactly what derive would have generated, only yours.

derive writes the obvious impl for you

#[derive(Debug, Clone, PartialEq)] generates the standard implementations: Debug for {:?} printing, Clone for .clone(), PartialEq for ==. It works field by field, so every field must support the trait too. It is the same code you would type by hand, written for you from a one-line list.

06

Three shapes of struct, three jobs

The named-field struct is the one you will reach for most, but Rust has two more shapes, and each earns its keep in a specific situation. A tuple struct has fields identified by position rather than name, like a tuple that carries a type name. A unit-like struct has no fields at all. They sound exotic, but the reasons to use them are mundane and you will meet both in real code. Run this:

three_shapes.rs
// Three struct shapes for three jobs.

// A tuple struct: fields by position, not name. The name carries the meaning,
// so Meters(3.0) is a different type from a bare f64 you cannot mix up.
#[derive(Debug)]
struct Meters(f64);

#[derive(Debug)]
struct Rgb(u8, u8, u8);

// A unit-like struct: no fields at all. Useful as a marker or a handle.
#[derive(Debug)]
struct Always;

fn main() {
    let height = Meters(1.83);
    let orange = Rgb(240, 113, 66);

    // Reach tuple-struct fields by .0, .1, .2, like a tuple.
    println!("height is {} meters", height.0);
    println!("orange is #{:02x}{:02x}{:02x}", orange.0, orange.1, orange.2);

    // A unit-like struct has exactly one value: itself.
    let marker = Always;
    println!("marker: {marker:?}");
}

Meters(f64) is a tuple struct: one field, reached by height.0 rather than by a name. The point of it is the type, not the field layout. A bare f64 could be meters, seconds, or dollars, and the compiler would let you add seconds to dollars without complaint. Wrapping it as Meters makes a distinct type the compiler will keep separate from a plain number, a cheap, zero-overhead way to stop unit-mixing bugs (the pattern is called a newtype, and it gets its full treatment later). Rgb(u8, u8, u8) shows the multi-field case, reached by .0, .1, .2.

Always is a unit-like struct: no fields, no parentheses, one single value that is just the type's name. It carries no data, so why declare it? Because sometimes you want a type purely as a marker or a handle, something to hang behaviour on or to use as a label, where the value itself holds nothing. You will see unit-like structs most often once traits arrive, as a type whose only job is to implement a trait. For now, recognize the shape: a struct can be empty.

Which shape, when

Reach for a named-field struct by default; names are documentation and you almost always want them. Reach for a tuple struct when the wrapper itself is the meaning and a field name would add nothing, the classic case being a one-field newtype like Meters(f64) that gives a primitive its own type. Reach for a unit-like struct when you need a type but no data, typically a marker you will give behaviour to. If you are unsure, use named fields; it is the choice you will rarely regret.

07

Mutability is per binding, not per field

Here is a place where a Go habit quietly misleads you. You might expect to mark some fields of a struct mutable and others fixed, the way some languages let you tag individual fields as readonly. Rust does not work that way, and the rule is simpler than you might guess: mutability belongs to the binding, not to the fields. The whole struct value is mutable or none of it is, and which one depends entirely on whether the let that owns it said mut. Run this:

mut_lives_on_the_binding.rs
// Mutability lives on the binding, not on the fields. The whole struct is
// mutable or none of it is; there is no per-field `mut`.
#[derive(Debug)]
struct Counter {
    label: String,
    value: u32,
}

fn main() {
    // `mut` here makes every field of this binding changeable.
    let mut hits = Counter { label: String::from("hits"), value: 0 };
    hits.value += 1;
    hits.value += 1;
    println!("{}: {}", hits.label, hits.value);

    // An immutable binding freezes the whole struct.
    let frozen = Counter { label: String::from("frozen"), value: 99 };
    // frozen.value += 1; // would not compile: cannot assign, `frozen` is not mut
    println!("{}: {} (cannot be changed)", frozen.label, frozen.value);

    // Move the value into a fresh `mut` binding to thaw it.
    let mut thawed = frozen;
    thawed.value = 100;
    println!("{}: {}", thawed.label, thawed.value);
}

The hits binding is let mut, so every field of it can change, which is why hits.value += 1 is allowed. The frozen binding is a plain let, so the entire value is fixed: the commented-out frozen.value += 1 would not compile, because you cannot assign through an immutable binding, not even to one field. There is no syntax for making just value changeable while label stays fixed. The binding decides for the whole value.

The last few lines show the way out, and it is pure ownership from III.03. let mut thawed = frozen moves the value out of the immutable binding and into a fresh mut one, and now its fields can change again. The data never changed; only the binding that owns it did. This follows from the same model as everything else in Rust: mutation is a capability of the access path to a value, and a binding is the root of that path.

The binding owns the mutability

There is no per-field mut. let mut s makes every field of s changeable; plain let s freezes all of them. To thaw an immutable value, move it into a new mut binding. Mutability is a property of how you hold the value, not of the type or its individual fields.

08

Who owns the fields

One question decides how a struct behaves under Rust's ownership rules: does a field own its data, or does it borrow it? A field of an owning type, a String, a number, another struct, is owned by the struct outright. The struct holds it, and when the struct is dropped the field is dropped with it; no annotation is needed. A field that is a reference is different: it points into data owned elsewhere, so the struct needs a lifetime from III.05 to promise it will not outlive what it points at. Run this:

owned_vs_borrowed_fields.rs
// A struct owns its non-reference fields outright. A field that is a reference
// borrows from somewhere else, so the struct needs a lifetime (from III.05).
#[derive(Debug)]
struct Owned {
    name: String, // the struct owns this String and drops it with itself
    port: u16,    // and owns this u16 by value
}

// The 'a says: a Header may not outlive the &str it points at.
#[derive(Debug)]
struct Header<'a> {
    name: &'a str,  // borrowed, not owned: it points into someone else's data
    value: &'a str,
}

fn main() {
    let config = Owned { name: String::from("api"), port: 8080 };
    println!("owned: {} on {}", config.name, config.port);

    let line = String::from("Content-Type: text/plain");
    let (name, value) = line.split_once(": ").unwrap();
    let header = Header { name, value }; // borrows from `line`
    println!("borrowed header: {} = {}", header.name, header.value);
    // `header` cannot outlive `line`; the lifetime makes that a compile error,
    // not a dangling pointer.
}

Owned needs no lifetime because both of its fields own their data. The String is the struct's to keep and to drop, and the u16 is a plain value stored inline. Move an Owned around, return it from a function, store it in a vector, and it carries its name and port with it, beholden to nothing outside itself. This is the common case, and it is why most structs you write never mention a lifetime at all.

Header<'a> is the other case. Its fields are &'a str, references that point into someone else's string rather than owning their own. That is exactly the situation III.05 described: a reference stored in a struct needs a named lifetime, and 'a is the struct's promise that no Header will outlive the data its name and value borrow from. In the program, header borrows from line, and the lifetime is what turns outliving line into a compile error rather than a dangling pointer at runtime.

Prefer owned fields until you have a reason not to

A struct full of owned fields is self-contained and easy to move and store, with no lifetime to thread through every function that touches it. A struct holding references avoids copying data but ties the struct's lifetime to whatever it borrows, which the lifetime annotation makes you spell out. When you are learning, default to owned fields: a String, not a &str. Reach for borrowed fields only when you have measured a copy you genuinely want to avoid and you are ready to manage the lifetime that comes with it.

09

The whole picture

Step back and the chapter is one idea seen from several sides. A struct is how you name your own type: a bundle of fields the value owns, built by naming each field, read with a dot, and freshened with shorthand and ..other when that saves typing. Behaviour for the type lives in impl blocks, where a self receiver makes a method (&self to read, &mut self to change, self to consume) and a no-self function is an associated function, the home of the new constructor. A one-line derive writes the routine impls for you, mutability rides on the binding rather than the field, and a field that borrows instead of owning brings a lifetime along.

Two threads run ahead of this chapter, and each has a home right next door. A struct bundles fields that are all present at once, an and of its parts; the other half of Rust's data modelling is the enum, a type that is one of several shapes, an or of its variants, and that is Part III, chapter 07. And taking a struct or an enum apart by its fields and variants, which you saw a glimpse of in let (name, value) = line.split_once(...), is the job of pattern matching in Part III, chapter 08.

The whole chapter in six lines

  1. A struct names your own type: a bundle of fields, built by naming each one and read with value.field.
  2. Field-init shorthand writes field for field: field, and struct update (..other) fills the fields you did not mention.
  3. An impl block holds behaviour; a method's receiver (&self, &mut self, self) says whether it reads, changes, or consumes the value.
  4. An associated function takes no self and is called as Type::f(...); by convention new is the basic constructor.
  5. #[derive(...)] generates routine impls (Debug, Clone, PartialEq); tuple and unit-like structs cover the by-position and no-field cases.
  6. Mutability is per binding, not per field, and a struct owns its non-reference fields; a borrowed field needs a lifetime.
End-of-chapter assessmentCheck your understanding20 questions. Your first attempt is recorded on your report card.Take it →