Skip to content
academia.sh

Lesson 09 / 14

References in Structs

Measures the condition that holding a reference in a struct's field places on the struct's own life: a struct cannot outlive the data it refers to. The same data is held both by moving it into a struct and by borrowing it into one; it is compared by running the code that a moved field ends together with the struct, while borrowed data ends after the struct.

Published Updated

In this lesson

  • Explains that holding a reference in a struct's field requires a lifetime parameter
  • Runs code to compare the end point of a moved field with that of a borrowed field
  • Shows with a rejection-acceptance pair that a struct cannot outlive the data it refers to
Contents

The previous lesson measured which signatures elision covers and which it does not among function parameters. But an explicit annotation can be required not only in function signatures but also in a struct’s own definition: if a struct’s field holds a reference, the struct itself becomes tied to that reference’s life. This lesson measures this condition by comparing two designs that hold the same data in a struct through two different paths — by moving it and by borrowing it.

The question the Go curriculum’s Concurrency course asks is different here too: there, a running unit’s stopping was measured; here, the end point of a field inside a struct is measured. The four end classes established in the Stack and Heap lesson show up again here, but this time compared across two different field kinds.

Throughout this topic the thing measured has been the same question: where is a reference’s validity range read from? The Lifetime Concept lesson measured this range for a reference, and the Explicit Lifetime Annotations and Lifetime Elision Rules lessons measured it for a function signature. This final lesson carries the same question into a struct definition: how does a struct’s own life relate to the life of a reference it holds.

Two Paths: Moving and Borrowing

When a struct wants to put a value into one of its fields, it has two options: it can move the value (the field becomes the owner) or it can hold a reference to the value (the field only borrows it). The move rule seen in the Ownership and Moves lesson applies to the first option — the field behaves like any ordinary owned value on its own. The second option opens a new question: how long can the struct itself live, can it live longer than the data it refers to?

The answer is no, and this is a rule the compiler makes mandatory in a struct definition. If a struct’s field holds a reference, the struct’s own definition has to carry a lifetime parameter — this parameter relates how long the struct itself can live, at most, to the data it refers to.

This is the struct-definition counterpart of the rule established for function signatures in the previous two lessons. There, a function had to say which input its returned reference was tied to; here, the struct itself has to say which source the reference it holds is tied to. The difference is where the relationship is established: not in the life of a function call, but in the life of a struct instance. No matter how long that instance lives — in a variable, in another struct’s field, inside a collection — the guarantee that it cannot outlive the source of the reference it holds does not change.

Measurement: A Moved Field Ends With the Struct, Borrowed Data Ends After the Struct

The measurement below defines two structs: OwningStruct owns a Trace value, BorrowingStruct only holds a reference to a Trace value and, for that reason, carries an <'a> parameter. Both structs operate on the same core (Trace, Ledger, Drop); the only difference measured is whether the field is moved or not.

// reference_in_struct.rs — comparing the end point of a moved field in a struct with that of a borrowed field.

use std::cell::RefCell;
use std::rc::Rc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

fn read(ledger: &Ledger) -> Vec<String> {
    ledger.borrow().clone()
}

fn newly_added(ledger: &Ledger, before: usize) -> String {
    let all = read(ledger);
    all[before..].join(", ")
}

struct OwningStruct {
    content: Trace,
}

struct BorrowingStruct<'a> {
    content: &'a Trace,
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));

    // Moved field: ends when the struct ends.
    let s0 = read(&ledger).len();
    {
        let moved = Trace::new("moved-field", &ledger);
        let instance = OwningStruct { content: moved };
        println!("field inside OwningStruct -> {}", instance.content.name);
        println!("ledger while OwningStruct stands -> [{}]", newly_added(&ledger, s0));
    }
    println!("once OwningStruct ends  -> {}", newly_added(&ledger, s0));

    // Borrowed field: the struct ends, the data still lives.
    let o0 = read(&ledger).len();
    {
        let borrowed_data = Trace::new("borrowed-data", &ledger);
        {
            let instance = BorrowingStruct { content: &borrowed_data };
            println!("inside BorrowingStruct  -> {}", instance.content.name);
        }
        println!("once BorrowingStruct ends -> [{}]", newly_added(&ledger, o0));
    }
    println!("once borrowed-data ends -> {}", newly_added(&ledger, o0));
}
field inside OwningStruct -> moved-field
ledger while OwningStruct stands -> []
once OwningStruct ends  -> moved-field
inside BorrowingStruct  -> borrowed-data
once BorrowingStruct ends -> []
once borrowed-data ends -> borrowed-data

