Skip to content
C++
Idiom
since C++98
Basic

Null Object Pattern

Replace nullptr returns and null checks with a do-nothing object that implements the full interface, eliminating guard code throughout callers.

Null Object Patternsince C++98

A do-nothing object that fully implements an interface, substituting for nullptr so callers never need to test for the absent case.

Overview

When a subsystem is optional β€” a logger, an event handler, a metrics sink β€” the naΓ―ve approach returns nullptr and requires every call site to guard against it. That null-check tax compounds across the codebase, mixing policy (is this configured?) with mechanism (do the work).

The Null Object pattern moves the decision to a single factory or constructor default. Callers receive a real object regardless; absent subsystems simply do nothing. The result is less branching, a cleaner interface contract, and no scattered defensive code.

Two forms cover most cases:

  • Runtime (virtual dispatch): Null behavior is selected at construction. Plugs into any existing abstract interface. Carries the usual virtual-call overhead.
  • Compile-time (policy template): Null behavior is a template parameter. Zero overhead; the compiler eliminates no-op calls entirely.

Examples

Runtime Null Object via Virtual Dispatch

The null implementation inherits from the same abstract base as real implementations. Passing by reference instead of pointer removes the semantic possibility of null from the call sites entirely:

cpp
// C++11
class Logger {
public:
    virtual void log(std::string_view msg) = 0;
    virtual ~Logger() = default;  // mandatory for polymorphic delete
};

class ConsoleLogger : public Logger {
public:
    void log(std::string_view msg) override {
        std::clog << "[LOG] " << msg << '\n';
    }
};

class NullLogger final : public Logger {  // final: enables devirtualization
public:
    void log(std::string_view) override {}  // intentional no-op
};

// One decision point selects the implementation
std::unique_ptr<Logger> make_logger(bool verbose) {   // C++11: unique_ptr
    return verbose
        ? std::make_unique<ConsoleLogger>()            // C++14: make_unique
        : std::make_unique<NullLogger>();
}

void process(Logger& logger) {
    logger.log("start");   // no null check; always valid
    // ...
    logger.log("end");
}

Singleton Null Object (Heap-Free)

When the null implementation is stateless, a function-local static avoids allocation. C++11 guarantees that initialization of function-local statics is thread-safe, making the Meyer's singleton idiom safe to use here:

cpp
// C++11
class Logger {
public:
    virtual void log(std::string_view msg) = 0;
    virtual ~Logger() = default;

    static Logger& null() noexcept {
        static struct NullLogger final : Logger {
            void log(std::string_view) override {}
        } instance;
        return instance;
    }
};

class Service {
    Logger& logger_;
public:
    explicit Service(Logger& log = Logger::null()) : logger_{log} {}
    void run() { logger_.log("running"); }
};

Service silent{};                   // NullLogger β€” no allocation
Service verbose{my_console_logger}; // real implementation

The default argument eliminates a two-constructor overload set. Clients that do not care about logging never mention Logger at all.

Compile-Time Null Object via Policy Template

When the null or active choice is fixed per instantiation, make it a template parameter. The optimizer eliminates dead no-op calls and may inline the active path aggressively:

cpp
// C++11
struct ConsoleLog {
    static void log(std::string_view msg) noexcept {
        std::clog << msg << '\n';
    }
};

struct NoLog {
    static void log(std::string_view) noexcept {}  // compiled away
};

template<typename LogPolicy = NoLog>  // C++11: default template argument
class Worker {
public:
    void run() {
        LogPolicy::log("starting");
        // ...
        LogPolicy::log("done");
    }
};

Worker<>            silent;   // NoLog::log disappears in optimized builds
Worker<ConsoleLog>  verbose;

Since C++20, a concept can constrain the policy type and produce a precise error if a caller passes something that does not satisfy the interface:

cpp
// C++20
template<typename T>
concept LogPolicy = requires(T t, std::string_view msg) {
    { t.log(msg) } -> std::same_as<void>;
};

template<LogPolicy Policy = NoLog>
class Worker { /* ... */ };

Realistic Example: Input Event Routing

The pattern shines in UI and game-engine event routing, where most objects have no handler most of the time:

cpp
// C++11
class InputHandler {
public:
    virtual void on_key_down(int scancode) = 0;
    virtual void on_mouse_move(int dx, int dy) = 0;
    virtual ~InputHandler() = default;

    static InputHandler& none() noexcept {
        static struct NullHandler final : InputHandler {
            void on_key_down(int) override {}
            void on_mouse_move(int, int) override {}
        } inst;
        return inst;
    }
};

class Widget {
    InputHandler* handler_ = &InputHandler::none();
public:
    void set_handler(InputHandler& h) noexcept { handler_ = &h; }
    void clear_handler() noexcept { handler_ = &InputHandler::none(); }

