Ch25.4: Declarations with Sharp Edges

中文(简体)

1. explicit(bool): Conditional Explicitness

A constructor can be explicit or not, and the choice is usually made once for the type. C++20 allows the choice to depend on a compile-time condition:

#include <type_traits>

template <typename T>
struct box
{
    T value;

    explicit(std::is_integral_v<T>) box(T v) : value{v} {}
};

The expression in explicit(...) must be a constant expression of type bool. When it is true, the constructor is explicit; when it is false, it is converting. The usual use is a wrapper that should accept implicit conversions for some element types but not for others:

box<double> d = 1.0;   // OK: explicit(false)
box<int> i = 1;        // error: explicit(true)
box<int> j{1};         // OK: direct initialization

This is not a runtime check. The condition is evaluated during template instantiation, and the resulting constructor is either explicit or not. It is useful for generic wrappers, strong typedefs and unit types where the conversion policy depends on the payload.

Do not use explicit(bool) to encode a runtime policy. If the decision depends on a value, it is not a conversion rule; write a named factory function instead.

2. [[no_unique_address]]: Empty Members

An empty class still has a size of at least one byte, because every object needs a distinct address. A member of an empty type normally costs that byte plus padding:

struct empty {};

struct without
{
    empty e;
    int x;
};

struct with
{
    [[no_unique_address]] empty e;
    int x;
};

[[no_unique_address]] tells the compiler that the member does not need a unique address, so it may overlap with other members. On the implementations used in this book, sizeof(without) is usually 8 and sizeof(with) is usually 4. The standard permits the overlap; it does not require a specific size.

static_assert(sizeof(with) <= sizeof(without));

This is how a stateless allocator, comparator or deleter can be stored in a container without growing it. The attribute is a layout permission, not a guarantee: the compiler may still give the member an address if it needs one, and the layout can differ between ABIs.

Do not apply it to a member whose address identity matters. If code takes &obj.e and compares it with another object's address, overlapping storage can break the assumption. Use it for policy objects that are genuinely stateless.

3. alignas and alignof

alignof(T) asks for the alignment requirement of a type. alignas(N) requests a stricter alignment for an object or a type:

#include <cstddef>

struct alignas(64) cache_line
{
    std::byte data[64];
};

static_assert(alignof(cache_line) == 64);
static_assert(sizeof(cache_line) == 64);

alignas can increase alignment; it cannot reduce it below the type's natural requirement. A type with alignas(64) is 64-byte aligned wherever it is used, and the compiler inserts padding in arrays and in containing structs to preserve that.

Alignment is not the same as size. A 64-byte-aligned object can be smaller or larger than 64 bytes; the alignment only fixes the starting address. Over-aligning every object wastes memory; use it for cache-line separation, SIMD, memory-mapped I/O or a documented ABI, not as a general performance hint.

The alignment of a type is also why a union's alignment is the strictest alignment among its members, and why the order of members in a struct can change its size. Layout is a first-class part of the ABI.

4. offsetof

offsetof(type, member) gives the byte offset of a member from the start of a standard-layout object:

#include <cstddef>

struct record
{
    int id;
    double value;
};

static_assert(offsetof(record, id) == 0);
static_assert(offsetof(record, value) >= sizeof(int));

offsetof is only defined for standard-layout types. A type with virtual functions, virtual bases, private data members in a non-standard-layout arrangement, or multiple access sections may not be usable. It is a compile-time macro, not a runtime reflection call; it does not know about a particular object.

C++26 reflection provides std::meta::offset_of (Ch24.3) for the same question over a reflection. The macro is still the portable answer for a fixed, standard-layout struct.

Offsets are ABI-dependent. A static assertion on an offset documents the layout your code expects; it does not make the layout portable. If the offset must match a wire format, use a serialization layer with explicit widths and endianness, not a raw struct.

5. volatile Is Not Synchronization

volatile tells the compiler that every access to the object is observable and must not be optimized away or reordered with respect to other volatile accesses. It does not provide atomicity, mutual exclusion or inter-thread ordering.

volatile std::uint32_t *mmio = reinterpret_cast<volatile std::uint32_t *>(0x40000000u);
*mmio = 1;

That is the legitimate use: memory-mapped I/O, a hardware register, or a variable modified by a signal handler. In those cases the compiler must actually perform the load or store.

This is the illegitimate use:

volatile int counter{};
++counter; // still a data race if another thread does the same

volatile does not make ++ atomic. Two threads can still read the same value, increment it and write it back. Use std::atomic<int> for inter-thread communication, and use a mutex when the protected state is more than one word. volatile is for the hardware and the signal handler, not for the scheduler.

6. A Few More Sharp Edges

Declaration What it actually means
constexpr member function Callable in a constant expression; it does not make the object immutable
mutable member Modifiable even in a const member function; use for caches and mutexes
static member function No this; it cannot access non-static members without an object
explicit conversion operator Requires a cast or a contextual conversion; it blocks implicit conversions
[[nodiscard]] A warning if the return value is ignored; it does not change the type
[[fallthrough]] Documents an intentional switch fallthrough; it does not change control flow

The common thread is that a declaration can carry a rule about initialization, layout, linkage, conversion or diagnostics. Read the rule, not the keyword's reputation. A constexpr function is not automatically faster; an inline variable is not automatically inlined; volatile is not atomic.

Key Takeaways

That closes the fine-print chapter. The features here are not new language directions; they are the rules that were already in the language but rarely got a page of their own.