🔴 🟡 🟢Exploring CWG Defect Reports (Part 1)
← Back to Posts

Exploring CWG Defect Reports (Part 1)

One of the many (certainly not the main!) takeaways from Ben Deane's CppNow 2026 talk was, in the words of Lewis Carroll's White Queen, to imagine six impossible things before breakfast. In sort of that spirit, I've started poking around more to understand what kinds of defects and issues in the C++ standard have come up and what the resolutions have entailed. Below, I explain what I learned about indirection and UB, casts, and lvalue-to-rvalue conversions specifically through CWG defect report numbers 232, 453, 242, and 446, and also indirectly (ha) through 315, 2823, 2875, 342, 1102, and 86.

232: Is indirection through a null pointer undefined behavior?

Previously, the standard conflicted itself about whether *nullptr was UB. Some sections like then-intro.execution paragraph 4 said it was, and then-dcl.ref paragraph 4 said that this UB was reason to not have "null references". However, the definition of unary * didn't mention it (and didn't explicitly say that if the operand is nullptr, this is UB). typeid(*p) even gave null dereference well-defined semantics (saying it should throw bad_typeid). Clearly this was an inconsistency that needed to be cleaned up.

From my understanding reading through the history of this discussion on the CWG page, this basically boiled down to an important semantic difference. Should the process of forming an lvalue from a nullptr be UB, or should only reading the value be undefined behavior? In the following example that Bill Gibbons gave, if you dereference then immediately take the address without accessing the memory, should that be UB?

char *p = 0; char *q = &*p;

Or should the following pointer arithmetic idiom of dereferencing a pointer to no object be UB if the value isn't used?

char a[10]; char *b = &a[10]; // equivalent to "char *b = &*(a+10);"

Tom Plum agreed by saying the standard's intent was that UB should trigger at the point of lvalue-to-rvalue conversion, so forming an lvalue from a dereference without reading should be fine. But Mike Miller here then asked what it meant to have created a "null lvalue": if null lvalues are legal, then null references are also legal, because references are just named lvalues. This undermines the fundamental C++ guarantee that references must always refer to a valid object!

Obviously having inconsistencies in the standard is a pretty big issue, and even more so when it's about core behavior like dereferencing null pointers. Compilers like GCC or clang use "dereferencing implies non-null" as an optimization assumption (allowing code elimination by assuming p != null after any dereference). Many optimizations also depend on null references not being a thing. If the above one-past-end iterator idiom was UB, then most existing iterator/range semantics would be broken (in fact, the later "safer" ways to iterate like std::span::end(), std::vector::end(), range-for still use one-past-end pointers internally. The increased safety comes from wrapping that pointer to prevent end-users from misusing it).

In October 2004, the committee tried to resolve this by inventing a new concept: empty lvalues that refer to no object. The idea is something like:

  • dereferencing null / one-past-end produces "empty lvalue"
  • taking the address of empty lvalue is fine (gives back original pointer)
  • lvalue-to-rvalue conversion of an empty lvalue is UB
  • binding a reference to an empty lvalue is UB

This would've meant that the above two examples were legal and typeid kept its behavior definition. But it created too many other edge cases to deal with. Here's my attempt at summarizing what I could find:

Even though *(a + N) would give an empty lvalue (and thus be allowed), that's not the only kind of expression or usage that would need addressing. Would *(a + N + 5) or *(a + N) = 5 be allowed? This would require a revisiting of everything in sizeof, decltype, member access, etc. to clarify empty lvalue behavior. Would sizeof(*p) where p is null also produce an empty lvalue or not? There's some line that would have to be drawn between "unevaluated" and "evaluated but produces empty lvalue". Tedious!

Similarly, what would happen to static member access through null pointers (CWG 315)? I.e. is ap->f() AKA (*ap).f() UB when ap is null and f() is static? Originally CWG said this was fine because .f() on a static member doesn't read from it. But this meant empty lvalues needed to propagate through member access expressions: UB would arise if f was non-static. This is an extremely hairy thing to add!

