Summary

Add support for lazy initialized values to standard library, effectively superseding the popular lazy_static crate.

use std::sync::Lazy;

// `BACKTRACE` implements `Deref<Target = Option<String>>` and is initialized
// on the first access
static BACKTRACE: Lazy<Option<String>> = Lazy::new(|| {
    std::env::var("RUST_BACKTRACE").ok()
});

Motivation

Working with lazy initialized values is ubiquitous, lazy_static and lazycell crates are used throughout the ecosystem. Although some of the popularity of lazy_static can be attributed to current limitations of constant evaluation in Rust, there are many cases when even perfect const fn can’t replace lazy values.

At the same time, working with lazy values in Rust is not easy:

  • Implementing them requires moderately tricky unsafe code. Multiple soundness holes were found in the implementations from crates.io.
  • C++ and Java provide language-level delayed initialization for static values, while Rust requires explicit code to handle runtime-initialization.
  • Rust borrowing rules require a special pattern when implementing lazy fields.

lazy_static is implemented using macros, to work-around former language limitations. Since then, various language improvements have made it possible to create runtime initialized (lazy) objects in a static scope, accomplishing the same goals without macros.

We can have a single canonical API for a commonly used tricky unsafe concept, so we probably should have it!

Guide-level explanation

Lazy values are a form of interior mutability. The key observation is that restricting a cell to single assignment allows to safely return a shared reference to the contents of the cell. Such cell is called OnceCell, by analogy with std::sync::Once type. The core API is as follows:

pub struct OnceCell<T> { ... }

impl<T> OnceCell<T> {
    /// Creates a new empty cell.
    pub const fn new() -> OnceCell<T>;

    /// Gets the reference to the underlying value.
    ///
    /// Returns `None` if the cell is empty.
    pub fn get(&self) -> Option<&T>;

    /// Sets the contents of this cell to `value`.
    ///
    /// Returns `Ok(())` if the cell was empty and `Err(value)` if it was
    /// full.
    pub fn set(&self, value: T) -> Result<(), T>;

    /// Gets the contents of the cell, initializing it with `f`
    /// if the cell was empty.
    ///
    /// # Panics
    ///
    /// If `f` panics, the panic is propagated to the caller, and the cell
    /// remains uninitialized.
    ///
    /// It is an error to reentrantly initialize the cell from `f`. Doing
    /// so results in a panic or a deadlock.
    pub fn get_or_init<F>(&self, f: F) -> &T
    where
        F: FnOnce() -> T,
    ;

    /// Gets the contents of the cell, initializing it with `f` if
    /// the cell was empty. If the cell was empty and `f` failed, an
    /// error is returned.
    ///
    /// # Panics
    ///
    /// If `f` panics, the panic is propagated to the caller, and the cell
    /// remains uninitialized.
    ///
    /// It is an error to reentrantly initialize the cell from `f`. Doing
    /// so results in a panic or a deadlock.
    pub fn get_or_try_init<F, E>(&self, f: F) -> Result<&T, E>
    where
        F: FnOnce() -> Result<T, E>,
    ;
}

Notable features of the API:

  • OnceCell is created empty, by a const fn.
  • Initialization succeeds at most once.
  • get_or_init and get_or_try_init methods can be used to conveniently initialize a cell.
  • get_ family of methods return &T.

Similarly to other interior mutability primitives, OnceCell comes in two flavors:

  • Non thread-safe std::cell::OnceCell.
  • Thread-safe std::sync::OnceLock.

Here’s how OnceCell can be used to implement lazy-initialized global data:

use std::{sync::{Mutex, OnceCell}, collections::HashMap};

fn global_data() -> &'static Mutex<HashMap<i32, String>> {
    static INSTANCE: OnceCell<Mutex<HashMap<i32, String>>> = OnceCell::new();
    INSTANCE.get_or_init(|| {
        let mut m = HashMap::new();
        m.insert(13, "Spica".to_string());
        m.insert(74, "Hoyten".to_string());
        Mutex::new(m)
    })
}

