Skip to content

NoDestructor Design Guide (I): motivation, API, and implementation ​

Hands-on track: this piece assumes you're already fluent in static storage duration, placement new, and aligned storage. If not, skim the full/ prerequisites first.

Chromium has a hard rule that struck us as odd at first: global objects are forbidden to have constructors, and forbidden to have destructors. Turn on -Wglobal-constructors and -Wexit-time-destructors, then dare to place an object with a constructor at global scope, and the compiler slaps you in the face at build time. The reasoning is actually pretty down-to-earth — constructors slow down startup, destructors trigger shutdown races, and the static initialization order fiasco (SIOF) is a murky ledger nobody wants to audit. But global singletons are needed everywhere: default configs, feature flags, random nonces — which of those isn't resident from the moment the process starts? Ban them, and how is the code supposed to be written at all?

We walked every ready-made path, and none clears both gates. The bare global dies first; it walks straight into the line of fire. The Meyers singleton looks clever — static T& f(){static T x;return x;} leans on C++11 magic statics to solve both thread safety and laziness of construction, and for a while we thought it was the answer. But it only solves the construction half: ~T() still has to run, and that whole pile of shutdown-time destruction races remains. The last path is hand-rolled placement new. It works, but every site has to remember to copy in a set of static_asserts and remember to handle LSan reachability — miss one spot and you've planted a time bomb.

NoDestructor is Chromium's patch for this dead end. Pull the idea apart and it's really just two moves: move the object into a function-local static, dodging the global-construction gate while picking up magic statics' thread-safe initialization for free; then simply never call ~T(), dodging the global-destruction gate along with it. The cost? The object "leaks on purpose" — no destruction, and the operating system sweeps it up when the process exits. It sounds crude, but for a global singleton that's supposed to live until the process's very last tick, this is exactly how it should behave.

The API ​

C++
template <typename T>
class NoDestructor {
public:
    template <typename... Args>
    explicit NoDestructor(Args&&... args);   // perfect-forwarding construct
    explicit NoDestructor(const T& x);        // copy construct (handy for initializer_list)
    explicit NoDestructor(T&& x);             // move construct
    NoDestructor(const NoDestructor&) = delete;
    ~NoDestructor() = default;                // ← key: doesn't call ~T()
    const T& operator*() const;  T& operator*();
    const T* operator->() const; T* operator->();
    const T* get() const;  T* get();
};

That's the entire surface — it genuinely surprised us on a first read. A smart-pointer facade of */->/get, with copy deleted outright: otherwise two NoDestructor instances each running ~char[] would be harmless enough, but if someone tried to deep-copy the storage_ inside, it would all fall apart. It also doesn't inherit from T, and that's deliberate: NoDestructor wants to play the role of a container, so semantically it must stay independent and stay out of T's inheritance chain. Everyday usage is a single line, static const NoDestructor<T> x(args); return *x; — memorize that one shape and you're set.

Implementation (complete, ~50 lines) ​

Expand codeCollapse43 lines
C++
// Platform: host | C++ Standard: C++20
#pragma once
#include <new>
#include <type_traits>
#include <utility>

namespace tamcpp::chrome {

template <typename T>
class NoDestructor {
public:
    template <typename... Args>
    explicit NoDestructor(Args&&... args) {
        new (storage_) T(std::forward<Args>(args)...);   // placement new
    }
    explicit NoDestructor(const T& x) { new (storage_) T(x); }
    explicit NoDestructor(T&& x)      { new (storage_) T(std::move(x)); }

    NoDestructor(const NoDestructor&) = delete;
    NoDestructor& operator=(const NoDestructor&) = delete;
    ~NoDestructor() = default;   // only destructs the char member, doesn't call ~T()

    const T& operator*()  const { return *get(); }
    T&       operator*()        { return *get(); }
    const T* operator->() const { return get(); }
    T*       operator->()       { return get(); }
    const T* get() const { return reinterpret_cast<const T*>(storage_); }
    T*       get()       { return reinterpret_cast<T*>(storage_); }

private:
    static_assert(!(std::is_trivially_constructible_v<T> &&
                    std::is_trivially_destructible_v<T>),
                  "T trivially ctble+dtble: use constinit T directly");
    static_assert(!std::is_trivially_destructible_v<T>,
                  "T trivially destructible: use plain function-local static T");

    alignas(T) char storage_[sizeof(T)];

    // Under LEAK_SANITIZER builds Chromium additionally holds a T* storage_ptr_ as an LSan reachability root
    // (crbug/40562930); the teaching version omits it and uses an LSan suppression file instead.
};

}  // namespace tamcpp::chrome

Running the code leaves just a handful of deliberate tradeoffs; we'll point them out one by one so you can match each against what you're reading:

DecisionImplementationReason
Storage is alignas(T) char[N]alignas(T) char storage_[sizeof(T)]Inline buffer, zero heap allocation, alignment satisfies placement new
Construction via placement newnew (storage_) T(forward<Args>(args)...)Constructs in place on storage_, no allocation
Destructor = default~NoDestructor() = defaultOnly destructs the char member (trivial), never calls ~T() — this is the root of "no destructor"
static_assert gatingTwo assertionsServes only non-trivially-destructible T; the trivial cases are routed to constinit / a plain static

How "no destructor" actually works ​

That one table row, ~NoDestructor() = default, is the heart of the whole mechanism, so we're pulling it out on its own — because it plays a rather elegant trick on type perspective.

storage_ is declared with type char[], and that is the only type the compiler sees when generating the destructor. A char destructor is trivial; nothing needs to be done. And T? T was constructed later, in place at storage_'s start address via placement new. As far as the compiler is concerned, it has no type-level relationship to storage_ whatsoever, so when destructing storage_ the compiler never thinks to recall it. Thus ~T() is never scheduled into NoDestructor's destruction path — T simply lives quietly until the process ends, when the operating system reclaims everything in one sweep. One shift in type perspective buys the entire "no destructor" semantics; we find that move rather beautiful.

References ​

pdf-latest-4-g85128cc · 85128cc · 2026-10-05