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.

std::optional as a Lazy Slot

std::optional<T> is the cleanest backing store for a nullable lazy member. It distinguishes three things that a raw pointer conflates: "not yet computed" (empty optional), "computed and has a value" (engaged optional), and ownership of the stored object. No heap allocation is needed for T itself β€” the optional holds T inline.

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

class Config {
public:
    explicit Config(std::string path) : path_{std::move(path)} {}

    // Returns cached parse result; computes it on first call.
    // value_or() is convenient for simple scalar caches.
    int thread_count() const {
        if (!thread_count_) {
            thread_count_ = parse_thread_count(path_);
        }
        return *thread_count_;  // or thread_count_.value()
    }

    // Explicit boolean check keeps the intent readable for complex types.
    const std::vector<std::string>& plugins() const {
        if (!plugins_cache_.has_value()) {
            plugins_cache_ = parse_plugins(path_);
        }
        return *plugins_cache_;
    }

    // Invalidate both caches when the source changes.
    void reload(std::string new_path) {
        path_ = std::move(new_path);
        thread_count_.reset();   // back to disengaged
        plugins_cache_.reset();
    }

private:
    std::string path_;
    mutable std::optional<int> thread_count_;
    mutable std::optional<std::vector<std::string>> plugins_cache_;

    int parse_thread_count(const std::string& p) const;
    std::vector<std::string> parse_plugins(const std::string& p) const;
};

value_or(default) is a handy shorthand when the fallback is cheap and correct:

cpp
// Returns cached value, or 4 if not yet computed β€” but only useful when
// returning the default on miss is intentional, not as a substitute for caching.
int n = thread_count_.value_or(4);

Thread-safety caveat. std::optional itself provides no synchronization. Two threads calling thread_count() simultaneously on a shared const Config object cause a data race on thread_count_ β€” undefined behavior. Options: use std::call_once (see below), wrap writes behind a std::mutex or std::shared_mutex, or switch to std::atomic for trivially copyable types.

std::call_once and the Double-Checked Locking Trap

Why naive double-checked locking is undefined behavior

The "double-checked locking" (DCL) pattern is tempting: check a flag without holding a lock, acquire the lock only if unset, then check again. Without atomics, this is a data race and therefore undefined behavior under the C++ memory model.

cpp
// BROKEN β€” do not use.
static Singleton* instance = nullptr;
static std::mutex mtx;

Singleton* get() {
    if (!instance) {          // unsynchronized read β€” data race with writer
        std::lock_guard lk{mtx};
        if (!instance) {
            instance = new Singleton();  // write happens here
        }
    }
    return instance;          // unsynchronized read β€” data race
}

The problem is not just compiler reordering. The C++ memory model defines a data race as two concurrent accesses to the same memory location, at least one of which is a write and neither of which happens-before the other. The outer if (!instance) read and the instance = new Singleton() write on another thread have no synchronization relationship, so the program has undefined behavior β€” regardless of what the generated assembly looks like on any particular platform.

Correct manual DCL with std::atomic

If you truly need the lowest-overhead check on the fast path, use std::atomic with acquire/release ordering:

cpp
#include <atomic>
#include <mutex>

static std::atomic<Singleton*> instance{nullptr};
static std::mutex mtx;

Singleton* get() {
    Singleton* p = instance.load(std::memory_order_acquire); // safe read
    if (!p) {
        std::lock_guard lk{mtx};
        p = instance.load(std::memory_order_relaxed);        // re-check under lock
        if (!p) {
            p = new Singleton();
            instance.store(p, std::memory_order_release);    // safe write
        }
    }
    return p;
}

The acquire load pairs with the release store: every thread that sees a non-null pointer is guaranteed to see the fully constructed object. This is correct, but also more complex than it needs to be for most use cases.

std::call_once β€” always correct

std::call_once with std::once_flag eliminates the manual synchronization entirely. The standard library is permitted to implement it using the same efficient techniques, but you cannot misuse it the way you can misuse raw atomics:

cpp
#include <mutex>
#include <memory>

namespace {
    std::once_flag flag;
    std::unique_ptr<Singleton> instance;
}

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

Exception semantics. If the callable throws, the once_flag is left unset: the next call to std::call_once with the same flag will invoke the callable again. This retry behavior is intentional β€” initialization failed, so it should be retried. Ensure the callable is either strongly exception-safe (no persistent side effects on throw) or idempotent on retry.