Here’s how OnceCell can be used to implement a lazy field:

use std::{fs, io, path::PathBuf, cell::OnceCell};

struct Ctx {
    config_path: PathBuf,
    config: OnceCell<String>,
}

impl Ctx {
    pub fn get_config(&self) -> Result<&str, io::Error> {
        let cfg = self.config.get_or_try_init(|| {
            fs::read_to_string(&self.config_path)
        })?;
        Ok(cfg.as_str())
    }
}

We also provide the more convenient but less powerful Lazy<T, F> and LazyLock<T, F> wrappers around OnceCell<T> and OnceLock<T>, which allows specifying the initializing closure at creation time:

pub struct LazyCell<T, F = fn() -> T> { ... }

impl<T, F: FnOnce() -> T> LazyCell<T, F> {
    /// Creates a new lazy value with the given initializing function.
    pub const fn new(init: F) -> LazyCell<T, F>;

    /// Forces the evaluation of this lazy value and returns a reference to
    /// the result.
    ///
    /// This is equivalent to the `Deref` impl, but is explicit.
    pub fn force(this: &LazyCell<T, F>) -> &T;
}

impl<T, F: FnOnce() -> T> Deref for LazyCell<T, F> {
    type Target = T;

    fn deref(&self) -> &T;
}

LazyLock directly replaces lazy_static!:

use std::{sync::{Mutex, LazyLock}, collections::HashMap};

static GLOBAL_DATA: LazyLock<Mutex<HashMap<i32, String>>> = LazyLock::new(|| {
    let mut m = HashMap::new();
    m.insert(13, "Spica".to_string());
    m.insert(74, "Hoyten".to_string());
    Mutex::new(m)
});

Moreover, once #[thread_local] attribute is stable, Lazy might supplant std::thread_local! as well:

use std::cell::{RefCell, Lazy};

#[thread_local]
pub static FOO: Lazy<RefCell<u32>> = Lazy::new(|| RefCell::new(1));

Unlike lazy_static!, Lazy can be used for locals:

use std::cell::LazyCell;

fn main() {
    let ctx = vec![1, 2, 3];
    let thunk = LazyCell::new(|| {
        ctx.iter().sum::<i32>()
    });
    assert_eq!(*thunk, 6);
}

Reference-level explanation

The proposed API is directly copied from once_cell crate.

Altogether, this RFC proposes to add four types:

  • std::cell::OnceCell, std::cell::LazyCell
  • std::sync::OnceLock, std::sync::LazyLock

OnceCell and OnceLock are important primitives. LazyCell and LazyLock can be stabilized separately from OnceCell, or optionally omitted from the standard library altogether. However, as they provide significantly nicer ergonomics for the common use case of static lazy values, it is worth developing in tandem.

Non thread-safe flavor is implemented by storing an UnsafeCell<Option<T>>:

pub struct OnceCell<T> {
    // Invariant: written to at most once.
    inner: UnsafeCell<Option<T>>,
}

The implementation is mostly straightforward. The only tricky bit is that reentrant initialization should be explicitly forbidden. That is, the following program panics:

let x: OnceCell<Box<i32>> = OnceCell::new();
let dangling_ref: Cell<Option<&i32>> = Cell::new(None);
x.get_or_init(|| {
    let r = x.get_or_init(|| Box::new(92));
    dangling_ref.set(Some(r));
    Box::new(62)
});
println!("would be use after free: {:?}", dangling_ref.get().unwrap());

Non thread-safe flavor can be added to core as well.

The thread-safe variant is implemented similarly to std::sync::Once. Crucially, it has support for blocking: if many threads call get_or_init concurrently, only one will be able to execute the closure, while all other threads will block. For this reason, most of std::sync::OnceLock API can not be provided in core. In the sync case, reliably panicking on re-entrant initialization is not trivial. For this reason, the implementation would simply deadlock, with a note that a deadlock might be elevated to a panic in the future.

