C++ Programming

Is This the Same const Object? A C++ Compile-Time Check

Ever wondered if two const references point to the same object at compile time? Dive into a C++ metaprogramming trick using constexpr to find out.

A

Alex Petrov

C++ enthusiast and systems programmer passionate about compile-time magic and performance optimization.

6 min read13 views

In C++, the const keyword is a promise of immutability. When you receive a const&, you trust that you won't—and can't—change the underlying object. But what if you have two such references? Can you tell if they’re pointing to the exact same object in memory? At runtime, it's trivial: a simple pointer comparison does the trick. But what if I told you we could answer this question before the program even runs?

Welcome to the fascinating world of C++ compile-time programming, where we can ask the compiler to perform checks and optimizations that seem like they should only be possible at runtime. Today, we're going to build a tool that does exactly that: a compile-time check to verify if two references point to the same const object.

The Problem: Runtime vs. Compile-Time Knowledge

Let's start with the basics. If you have two pointers or references at runtime, checking if they refer to the same object is straightforward.

#include <iostream>

struct MyConfig { int id; };

void runtime_check(const MyConfig& a, const MyConfig& b) {
    if (&a == &b) {
        std::cout << "They are the same object!\n";
    } else {
        std::cout << "They are different objects.\n";
    }
}

int main() {
    MyConfig c1{1};
    MyConfig c2{2};

    runtime_check(c1, c1); // Prints "They are the same object!"
    runtime_check(c1, c2); // Prints "They are different objects."
}

This works perfectly. The & operator gets the memory address of the object, and we compare those addresses. Simple.

But this is a runtime check. The comparison happens when the runtime_check function is executed. We can't use this result to, say, enable a specific optimization using if constexpr, because the addresses of local variables like c1 and c2 are not known at compile time. The compiler knows they will exist on the stack, but their exact memory addresses are determined much later.

So, is a compile-time check impossible? Not quite. We just need to find objects whose addresses are known at compile time.

The Key Insight: Static Storage Duration

The C++ standard guarantees that the addresses of objects with static storage duration are constant expressions. These are the unsung heroes of compile-time constants. What kind of objects fit this description?

  • Global variables: Variables declared outside any function.
  • Static member variables: Variables declared with static inside a class or struct.
  • Static local variables: Variables declared with static inside a function.

If we have a pointer or reference to one of these objects, its address is fixed and known at link time. This is the piece of information we can leverage to perform a check during compilation.

Building Our Compile-Time Checker

Advertisement

To build our tool, we'll combine two powerful modern C++ features: constexpr functions and non-type template parameters.

  1. constexpr: This keyword allows a function to be executed at compile time, provided its inputs are also compile-time constants.
  2. Non-Type Template Parameters (NTTPs): Since C++17, we can use const auto& as a template parameter. This allows us to pass a reference to a static object directly as a template argument.

Let's put them together. The result is surprisingly simple and elegant.

// C++17 or later is required for `const auto&` NTTP

template <const auto& Lhs, const auto& Rhs>
constexpr bool is_same_object() {
    // This address comparison happens at compile time!
    return &Lhs == &Rhs;
}

That's it! That's the entire function. It takes two references to objects with static storage duration as template arguments. Inside the function, it compares their addresses. Because the function is constexpr and the inputs are compile-time constants, the entire comparison is resolved by the compiler, yielding a simple true or false.

Putting It to the Test

Now, let's see how we can use this. We'll pair our new function with if constexpr, which uses a compile-time boolean to conditionally compile one branch of code and discard the other.

#include <iostream>

// Our compile-time checker
template <const auto& Lhs, const auto& Rhs>
constexpr bool is_same_object() {
    return &Lhs == &Rhs;
}

// Some global const objects (static storage duration)
namespace Configs {
    inline constexpr struct { const char* name = "Default"; } Default{};
    inline constexpr struct { const char* name = "HighPerf"; } HighPerf{};
}

// A function that takes a reference to some configuration
template <const auto& CurrentConfig>
void process_with_config() {
    if constexpr (is_same_object<CurrentConfig, Configs::Default>()) {
        // This code path is chosen only if CurrentConfig is Configs::Default
        std::cout << "Processing with the well-known Default config. Applying special optimizations.\n";
    } else if constexpr (is_same_object<CurrentConfig, Configs::HighPerf>()) {
        std::cout << "Processing with High-Performance config. Enabling all cores!\n";
    } else {
        // Generic fallback path
        std::cout << "Processing with a custom configuration.\n";
    }
}

int main() {
    // These function calls resolve to different code at compile time!
    process_with_config<Configs::Default>();
    process_with_config<Configs::HighPerf>();
}

When you compile and run this, you'll see:

Processing with the well-known Default config. Applying special optimizations.
Processing with High-Performance config. Enabling all cores!

The beauty here is that there's no runtime if/else branching. The compiler generates two distinct versions of the `process_with_config` function body, one for each configuration. The unused branches are completely eliminated from the final executable.

Limitations and Considerations

This technique is powerful, but it's not a silver bullet. You have to be aware of its limitations.

Static Storage Duration is a Must

The most important rule is that this only works for objects with static storage duration. If you try to use it with a local variable, you'll get a compile error because its address is not a constant expression.

void this_will_fail() {
    static const int my_static_int = 10;
    const int my_local_int = 20;

    // This is OK! my_static_int has a fixed address.
    constexpr bool check1 = is_same_object<my_static_int, my_static_int>();

    // This will FAIL to compile!
    // error: '&my_local_int' is not a valid template argument because 'my_local_int' is not a variable with static storage duration
    constexpr bool check2 = is_same_object<my_local_int, my_local_int>(); 
}

Linkage Matters

For an object to be usable as a `const auto&` non-type template parameter, it must have external linkage. This means it must be visible across different translation units (source files).

  • Using inline constexpr for globals (as in the example) gives them external linkage and is the modern, preferred approach.
  • Declaring a variable extern const also works.
  • A variable declared `static` at global scope has internal linkage, making it invisible to other files and thus ineligible for this specific template technique.

C++17 is Required

The ability to use auto as a non-type template parameter was introduced in C++17. While similar tricks were possible in older C++ versions (often involving passing pointers instead of references), the `const auto&` syntax is by far the cleanest and most expressive way to achieve this.

Conclusion: A Powerful Tool for Your Metaprogramming Arsenal

So, is this the same const object? As we've seen, C++ gives us the tools to answer that question at compile time, opening the door for powerful, zero-cost abstractions and optimizations.

By combining `constexpr` with C++17's `const auto&` non-type template parameters, we can create a simple, readable check that operates on objects with static storage duration. It's a perfect example of how modern C++ empowers developers to shift logic from runtime to compile time, resulting in faster, more specialized, and often safer code.

The next time you're designing a system with well-known constant objects, remember this trick. You might just be able to give your compiler the knowledge it needs to build something truly special.

Tags

You May Also Like