Jump to content

From C to Rust: Lifetimes — Compile-Time Garbage Collection

From JOHNWICK
Revision as of 17:59, 23 November 2025 by PC (talk | contribs) (Created page with "Note: This post builds on concepts from From C to Rust: ownership. If you haven’t read that yet, start there to understand Rust’s ownership system, which forms the foundation for lifetimes. 500px In the previous post on ownership, we saw how Rust prevents use-after-free and double-free bugs by tracking which variable owns each heap allocation. The owner is responsible for cleanup, borrowers can temporarily access the data, an...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Note: This post builds on concepts from From C to Rust: ownership. If you haven’t read that yet, start there to understand Rust’s ownership system, which forms the foundation for lifetimes.

In the previous post on ownership, we saw how Rust prevents use-after-free and double-free bugs by tracking which variable owns each heap allocation. The owner is responsible for cleanup, borrowers can temporarily access the data, and the compiler enforces that borrowers don’t outlive owners. But ownership alone doesn’t solve everything. Consider this question: How long is a borrowed reference actually valid?

The Missing Piece: Temporal Relationships

Ownership tells you who is responsible for memory, but it doesn’t fully specify when references become invalid. In C, you already reason about this:

void process_data(Buffer* buf) {

   char* ptr = buf->data;
   buffer_resize(buf, new_size); // Might reallocate
   printf("%s", ptr); // Is ptr still valid?

}

You know ptr might be dangling after the resize, but the compiler doesn’t. The ownership post showed how Rust tracks who owns buf, but it didn’t explain how Rust knows that ptr must not outlive buf’s data. This is where lifetimes enter the picture.

Lifetimes: Expiration Dates on References

Think of lifetimes as compile-time labels that track how long a reference remains valid. Not a runtime value, but metadata the compiler uses to verify your program never uses a reference after it expires. Every reference in Rust has an associated lifetime. Most of the time, the compiler infers these automatically, but when it can’t, you write them explicitly using labels like 'a, 'b, etc. Let’s use hypothetical annotated C to illustrate the concept:

// Hypothetical annotated C char<'a>* get_substring(char<'a>* source, int start) {

   return source + start;

}

This signature says: “I take a pointer valid for lifetime 'a, and I return a pointer also valid for lifetime 'a.” The returned pointer can’t outlive the input. The compiler verifies any code using the return value respects this constraint. How Lifetimes Nest Lifetimes follow scope boundaries:

An inner scope 'b cannot outlive an outer scope 'a. Any reference with lifetime 'b automatically becomes invalid when 'b ends, even if the outer scope 'a continues. This catches stack-based dangling pointers:

char<'local>* get_user_name() {
    char<'local> name[64];
    strcpy(name, "Alice");
    return name; // Compiler error: 'local lifetime ends at '}'
}

The compiler knows name has lifetime 'local (the function scope). Returning it means returning a pointer whose lifetime ends when the function returns—caught immediately.

References That Can’t Outlive Their Referent

The most common lifetime relationship ties a reference to the data it points to:

// Hypothetical annotated C
char<'b>* buffer_get_slice(Buffer<'b>* buf, size_t start) {
    return buf->data + start;
}

Both the buffer and the returned slice have lifetime 'b. The slice borrows from the buffer’s data, so it cannot outlive the buffer.

If you try to mutate the buffer while a slice exists, the compiler detects the conflict:

Buffer buf = buffer_new(); char<'buf>* slice = buffer_get_slice(&buf, 0, 10); buffer_resize(&buf, 1024); // Error: can't mutate while borrowed printf("%s", slice);

This is Rust’s borrow checker in action. The compiler sees that slice holds a reference with lifetime 'buf, so operations that could invalidate that reference are rejected.

Multiple Lifetimes

Real programs have multiple objects with different lifetimes interacting. A parser holding both input data and temporary buffers needs two lifetime parameters:

typedef struct {
    char<'input>* source;
    char<'scratch>* buffer;
    size_t pos;
} Parser<'input, 'scratch>;
     
