Skip to content
C++
Idiom
since C++11
Intermediate

Lazy Initialization

Defer expensive object construction or computation to first use, paying the cost only when the value is actually needed.

Lazy Initializationsince C++11

A technique that defers the construction or computation of a value until the first access, avoiding unnecessary cost when the value may never be needed.

Overview

Eager initialization is simple: build everything upfront in constructors and at program startup. Lazy initialization flips the trade-off β€” you pay for an object only when (and if) it is first used. This matters when construction is expensive (database connections, file parsing, heavy computation), when the value is needed only in some code paths, or when initialization order across translation units is a concern.

The central challenge is thread safety. Pre-C++11, lazy initialization required careful manual synchronization, and correct double-checked locking was notoriously difficult to implement. C++11 resolved this at the language level with two reliable mechanisms: guaranteed thread-safe initialization of function-local statics, and std::call_once paired with std::once_flag in <mutex>.

Use lazy initialization when:

  • Construction requires expensive resources (I/O, network, allocation) and may not be needed on every execution path.
  • Optional subsystems are only activated by certain runtime conditions.
  • You need to cache the result of an idempotent computation inside a const member function.
  • You are breaking cyclic initialization-order dependencies between translation units.

Avoid it when:

  • The object is always used β€” eager initialization avoids the branch on every access.
  • Construction failure must surface early, not buried in an arbitrary code path.
  • Profiling shows the branch overhead exceeds the construction cost.

Patterns

Function-local static (C++11)

The simplest thread-safe lazy initialization. C++11 guarantees that a block-scope static variable is initialized exactly once; concurrent threads block until initialization completes. No explicit mutex is required.

cpp
#include <string>

class DatabasePool {
public:
    static DatabasePool& instance() {
        static DatabasePool pool{"postgres://localhost/prod"};  // C++11: thread-safe
        return pool;
    }

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

private:
    explicit DatabasePool(std::string dsn);
    std::string dsn_;
};

The compiler emits a hidden guard variable per static local. After initialization, reading the guard is the only overhead on the hot path. This is the canonical way to implement a lazy singleton in modern C++.

std::optional member with mutable (C++17)

For lazy fields inside a class, std::optional<T> combined with mutable lets a logically const object cache a derived value on first access. This avoids heap allocation for the cache slot itself and makes the empty/initialized states explicit.

cpp
#include <optional>
#include <string>
#include <vector>

class Document {
public:
    explicit Document(std::string text) : text_{std::move(text)} {}

    const std::vector<std::string>& words() const {
        if (!words_cache_) {
            words_cache_ = tokenize(text_);  // computed once on first call
        }
        return *words_cache_;
    }

    void set_text(std::string text) {
        text_ = std::move(text);
        words_cache_.reset();  // invalidate on mutation
    }

private:
    std::string text_;
    mutable std::optional<std::vector<std::string>> words_cache_;  // C++17

    static std::vector<std::string> tokenize(const std::string& s);
};

A raw mutable T* initialized to nullptr is the C++11/14 equivalent but requires manual memory management. Prefer std::optional when the value fits on the stack or is moved in; use a unique_ptr only when the stored type is large enough to justify separate heap allocation.

std::call_once (C++11)

When initialization has side effects that must happen exactly once across threads β€” and the result is not naturally scoped as a local static β€” std::call_once with std::once_flag gives explicit control.

cpp
#include <mutex>
#include <memory>

namespace {
    std::once_flag config_flag;               // C++11
    std::unique_ptr<Config> global_config;
}

const Config& get_config() {
    std::call_once(config_flag, []() {        // C++11: exactly once across all threads
        global_config = Config::load_from_disk("/etc/app/config.toml");
    });
    return *global_config;
}

If the callable throws, the once_flag is left unset and another thread will retry β€” which is correct for recoverable initialization failures. If the callable has partial side effects, ensure it is strongly exception-safe or the retry will see inconsistent state.