When to reach for call_once vs. the static-local pattern. The static-local pattern (static T obj;) is simpler and equally thread-safe for C++11 and later. Prefer call_once when the flag or the callable must be shared across unrelated call sites, when initialization spans multiple steps that are easier to express as a lambda body, or when you need fine-grained control over the retry lifecycle.

std::atomic<std::shared_ptr<T>> for Lock-Free Lazy Initialization

C++20 introduced std::atomic<std::shared_ptr<T>> as a first-class specialization (replacing the deprecated free functions std::atomic_load / std::atomic_store). This enables a lock-free lazy singleton when the cost of construction is acceptable on only one thread:

cpp
#include <atomic>
#include <memory>

// C++20: std::atomic<std::shared_ptr<T>> is a proper specialization.
static std::atomic<std::shared_ptr<Singleton>> g_instance;

std::shared_ptr<Singleton> get_singleton() {
    auto p = g_instance.load(std::memory_order_acquire);
    if (!p) {
        // Race: multiple threads may construct here.
        auto candidate = std::make_shared<Singleton>();
        // compare_exchange_strong atomically publishes candidate if slot is still null.
        // If another thread won the race, we discard our candidate.
        if (g_instance.compare_exchange_strong(
                p,                              // expected (null)
                candidate,                      // desired
                std::memory_order_release,
                std::memory_order_acquire))
        {
            p = candidate;  // we won
        }
        // If we lost, p now holds the winner's pointer (loaded by CAS failure).
    }
    return p;
}

This pattern allows multiple threads to race to construct the object; the loser discards its copy. That is acceptable only when construction is side-effect-free (or idempotent) and cheap enough that a wasted construction is tolerable. When construction has observable side effects or is expensive, use call_once or the static-local pattern instead.

The std::shared_ptr null check β€” if (!p) after load β€” is safe because std::atomic<std::shared_ptr<T>> is always lock-free on pointer-sized operations (implementations may use internal locking for older ABIs, but the load/store interface is always correct and data-race-free).

Lazy Evaluation via Lambdas and std::function

Lazy evaluation β€” deferring computation until the result is needed β€” can be decoupled from the site that produces it using a thunk: a zero-argument callable that yields the value when invoked. std::function<T()> is the standard vocabulary type for storing a thunk.

Evaluate-once thunk

Combining a thunk with std::optional gives an evaluate-once cache that is agnostic to how the value is produced:

cpp
#include <functional>
#include <optional>
#include <string>

template <typename T>
class Lazy {
public:
    explicit Lazy(std::function<T()> producer)
        : producer_{std::move(producer)} {}

    const T& get() const {
        if (!cache_) {
            cache_ = producer_();   // call the thunk exactly once
        }
        return *cache_;
    }

    void reset() { cache_.reset(); }  // force re-evaluation on next get()

private:
    std::function<T()> producer_;
    mutable std::optional<T> cache_;
};

// Usage: heavy computation deferred until first access.
Lazy<std::string> greeting{[]() {
    return fetch_personalized_greeting_from_api();
}};

// Elsewhere:
std::puts(greeting.get().c_str());  // computed here
std::puts(greeting.get().c_str());  // cached β€” no second API call

This idiom is not thread-safe as written. Add a std::mutex around cache_ access or use std::call_once inside get() if the Lazy<T> instance is shared across threads.

Generator exhaustion pattern

When the producer should be called at most once and then discarded (freeing any captured state), nulling the std::function after the first call makes that policy explicit:

cpp
template <typename T>
class OnceSupplier {
public:
    explicit OnceSupplier(std::function<T()> fn) : fn_{std::move(fn)} {}

    T get() {
        if (fn_) {                     // still has a producer?
            value_ = fn_();            // evaluate
            fn_ = nullptr;             // release captured resources
        }
        return *value_;
    }

private:
    std::function<T()> fn_;
    std::optional<T> value_;
};

The fn_ = nullptr assignment destructs any captures (file handles, large buffers, shared ownership) immediately after the single evaluation, rather than holding them alive for the object's lifetime.

Deferred argument binding

Lambdas let you capture expensive-to-copy arguments by value when you create the thunk, binding them to the future computation without evaluating it yet:

cpp
auto make_report = [data = std::move(large_dataset), fmt = format_spec]() {
    return render_report(data, fmt);   // runs only when invoked
};