In the OwningStruct section, the moved value moves into instance’s field, and from that point on it has no binding of its own — it is reachable only through instance.content. While instance stands, the ledger is empty; once instance’s scope closes, moved-field drops into the ledger. This repeats the “value moved into a struct field” pattern measured in the Ownership and Moves lesson: the field ends together with the struct itself.

In the BorrowingStruct section, borrowed_data stays in its own binding and instance only holds a reference to it. When instance’s own inner scope closes, the ledger is still empty — the end of the BorrowingStruct instance produces no record at all, because &'a Trace does not implement Drop. borrowed_data itself only drops into the ledger once its own scope (the outer block) closes — and that is after the BorrowingStruct instance ends.

What the two sections have in common is that only one record is produced in each: the sole record in the OwningStruct section is moved-field, the sole record in the BorrowingStruct section is borrowed-data. BorrowingStruct itself never drops into the ledger — because the struct does not implement Drop, it only looks, through the reference it holds, at a Trace value living elsewhere. This shows that a struct holding a reference has no observable side effect from its own end; the only thing observable is the end of the data it refers to, and that end never comes before the struct’s own end — at the earliest, the two can coincide by ending together in the same scope.

LI9. A value moved into a struct’s field ends together with the struct itself. The data behind a reference held in a struct’s field, by contrast, ends after the struct — the struct itself never outlives its own data, but the data it refers to can, and usually does, outlive the struct.

The Struct’s Own Method Is Still Written With Elision

Although BorrowingStruct<'a> carries a lifetime parameter, the methods written on it are not necessarily required to repeat that parameter. The elision rules established in the previous lesson also apply here: the reference returned by a method taking &self is tied to self’s own life by the third rule, and this makes writing 'a separately unnecessary. This is a concrete example showing that the two lessons are not independent of each other — the same rules are applied again and again in different contexts.

// method_elision.rs — a method on a struct carrying a lifetime parameter is still written with elision.

use std::cell::RefCell;
use std::rc::Rc;

type Ledger = Rc<RefCell<Vec<String>>>;

struct Trace {
    name: String,
    ledger: Ledger,
}

impl Trace {
    fn new(name: &str, ledger: &Ledger) -> Trace {
        Trace { name: name.to_string(), ledger: Rc::clone(ledger) }
    }
}

impl Drop for Trace {
    fn drop(&mut self) {
        self.ledger.borrow_mut().push(self.name.clone());
    }
}

struct BorrowingStruct<'a> {
    content: &'a Trace,
}

impl<'a> BorrowingStruct<'a> {
    // Without annotation: elision's third rule (self) applies here too.
    fn name(&self) -> &str {
        self.content.name.as_str()
    }
}

fn main() {
    let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
    let data = Trace::new("shared-data", &ledger);
    let instance = BorrowingStruct { content: &data };
    println!("{}", instance.name());
}
shared-data

The impl<'a> BorrowingStruct<'a> block has to introduce 'a — the struct definition requires it — but the fn name(&self) -> &str signature on its own carries no annotation at all. The compiler behaves here just as it did in the previous lesson: since &self is the single reference input, the third rule applies, and the returned &str is tied to self’s life. Because self is itself a reference to BorrowingStruct<'a>, the returned slice also stays, indirectly, within 'a‘s bounds — but this relationship is never written in the name method’s own signature, because elision already covers it.

This distinction shows the difference between where 'a is defined and where it is used. 'a has to appear in the struct’s definition and in the impl block’s header, because the field itself is tied to it. But if a method’s returned value is always reached through self, that method’s own signature behaves correctly without writing 'a again — the elision rule here works through a single input (self) and does not concern itself with 'a at all. The place where 'a would need to be written explicitly would be if a method’s returned reference were tied to a source other than self (for example, another reference given to the method as a separate parameter) — which means the previous lesson’s limiting measurement applies here too.

