Singleton Pattern


  • Description: One instance per process, global access point — widely considered an anti-pattern; covers Meyers singleton, thread safety, testability problems, and modern alternatives (DI, std::call_once, inline variable).
  • My Notion Note ID: K2C-2-1
  • Created: 2026-05-22
  • Updated: 2026-05-22
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. The Problem

  • Some resources need exactly one instance per process — logger, config registry, hardware controller, thread pool, GPU context.
  • Plain global object → eager init, undefined cross-TU init order ("static initialization order fiasco"), no lazy construction.
  • Want: lazy init + single instance + controlled access through one well-known entry point.

2. Structure

Role Responsibility
Singleton class Holds the single instance; private ctor; deletes copy/move.
instance() Static accessor — returns reference to the one instance.
Storage Function-local static, class-static pointer, or inline variable.
  • One class plays all roles. No collaborators in the GoF diagram.

3. C++ Implementations

3.1 Meyers singleton (preferred)

  • Function-local static → thread-safe init guaranteed since C++11 ([N2660] "magic statics").
  • Destruction order = reverse of first-use order across TUs.
class Logger {
public:
    static Logger& instance() {
        static Logger inst;          // initialized on first call, thread-safe
        return inst;
    }

    Logger(const Logger&)            = delete;
    Logger& operator=(const Logger&) = delete;
    Logger(Logger&&)                 = delete;
    Logger& operator=(Logger&&)      = delete;

    void log(std::string_view msg) { /* ... */ }

private:
    Logger() = default;
    ~Logger() = default;
};

// Usage
Logger::instance().log("hello");
  • Delete copy + move explicitly. Without that, auto x = Logger::instance(); silently copies (if copy ctor exists) and you have two.

3.2 GoF classic form (avoid)

class Logger {
public:
    static Logger* instance() {
        if (!inst_) inst_ = new Logger;     // race in multi-threaded code
        return inst_;
    }
private:
    Logger() = default;
    static Logger* inst_;
};

Logger* Logger::inst_ = nullptr;
  • Race on the null check + assignment. Naive double-checked locking is broken on weakly-ordered memory models without std::atomic. The new is never freed → leaks until process exit.

3.3 inline static (C++17)

class Config {
public:
    static Config& instance() { return inst_; }
private:
    Config() = default;
    inline static Config inst_{};   // eager, but single definition across TUs
};
  • Eager — runs before main enters. Subject to static initialization order fiasco if inst_ depends on other globals across TUs.

4. Thread Safety

  • Meyers singleton (C++11+) — fully thread-safe init. Compiler emits an internal guard variable + atomic flag.
  • Methods on the singleton — not auto-thread-safe. Add std::mutex per method or design the interface as immutable.
  • Double-checked locking (DCLP) — historically broken in C++03; correct in C++11 only with std::atomic + acquire/release ordering. Even when correct, more code than Meyers for no gain.
// Correct DCLP — but Meyers singleton subsumes this. Shown for context.
static std::atomic<Logger*> ptr{nullptr};
static std::mutex m;

Logger* get() {
    Logger* p = ptr.load(std::memory_order_acquire);
    if (!p) {
        std::lock_guard lk{m};
        p = ptr.load(std::memory_order_relaxed);
        if (!p) {
            p = new Logger;
            ptr.store(p, std::memory_order_release);
        }
    }
    return p;
}
  • std::call_once + std::once_flag is the readable alternative if Meyers isn't viable (e.g. parameterized init).
static std::once_flag flag;
static std::unique_ptr<Logger> inst;

Logger& Logger::instance() {
    std::call_once(flag, []{ inst = std::make_unique<Logger>(); });
    return *inst;
}

5. When to Use, When Not To

Use when:

  • External resource genuinely is single — physical device, hardware MMIO, OS-level mutex, single GPU context.
  • Cross-cutting infrastructure — logger, metrics sink — and dependency injection is overkill for the project (small CLI tool, throwaway script).

Avoid when:

  • "We only need one" is a current observation, not a hard requirement. Tomorrow's requirement is two.
  • The class holds mutable state that tests want to reset or fake out.
  • The class is reached from many call sites that don't otherwise know about it → hidden coupling.

6. Why Singleton Is an Anti-Pattern

  • Hidden global state — function signatures lie. void foo() actually reads from Logger::instance() and Config::instance(). Reasoning about side effects requires reading the body.
  • Testability — code under test reaches into instance(); tests can't substitute a fake. Either expose a reset_for_test() (ugly) or refactor to take the dependency as a parameter (defeats the singleton).
  • Lifetime tangles — static dtor order across TUs is unspecified. Singleton A using singleton B in its dtor → UB if B died first.
  • Concurrency footguns — single instance amplifies contention on its mutex.
  • Hostile to parallel testingpytest -p xdist / GoogleTest sharding spawn multiple test workers sharing the singleton state.

Modern alternative: dependency injection. Pass the logger / config as a constructor parameter (or via a small composition root). Production wires one instance; tests wire a fake.

struct App {
    explicit App(Logger& log, Config& cfg) : log_{log}, cfg_{cfg} {}
    void run() { log_.log("starting"); /* ... */ }
private:
    Logger& log_;
    Config& cfg_;
};

int main() {
    Logger log;
    Config cfg;
    App{log, cfg}.run();        // one place to wire, no Singleton class needed
}
  • The "single instance" property is enforced by main constructing one, not by the class.

7. Related Patterns

  • Singleton vs static class (only static members, no instances) — static class has no construction phase → no lazy init, no polymorphism, can't satisfy an interface. Singleton can hide behind an abstract base; static class can't.
  • Singleton vs Monostate — Monostate = every instance shares the same static data; clients construct freely. Less surprising than Singleton because the "one instance" detail is invisible.
  • Singleton vs Service Locator — Service Locator is a registry of singletons keyed by type. Same hidden-global-state critique applies, but more flexible (can swap implementations at startup).
  • Singleton vs Dependency Injection — DI inverts ownership: caller decides who provides the dependency. Eliminates the hidden global; trades it for plumbing.
  • Singleton vs Factory Method — Factory returns a fresh instance per call (or a configured one). Singleton always returns the same instance.

8. References