Creating a C++ Compatibility Layer for Future Standard Features: Best Practices and Trade-offs
When developing in a constrained environment—such as a codebase stuck on C++17 while desiring modern features from C++20 (like std::span) or C++23 (like std::expected)—developers often look for ways to bridge the standard library gap. A common approach is introducing a "compat" or polyfill namespace that aliases third-party implementations on older standards and switches to std:: when the compiler is upgraded.
Is this compatibility header design a good idea, or does it add unnecessary complexity? Here is a breakdown of the benefits, architectural pitfalls, and recommended best practices.
The Short Answer: Yes, But With Caveats
Creating a compat layer is a standard industry practice. Major projects like Chromium, LLVM, WebKit, and Abseil use compatibility shims to backport standard library types. Doing this allows developers to write future-proof code without having to perform massive codebase-wide refactors when the toolchain eventually upgrades.
However, implementing it naively can introduce subtle bugs, ODR (One Definition Rule) violations, and ABI mismatches. To make it work reliably, certain architectural details must be addressed.
Crucial Improvements for a C++ Compat Layer
1. Rely on Feature-Test Macros, Not Just __has_include
Using __has_include(<expected>) alone can be fragile. A compiler’s standard library may contain the <expected> header file, but its contents might be disabled or gated behind a -std=c++23 flag. If compiled with -std=c++17, the header might include nothing or produce a compiler error.
Instead, use standard C++ Feature-Test Macros (defined in <version> since C++20):
#pragma once
#if __has_include(<version>)
#include <version>
#endif
#if defined(__cpp_lib_expected) && __cpp_lib_expected >= 202202L
#include <expected>
#define COMPAT_HAS_STD_EXPECTED 1
#else
#include <tl/expected.hpp>
#define COMPAT_HAS_STD_EXPECTED 0
#endif
namespace compat {
#if COMPAT_HAS_STD_EXPECTED
using std::expected;
using std::unexpected;
using std::unexpect_t;
using std::unexpect;
#else
using tl::expected;
using tl::unexpected;
using tl::unexpect_t;
using tl::unexpect;
#endif
} // namespace compat
2. Bring Along Auxiliary Types and Helpers
Types like std::expected or std::span rarely exist in isolation. For example, std::expected relies on std::unexpected and tag types like std::unexpect_t. Similarly, std::span relies on std::dynamic_extent.
If only the primary class template is aliased, consuming code will quickly run into missing support symbols when attempting real-world error handling or generic programming.
3. Be Mindful of API Divergences
Proposal implementations (such as Sy Brand's tl::expected or Martin Moene's span-lite) often precede the finalized ISO standard. As standards evolve through ISO committee meetings, naming conventions and member functions can change (e.g., .value() vs. .error() semantics, Monadic operations, or explicit constructors).
Ensure your chosen third-party fallback is strictly updated to match the final published ISO specification to prevent breaking changes during future standard upgrades.
4. Beware of ABI and Cross-Boundary Leakage
If your project outputs a library (static or shared) consumed by other projects, exposing compat::expected in public headers can cause ABI mismatches:
- If Consumer A builds in C++17 mode,
compat::expectedistl::expected. - If Consumer B builds in C++23 mode,
compat::expectedisstd::expected.
Passing these across module boundaries will lead to linker errors or undefined behavior. If your compatibility layer is internal to a single application binary, this is not an issue.
Is a Compat Layer Better Than Direct Inclusion?
Consider the two primary alternatives:
- Approach A (Direct Inclusion): Use
tl::expectedeverywhere directly. When upgrading to C++23, perform a global search-and-replace tostd::expected. - Approach B (Compat Layer): Use
compat::expectedeverywhere. When upgrading, simply adjust the compatibility header (or keep it as a no-op shim).
The compat approach is generally superior for active, multi-developer codebases. It serves as self-documenting intent that the project intends to use standard semantics, and it isolates polyfill logic to a single directory rather than scattering third-party namespace references across hundreds of files.
Summary
Building a compat header setup is well worth the effort if you follow these rules:
- Use standard feature-test macros (
__cpp_lib_*) from<version>. - Alias all corresponding tag types and helper functions into the namespace.
- Use well-maintained, standard-conforming polyfill libraries (such as TartanLlama or Microsoft GSL).
- Keep polyfilled types internal to your module/binary boundaries to prevent ABI issues.