Drawbacks

  • This is a moderately large addition to stdlib, there’s a chance we do something wrong. This can be mitigated by piece-wise stabilization (in particular, LazyCell convenience types are optional) and the fact that API is tested in the crates.io ecosystem via once_cell crate.

  • The design of LazyCell type uses default type-parameter as a workaround for the absence of type inference of statics.

  • We use the same name for unsync and sync types, which might be confusing.

Rationale and alternatives

Why not LazyCell as a primitive?

On the first look, it may seem like we don’t need OnceCell, and should only provide LazyCell. The critical drawback of LazyCell is that it’s not always possible to provide the closure at creation time.

This is important for lazy fields:

struct Ctx {
    config_path: PathBuf,
    config: Lazy<String, ???>,
}

impl Ctx {
    pub fn new(config_path: PathBuf) -> Ctx {
        Ctx {
            config_path,
            config: Lazy::new(|| {
                // We would like to write something like
                // `fs::read_to_string(&self.config_path)`
                // here, but we can't have access to `self`
                ???
            })
        }
    }
}

Or for singletons, initialized with parameters:

use std::{env, io, sync::OnceCell};

#[derive(Debug)]
pub struct Logger { ... }

static INSTANCE: OnceCell<Logger> = OnceCell::new();
impl Logger {
    pub fn global() -> &'static Logger {
        INSTANCE.get().expect("logger is not initialized")
    }
    fn from_cli(args: env::Args) -> Result<Logger, std::io::Error> { ... }
}

fn main() {
    let logger = Logger::from_cli(env::args()).unwrap();

    // Note how we use locally-created value for initialization.
    INSTANCE.set(logger).unwrap();

    // use `Logger::global()` from now on
}

Why OnceCell as a primitive?

It is possible to imagine a type, slightly more general than OnceCell:

struct OnceFlipCell<U, V> { ... }

impl<U, V> OnceFlipCell<U, V> {
    const fn new(initial_value: U) -> OnceFlipCell<U, V>;

    fn get_or_init<F: FnOnce(U) -> V>(&self, f: F) -> &V;
}

type OnceCell<T> = OnceFlipCell<(), T>;

That is, we can store some initial state in the cell and consume it during initialization. In practice, such flexibility seems to be rarely required. Even if we add a type, similar to OnceFlipCell, having a dedicated OnceCell (which could be implemented on top of OnceFlipCell) type simplifies a common use-case.

Variations of set

The RFC proposes “obvious” signature for the set method:

fn set(&self, value: T) -> Result<(), T>;

Note, however, that set establishes an invariant that the cell is initialized, so a more precise signature would be

fn set(&self, value: T) -> (&T, Option<T>);

To be able to return a reference, set might need to block a thread. For example, if two threads call set concurrently, one of them needs to block while the other moves the value into the cell. It is possible to provide a non-blocking alternative to set:

fn try_set(&self, value: T) -> Result<&T, (Option<&T>, T)>

That is, if value is set successfully, a reference is returned. Otherwise, the cell is either fully initialized, and a reference is returned as well, or the cell is being initialized, and no valid reference exist yet.

Support for no_std

The RFC proposes to add cell::OnceCell and cell::LazyCell to core, while keeping sync::OnceLock and sync::LazyLock std-only. However, there’s a subset of OnceLock that can be provided in core:

impl<T> OnceCell<T> {
    const fn new() -> OnceCell<T>;
    fn get(&self) -> Option<&T>;
    fn try_set(&self, value: T) -> Result<&T, (Option<&T>, T)>
}

It is possible because, while OnceCell needs blocking for full API, its internal state can be implemented as a single AtomicUsize, so the core part does not need to know about blocking. It is unclear if this API would be significantly useful. In particular, the guarantees of non-blocking set are pretty weak, and are not enough to implement the Lazy wrapper.

