Nifty Counter
A header-based reference-counting technique that guarantees a static object is initialized before any translation unit that includes its header uses it.
Nifty Countersince C++98A header-embedded reference-counting pattern that constructs a shared static object exactly once β on first inclusion β and destroys it after the last including translation unit is torn down, eliminating static initialization order dependence without requiring callers to change anything.
Overview
The Static Initialization Order Fiasco (SIOF) is one of C++'s most persistent hazards. When a static object in one translation unit (TU) depends on a static object in another TU at construction time, the standard provides no ordering guarantee. The nifty counter idiom β sometimes called the Schwarz counter β solves this entirely within the library: the header plants a sentinel object in every including TU that drives initialization and shutdown of the shared resource.
The standard library relies on this technique directly. std::ios_base::Init, pulled in by every <iostream> inclusion, is the canonical nifty counter that keeps std::cin, std::cout, std::cerr, and std::clog alive for any TU that touches them.
Why the naive approach fails
// logger.hpp β naive, broken
extern Logger& gLogger; // defined in logger.cpp
// module_a.cpp
#include "logger.hpp"
static Foo foo; // Foo's constructor calls gLogger.log(...)
// SIOF: gLogger may not be initialized yetThe relative initialization order of gLogger (in logger.cpp) and foo (in module_a.cpp) is unspecified by the standard. It may work in debug builds and silently corrupt memory in release, or crash only when the link order changes.
Syntax
The idiom splits across exactly two files: a header that every client includes, and a single .cpp that owns the storage.
logger.hpp:
#pragma once
#include <string_view>
class Logger {
public:
void log(std::string_view msg);
};
// Single shared reference; storage lives in logger.cpp
extern Logger& gLogger;
// Anonymous namespace gives each TU its own instance of the
// sentinel without symbol collisions at link time.
namespace {
struct LoggerInit {
LoggerInit();
~LoggerInit();
};
// Initialized before any static declared after this #include in the same TU.
static LoggerInit loggerInitInstance;
}logger.cpp:
#include "logger.hpp"
#include <new>
// Plain int β zero-initialized during constant initialization,
// which precedes all dynamic initialization. This ordering is guaranteed.
static int loggerRefCount = 0;
// alignas ensures placement new doesn't produce UB. C++11 alignas.
// std::aligned_storage (C++11) was deprecated in C++23; prefer this form.
alignas(Logger) static unsigned char loggerStorage[sizeof(Logger)];
Logger& gLogger = reinterpret_cast<Logger&>(loggerStorage);
LoggerInit::LoggerInit() {
if (loggerRefCount++ == 0)
::new (&gLogger) Logger{}; // placement new; valid since C++98
}
LoggerInit::~LoggerInit() {
if (--loggerRefCount == 0)
gLogger.~Logger(); // explicit destructor call; valid since C++98
}
void Logger::log(std::string_view msg) { /* ... */ }The zero-initialization of loggerRefCount is the load-bearing guarantee. Plain scalars with static storage duration are constant-initialized before any dynamic initialization fires, so the counter is reliably 0 when the first LoggerInit constructor runs regardless of which TU gets there first.
Examples
Shared event bus with multi-TU subscribers
// event_bus.hpp
#pragma once
#include <functional>
#include <mutex>
#include <string>
#include <vector>
class EventBus {
public:
using Handler = std::function<void(const std::string&)>; // C++11
void subscribe(std::string topic, Handler h);
void publish(const std::string& topic, const std::string& payload);
private:
std::mutex mu_; // C++11
std::vector<std::pair<std::string, Handler>> handlers_;
};
extern EventBus& gBus;
namespace {
struct EventBusInit { EventBusInit(); ~EventBusInit(); };
static EventBusInit eventBusInitInstance;
}// event_bus.cpp
#include "event_bus.hpp"
#include <new>
static int refCount = 0;
alignas(EventBus) static unsigned char storage[sizeof(EventBus)]; // C++11
EventBus& gBus = reinterpret_cast<EventBus&>(storage);
EventBusInit::EventBusInit() { if (refCount++ == 0) ::new (&gBus) EventBus{}; }
EventBusInit::~EventBusInit() { if (--refCount == 0) gBus.~EventBus(); }
void EventBus::subscribe(std::string topic, Handler h) {
std::lock_guard lock{mu_}; // C++17 CTAD
handlers_.emplace_back(std::move(topic), std::move(h));
}
void EventBus::publish(const std::string& topic, const std::string& payload) {
std::lock_guard lock{mu_}; // C++17
for (auto& [t, h] : handlers_) // C++17 structured bindings
if (t == topic) h(payload);
}Any TU that includes event_bus.hpp can safely use gBus from its own static constructors:
// audio_system.cpp
#include "event_bus.hpp"
static struct AudioSystem {
AudioSystem() {
// Safe: eventBusInitInstance ran before this constructor
// because it was declared earlier in this TU via the header.
gBus.subscribe("audio/mute", [](const std::string& v) { /* ... */ });
}
} audioSystem;Comparing with the Meyers singleton (C++11)
C++11 made function-local statics thread-safe, enabling the simpler Meyers singleton:
Logger& getLogger() {
static Logger instance; // C++11: initialization guaranteed thread-safe
return instance;
}Prefer this when call sites can tolerate a function call. The nifty counter is the right choice when the object's name must be a variable reference (std::cout, gLogger, gBus) rather than a function call β whether for API stability, macro compatibility, or standard-library-style conventions.
C++17 inline variables β partial relief
C++17 inline variables permit a variable definition in a header without ODR violations:
// C++17
inline constexpr int kMaxRetries = 3; // constant-initialized; no SIOF
inline const std::string kServiceName = "svc"; // dynamic init β SIOF risk remainsInline variables eliminate SIOF only for types with trivial or constexpr constructors. For non-trivially-constructible globals, the nifty counter remains necessary.
Best Practices
- Always
alignasthe raw buffer. An unalignedchararray causes undefined behavior on placement new for any type with alignment greater than 1.alignas(T) unsigned char buf[sizeof(T)]is the correct form in all standards. Avoidstd::aligned_storage, deprecated in C++23. - Keep
refCountas a plainint. Static initialization is effectively single-threaded from the perspective of user code (it precedesmainand user-created threads in well-behaved programs).std::atomicadds unnecessary overhead and doesn't protect against the actual SIOF-era race, which the counter design already prevents. - Use an anonymous namespace for the sentinel. This gives each TU a privately-linked
LoggerInitinstance while guaranteeing they all reference the samegLoggerfromlogger.cpp. A named namespace or file-scope static would risk ODR violations or duplicate symbols. - Include the owning
.cppin the sentinel's TU count. Becauselogger.cppitself includeslogger.hpp, it also gets aLoggerInitinstance. This meansrefCountstarts at 1 before any other TU's instance fires. This is benign and correct β the last destructor to run will still reduce the count to 0. - Prefer the Meyers singleton for new code when naming flexibility is not required. The nifty counter is the correct technique for library-style APIs where a global reference is part of the contract.
Common Pitfalls
Omitting alignas on the storage buffer
// Undefined behavior β char has alignment 1; Logger may require alignment 8 or more
static unsigned char loggerStorage[sizeof(Logger)];// Correct (C++11)
alignas(Logger) static unsigned char loggerStorage[sizeof(Logger)];Making the reference inline
If you write inline Logger& gLogger = ... in the header, every TU gets its own Logger object rather than sharing one. The extern declaration in the header paired with the definition in the .cpp is essential.
Using the object from a thread spawned during static initialization
Nifty counter guarantees ordering within the static initialization phase, not across threads spawned during that phase. If another static object's constructor starts a thread that touches gLogger before the including TU's LoggerInit constructor has run, the access is still a race. This is a broader SIOF variant the idiom cannot eliminate.
Double-inclusion concern
The anonymous namespace ensures loggerInitInstance has internal linkage, so multiple inclusions of the header in the same TU (via #pragma once or include guards) produce only one sentinel instance per TU. Without #pragma once or guards, double inclusion would double-increment the counter, causing premature or deferred destruction.
See Also
reference/idioms/lazy-initializationβ function-local statics as the simpler SIOF workaround when a function call is acceptablereference/idioms/monostateβ an alternative global-state pattern using a class with only static membersreference/idioms/counted-pointerβ reference counting applied to heap object lifetime rather than static initialization orderstd::ios_base::Initβ the standard library's own nifty counter implementation, specified since C++98