LI10. A struct definition carrying a lifetime parameter does not mean every method on that struct must also carry an explicit annotation. Elision rules operate on struct methods too; the parameter is needed only in the struct’s definition, for the field itself.

Limiting Measurement: A Struct Definition Cannot Be Written Without a Lifetime Parameter

The BorrowingStruct definition cannot be written without the <'a> parameter. A field written as content: &Trace, with no lifetime specified at all, is rejected by the compiler, because the struct definition nowhere says which source the reference is tied to or that the struct will not outlive that source. This is the struct-definition counterpart of the pattern seen in the previous two lessons: a reference cannot exist without its relationship being written into the source.

Elision rules do not come into play here at all — the three rules seen in the previous lesson were defined only for elided references in function signatures, not for struct fields. A struct’s field is neither an input nor an output; a struct is not a signature re-evaluated on every call, it is a data layout that stands on its own. There is no “single correct reading” the compiler could assume here either: if a field holds a reference, that reference’s source has to be outside the struct, and who that source is, is information only the person writing it can know.

The accepted form is the BorrowingStruct<'a> above — the <'a> parameter is added to the struct itself, the field becomes &'a Trace, and the compiler verifies this relationship: no instance of BorrowingStruct can outlive the data content points to. The measurement above stayed within this boundary — instance always ended before borrowed_data, never after. Suppose the instance value had been moved outside its own inner scope, returned, or assigned to a variable in the outer scope: that would not compile, because the reference the instance holds cannot outlive borrowed_data — the restriction is the struct-field form of the boundary run in the Borrow Checker lesson.

LI11. If a struct has a field carrying a reference, it cannot be defined without a lifetime parameter. Once the parameter is added and the relationship is written, the struct becomes usable, but no instance of that struct can outlive the data it refers to — the parameter does not remove this boundary, it only writes it into the source.

Two Designs, One Design Decision

The choice between OwningStruct and BorrowingStruct is not a question of correctness, it is a design decision. A moved field makes the struct independent of the data: once OwningStruct is built, it can be moved, returned, or stored for a long time, without caring whether the data lives anywhere else. The cost of this is a copy or a move — the data now belongs only to that struct.

A borrowed field, by contrast, makes sharing the data possible: the same Trace value can be used both in its own binding and read through BorrowingStruct, without producing a copy at all. The cost of this is that the struct’s own life becomes tied to the data — since BorrowingStruct cannot outlive its data, using it as a return value or storing it for a long time is generally more restricted. Which design to choose depends on how long the struct will live and whether the data will also be used elsewhere.

There is no third path between these two options — a field either owns or it borrows, there is no middle ground. A field can also have write access to the data it borrows (&'a mut Trace), but this still falls into the borrowing category: the struct still does not own the data, it only holds a reference carrying permission to modify it, and it is still subject to the same lifetime condition. The shared-ownership tools seen further on in the course (heap-allocated single ownership, reference counting) may look like a third path standing between these two options, but they are not — they too are, at bottom, a form of ownership, only how the ownership rule is applied differs. Holding a reference remains the one case where the data never passes into the struct.

Summary

  • If a struct’s field holds a reference, the struct definition must carry a lifetime parameter; a definition written without this parameter is rejected.
  • A value moved into a struct’s field ends together with the struct itself: its life ends where it was moved.
  • The data behind a reference held in a struct’s field ends after the struct itself — a struct can never outlive its data.
  • The lifetime parameter does not remove this boundary, it only writes it into the source; even once the parameter is added, a struct instance cannot outlive the data it refers to.
  • The choice between a moved field and a borrowed field is a design decision: one makes the struct independent of the data, the other makes sharing the data possible.
  • The lifetime parameter is required only in the struct’s definition; the methods of a struct carrying the parameter may need no annotation of their own, as long as elision rules apply.

Next Step

Throughout this course’s first two topics, a value either moved into a struct or was borrowed by one, and in both cases that value had a single owner — the owner of a moved field was the struct, the owner of borrowed data was its own scope; even in the borrowed case, ownership was never split, only shared temporarily. The next topic removes this assumption: can a value have more than one owner, and if so, what does “ending” mean? Beginning with heap-allocated single ownership, this topic first measures a single heap ownership that makes recursive types possible, then measures genuinely shared ownership through reference counting.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close