While it is possible to implement blocking in #[no_std] via a spin lock, we explicitly choose not to do so. Spin locks are a sharp tool, which should only be used in specific circumstances (namely, when you have full control over thread scheduling). #[no_std] code might end up in user space applications with preemptive scheduling, where unbounded spin locks are inappropriate.

A spin-lock based implementation of OnceCell is provided on crates.io in conquer-once crate.

Poisoning

As a cell can be empty or fully initialized, the proposed API does not use poisoning. If an initialization function panics, the cell remains uninitialized. An alternative would be to add poisoning, which will make all subsequent get calls to panic.

Similarly, because OnceCell provides strong exception safety guarantee, it implements UnwindSafe:

impl<T: UnwindSafe>                    UnwindSafe for OnceCell<T> {}
impl<T: UnwindSafe + RefUnwindSafe> RefUnwindSafe for OnceCell<T> {}

Default type parameter on Lazy

Lazy is defined with default type parameter.

pub struct Lazy<T, F = fn() -> T> { ... }

This is important to make using Lazy in static contexts convenient. Without this default, the user would have to type T type twice:

static GLOBAL_DATA: Lazy<Mutex<HashMap<i32, String>>, fn() -> Mutex<HashMap<i32, String>>
    = Lazy::new(|| ... );

If we allow type inference in statics, this could be shortened to

static GLOBAL_DATA: Lazy<Mutex<HashMap<i32, String>>, _>
    = Lazy::new(|| ... );

There are two drawbacks of using fn pointer type:

  • fn pointers are not ZSTs, so we waste one pointer per static lazy value. Lazy locals will generally rely on type-inference and will use more specific closure type.
  • Specifying type for local lazy value might be tricky: let x: Lazy<i32> = Lazy::new(|| closed_over_var) fails with type error, the correct syntax is let x: Lazy<i32, _> = Lazy::new(|| closed_over_var).

Only thread-safe flavor

It is possible to add only sync version of the types, as they are the most useful. However, this would be against zero cost abstractions spirit. Additionally, non thread-safe version is required to replace thread_local! macro without imposing synchronization.

Synchronization Guarantees

In theory, it is possible to specify two different synchronization guarantees for get operation, release/acquire or release/consume. They differ in how they treat side effects. If thread A executes get_or_init(f), and thread B executes get and observes the value, release/acquire guarantees that B also observes side-effects of f.

Here’s a program which allows to observe the difference:

static FLAG: AtomicBool = AtomicBool::new(false);
static CELL: OnceCell<()> = OnceCell::new();

// thread1
CELL.get_or_init(|| FLAG.store(true, Relaxed));

// thread2
if CELL.get().is_some() {
  assert!(FLAG.load(Relaxed))
}

Under release/acquire, the assert never fires. Under release/consume, it might fire.

Release/consume can potentially be implemented more efficiently on weak memory model architectures. However, the situation with consume ordering is cloudy right now:

Given the cost of consume ordering for minimal benefit, this crate proposes to specify and implement acquire/release ordering. If at some point Rust adds a consume/release option to std::sync::atomic::Ordering, the option of adding API methods that accept an Ordering can be considered.

Prior art

The primary bit of prior art here is the once_cell library, which itself draws on multiple sources:

Many languages provide library-defined lazy values, for example Kotlin. Typically, a lazy value is just a wrapper around closure. This design doesn’t always work in Rust, as closing over self runs afoul of the borrow checker, we need a more primitive OnceCell type.

Unresolved questions

  • What is the best naming/place for these types?
  • What is the best naming scheme for methods? Is it get_or_try_init or try_insert_with?
  • Is the F = fn() -> T hack worth it?
  • Which synchronization guarantee should we pick?

Future possibilities

  • Once #[thread_local] attribute is stable, cell::Lazy can serve as a replacement for std::thread_local! macro.
  • Supporting type inference in constants might allow us to drop the default type parameter on Lazy.