    void dispatch_key(int sc)      { handler_->on_key_down(sc); }
    void dispatch_mouse(int dx, int dy) { handler_->on_mouse_move(dx, dy); }
};

Every widget is born ready to receive events. No widget checks whether it has a handler before dispatching; that check moved permanently into the handler abstraction.

Null Object vs std::optional

These address structurally different problems:

cpp
// std::optional<T> β€” C++17
// "no value" is meaningful to the caller; it must handle both branches
std::optional<DatabaseConfig> load_db_config();

if (auto cfg = load_db_config()) {
    connect(*cfg);
} else {
    use_in_memory_fallback();   // caller takes a different path
}

// Null Object
// absence is transparent; caller code is identical in both cases
DatabaseConnection& open_connection();  // returns NullConnection if offline

open_connection().query("SELECT 1");    // always valid β€” NullConnection discards it

Use std::optional when the calling code must behave differently for the absent case. Use the null object when absence should be silent and uniform. Conflating them produces either redundant checks on top of null objects or callers that cannot react to genuine absence.

std::variant<RealImpl, NullImpl> (C++17) with std::visit is occasionally used when the "null" type needs distinct visible behavior β€” but for genuine no-ops it adds complexity without benefit.

Best Practices

Return references, not raw pointers. A raw pointer return keeps null semantically possible. A reference communicates that the caller always receives a valid object.

Keep null objects stateless. The moment a null implementation accumulates counters, fallbacks, or conditional logic, it has become a real alternate implementation with different semantics. Separate that concern into its own named class.

Mark null implementations final. They will not be subclassed. final (C++11) lets the compiler devirtualize calls through a statically known concrete type.

Prefer the singleton/static form for valueless null objects. One static instance per null type is sufficient, avoids allocation, and eliminates shared-ownership questions.

Prefer the policy template on hot paths. Virtual dispatch costs one indirect call and prevents inlining across the call boundary. When the null/active decision is compile-time, the policy form costs nothing.

Common Pitfalls

A null object that is not actually inert. If the no-op writes to stderr, increments a counter, or throws under some condition, it is not a null object. It is a real implementation with surprising behavior. Null must mean null.

Using the pattern to suppress errors that should surface. When a required dependency is absent, silent behavior hides bugs. Reserve null objects for subsystems whose absence is correct and expected, not as a way to make missing configuration invisible.

Omitting virtual ~Base() = default. Destroying a derived object through a base pointer without a virtual destructor is undefined behavior in every C++ standard. Even the NullLogger must be destroyed correctly; ensure the base declares a virtual destructor.

Reaching for null object when the caller should know. If business logic should respond differently when the subsystem is absent, a null object actively prevents correct behavior. Know which tool fits the problem before applying the pattern.

Template parameter explosion. Each distinct LogPolicy produces a distinct class instantiation, increasing binary size and compile times. For subsystems invoked rarely, virtual dispatch is often the better tradeoff; profile before committing to the policy form.

Stateful Null Objects (Counting / Logging Fallback)

The strict form of the null object is entirely inert β€” it records nothing and returns nothing. A useful relaxation for testing is the diagnostic null: a null-like object that stays silent toward the system under test but keeps internal counters the test can interrogate.

The key distinction is that CountingLogger is not a production null object; it is a test double that happens to implement the same interface. Keeping the two classes separate prevents diagnostic state from leaking into production builds:

cpp
// C++11
class Logger {
public:
    virtual void log(std::string_view msg) = 0;
    virtual ~Logger() = default;

    static Logger& null() noexcept {
        static struct NullLogger final : Logger {
            void log(std::string_view) override {}  // pure no-op
        } instance;
        return instance;
    }
};

// Test-only: silent toward the system, observable by the test
class CountingLogger final : public Logger {  // C++11
public:
    void log(std::string_view) override { ++count_; }

    int log_count() const noexcept { return count_; }
    void reset() noexcept { count_ = 0; }

private:
    int count_ = 0;
};

// ------- unit test -------
void test_happy_path_emits_no_logs() {
    CountingLogger counter;
    MyService svc{counter};

    svc.process_valid_input("hello");

    assert(counter.log_count() == 0);  // verify silence by construction
}

NullLogger is used in production to silence logging entirely. CountingLogger replaces it only in tests that need to assert on log frequency β€” for example, verifying that a normal operation path never triggers error logging. When a test does not care about log counts, it should still prefer NullLogger to avoid paying for the counter increment.

Composite Null Object

In event-driven systems, multiple subsystems observe the same event stream. Some listeners may not be registered for a given event type. A CompositeHandler that wraps a collection of InputHandler* values β€” each of which might be &InputHandler::none() β€” delegates unconditionally to every entry. Each handler either acts or no-ops without the composite needing to know which:

cpp
// C++11
#include <vector>
#include <algorithm>

class CompositeHandler final : public InputHandler {
public:
    void add(InputHandler& h) { handlers_.push_back(&h); }

    void on_key_down(int sc) override {
        for (auto* h : handlers_) h->on_key_down(sc);
    }

    void on_mouse_move(int dx, int dy) override {
        for (auto* h : handlers_) h->on_mouse_move(dx, dy);
    }

private:
    std::vector<InputHandler*> handlers_;
};

// Usage
CompositeHandler root;
root.add(InputHandler::none());   // placeholder for audio system (not yet wired)
root.add(ui_handler);             // real UI handler
root.add(InputHandler::none());   // placeholder for replay recorder

// Dispatch reaches all three; null entries are silent
root.on_key_down(KEY_ESCAPE);

This pattern appears frequently in game engines and GUI toolkits where optional subsystems β€” sound, accessibility, replay recording β€” are plugged in at startup and left as null objects when not needed. The composite never branches on whether a handler is real or null; it simply iterates.

Dependency Injection and Null Object Defaults

A common dependency injection (DI) pattern provides null objects for all optional services by default. The struct is usable immediately without configuration; production main() wires in real implementations, while unit tests override only the services they care about.

Using Logger::null() as the default reference means tests that focus on business logic are insulated from logging noise without any explicit setup:

cpp
// C++17
struct AppServices {
    Logger&        log     = Logger::null();          // optional: silence by default
    MetricsClient& metrics = MetricsClient::null();   // optional: no-op sink
    FeatureFlags&  flags   = FeatureFlags::null();    // optional: all flags off

    // Required services are NOT defaulted β€” absence must be explicit
    // DatabaseConnection& db;  // would be a reference member, must be init'd
};

// Production main: wire in every real implementation
int main() {
    ConsoleLogger    logger;
    PrometheusClient metrics{"localhost:9090"};
    RemoteFlags      flags{"https://flags.example.com"};

    AppServices svc{logger, metrics, flags};
    App app{svc};
    return app.run();
}

// Unit test: override only what this test exercises
void test_feature_toggle_gate() {
    StubFeatureFlags flags;
    flags.enable("new_checkout");

    AppServices svc;          // log and metrics stay as null objects
    svc.flags = flags;        // only flags is wired

    CheckoutFlow flow{svc};
    assert(flow.uses_new_checkout() == true);
}

The design enforces a clean separation: required dependencies are explicit constructor parameters (the compiler rejects missing ones), optional services fall back to inert null objects. This prevents the class of bug where a missing optional service causes a null-pointer dereference rather than a harmless no-op.

Best Practice: distinguish required from optional dependencies at the type level. Required services are non-defaulted constructor parameters; optional services are defaulted references pointing at null objects. This boundary is visible in the code and enforced by the compiler.

Null Object in Reactive / Observer Patterns

In a Subject<T> / Observer<T> design, the subject notifies its observers whenever its value changes. Without null objects the subject must guard every notification with if (observer_). That check couples the subject's notification logic to the lifecycle of its observers.

Using Observer::null() as the default eliminates the guard entirely. The subject's loop is unconditional; unregistered slots call the no-op:

cpp
// C++11
template<typename T>
class Observer {
public:
    virtual void on_update(const T& value) = 0;
    virtual ~Observer() = default;

    static Observer& null() noexcept {
        static struct NullObserver final : Observer<T> {
            void on_update(const T&) override {}  // no-op
        } instance;
        return instance;
    }
};

template<typename T>
class Subject {
public:
    // Slots are initialized to null; no guard needed during notification
    void set_observer(int slot, Observer<T>& obs) {
        if (slot < kMaxSlots) observers_[slot] = &obs;
    }

    void clear_observer(int slot) noexcept {
        if (slot < kMaxSlots) observers_[slot] = &Observer<T>::null();
    }

    void notify(const T& value) {
        for (auto* obs : observers_)
            obs->on_update(value);  // always valid; null slots are silent
    }

private:
    static constexpr int kMaxSlots = 4;
    Observer<T>* observers_[kMaxSlots] = {
        &Observer<T>::null(), &Observer<T>::null(),
        &Observer<T>::null(), &Observer<T>::null()
    };
};

// Usage β€” C++11
Subject<float> temperature;
TemperatureDisplay display;
temperature.set_observer(0, display);

temperature.notify(98.6f);  // display.on_update called; slots 1-3 are silent

The notify loop reads identically whether zero observers or four observers are registered. This invariant makes the Subject easier to reason about, test, and extend β€” adding a new slot never requires auditing the notification loop for null guards.