How would the typeid(*null) exception work? typeid would receive an empty lvalue and detect its emptiness to be able to throw bad_typeid. So runtime would have to be able to distinguish empty from normal lvalues, which is bad for the separation of concerns between compile- and run-time. This abstraction incoherence would just be terrible design.

constexpr expression evaluation would also get really annoying. The compiler would have to track whether every lvalue was empty and reject anything that did lvalue-to-rvalue conversions on them. I think this is technically doable, but just needlessly annoying and hard to deal with because of this policy.

So for the above reasons (and probably more), in November 2023 CWG scrapped this empty lvalue thing. Not only was this an extremely far-reaching resolution to adopt, but consensus was that this was an EWG-level language design problem and not a CWG-level wording fix. The discussion moved to CWG 2823, where dereferencing an invalid pointer was specifically made UB. The side effects here were that

  • CWG 315 was retroactively flipped: static member calls through nullptrs were now UB
  • &*p where p is null is also now UB, diverging from C (documented in CWG 2875, which was accepted November 2025 and is now in DRaft Working Paper (DRWP) status for possibly C++29 or later)
  • &a[N] for an array of size N is now UB in C++ but defined in C. This divergence is also documented in CWG 2875.

Also, though I've been saying "dereference", CWG 342 standardized the language to "indirection" over "dereference" to be more aligned with C's terminology, and actually through CWG 1102, the standard replaced "dereferencing the null pointer" with "effect of attempting to modify a const object" as the canonical example of UB.

Wow! It's quite interesting to track the history of a defect report that pointed to an existential intent question that still has non-solidified standards ramifications 26 years later! (Indeed, I'm writing this on June 6, 2026, almost exactly 26 years after CWG 232 was submitted on June 5, 2000.)

453: References may only bind to valid objects

CWG 453 spent years trying to pin down a definition of "valid object" for reference binding: this defect report basically points out that the standard says "a reference shall be initialized to refer to a valid object or function" but never defines "valid object". Is f1 a valid object if the constructor receives f1 as a reference without full initialization? The March 2024 resolution was to replace "valid object" with specific rules:

  • references can bind to objects outside their lifetime (e.g. during construction)
  • binding through a type-incompatible glvalue (like *reinterpret_cast<int*>(&char_obj)) is UB
  • evaluating a reference before its initialization is UB
  • specifying that the object designated by such a glvalue can also be outside its lifetime.

Had empty lvalues been adopted as from #232, binding references to empty lvalues would need to be separately addressed (this is different from "object outside its lifetime"!)

242: Interpretation of old-style casts

expr.cast deals with how a C-style cast (t)(expr) is supposed to be handled. The following interpretations are to be tried in order, and we should use the first one that applies (you can see this implemented in clang):

const_cast
static_cast
static_cast + const_cast
reinterpret_cast
reinterpret_cast + const_cast

(Just to review, static_cast is for well-defined compatible conversions, const_cast is for adding/removing cv qualifiers, and reinterpret_cast is for low-level bit-pattern reinterpretation. Their safety is roughly descending in that order, because static_cast is compile-time checked, const_cast can result in UB if used incorrectly, and reinterpret_cast circumvents type-safety completely. See also this)

The intent here is that if we have something like this example from the defect report:

struct A {};
struct I1 : A {};
struct I2 : A {};
struct D : I1, I2 {};
A *foo( D *p ) {
	return (A*)( p ); // ill-formed static_cast interpretation
}

trying to do the derived-to-base pointer conversion (A*)(p) should be one of the conversions that can be performed using static_cast. This cast is ill-formed because of ambiguity (which A should the pointer convert to, the one offset in I1 or the one offset in I2?), and the compiler should error -- and crucially, not fall through to any of the other (more dangerous) types of casts.

DR 242 points out that the description of static_cast in expr.static.cast implied something that didn't match the intent. Specifically, the line

An expression e can be explicitly converted to a type T using a static_cast of the form static_cast<T>(e) if the declaration "T t(e);" is well-formed, for some invented temporary variable t.