char<'input>* parse_token(Parser<'input, 'scratch>* parser) {
    // Can return pointer into source (lifetime 'input)
    return parser->source + parser->pos;
}
The parser borrows from two sources with independent lifetimes. When returning a token, the compiler enforces you can only return references with lifetime 'input, not the temporary 'scratch lifetime.
Trying to return temporary data as if it were permanent fails at compile time:
char<'input>* parse_token(Parser<'input, 'scratch>* parser) {
    // Error: returning 'scratch pointer as 'input
    return parser->buffer;
}

The Library Card Analogy

Think of lifetimes like a library system. When you check out a book (allocate memory), you get a library card with an expiration date. You can make photocopies of pages (create references), but those copies don’t extend your card’s validity. When your card expires (lifetime ends), all your copies become invalid too. If you lend your book to a friend (pass a reference to a function), their temporary access can’t last longer than your original checkout period. They can’t give the book to someone else who keeps it after you’ve returned it.

This is what lifetime annotations enforce: a strict hierarchy of validity periods where nothing can outlive its source.

Static Lifetime: The Exception

Some data genuinely lives forever. String literals, global constants, and static variables have lifetime 'static. These can be safely returned from any function and stored anywhere because they never become invalid:

char<'static>* get_error_message() {

   return "Something went wrong"; // String literal lives forever

}

A 'static lifetime can satisfy any other lifetime requirement because it outlives everything.

Lifetime Elision: Inferring the Obvious

Writing lifetime annotations everywhere would be tedious. When there’s only one input reference, any output reference clearly derives from it. The compiler doesn’t need you to spell it out:

// These two are equivalent: char* first_word(char* s); char<'a>* first_word(char<'a>* s);

But with multiple inputs of different lifetimes, you must be explicit:

// Ambiguous without annotations: char* longer_string(char* s1, char* s2);

// Clear with annotations: char<'a>* longer_string(char<'a>* s1, char<'a>* s2);

This says both inputs must have the same minimum lifetime 'a, and the output is valid for that lifetime. If you pass strings with different actual lifetimes, the compiler picks the shorter as 'a, ensuring safety.

Zero Runtime Cost

Lifetime tracking has zero runtime cost. These annotations exist only at compile time. The generated machine code is identical to regular C. You’re not adding reference counting, garbage collection, or dynamic checks.

All complexity lives at compile time. The runtime pays nothing for the safety guarantees:

The cost is in complexity. You must think explicitly about something previously handled implicitly. You must write annotations when the compiler can’t infer them. You may need to restructure code when lifetimes don’t work out. But in exchange, entire bug categories become impossible: use-after-free, dangling pointers, iterator invalidation, data races from unsynchronized mutation during iteration. All caught at compile time by simple, deterministic checks.

When Lifetimes Are Insufficient

Sometimes single ownership and borrowed references aren’t flexible enough. You might need shared ownership where multiple parts of the program hold references to the same data, and the data lives until the last reference goes away. This is where reference counting (Rc, Arc) enters the picture. But that’s a topic for another article. The key insight: lifetimes solve the common case — data owned by a single entity, with temporary borrows that don’t outlive the owner. This covers the vast majority of systems programming patterns. When you need something more complex, you can opt into it explicitly, paying runtime cost only where necessary.

Lifetimes as Compile-Time Garbage Collection

Rust’s lifetimes formalize the mental model experienced C programmers already use. The difference is that Rust makes these relationships explicit and verifiable.

Think of lifetimes as compile-time garbage collection. Instead of tracking references at runtime and cleaning up when the count reaches zero, you prove to the compiler at compile time that references never outlive their referents. Memory management still happens exactly where you specify — the compiler just ensures you didn’t make mistakes about when it’s safe to access what. Combined with ownership (covered in the previous post), lifetimes complete Rust’s memory safety story:

  • Ownership determines who is responsible for cleanup
  • Lifetimes ensure borrows don’t outlive the borrowed data
  • The borrow checker enforces both at compile time with zero runtime overhead

For C programmers used to unlimited freedom with pointers, this feels restrictive at first. But that freedom included the freedom to shoot yourself in the foot. Lifetimes take away just enough freedom to make your code dramatically safer, while keeping the zero-cost abstraction promise that drew us to systems programming in the first place.


Read the full article here: https://medium.com/rustaceans/from-c-to-rust-lifetimes-compile-time-garbage-collection-84528750473e