// Pass the thunk across an API boundary; the callee decides when to evaluate.
scheduler.defer_on_idle(std::move(make_report));

Memoization in const Member Functions

Memoization is the classic application of mutable in C++: a logically const function caches a derived result so that repeated calls are O(1) after the first. The mutable keyword grants write access to a member even through a const object reference.

cpp
#include <optional>
#include <mutex>
#include <cmath>

class Triangle {
public:
    Triangle(double a, double b, double c) : a_{a}, b_{b}, c_{c} {}

    // Logically const: the triangle's area does not change.
    // [[nodiscard]] encourages callers not to discard the result.
    [[nodiscard]] double area() const {
        if (!area_cache_) {
            double s = (a_ + b_ + c_) / 2.0;
            area_cache_ = std::sqrt(s * (s-a_) * (s-b_) * (s-c_));  // Heron's formula
        }
        return *area_cache_;
    }

private:
    double a_, b_, c_;
    mutable std::optional<double> area_cache_;
};

Thread safety in mutable const objects

The mutable keyword does not grant thread safety. When a const method modifies a mutable member, concurrent callers see a data race. There are two standard remedies:

Option A β€” mutable std::mutex (always correct):

cpp
class Triangle {
public:
    [[nodiscard]] double area() const {
        std::lock_guard lk{mu_};
        if (!area_cache_) {
            double s = (a_ + b_ + c_) / 2.0;
            area_cache_ = std::sqrt(s * (s-a_) * (s-b_) * (s-c_));
        }
        return *area_cache_;
    }

private:
    double a_, b_, c_;
    mutable std::optional<double> area_cache_;
    mutable std::mutex mu_;   // guards area_cache_ in concurrent contexts
};

Option B β€” std::atomic for trivial types (lock-free, C++11):

cpp
#include <atomic>
#include <cmath>
#include <limits>

class Triangle {
public:
    explicit Triangle(double a, double b, double c) : a_{a}, b_{b}, c_{c} {}

    [[nodiscard]] double area() const {
        double cached = area_cache_.load(std::memory_order_acquire);
        if (cached < 0.0) {                     // sentinel: not computed yet
            double s = (a_ + b_ + c_) / 2.0;
            cached = std::sqrt(s * (s-a_) * (s-b_) * (s-c_));
            area_cache_.store(cached, std::memory_order_release);
        }
        return cached;
    }

private:
    double a_, b_, c_;
    // Initialized to -1.0 as "not yet computed" sentinel.
    mutable std::atomic<double> area_cache_{-1.0};
};

The atomic approach avoids mutex overhead when T is trivially copyable and has a usable sentinel value. The std::optional<double> cannot itself be stored in std::atomic directly (it is not trivially copyable), so the sentinel-value trick replaces has_value(). When no natural sentinel exists, fall back to Option A.

The rule of thumb: any mutable member that is written inside a const member function requires either std::atomic (if trivially copyable and a sentinel is available) or std::mutex/std::call_once if the object might be accessed from multiple threads. A mutable std::optional<T> without synchronization is only safe when the object is used from a single thread.

C++26 std::lazy β€” Async Lazy Evaluation

Proposal P2506 introduces std::lazy<T> as a coroutine return type targeting C++26. It is unrelated to the memoization patterns above: std::lazy<T> is an asynchronous lazy type from the senders/receivers framework, designed for composing coroutine pipelines where work is deferred until a coroutine is co_awaited.

cpp
// Hypothetical C++26 β€” interface subject to change before standardization.
#include <execution>  // senders/receivers + std::lazy

std::lazy<std::string> fetch_data(std::string url) {
    // co_await suspends here; no work runs until the lazy is awaited.
    auto response = co_await async_http_get(url);
    co_return response.body();
}

// Nothing executes yet β€” construction is free.
auto data_task = fetch_data("https://api.example.com/data");

// Work begins only when the lazy is scheduled on an execution context.
// std::this_thread::sync_wait(std::move(data_task));  // blocks until done

std::lazy<T> is a cold coroutine: it does not start until it is awaited. This differs from std::async(std::launch::async, ...), which may start the task immediately on a thread pool, and from std::async(std::launch::deferred, ...), which is synchronous and single-threaded. std::lazy<T> is designed for structured concurrency in async contexts and is out of scope for the synchronous lazy initialization patterns covered in this page.

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