seems to imply that A* t(p) is ill-formed because of ambiguity, and therefore (A*)(p) is NOT one of the conversions that can be done using static_cast, and would fall through to reinterpret_cast<A*>(p) which is well-formed via expr.reinterpret.cast.

The CD4 status of this DR means this wording was clarified in C++17. Along with the above example of ambiguous upcasts, the standard also revised the wording for ambiguous downcasts, virtual base downcasts, reference casts, and pointer-to-member casts. Ambiguous downcasts, reference casts, and pointer-to-member casts are can't be handled by static_cast for similar reasons to ambiguous upcasts. Virtual base downcasts have offsets that are looked up through vtables at runtime, so static_cast can't handle them at compile time.

446: Does an lvalue-to-rvalue conversion on the "?" operator produce a temporary?

Consider the following:

struct B {
	int v;
	B (int v) : v(v) { }
	void inc () { ++ v; }
};
struct D : B {
	D (int v) : B(v) { }
};

B b1(42);
(0 ? B(13) : b1).inc();
assert(b1.v == 42); // should this pass?

What exactly does that conditional 0 ? B(13) : b1 do? Does it result in an lvalue referring to b1 itself (which would mean .inc() modifies b1, and the assert fails), or a temporary copy (rvalue) of b1 (where .inc() modifies a temporary, b1 itself is untouched, and the assert passes)?

Well, the second operand B(13) is a prvalue (temporary), and the third operand b1 is an lvalue. The standard requires the conditional expression to have a single type and value category regardless of the branch (for more funny examples of this, you should check out Jonathan Muller's CppNow 2026 lightning talk when it comes out). So since one operand is an rvalue, the overall expression must be an rvalue, and b1 must undergo lvalue-to-rvalue conversion: the value is "read out" of b1.

Yeah, that's fine and all for scalar types like int where an original and a copy wouldn't have any difference, but what about class types? Should the lvalue-to-rvalue conversion call the copy constructor to create a temporary? This results in e.g. address identity changes. Or should this conversion happen by "reinterpreting" the lvalue as an rvalue without copying? Then the .inc() would modify the original object through what is supposed to be an rvalue, which is weird because end users expect rvalues to be disposable. The distinction also mattered for e.g. taking a const reference to extend lifetime -- this would have different semantics depending on if it was acting on a copy or not.

This DR points out that the standard's wording at the time was ambiguous: it said this conversion for classes "produces the value contained in the object" but didn't explicitly mandate a copy/temporary. Consider the following test cases:

#include <cassert>
struct B {
    int v;
    B (int v) : v(v) { }
    void inc () { ++ v; }
};
struct D : B {
    D (int v) : B(v) { }
};
int main () {
    B b1(42);
    D d1(42);
    (0 ? B(13) : b1).inc();
    assert(b1.v == 42);
    (0 ? D(13) : b1).inc();
    assert(b1.v == 42);
    (0 ? B(13) : d1).inc();
    assert(d1.v == 42);
}

The core idea should ideally be something like cond ? a : b should have one type and one value category regardless of the runtime branch. So if either branch is a temporary, the whole expression should be a temporary. Otherwise the principle that value categories should be a static property of expressions is violated. In the first case, the B(13) is a prvalue -> expression should be an rvalue -> b1 should be untouched and the assert should pass. In the second case, D(13) converts to B, and the assert also passes due to same reasoning as the first case. In the third case, the D object gets sliced down to a B temporary, and then again the same value category reasoning applies and the assert should pass.

When this was posted to the Usenet clc++m group in 2003, different providers (GCC, MSVC, SUN...) showed different results on whether they copied or made a temporary. Clearly this ambiguity had resulted in diverging handling. Some compilers just reinterpreted the expression as an rvalue without copying, which was an issue for mental models of value categories, as we saw above.

In March 2004 it was decided that all ? operators returning a class rvalue should copy the second or third operand to a temporary. This also resolved CWG 86.