C++Online 2026 · Poster Session

Bridging the Safety Gap:
Hardening the STL in Pre-C++26 Environments

Sebastian Müller — Swift Navigation

Section 1

Abstract

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++.

Section 2

Motivation

!

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.

Full motivation — industry context & the UB problem

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.

Show UB example — dereferencing an empty optional
C++ — Undefined Behavior example
#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.

What does "Hardened STL" actually mean in C++26?

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.

Section 3

Solution: Hardened STL Wrappers

1

Aggregation, not inheritance — wrap the STL type to prevent implicit conversion back to the unsafe base class.

2

Single type aliastemplate <typename T> using optional = safe::optional<T>; propagates hardening architecture-wide.

3

Precondition checks — intercept UB-prone operations (operator*, operator->) and assert before forwarding.

Full explanation — why aggregation over inheritance

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.

Show full implementation — safe::optional (safe_optional.hpp)
safe_optional.hpp
#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
Section 4

Discussion

  • Immediate applicability: The wrapper approach works with any C++17 (or later) toolchain today. Organizations do not need to wait for compiler vendors to ship C++26-conforming standard libraries or for their internal tooling certification processes to catch up.
  • Architecture-wide adoption via type aliases: A single 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.
  • No external dependencies: Unlike existing hardened alternatives such as libc++'s _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.
  • Smooth migration path to C++26: Because the hardened wrappers mirror the standard interface and enforce the same preconditions that C++26 mandates, migrating to a conforming standard library later requires little more than updating the alias. Developers are already accustomed to the fail-fast behavior, reducing surprise and friction.
  • Scope of coverage: The wrappers guard explicit accessor calls (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).
  • Implementation verbosity: Because aggregation strictly prevents implicit inheritance, the full public API of the underlying STL type must be explicitly re-implemented in each wrapper. For a type like 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.

Runtime

0.25 ns with safety checks disabled
0.38 ns (+0.13 ns) with safety checks enabled
Full performance analysis

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.

Section 5

Conclusion

Zero release-build overhead, architecture-wide rollout through a single alias, and a natural migration path to C++26.

Full conclusion

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.