std::async with deferred launch (C++11)

std::async with std::launch::deferred delays execution until .get() or .wait() is called on the returned future. No background thread is spawned; the callable runs synchronously in the calling thread at evaluation time.

cpp
#include <future>
#include <vector>

auto fut = std::async(
    std::launch::deferred,        // C++11: run on demand, not now
    compute_eigenvalues,
    matrix
);

// ... other work that may short-circuit ...

auto eigenvalues = fut.get();    // computation happens here, if at all

This is most useful for optional computations passed across API boundaries, where the caller decides whether to evaluate.

Thread Safety

PatternThread-safeNotes
Function-local staticYes (C++11+)Zero overhead after first init
mutable std::optionalNoRequires external synchronization
std::call_onceYesRetries on exception
std::async(deferred)N/AInherently single-threaded

The mutable std::optional pattern is not thread-safe for shared objects. With std::shared_mutex (C++17) you can allow concurrent reads while serializing initialization:

cpp
#include <shared_mutex>  // C++17
#include <optional>

class Document {
public:
    const std::vector<std::string>& words() const {
        {
            std::shared_lock lock{mu_};        // C++14 lock type, C++17 mutex
            if (words_cache_) return *words_cache_;
        }
        std::unique_lock lock{mu_};            // C++11: exclusive for write
        if (!words_cache_) {                   // re-check after upgrade
            words_cache_ = tokenize(text_);
        }
        return *words_cache_;
    }

private:
    std::string text_;
    mutable std::optional<std::vector<std::string>> words_cache_;
    mutable std::shared_mutex mu_;             // C++17
};

The re-check inside the write lock is mandatory. Between releasing the read lock and acquiring the write lock, another thread may have completed initialization. Omitting the re-check causes redundant work at best and data corruption at worst.

Best Practices

Prefer the static-local pattern for singletons. It is the most correct, most readable, and has zero synchronization cost on the hot path after the first call. Reach for std::call_once only when you need more control over the callable or when the flag must be shared across unrelated call sites.

Pair cache invalidation with every mutating operation. Lazy values are never automatically updated. Any non-const member function that changes the source data must clear the cache β€” call optional::reset() or zero the pointer. A generation counter incremented on writes and checked on read is a systematic guard against stale reads.

Mark logical constness correctly. A function that lazily populates a cache should be const; the cached member should be mutable. This is the intended use of mutable β€” not a license to mutate observable state behind a const interface. The distinction: caching a derived value is logically const; updating the authoritative data is not.

Profile before applying. On a hot path called millions of times, the branch if (!cache_) adds up. Measure: if construction is cheap and the value is almost always accessed, eager initialization has lower total cost.

Common Pitfalls

Missing re-check after lock upgrade. In the reader-writer pattern, acquiring the write lock after releasing the read lock is not atomic. Always re-check the initialized condition inside the write lock.

Circular static-local initialization. If initializing static-local A triggers initialization of static-local B which triggers A, the behavior is undefined. The standard does not specify a recovery path. Restructure to break the cycle.

Ignoring call_once exception semantics. A callable that throws leaves the once_flag unset. If partial side effects occurred (a file was opened, a connection established), the retry will attempt the operation again. Design the callable to be idempotent or to clean up on failure.

Exposing references to lazily initialized members. Returning a const T& to a cached member ties the caller's lifetime to the object. If the object is destroyed or the cache is invalidated, the reference dangles. Document the lifetime contract or return by value for small types.

See Also

  • std::optional β€” C++17 vocabulary type; preferred backing storage for nullable lazy members
  • std::call_once / std::once_flag β€” C++11 primitives for exactly-once initialization across threads
  • std::shared_mutex β€” C++17 reader-writer lock enabling concurrent reads during lazy population
  • Static storage duration β€” the language guarantee behind thread-safe function-local statics (C++11)
  • std::async / std::future β€” C++11 deferred evaluation across call boundaries