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 0c130cda66d7 from 9 February 2026. The incubator was frozen on 20 February 2026. LifetimeCheck remained an incubator-only experiment and was not transferred to llvm-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}
An rvalue-reference call after CIRGen (simplified)

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 (!s32i for 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&& parameter.

  • std::move itself does not remain as a cir.call; CIRGen erases it as a cast and passes the address of value directly.

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::move is 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 to std::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() invokes checkOperators() only for overloaded operators. None of the four names above reaches the helper in the frozen snapshot. get(), reset(), and release() are ordinary methods. operator bool is a conversion function, for which isOverloadedOperator() is also false. It therefore follows the ordinary const-Owner path. Of the operations discussed below, only operator* and operator-> enter checkOperators(), and neither name is accepted by the helper. release() is, in addition, a std::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.

C++ smart-pointer behavior and LifetimeCheck dispatch at the article’s baseline
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

C++ state and checker state for a moved-from smart pointer
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 initialization int b(std::move(a)) reaches it as a cir.load followed by a cir.store, with no call to std::move. An ordinary copy int b = a has 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.

Store heuristic in 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
Step-by-step use-after-move intent warning for a scalar value

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.

Sutter, Herb. 2019. Lifetime safety: Preventing common dangling.” {C++ Standards Committee Paper} P1179R1. ISO/IEC JTC1/SC22/WG21. https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1179r1.pdf.