This poster explores how the philosophy behind C++26 standard library hardening can be practically backported to earlier standards. By providing thin, carefully designed wrappers around standard library types, we can enforce explicit runtime precondition checks at the application level, catching common Undefined Behavior pitfalls during development and bridging the safety gap for environments that cannot yet migrate to modern C++.
Safety-critical industries rely on pre-C++26 standards. C++17 is the newest popular standard in automotive — C++26 hardening tools remain inaccessible to legacy codebases.
The STL prioritizes performance over safety. operator* on an empty optional, wrong variant access, or out-of-bounds operator[] — all lead to silent UB.
Many safety-critical industries heavily rely on pre-C++26 standards to power their foundational software. In the automotive industry, for instance, C++17 is currently considered the newest popular standard in widespread use, as the adoption of new language standards typically lags behind by years, if not decades, due to strict certification and tooling requirements. While the upcoming C++26 standard introduces standard library hardening mechanisms that would benefit these exact industries, these tools currently remain inaccessible to legacy codebases.
Historically, the Standard Template Library (STL) has prioritized raw
performance over runtime safety checks, leaving developers to navigate a
minefield of Undefined Behavior (UB) pitfalls. Common vocabulary types like
std::optional, std::variant, and std::span—along with everyday containers
like std::vector and std::string_view—contain deliberate, unchecked
operations. Simple actions like using operator* on an empty optional,
accessing the wrong variant alternative, or an out-of-bounds access via
operator[] can lead to silent data corruption, security vulnerabilities, or
catastrophic crashes.
#include <iostream>
#include <string>
// Returns disengaged optional if user is not found.
std::optional<int> find_user_age(const std::string& name);
void greet(const std::string& name) {
auto age = find_user_age(name);
// BUG: developer forgot to check has_value().
// Compiles without warning — Undefined Behavior at runtime.
std::cout << "Happy birthday #" << *age << ", " << name << "!\n";
}
In pre-C++26 environments, this code compiles cleanly and may appear to work
during casual testing if the lookup happens to succeed. When it doesn't, *age
reads from an empty std::optional — invoking Undefined Behavior that can
manifest as garbage output, a crash, or worse: silent data corruption that
propagates undetected through the system.
C++26 introduces the concept of hardened preconditions via P3471 ("Standard
Library Hardening"). This is not a new set of safe types or a parallel safe
API. Instead, it mandates that standard library implementations must check
certain function preconditions at runtime and terminate the program when they
are violated. For example, a C++26-conforming implementation of
std::optional::operator* must verify that the optional is engaged before
dereferencing, and abort if it is not. The key distinction is that this is an
implementation-level requirement: the standard specifies what must be
checked, and the vendor's standard library (libc++, libstdc++, MSVC STL) is
responsible for how those checks are enforced.
This means C++26 hardening cannot be adopted simply by bumping a compiler flag—it requires a conforming standard library implementation (honorable mention: some STL implementations offer this flag already, but this is not widely available). For organizations locked to older toolchains, that conforming library is simply not available. The wrapper approach presented here fills exactly this gap: it applies the same philosophy of mandatory precondition checks, but does so at the user level, independent of the underlying standard library implementation.
Aggregation, not inheritance — wrap the STL type to prevent implicit conversion back to the unsafe base class.
Single type alias — template <typename T> using optional = safe::optional<T>; propagates hardening architecture-wide.
Precondition checks — intercept UB-prone operations (operator*, operator->) and assert before forwarding.
The fundamental principle of this approach is to create a drop-in replacement
wrapper class that aggregates the underlying STL type while exposing the exact
same interface. While this poster uses optional as its primary example, this
structural concept is universally applicable to any STL container or vocabulary
type fraught with UB pitfalls (such as variant, span, or vector).
A naive approach to hardening might involve inheriting directly from the STL
type (e.g., class optional : public std::optional<T>). However, this
introduces critical safety loopholes. Because STL types are not designed for
polymorphic inheritance, "overriding" accessors only shadows them. More
importantly, inheritance allows implicit conversion to the base class. If a
safe::optional is passed by reference to an older API expecting a
std::optional&, the original unsafe methods are exposed and the safety checks
are bypassed entirely. If passed by value, object slicing occurs, completely
stripping away the hardened interface. Aggregation strictly prevents these
implicit conversions, ensuring the unsafe base interface remains encapsulated
and inaccessible.
To seamlessly integrate these wrappers into an existing codebase, developers
can employ a central type alias (e.g., template <typename T> using optional =
safe::optional<T>;). This powerful technique creates a single point of
control, allowing an entire architecture to switch between the standard
implementation and the hardened implementation instantly.
If backward compatibility to STL types is needed for interfacing with
third-party code, adding a base() function that exposes the aggregated
std::optional can be considered. Note that this also opens a backdoor to
intentionally bypass the safety measures implemented in the wrapper class,
so this should be done with great care.
The wrapper works by intercepting calls to potentially dangerous operations.
For instance, in the case of optional, using operator* or operator-> on
an empty object traditionally results in Undefined Behavior. Our wrapper
intercepts these calls and injects a precondition check prior to forwarding the
operation to the aggregated STL object. Instead of silent corruption, the
program terminates, enforcing strict structural invariants.
#ifndef SAFE_OPTIONAL_HPP
#define SAFE_OPTIONAL_HPP
#include <cassert>
#include <optional>
#include <utility>
namespace safe {
template <typename T>
class optional {
public:
constexpr optional() noexcept = default;
constexpr optional(std::nullopt_t) noexcept : m_base{std::nullopt} {}
constexpr optional(const T& value) : m_base{value} {}
constexpr optional(T&& value) : m_base{std::move(value)} {}
template <typename... Args>
constexpr explicit optional(std::in_place_t, Args&&... args)
: m_base{std::in_place, std::forward<Args>(args)...} {}
// UB-prone accessors — guarded with precondition check
constexpr T& operator*() & noexcept {
assert(has_value() && "UB prevented: dereferencing empty safe::optional");
return *m_base;
}
constexpr const T& operator*() const& noexcept {
assert(has_value() && "UB prevented: dereferencing empty safe::optional");
return *m_base;
}
constexpr T&& operator*() && noexcept {
assert(has_value() && "UB prevented: dereferencing empty safe::optional");
return std::move(*m_base);
}
constexpr const T&& operator*() const&& noexcept {
assert(has_value() && "UB prevented: dereferencing empty safe::optional");
return std::move(*m_base);
}
constexpr T* operator->() noexcept {
assert(has_value() && "UB prevented: member access on empty safe::optional");
return m_base.operator->();
}
constexpr const T* operator->() const noexcept {
assert(has_value() && "UB prevented: member access on empty safe::optional");
return m_base.operator->();
}
// Safe forwards — no UB possible, pure delegation
constexpr bool has_value() const noexcept { return m_base.has_value(); }
constexpr explicit operator bool() const noexcept { return m_base.has_value(); }
constexpr T& value() & { return m_base.value(); }
constexpr const T& value() const& { return m_base.value(); }
constexpr T&& value() && { return std::move(m_base).value(); }
constexpr const T&& value() const&& { return std::move(m_base).value(); }
template <typename U>
constexpr T value_or(U&& fallback) const& {
return m_base.value_or(std::forward<U>(fallback));
}
template <typename U>
constexpr T value_or(U&& fallback) && {
return std::move(m_base).value_or(std::forward<U>(fallback));
}
template <typename... Args>
T& emplace(Args&&... args) {
return m_base.emplace(std::forward<Args>(args)...);
}
void reset() noexcept { m_base.reset(); }
void swap(optional& other) noexcept(noexcept(m_base.swap(other.m_base))) {
m_base.swap(other.m_base);
}
// ONLY necessary when interfacing with third party code
std::optional<T>& base() noexcept {
return m_base;
}
const std::optional<T>& base() const noexcept {
return m_base;
}
// Comparisons — optional-to-optional, pure delegation
friend constexpr bool operator==(const optional& a, const optional& b) { return a.m_base == b.m_base; }
friend constexpr bool operator!=(const optional& a, const optional& b) { return a.m_base != b.m_base; }
friend constexpr bool operator< (const optional& a, const optional& b) { return a.m_base < b.m_base; }
friend constexpr bool operator<=(const optional& a, const optional& b) { return a.m_base <= b.m_base; }
friend constexpr bool operator> (const optional& a, const optional& b) { return a.m_base > b.m_base; }
friend constexpr bool operator>=(const optional& a, const optional& b) { return a.m_base >= b.m_base; }
// Comparisons — optional-to-nullopt
friend constexpr bool operator==(const optional& o, std::nullopt_t) noexcept { return !o.has_value(); }
friend constexpr bool operator==(std::nullopt_t, const optional& o) noexcept { return !o.has_value(); }
friend constexpr bool operator!=(const optional& o, std::nullopt_t) noexcept { return o.has_value(); }
friend constexpr bool operator!=(std::nullopt_t, const optional& o) noexcept { return o.has_value(); }
// Comparisons — optional-to-value
template <typename U>
friend constexpr bool operator==(const optional& o, const U& v) { return o.m_base == v; }
template <typename U>
friend constexpr bool operator==(const U& v, const optional& o) { return v == o.m_base; }
template <typename U>
friend constexpr bool operator!=(const optional& o, const U& v) { return o.m_base != v; }
template <typename U>
friend constexpr bool operator!=(const U& v, const optional& o) { return v != o.m_base; }
template <typename U>
friend constexpr bool operator< (const optional& o, const U& v) { return o.m_base < v; }
template <typename U>
friend constexpr bool operator< (const U& v, const optional& o) { return v < o.m_base; }
template <typename U>
friend constexpr bool operator<=(const optional& o, const U& v) { return o.m_base <= v; }
template <typename U>
friend constexpr bool operator<=(const U& v, const optional& o) { return v <= o.m_base; }
template <typename U>
friend constexpr bool operator> (const optional& o, const U& v) { return o.m_base > v; }
template <typename U>
friend constexpr bool operator> (const U& v, const optional& o) { return v > o.m_base; }
template <typename U>
friend constexpr bool operator>=(const optional& o, const U& v) { return o.m_base >= v; }
template <typename U>
friend constexpr bool operator>=(const U& v, const optional& o) { return v >= o.m_base; }
private:
std::optional<T> m_base;
};
} // namespace safe
#endif // SAFE_OPTIONAL_HPP
using declaration
(e.g., template <typename T> using optional = safe::optional<T>;) can
propagate hardening across an entire codebase without touching call sites,
making rollout minimally invasive and easily reversible._LIBCPP_HARDENING_MODE, Abseil's vocabulary types, or Microsoft's
GSL, this approach is vendor-neutral, requires no third-party libraries, and
works with any conforming C++17 toolchain.operator*, operator->, operator[]). They do not protect against
broader categories of UB such as iterator invalidation, dangling references
obtained via value(), use-after-move, or data races. These require separate
mitigation strategies (static analysis, sanitizers, careful API design).std::optional,
this includes all ref-qualified overloads of operator* and value(), the
complete set of comparison operators, converting constructors, monadic
operations (C++23), std::hash specializations, and deduction guides.
Getting every signature exactly right is laborious and error-prone; missing
or subtly incorrect overloads can break template code that relies on exact
type traits or overload resolution. This cost is mitigated by the fact that
the vast majority of these functions are simple constexpr forwards,
leaving limited room for semantic errors.
The implementation above uses assert as the precondition check mechanism. This
is a deliberate, illustrative choice — not a prescription. assert is disabled
when NDEBUG is defined (i.e., in typical release builds), which means these
checks are active during development and testing. This is consistent with the
intended usage model: when combined with extensive automated testing in debug
mode, this fail-fast strategy surfaces hidden logical errors early in the
development lifecycle, catching violations that would otherwise deploy as
Undefined Behavior. In a release build, the performance is identical to the
non-hardened STL implementation.
Note this approach differs from the hardened STL approach, which involves
calling the abort handler independent of NDEBUG. Both approaches are viable,
but the latter will lead to a runtime cost also in production code.
Benchmarks for this proof of concept show that the performance with disabled
asserts is identical to the STL implementation. In an optimized build with
asserts enabled, the safety guarded functions show an average increase of 0.13 ns
absolute and 50% relative runtime (environment: MacOS, LLVM Clang 21, Google
Benchmarks, O2 optimization flag).
The benchmark source code is available in benchmark.cc.
Zero release-build overhead, architecture-wide rollout through a single alias, and a natural migration path to C++26.
Improving runtime safety and catching Undefined Behavior in legacy C++ codebases does not have to wait for C++26. By utilizing a simple type alias alongside aggregated wrapper classes, organizations can deploy a drop-in replacement that immediately hardens their standard library usage across an entire architecture. The advantages are compelling: zero release-build overhead, architecture-wide rollout through a single alias, and a natural migration path to C++26 once the toolchain permits. Combined with thorough automated testing in debug mode, this fail-fast strategy yields significant and immediate ROI by surfacing silent data corruption and avoiding catastrophic runtime vulnerabilities before they reach production.
These benefits do not come for free. The aggregation-based design demands verbose re-implementation of every public API surface, and debug-only checks leave a gap for bugs that only surface under production conditions. Third-party boundaries may require explicit escape hatches that temporarily re-expose unchecked interfaces. Teams should weigh these trade-offs against their project's risk profile and complement the wrappers with static analysis, sanitizers, and thorough test coverage to address the categories of UB that precondition checks alone cannot catch.
Ultimately, adopting hardened wrappers secures pre-C++26 environments today and trains developers to expect safe behavior, paving the way for a smooth transition to C++26 standard library hardening once the toolchain permits.