This article continues from Building the LifetimeCheck Pass. The previous part defined the state model from the lifetime safety profile (Sutter 2019). Here we focus on the semantic information that makes this model useful for C++: AST attributes, smart pointer recognition, and move operations.
Post-freeze note (added 26 July 2026).
This article is dated 11 February 2026 and describes the LifetimeCheck implementation at ClangIR incubator commit
0c130cda66d7from 9 February 2026. The incubator was frozen on 20 February 2026. LifetimeCheck remained an incubator-only experiment and was not transferred tollvm-project. Observations made by reviewing the frozen project after that date are explicitly marked as post-freeze notes. Where a source example uses a standard-library owner, read it as C++ motivation unless the text ties it to an annotated test-local stand-in: the frozen classifier did not infer Owner status from the system-library type alone.
Leveraging AST semantics
ClangIR operations carry AST attributes that preserve semantic
information. The LifetimeCheck pass uses these through the AST attribute
interfaces defined in ASTAttrInterfaces.td.
This is one of the most important parts of the example: semantic
questions are wrapped in named interface methods instead of being
scattered as raw AST operations throughout the pass.
To understand how this works, let’s see what CIR looks like for a simple program:
C++ source:
1void consume(int &&);
2
3void test() {
4 int value = 42;
5 consume(
6 std::move(value));
7}
ClangIR (simplified):
1cir.func @test() {
2 %value = cir.alloca !s32i
3 loc("test.cpp":4:3)
4 %c42 = cir.const #cir.int<42> : !s32i
5 loc("test.cpp":4:15)
6 cir.store %c42, %value
7 loc("test.cpp":4:3)
8 cir.call @consume(%value)
9 loc("test.cpp":5:3)
10 cir.return
11}
Key observations:
Each CIR operation carries an MLIR location. CIRGen normally provides a concrete source location, although MLIR also permits unknown and compound locations.
Types are preserved (
!s32ifor the scalar value).Operations are in SSA form (
%value,%c42, etc.).The call remains explicit, and its callee can be resolved to the AST function attribute that records the
int&¶meter.std::moveitself does not remain as acir.call; CIRGen erases it as a cast and passes the address ofvaluedirectly.
Post-freeze note (added 26 July 2026).
A later review corrected an earlier schematic listing that showed a fictitious
cir.call @move. In both the article’s baseline and the frozen tree,std::moveis represented by the value category of its operand and is erased during CIRGen. The pass recognizes move intent here from the resolved callee’s rvalue-reference parameter, not from a call tostd::move.
Detecting move intent in calls
To detect if a function parameter is an rvalue reference (T&&), indicating a potential move, the pass uses the following implementation. This demonstrates the key pattern of type categorization in move semantics. The pass handles Owner, Pointer, and Value types differently because they have distinct moved-from semantics:
1void LifetimeCheckPass::checkArgForRValueRef(
2 CallOp callOp, unsigned argIdx,
3 ASTFunctionDeclInterface funcDecl) {
4 // Use interface method instead of direct AST access
5 if (!funcDecl.isParamRValueReference(argIdx))
6 return;
7
8 auto arg = callOp.getArgOperand(argIdx);
9
10 // LoadOp means by-value, not a move - check for moved-from value
11 if (auto loadOp = arg.getDefiningOp<cir::LoadOp>()) {
12 auto srcAddr = loadOp.getAddr();
13 if (isValueTypeMovedFrom(srcAddr)) {
14 checkPointerDeref(srcAddr, callOp.getLoc());
15 }
16 return;
17 }
18
19 // Handle move semantics for address arguments
20 mlir::Value addr = arg;
21 if (!addr.getDefiningOp<cir::AllocaOp>() || !getPmap().count(addr))
22 return;
23
24 // Type categorization: Owner vs Pointer vs Value
25 // Each category has different moved-from semantics
26
27 if (owners.count(addr)) {
28 // Owner types carrying [[gsl::Owner]]
29 if (getPmap()[addr].count(State::getInvalid()) ||
30 getPmap()[addr].count(State::getNullPtr())) {
31 checkPointerDeref(addr, callOp.getLoc());
32 return;
33 }
34 if (!isSkippableTemporary(addr))
35 markOwnerAsMovedFrom(addr, callOp.getLoc());
36 return;
37 }
38
39 if (ptrs.count(addr)) {
40 // Pointer types (raw pointers, references, iterators)
41 if (getPmap()[addr].count(State::getInvalid())) {
42 checkPointerDeref(addr, callOp.getLoc());
43 return;
44 }
45 if (!isSkippableTemporary(addr))
46 markPointerOrValueTypeAsMovedFrom(addr, callOp.getLoc());
47 return;
48 }
49
50 // Value types (primitives, structs without pointer semantics)
51 if (getPmap()[addr].count(State::getInvalid())) {
52 checkPointerDeref(addr, callOp.getLoc());
53 return;
54 }
55 if (!isSkippableTemporary(addr))
56 markPointerOrValueTypeAsMovedFrom(addr, callOp.getLoc());
57}
The Pointer branch describes a conservative implementation. It is not
the same rule as ordinary raw-pointer copy. In C++, the copy preserves
the alias; the frozen checker records the source pointer one hop deep
(pmap[q] = {p}) rather than copying pmap[p].
The rvalue-reference path above instead marks the tracked
Pointer-category address moved-from after the invalid-state check.
The interface method isParamRValueReference is defined in the
AST attribute interface TableGen file:
1InterfaceMethod<"", "bool", "isParamRValueReference",
2 (ins "unsigned":$paramIdx), [{}],
3 /*defaultImplementation=*/ [{
4 if (paramIdx >= $_attr.getAst()->getNumParams())
5 return false;
6 auto *param = $_attr.getAst()->getParamDecl(paramIdx);
7 return param->getType()->isRValueReferenceType();
8 }]
9>
This approach is better than scattering direct AST access throughout the pass because:
Interface methods provide a local API for the analysis.
They handle null checks and edge cases.
They are reusable across multiple passes.
They clearly document what AST information is being accessed.
Smart pointer detection
Standard library smart pointers have special semantics: after a move, they are guaranteed to be null (unlike general owner types). The pass uses an AST interface from the implementation to detect them:
1// In ASTAttrInterfaces.td
2InterfaceMethod<"", "bool", "isSmartPointerOwner", (ins), [{}],
3 /*defaultImplementation=*/ [{
4 if (!$_attr.getAst()->getDeclContext()->isStdNamespace())
5 return false;
6 llvm::StringRef name = $_attr.getAst()->getName();
7 return name == "unique_ptr" || name == "shared_ptr";
8 }]
9>
Usage in the pass:
1bool isSmartPointer = false;
2if (auto recordTy = mlir::dyn_cast<cir::RecordType>(type)) {
3 if (auto astAttr = recordTy.getAst()) {
4 isSmartPointer = astAttr.isSmartPointerOwner();
5 }
6}
7
8if (isSmartPointer) {
9 // Overloaded operators receive smart-pointer-specific handling.
10 // Ordinary methods follow the usual Owner method dispatch.
11}
Smart pointer special handling
Smart pointers (std::unique_ptr,
std::shared_ptr) have a well-defined empty state after
move. Therefore, we have to distinguish an operation on the
smart-pointer object from a dereference of its stored pointer. The
implementation contains the following name-based helper:
1bool LifetimeCheckPass::isSmartPointerSafeMethod(
2 llvm::StringRef methodName) {
3 return methodName == "get" ||
4 methodName == "release" ||
5 methodName == "reset" ||
6 methodName == "operator bool";
7}
Post-freeze note (added 26 July 2026; corrected 27 July 2026).
A review of the call dispatch at the article’s baseline shows that this list must not be read as a complete safe-method dispatch table.
checkCall()invokescheckOperators()only for overloaded operators. None of the four names above reaches the helper in the frozen snapshot.get(),reset(), andrelease()are ordinary methods.operator boolis a conversion function, for whichisOverloadedOperator()is also false. It therefore follows the ordinary const-Owner path. Of the operations discussed below, onlyoperator*andoperator->entercheckOperators(), and neither name is accepted by the helper.release()is, in addition, astd::unique_ptr-only operation. In the checker examples below, the standard-library names denote the annotated test-local stand-ins used by the incubator tests.
The relevant dispatch is as follows:
1if (methodDecl.isOverloadedOperator())
2 return checkOperators(callOp, methodDecl);
3
4// For ordinary methods, a non-const use follows the Owner rule.
5if (auto owner = getNonConstUseOfOwner(callOp, methodDecl))
6 return checkNonConstUseOfOwner(owner, callOp.getLoc());
The actual operator path handles dereferencing operators. For the
standard-library smart-pointer operations discussed here, only operator*
and operator->
reach this code, and neither name matches the safe-method helper:
1void LifetimeCheckPass::checkOperators(
2 CallOp callOp, ASTCXXMethodDeclInterface m) {
3 auto addr = getThisParamOwnerCategory(callOp);
4 if (!addr)
5 return;
6
7 if (isSmartPointerType(addr.getType(), IsSmartPointerTyCache)) {
8 std::string methodName = m.getDeclName().getAsString();
9 if (isSmartPointerSafeMethod(methodName))
10 return;
11 checkPointerDeref(addr, callOp.getLoc());
12 return;
13 }
14
15 // ... handling for other Owner types ...
16}
Consider the following code:
1std::unique_ptr<int> ptr = std::make_unique<int>(42);
2std::unique_ptr<int> ptr2 = std::move(ptr);
3if (ptr) { // C++: false; checker: no warning
4 // ...
5}
6ptr.reset(); // C++: empty; no checker warning
7int *p = ptr.get(); // C++: nullptr; no checker warning
8int value = *ptr; // ERROR: non-null pointer required
The relevant standard clauses are as follows:
For
std::unique_ptr:[unique.ptr.single.observers].For
std::shared_ptr:[util.smartptr.shared.obs].
In both cases, dereferencing through operator*
or operator->
requires a non-null stored pointer.
| Operation | C++ behavior after move | LifetimeCheck behavior |
|---|---|---|
get() |
Returns the stored pointer, which is null. | Takes the ordinary const-method path. It produces no warning and does not clear the invalid pmap state. |
release() |
unique_ptr only; returns the
stored pointer and leaves the object empty. For an already empty object
it returns null. |
Takes the ordinary non-const Owner path, not the safe-method helper. It produces no warning and does not restore the moved-from pmap state. |
reset() |
Destroys the currently owned object, if any, and leaves the smart pointer empty. | Takes the ordinary non-const Owner path. It produces no warning, while the source remains invalid in the pmap. |
reset(p) |
Makes the smart pointer own
p; dereferencing a non-null p is valid. |
Reinitialization is not modeled. The invalid pmap state is not cleared, so a subsequent dereference is still diagnosed. |
operator bool |
Returns false for the empty moved-from object. | Takes the ordinary const-Owner path because a conversion function is not an overloaded operator. It produces no warning and does not change the invalid pmap state. |
operator*,
operator-> |
Require a non-null stored pointer. | Reach the operator checker and produce an invalid-pointer diagnostic for the moved-from source. |
Unlike reset(), reset(new int(7))
makes ptr non-empty in C++. The
checker does not clear invalid from pmap[ptr],
so it still diagnoses a subsequent dereference.
Detecting use-after-move
| Event | C++ state of the source | LifetimeCheck pmap state |
|---|---|---|
| Before the move | The smart pointer may own an object. | The Owner entry refers to its current owned generation. |
q = std::move(p) |
p becomes empty and
q receives the old ownership. |
p is marked invalid;
q has its own tracked Owner entry. |
p.reset() |
p is empty. |
The ordinary non-const Owner path runs,
but the invalid state of p is not cleared. |
p.reset(new T) |
p becomes non-empty and may
be dereferenced. |
The invalid state is not cleared because this reinitialization is not modeled. |
Observe with get() or
operator bool |
The operation observes the empty state without dereferencing it. | No diagnostic is produced and the pmap state is unchanged. |
Dereference with operator* or
operator-> |
The operation requires a non-null stored pointer. | The invalid state causes a diagnostic. |
One of the pass’s key responsibilities is detecting use-after-move bugs. Let’s trace through the algorithm.
Marking values as moved-from
The frozen pass also has a store heuristic for Value-category
objects. When a StoreOp receives
data from a LoadOp, the helper may
mark the loaded source as invalid:
Post-freeze note (added 26 July 2026).
A later audit found that
checkMovedFromValue()does not test an AST move marker or an xvalue flag. The initializationint b(std::move(a))reaches it as acir.loadfollowed by acir.store, with no call tostd::move. An ordinary copyint b = ahas the same shape. Thus the heuristic catches the incubator’s move-initialization test, but it cannot distinguish that test from an ordinary Value copy at this snapshot.
1void LifetimeCheckPass::checkMovedFromValue(StoreOp storeOp) {
2 auto loadOp = storeOp.getValue().getDefiningOp<cir::LoadOp>();
3 if (!loadOp)
4 return;
5
6 auto srcAddr = loadOp.getAddr();
7
8 // 1. Check if source is already moved-from (use-after-move!)
9 if (isValueTypeMovedFrom(srcAddr)) {
10 checkPointerDeref(srcAddr, storeOp.getLoc());
11 return;
12 }
13
14 // 2. Track a Value-to-Value initialization.
15 auto destAddr = storeOp.getAddr();
16 auto allocaOp = destAddr.getDefiningOp<cir::AllocaOp>();
17 if (!allocaOp)
18 return;
19
20 if (isValueType(destAddr) && getPmap().count(srcAddr) &&
21 isValueType(srcAddr)) {
22 if (!hasInvalidState(srcAddr) && !loadOp.getIsDeref()) {
23 // No explicit test here distinguishes std::move from a copy.
24 markPointerOrValueTypeAsMovedFrom(srcAddr, storeOp.getLoc());
25 }
26 }
27}
Helper methods make the logic clear:
1bool LifetimeCheckPass::isValueType(mlir::Value addr) {
2 return !owners.count(addr) && !ptrs.count(addr);
3}
4
5bool LifetimeCheckPass::hasInvalidState(mlir::Value addr) {
6 return getPmap().count(addr) &&
7 getPmap()[addr].count(State::getInvalid());
8}
9
10bool LifetimeCheckPass::isValueTypeMovedFrom(mlir::Value addr) {
11 return isValueType(addr) && hasInvalidState(addr);
12}
Note the use of semantic helper methods with descriptive names. This pattern keeps conditionals readable and avoids complex inline expressions.
checkMovedFromValue()The flowchart in Figure 2 shows two
roles of checkMovedFromValue(). It diagnoses an
already-invalid source, and otherwise marks a Value source invalid
during a qualifying Value-to-Value initialization. The control flow does
not prove that the source program used std::move.
Use-after-move detection algorithm
When loading from a moved-from value:
1void LifetimeCheckPass::checkLoad(LoadOp loadOp) {
2 auto addr = loadOp.getAddr();
3 if (!getPmap().count(addr))
4 return;
5
6 // For pointer types, only check on dereference
7 if (ptrs.count(addr)) {
8 if (!loadOp.getIsDeref())
9 return;
10 checkPointerDeref(addr, loadOp.getLoc());
11 return;
12 }
13
14 // For value types, check if moved-from
15 if (isValueTypeMovedFrom(addr)) {
16 checkPointerDeref(addr, loadOp.getLoc());
17 return;
18 }
19}
The checkPointerDeref method
emits a diagnostic:
1void LifetimeCheckPass::checkPointerDeref(mlir::Value addr,
2 mlir::Location loc,
3 DerefStyle derefStyle) {
4 // ... validation checks ...
5
6 auto varName = getVarNameFromValue(addr);
7 auto D = emitWarning(loc);
8
9 bool isValueType = this->isValueType(addr);
10
11 if (isValueType)
12 D << "use of moved-from value '" << varName << "'";
13 else
14 D << "use of invalid pointer '" << varName << "'";
15
16 // Emit history showing where it became invalid
17 emitInvalidHistory(D, addr, loc, derefStyle);
18}
Example diagnostic output:
1warning: use of moved-from value 'x'
2 int value = x;
3 ^
4note: moved here via std::move or rvalue reference
5 auto y = std::move(x);
6 ^
For scalar values such as int, this
diagnostic should be read as an intent warning rather than a claim about
undefined behaviour. A move from a scalar is a copy, so the source
object remains valid. The genuine invalid operation in the smart-pointer
examples above is different: dereferencing an empty std::unique_ptr or
std::shared_ptr
violates the non-null precondition.
| Step | C++ code | LifetimeCheck actions |
|---|---|---|
| 1 | int x = 42; |
AllocaOp: Create
x |
StoreOp: Initialize
x |
||
| pmap[x] = {x}, category: Value | ||
| 2 | int y = std::move(x); |
CIRGen erases the std::move
cast; there is no move call |
LoadOp: Load from
x |
||
StoreOp: Store to
y |
||
Heuristic: Mark x as
moved-from |
||
invalid \(\in\) pmap[x],
pmap[y] = {y} |
||
| 3 | int z = x; |
LoadOp: Attempt to load
from x |
| Check: isValueTypeMovedFrom(x)? YES | ||
| DIAGNOSTIC: use of moved-from value | ||
| Note: moved at step 2 via std::move or rvalue reference |
Conclusion
Use-after-move detection needs both sides of ClangIR. AST attributes identify C++ call semantics, while the LifetimeCheck state model records which value has become invalid. The store-only Value heuristic is less precise because it lacks an explicit move marker. The next article, Control Flow, Scope, and Advanced Lifetime Cases, extends the same model through memory operations, structured control flow, scopes, coroutines, and returned-lambda safety.
Discussion
Register with a username and password to join the discussion.