Advanced C++

How to Verify const Variable Identity at C++ Compile-Time

Tired of subtle runtime bugs? Learn how to verify that two C++ const variables are the exact same object, not just equal in value, all at compile-time.

A

Alex Petrov

Senior C++ developer specializing in systems programming, metaprogramming, and modern C++ idioms.

7 min read13 views

Are They Really the Same? Verifying `const` Identity at C++ Compile-Time

In the world of C++, the `==` operator tells us if two things have the same value. But what if you need to know if they are the exact same object? This is the difference between value and identity, and confusing the two can lead to subtle, hair-pulling bugs. Fortunately, modern C++ gives us powerful tools to enforce identity checks not at runtime, but before our code even finishes compiling.

Why Verify `const` Identity at Compile-Time?

Imagine you have two identical-looking keys. They both have the same shape, cuts, and size. They are equal in value. But one opens your front door, and the other opens your office. They don't have the same identity. Using the wrong one has consequences.

In C++, this happens all the time. You might have a library that defines a specific global configuration object or an error code:

// my_library/errors.h
namespace my_lib {
  inline const ErrorCode OK = {0, "Success"};
}

Your code might rely on getting a reference to this exact `OK` object. Another part of the codebase could mistakenly define its own version:

// some_other_module/utils.h
const ErrorCode local_OK = {0, "Success"};

A runtime check `if (errorCode == my_lib::OK)` would pass, but your program's logic might be subtly broken because it's not the canonical object the library expects. By verifying the object's identity at compile-time, we can turn this potential runtime heisenbug into a clear, immediate compilation error. It's about creating self-documenting, robust APIs that are impossible to misuse.

The Core Problem: Value vs. Identity

The issue often stems from how C++ handles constants across different translation units (i.e., different `.cpp` files). With the introduction of `inline` variables in C++17, it became much easier to define constants in headers. However, this can create distinct objects in memory.

Consider this setup:

// constants.h
#pragma once

// A global constant
inline const int GLOBAL_ID = 100;

namespace Config {
  // A namespaced constant
  inline const int TIMEOUT_MS = 5000;
}

Now, let's say two different files include this header and use the constant.

// module_a.cpp
#include "constants.h"

const int* get_global_id_addr_a() {
  return &GLOBAL_ID; // Address of GLOBAL_ID in this translation unit
}

// module_b.cpp
#include "constants.h"

const int* get_global_id_addr_b() {
  return &GLOBAL_ID; // Address of GLOBAL_ID in this translation unit
}

While `GLOBAL_ID` always has the value `100`, the addresses returned by `get_global_id_addr_a()` and `get_global_id_addr_b()` might be different! The One Definition Rule (ODR) for `inline` variables states that while the definition can appear in multiple translation units, all definitions must be identical, and the program behaves as if there is only one. Linkers are good at merging these identical constants (a process called COMDAT folding), but it's not a guarantee you can rely on for program correctness.

The goal is to write code that can definitively prove, at compile-time, that we are always referring to the single, canonical instance of a variable.

The Classic (and Limited) Approach: Runtime Pointers

Advertisement

The most straightforward way to check for identity is to compare addresses. If two pointers point to the same address, they refer to the same object.

void check_config(const ConfigObject& cfg) {
  if (&cfg == &my_lib::DEFAULT_CONFIG) {
    // It's the default config object!
  } else {
    // It's a custom config object.
  }
}

This works perfectly fine, but it's a runtime check. The check only happens when `check_config` is called. If an incorrect object is passed, the error is only discovered when you run the program (and hopefully, your tests cover that specific path). We can do better.

The Modern C++ Solution: Non-Type Template Parameters

The magic key to unlocking compile-time identity verification is a feature enhanced in C++20: passing references to global objects as non-type template parameters (NTTPs).

Before C++20, you could only use pointers and references to objects with external linkage as NTTPs. C++20 relaxed this, allowing references to objects with internal linkage as well, which makes `inline` variables fair game.

Here's how it works. We can create a template that accepts the objects themselves by reference:

#include <type_traits>

// The magic happens here!
template<const auto& Lhs, const auto& Rhs>
consteval bool is_same_object() {
    // This comparison is done by the compiler.
    return &Lhs == &Rhs;
}

// Let's define two constants
namespace Constants {
    inline const int Alpha = 1;
    inline const int Beta = 2;
}

The template parameter `const auto& Lhs` captures a reference to a specific, unique object at compile-time. Because the function is `consteval`, it *must* be evaluated by the compiler. Inside, we can take the addresses of the referenced objects and compare them.

We can then use `static_assert` to enforce our identity contracts:

// This will compile successfully
static_assert(is_same_object<Constants::Alpha, Constants::Alpha>(), "Error: Expected the same object.");

// This will trigger a compile-time error, as intended!
// static_assert(is_same_object<Constants::Alpha, Constants::Beta>(), "Error: Expected the same object.");
// Compiler output: 
// error: static assertion failed: Error: Expected the same object.