Best Practice: initialize observer slots to null() in the member initializer list. A zero-initialized pointer that is later checked is a liability; a null object that is immediately usable is not.

Performance: Virtual Dispatch vs Template vs Inline

Choosing between the three null object forms involves a real performance tradeoff. The table below gives representative per-call costs on a modern out-of-order CPU (x86-64, GCC/Clang -O2). Actual numbers depend on branch predictor training and cache state:

cpp
// C++11 β€” virtual dispatch null object
// ~1–3 ns per call; indirect branch, but predictor learns a single target quickly
class NullLogger final : public Logger {
    void log(std::string_view) override {}
};
Logger& logger = Logger::null();
logger.log("msg");   // one indirect call; typically well-predicted

// C++11 β€” template policy null object (NoLog from earlier)
// ~0 ns; optimizer sees a no-op body and eliminates the call entirely
Worker<NoLog> w;
w.run();             // LogPolicy::log calls do not appear in the object file

// C++11 β€” std::function wrapping an empty lambda
// ~3–5 ns per call; std::function adds type-erasure overhead (heap or SBO)
std::function<void(std::string_view)> sink = [](std::string_view) {};
sink("msg");         // indirect call through function wrapper

A practical decision rule:

  • Hot path (>1 M calls/sec): use the template policy form. The call is eliminated entirely; binary size grows per instantiation but runtime cost is zero.
  • Warm path: virtual dispatch to a final null object. The compiler may devirtualize when the concrete type is visible; even without devirtualization, branch prediction keeps the cost low.
  • Runtime-swappable policy: std::function is justified only when the no-op/active choice must be changed at runtime without recompiling. Accept the 3–5 ns overhead knowingly.

Best Practice: profile before selecting the form. Most logging and metrics call sites are not the bottleneck. Reach for the template form only when a profiler identifies the virtual dispatch as a measurable cost, not as a default preference.

Common Pitfall: defaulting to std::function for flexibility. std::function carries type-erasure overhead and may heap-allocate the closure. Using it as a default null object "because it's flexible" pays the cost on every call even when the policy never changes at runtime.

Testing Strategy: Inject Null or Real

A test fixture that wires in strict null objects β€” nulls that throw on any call β€” turns silent misbehavior into an immediate test failure. Real null objects (inert) are used only when the test explicitly does not care about a given interaction.

The pattern distinguishes three test-double roles for the same interface:

ClassBehaviorUse when
NullLoggerinert no-optest does not care about log calls
CountingLoggersilent but observabletest asserts on log frequency
StrictNullLoggerthrows on any calltest must verify no log calls occur by construction
cpp
// C++11
class StrictNullLogger final : public Logger {
public:
    void log(std::string_view msg) override {
        throw std::logic_error{
            std::string{"Unexpected log call: "} + std::string{msg}
        };
    }
};

// Test fixture β€” real objects by default, null objects where unneeded
struct ServiceTests {
    StrictNullLogger strict_log;  // any log call fails the test immediately
    CountingLogger   spy_log;     // used when the test inspects log counts
    NullLogger       quiet_log;   // used when the test doesn't care

    // Build an instance under test with whatever combination is needed
    MyService make_svc_strict()  { return MyService{strict_log}; }
    MyService make_svc_counting(){ return MyService{spy_log};     }
    MyService make_svc_quiet()   { return MyService{quiet_log};   }
};

void test_normal_path_is_silent() {
    ServiceTests f;
    auto svc = f.make_svc_strict();  // any log call = test failure

    svc.process("valid input");      // must not log β€” strict null enforces this
}

void test_error_path_logs_once() {
    ServiceTests f;
    auto svc = f.make_svc_counting();

    svc.process("bad input");

    assert(f.spy_log.log_count() == 1);  // exactly one error log expected
}

void test_bulk_processing_perf() {
    ServiceTests f;
    auto svc = f.make_svc_quiet();   // don't care about logs, minimize overhead

    for (int i = 0; i < 100'000; ++i)   // C++14: digit separator
        svc.process("item");
}

StrictNullLogger is the most valuable of the three in practice. It converts the assumption "this code path should not log" from an undocumented convention into an executable invariant. When a future refactor accidentally adds a log call to a previously silent path, the strict logger surfaces the regression immediately rather than letting it accumulate silently in production log files.

Best Practice: use StrictNullLogger (or equivalent) as the default in test fixtures, and explicitly opt into NullLogger or CountingLogger only for tests that need the relaxed behavior. This inverts the usual "silence by default" and makes unexpected interactions visible.

Common Pitfall: wiring NullLogger into every test unconditionally. Silent nulls hide accidental log calls in tests that should be verifying silence. Reserve the inert NullLogger for tests where log output is genuinely irrelevant, not as a convenient way to avoid hearing about unexpected behavior.

See Also