Lifetime safety in Clang — the borrow checker's first rule, retrofitted¶
Level: 301 · for C and C++ programmers
One line: Clang 23 warns when a pointer is used after the object it points at has died, naming the same three places rustc names for E0597 — because it runs the same rule, modelled on Rust's borrow checker — and it stops exactly where Rust's second rule, one writer or many readers, begins.
The analysis is Google's: an RFC to LLVM in May 2025 ↗ by Utkarsh Saxena, Dmytro Hrybenko, Yitzhak Mandelbaum, Jan Voung and Kinuko Yasuda, and the temporal-safety half of Mandelbaum's C++Now 2026 talk, A Path to Practically Safe C++ ↗. Clang's own documentation ↗ says the design is "inspired by Polonius, the Rust borrow checker".
The program¶
The talk's demo, with the includes and a main added:
#include <iostream>
#include <string>
#include <string_view>
void foo() {
std::string_view view;
{
std::string small = "small scoped string";
view = small;
}
std::cout << view;
}
int main() { foo(); }
view points into small's characters, small is destroyed at the closing brace, and view is read after it. A use-after-free through a string_view: AddressSanitizer reports stack-use-after-scope on any run that reaches the line, and -Wall -Wextra print nothing — on Apple's clang 21 and on Clang 23 alike.
What Clang 23 says¶
<source>:9:16: warning: local variable 'small' does not live long enough [-Wlifetime-safety-use-after-scope]
9 | view = small;
| ^~~~~
<source>:10:5: note: local variable 'small' is destroyed here
10 | }
| ^
<source>:11:18: note: later used here
11 | std::cout << view;
| ^~~~
1 warning generated.
What rustc says¶
The same program in Rust — let view: &str;, assigned &small inside a block and read after it:
error[E0597]: `small` does not live long enough
--> scoped_string.rs:5:16
|
4 | let small = String::from("small scoped string");
| ----- binding `small` declared here
5 | view = &small;
| ^^^^^^ borrowed value does not live long enough
6 | }
| - `small` dropped here while still borrowed
7 | println!("{view}");
| ---- borrow later used here
The same three places, in the same order — what the talk calls a 3-point diagnostic:
| The point | Clang 23 | rustc |
|---|---|---|
| where the pointer takes hold | local variable 'small' does not live long enough |
borrowed value does not live long enough |
| where the object dies | local variable 'small' is destroyed here |
`small` dropped here while still borrowed |
| where the pointer is read | later used here |
borrow later used here |
One word differs, at the front of the first line: warning against error.
The rule underneath¶
The talk builds the check from three facts, each computed at every point in a function:
- Invalidation — where an object ends: a destructor at a closing brace,
delete,unique_ptr::reset, apush_backthat may reallocate. - Points-to sets — for each pointer, every object it may point at here. Clang's RFC calls them origin sets holding loans, which is Polonius's vocabulary.
- Liveness — whether the pointer can still be read before something overwrites it.
A violation is all three at one point: an object is invalidated, a points-to set still contains it, and that set is live. Polonius ↗, the next formulation of Rust's borrow checker, states the same rule as Datalog in its reference implementation ↗:
loan_live_at(Loan, Point) :-
origin_contains_loan_on_entry(Origin, Loan, Point),
origin_live_on_entry(Origin, Point).
errors(Loan, Point) :-
loan_invalidated_at(Loan, Point),
loan_live_at(Loan, Point).
A loan is one borrow of an object, an origin is a points-to set, and origin_live_on_entry is liveness. The talk's slogan — a live points-to set should not contain an expired object — is rule 8 in English.
Liveness — the program both accept¶
The talk's second example:
#include <iostream>
#include <memory>
int foo() {
int* p;
{
std::unique_ptr<int> x = std::make_unique<int>(5);
p = x.get();
}
std::unique_ptr<int> y = std::make_unique<int>(42);
p = y.get();
std::cout << *p;
return 0;
}
int main() { return foo(); }
For three lines p holds the address of a freed int, and Clang 23 prints nothing: nothing reads p until it has been pointed at y. In the talk's words the first value is not alive, and the second assignment kills it.
rustc accepts the same shape — liveness() in the example further down — and reports what it saw, as a lint rather than an error:
warning: value assigned to `p` is never read
--> liveness_warning.rs:5:9
|
5 | p = &x;
| ^^^^^^ this value is reassigned later and never used
...
8 | p = &y;
| ------ `p` is overwritten here before the previous value is read
|
= note: `#[warn(unused_assignments)]` (part of `#[warn(unused)]`) on by default
That is non-lexical lifetimes: a borrow lasts to its last use, not to the end of the scope that declared the reference.
Points-to — a pointer that may hold either¶
The talk's third:
#include <memory>
int foo(int i, bool cond) {
int* p = &i;
if (cond) {
std::unique_ptr<int> x = std::make_unique<int>(5);
p = x.get();
} // p points to {i, x}
return *p;
}
int main(int argc, char**) { return foo(0, argc > 1); }
<source>:7:13: warning: local variable 'x' does not live long enough [-Wlifetime-safety-use-after-scope]
7 | p = x.get();
| ^
<source>:8:5: note: local variable 'x' is destroyed here
8 | } // p points to {i, x}
| ^
<source>:7:13: note: result of call to 'get' aliases the storage of local variable 'x'
7 | p = x.get();
| ^~~~~~~
<source>:9:13: note: later used here
9 | return *p;
| ^
1 warning generated.
After the if, the two paths merge and p's set is {i, x} — with x dead on one of them. Run with no arguments, this program never takes that path at all. Both checkers object anyway, because the rule is about what the set may hold:
error[E0597]: `x` does not live long enough
--> points_to_two.rs:5:13
|
4 | let x = Box::new(5);
| - binding `x` declared here
5 | p = &x;
| ^^ borrowed value does not live long enough
6 | } // p points to {i, x}
| - `x` dropped here while still borrowed
7 | *p
| -- borrow later used here
The fix is the same in both languages: declare x before the if, as either() does below.
Across a call — the contract in the signature¶
The check reads one function at a time — compositional rather than inter-procedural, in the talk's words — so a call is judged by the callee's declared contract, never by its body. In C++ the contract is [[clang::lifetimebound]]: the result may point into this parameter.
#include <iostream>
#include <string>
#include <string_view>
std::string_view first_word(std::string_view s [[clang::lifetimebound]]) {
return s.substr(0, s.find(' '));
}
int main() {
std::string_view w = first_word(std::string("hello world")); // temporary dies at ;
std::cout << w << '\n';
}
<source>:10:37: warning: object backing the pointer will be destroyed at the end of the full-expression [-Wdangling-gsl]
10 | std::string_view w = first_word(std::string("hello world")); // temporary dies at ;
| ^~~~~~~~~~~~~~~~~~~~~~~~~~
<source>:10:37: warning: temporary object does not live long enough [-Wlifetime-safety-use-after-scope]
10 | std::string_view w = first_word(std::string("hello world")); // temporary dies at ;
| ^~~~~~~~~~~~~~~~~~~~~~~~~~
<source>:10:63: note: temporary object is destroyed here
10 | std::string_view w = first_word(std::string("hello world")); // temporary dies at ;
| ^
<source>:10:26: note: result of call to 'first_word' aliases the storage of temporary object
10 | std::string_view w = first_word(std::string("hello world")); // temporary dies at ;
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
<source>:11:18: note: later used here
11 | std::cout << w << '\n';
| ^
2 warnings generated.
The first warning is Clang's older check, which sees one statement at a time; the second is the new one, following the result through the call because of the attribute. Leave the attribute off and the new check says nothing at the call — measured on Clang 23.1 with a two-argument longer(a, b), both declared-only and defined in the same file.
rustc refuses the same call:
error[E0716]: temporary value dropped while borrowed
--> temporary_string.rs:6:25
|
6 | let w = first_word(&String::from("hello world"));
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - temporary value is freed at the end of this statement
| |
| creates a temporary value which is freed while still in use
7 | println!("{w}");
| - borrow later used here
|
help: consider using a `let` binding to create a longer lived value
|
6 ~ let binding = String::from("hello world");
7 ~ let w = first_word(&binding);
|
fn first_word(s: &str) -> &str carries the contract the attribute states — lifetime elision wrote one 'a on both sides for you — and in Rust it is not optional: return a reference without saying where it points and the function itself is E0106. Checking each body on its own against its callees' signatures is how rustc has always worked; the talk's proposal is to get C++ there with annotations standing in for types.
The shapes both accept¶
Declare the owner where it outlives every use, and both checkers are satisfied:
fn either(cond: bool) -> i32 {
let i = 1;
let x = Box::new(5); // declared outside the `if`, so it outlives p
let mut p = &i;
if cond {
p = &x;
}
*p
}
Verified output of lifetime_safety_in_clang.rs — regenerated by tools/run_examples.py, never hand-typed.
Where they part — one writer or many readers¶
Two C++ programs, one line apart:
#include <cstdio>
#include <vector>
int main() {
std::vector<int> v = {1, 2, 3, 4};
int& first = v[0];
v.push_back(5); // may reallocate: 'first' now dangles
std::printf("%d\n", first);
}
With v[1] = 7; in place of the push_back, Clang 23 is silent. With the push_back:
<source>:6:18: warning: local variable 'v' is later invalidated [-Wlifetime-safety-invalidation]
6 | int& first = v[0];
| ^
<source>:7:7: note: local variable 'v' is invalidated here
7 | v.push_back(5); // may reallocate: 'first' now dangles
| ~~^~~~~~~~~~~~
<source>:8:25: note: later used here
8 | std::printf("%d\n", first);
| ^~~~~
1 warning generated.
rustc refuses both:
After first borrows v[0] … |
Clang 23 -Wlifetime-safety |
rustc |
|---|---|---|
v.push_back(5), then read first |
a warning, from a check Clang's docs mark experimental | E0502 |
v[1] = 7, then read first |
nothing | E0502 |
The second row is Rust's other rule. v[1] = 7 needs &mut v, and while first is live nothing else may touch v — whether or not the write could move the buffer:
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> write_while_borrowed.rs:4:5
|
3 | let first = &v[0];
| - immutable borrow occurs here
4 | v[1] = 7; // a write -- but not one that can move the buffer
| ^ mutable borrow occurs here
5 | println!("{first}");
| ----- immutable borrow later used here
|
= help: use `.split_at_mut(position)` to obtain two mutable non-overlapping sub-slices
That program is memory-safe in C++, and Rust refuses it anyway. What the refusal buys is not needing a list of dangerous operations. Clang has to know that push_back can reallocate and operator[] cannot; to rustc every &mut counts as an invalidation, so a method nobody has annotated is covered on the day it is written — and the same exclusivity, held across threads, is what rules out data races. Clang's docs name what the design leaves out, "exclusivity enforcement (alias-xor-mutability)", and the RFC lists "limited forms of exclusivity" as future work.
What the check is not¶
- A proof. Clang's docs call it "designed for bug finding, not verification". It is off unless you pass
-Wlifetime-safety— the docs suggest starting with-Wlifetime-safety-permissive— and at a call it cannot see into, it assumes nothing happened. - Aware of moves. A moved-from owner still counts as destroyed at its closing brace, so the
-movedwarnings in the strict group can flag a pointer whose object lives on under a new owner. The docs' advice is to take the pointer from the new owner, after the move. - In most compilers yet. Measured with the talk's program on Compiler Explorer: Clang 20.1 knows neither flag; 21.1 accepts
-Wexperimental-lifetime-safetyand prints nothing; 22.1 warns only once a hidden-Xclang -fexperimental-lifetime-safetyis added; 23.1.0 is the first release where-Wlifetime-safetyworks. Apple's clang 21, which the other pages in this section use, prints nothing even with the hidden flag. - Matched by Polonius on the Rust side — not yet. Stable
rustcruns NLL; Polonius is on nightly as-Zpolonius=next. Every program on this page gets the same verdict from both (rustc1.100.0-nightly, 2026-08-27). Where they differ is a program like NLL's problem case #3 ↗, a conditional return of a borrow:E0499on stable 1.98.0, accepted by-Zpolonius=next.
If you are coming from another language¶
- C++ — If you have fixed a
-Wlifetime-safetywarning, you can already readE0597: same three places, same order. Three things change. The check is an error you cannot switch off. Every signature carries its contract, so there is no unannotated function to be optimistic about. And exclusivity comes with it — thev[1] = 7row above, which is the part that feels wrong for the first fortnight, and the part that makes the rest checkable without a list of dangerous operations. - Python — A reference keeps its object alive, so nothing on this page can dangle for you; the whole analysis answers a question reference counting never has to ask. The one place Python enforces the second rule is a buffer that can move: a
bytearraywith a livememoryviewrefuses to grow, withBufferError: Existing exports of data: object cannot be re-sized(Python 3.14.7). That isE0502for one type, checked at run time. - ABAP — The runtime owns every object and every table line, so reading freed memory is not a failure ABAP lets you meet. What transfers is a habit you may already have: not holding a reference or field symbol into an internal table across a statement that changes that table. Rust turns the habit into the
E0502above, and checks it for you.
Practice¶
One contract, two spellings. Write longer(a, b), returning whichever string is longer, with the lifetimes it needs — and the C++ declaration Clang 23 needs in order to check its callers the same way. Call it with both strings alive; then move one into an inner block, use the result after the block, and predict the error. Finally tie the result to a alone, and find the call that now compiles.
Solution
lifetime_safety_in_clang_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: one contract, two spellings.
//!
//! rustc --edition 2024 lifetime_safety_in_clang_kata.rs -o /tmp/k && /tmp/k
// The result may point into `a` or into `b`, so both must outlive it.
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
// The result points into `a` only; `b` may die as soon as the call returns.
fn first<'a>(a: &'a str, _b: &str) -> &'a str {
a
}
fn main() {
println!("THE CONTRACT, TWO SPELLINGS");
println!(" Rust: fn longer<'a>(a: &'a str, b: &'a str) -> &'a str");
println!(" C++: std::string_view longer(std::string_view a [[clang::lifetimebound]],");
println!(" std::string_view b [[clang::lifetimebound]]);");
println!(" Both say the result may point into a or into b. Leave the lifetimes");
println!(" out and rustc refuses the function (E0106). Leave the attributes out");
println!(" and C++ compiles it, and -Wlifetime-safety assumes the result borrows");
println!(" from nothing -- so it stays silent at every caller.");
println!();
let a = String::from("borrow");
let b = String::from("checker");
println!("BOTH ARGUMENTS ALIVE");
println!(" longer(&a, &b) = {}", longer(&a, &b));
println!();
println!("ONE ARGUMENT DIES FIRST");
println!(" let r;");
println!(" {{ let b = String::from(\"checker\"); r = longer(&a, &b); }}");
println!(" println!(\"{{r}}\");");
println!(" -> E0597: `b` does not live long enough. Refused even on a call where");
println!(" the body would have returned `a`: the checker judges a call by the");
println!(" signature and never reads the body. That is the compositional");
println!(" analysis the talk proposes for C++, and how rustc has always worked.");
println!();
let r;
{
let short_lived = String::from("checker");
r = first(&a, &short_lived);
}
println!("THE RESULT TIED TO `a` ONLY");
println!(" first(&a, &short_lived) outlives the block: r = {r}");
println!(" In C++ that is [[clang::lifetimebound]] on `a` alone. Clang 23 checks");
println!(" that claim against the body too -- under -Wlifetime-safety-all, as a");
println!(" warning you opt into; rustc checks the body against 'a on every build.");
assert_eq!(longer(&a, &b), "checker");
assert_eq!(r, "borrow");
}
Verified output of lifetime_safety_in_clang_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
THE CONTRACT, TWO SPELLINGS
Rust: fn longer<'a>(a: &'a str, b: &'a str) -> &'a str
C++: std::string_view longer(std::string_view a [[clang::lifetimebound]],
std::string_view b [[clang::lifetimebound]]);
Both say the result may point into a or into b. Leave the lifetimes
out and rustc refuses the function (E0106). Leave the attributes out
and C++ compiles it, and -Wlifetime-safety assumes the result borrows
from nothing -- so it stays silent at every caller.
BOTH ARGUMENTS ALIVE
longer(&a, &b) = checker
ONE ARGUMENT DIES FIRST
let r;
{ let b = String::from("checker"); r = longer(&a, &b); }
println!("{r}");
-> E0597: `b` does not live long enough. Refused even on a call where
the body would have returned `a`: the checker judges a call by the
signature and never reads the body. That is the compositional
analysis the talk proposes for C++, and how rustc has always worked.
THE RESULT TIED TO `a` ONLY
first(&a, &short_lived) outlives the block: r = borrow
In C++ that is [[clang::lifetimebound]] on `a` alone. Clang 23 checks
that claim against the body too -- under -Wlifetime-safety-all, as a
warning you opt into; rustc checks the body against 'a on every build.
See also¶
- Use-after-free — the bug itself, and the C program that printed someone else's data
- Iterator invalidation —
push_backanderase, and theE0502they meet - Borrowing — the two rules, and why a borrow ends at its last use
- Lifetimes at the call site — a signature read as a contract
- Safe Buffers — the spatial half of the same talk
- Lifetime Safety Analysis ↗ — Clang's docs: every flag, the annotations, the limitations
- The talk's demo on Compiler Explorer ↗ — the same program with a
[[clang::lifetimebound]]function in the middle of the chain - C and C++ — the nine bugs, and the other reply
Po polsku¶
Ta strona dotyczy bezpieczeństwa czasowego (temporal safety): wskaźnika nie wolno użyć po tym, jak obiekt, na który wskazuje, przestał istnieć. Clang 23 ma analizę, która to sprawdza (-Wlifetime-safety), zbudowaną wprost na wzór rustowego borrow checkera (kontrolera pożyczeń). Dokumentacja Clanga mówi otwarcie, że inspiracją był Polonius.
Model opiera się na trzech pojęciach, które lepiej znać po angielsku, bo tak nazywają je i Clang, i Polonius. Pożyczka (loan) to wzięcie adresu obiektu. Zbiór możliwych celów wskaźnika (points-to set, u Clanga i w Poloniusie origin) to wszystkie pożyczki, które wskaźnik może w danym miejscu trzymać. Żywotność (liveness) mówi, czy wskaźnik zostanie jeszcze odczytany. Błąd jest wtedy, gdy w jednym punkcie programu obiekt przestaje istnieć, a żywy zbiór wciąż go zawiera — i to jest dokładnie reguła, którą rustc zgłasza jako E0597. Oba kompilatory wskazują nawet te same trzy miejsca: gdzie wzięto adres, gdzie obiekt zniknął i gdzie wskaźnik został użyty.
Różnica jest jedna, ale zasadnicza. Rust ma drugą regułę — wielu czytelników albo jeden piszący (aliasing XOR mutability) — więc odrzuca także v[1] = 7, gdy trzymasz referencję do v[0], choć w C++ ten zapis jest bezpieczny. Clang tej reguły nie ma i ostrzega tylko przy operacjach, o których wie, że unieważniają wskaźniki, jak push_back. Poza tym to ostrzeżenie, które trzeba włączyć, a nie błąd kompilacji.
Szukaj po polsku: kontroler pożyczeń · wiszący wskaźnik · czas życia obiektu · Clang lifetime safety · -Wlifetime-safety · Polonius borrow checker · rust E0597