This is incredibly powerful. We've moved a check from runtime to compile-time, eliminating a class of bugs entirely.

A Practical Example: A Type-Safe State Machine

Let's apply this to a more realistic scenario. Imagine a simple state machine where transitions are controlled by functions. We want to ensure that only the predefined state objects can be used.

// states.h
#include <string>

struct State {
    int id;
    std::string name;

    // States must be non-movable and non-copyable to have a stable identity
    State(const State&) = delete;
    State& operator=(const State&) = delete;
};

namespace States {
    inline const State IDLE{0, "Idle"};
    inline const State RUNNING{1, "Running"};
    inline const State STOPPED{2, "Stopped"};
}

// Our verification helper from before
template<const auto& Lhs, const auto& Rhs>
consteval bool is_same_object() { return &Lhs == &Rhs; }

Now, we can create a template function that performs an action, but only if it's being used with a specific, known state object.

#include "states.h"
#include <iostream>

// A generic function to enter a state
template<const auto& TargetState>
void enter_state() {
    // Enforce that we can only enter the RUNNING state with this function
    static_assert(is_same_object<TargetState, States::RUNNING>(),
                  "This function can only be used to enter the RUNNING state.");

    std::cout << "Entering state: " << TargetState.name << std::endl;
}

int main() {
    // This is valid and will compile
    enter_state<States::RUNNING>();

    // This will cause a compile-time error!
    // enter_state<States::IDLE>();
    /*
    * Compiler error:
    * error: static assertion failed: "This function can only be used to enter the RUNNING state."
    * static_assert(is_same_object<TargetState, States::RUNNING>(), ...
    */
}

Without this compile-time check, we would need a runtime `if` or `switch` inside `enter_state` to validate the state, adding overhead and clutter. The `static_assert` makes the function's contract part of the compilation process.

Method Comparison: Runtime vs. Compile-Time

Here's a quick breakdown of the different approaches:

MethodWhen It RunsError TypeC++ VersionBest For
Runtime Pointer Check (`&a == &b`)RuntimeLogic error, crash, or exceptionC++98+Dynamic situations where the objects aren't known until the program is running.
`static_assert` with NTTPsCompile-TimeCompilation errorC++20+Enforcing API contracts with global/static constants and building fundamentally safer systems.
`if consteval` CheckCompile-Time or RuntimeCompilation or runtime errorC++20+Writing a single function that can perform the check in either context.

Putting It All Together: A Reusable Verification Helper

Our `is_same_object()` function is great, but for a library, we can make it even more idiomatic using a `std::bool_constant` wrapper. This makes it play nicely with other template metaprogramming utilities.

#include <type_traits>

// C++20 struct-based helper
template<const auto& Lhs, const auto& Rhs>
struct is_same_instance : std::bool_constant<&Lhs == &Rhs> {};

// C++20 variable template for convenience
template<const auto& Lhs, const auto& Rhs>
inlined constexpr bool is_same_instance_v = is_same_instance<Lhs, Rhs>::value;

This is clean, reusable, and follows standard library conventions. Your `static_assert`s become even more readable:

static_assert(is_same_instance_v<States::RUNNING, States::RUNNING>);

static_assert(!is_same_instance_v<States::RUNNING, States::IDLE>);

Limitations and Considerations

While powerful, this technique has a few key requirements:

  1. Static Storage Duration: The objects you pass as template arguments must have static storage duration (e.g., global variables, `static` member variables) or thread-local storage duration. You cannot pass a reference to a local variable on the stack.
  2. Linkage: The objects must have external or internal linkage. `inline` variables work perfectly.
  3. Linker Shenanigans: Be aware that linkers can sometimes merge identical constants to save space (COMDAT folding). This could, in theory, cause `&obj1 == &obj2` to be true for two `inline` variables defined in different translation units that you intended to be different. However, the `static_assert` using NTTPs is about enforcing which *variable name* you're allowed to use in the source code, which generally protects you from this ambiguity in practice.

Key Takeaways

Moving checks from runtime to compile-time is a hallmark of modern C++ development. For verifying object identity, remember:

  • Value is not Identity: `a == b` doesn't mean `&a == &b`. Relying on value equality when identity is required is a source of bugs.
  • Use C++20 NTTPs: Passing object references via `const auto&` template parameters is the key to performing identity checks at compile-time.
  • Enforce Contracts with `static_assert`: Use `static_assert` to make identity requirements a non-negotiable part of your API, providing immediate feedback to developers.
  • Build Safer Systems: This technique is perfect for state machines, configuration management, and any system using canonical objects where correctness is critical.

Next time you find yourself writing a runtime pointer comparison for a global constant, ask yourself: can the compiler do this for me? With modern C++, the answer is very likely yes.

Tags

You May Also Like