How to use the lazy initialization pattern with Rust 1.80

Written by Yashodhan Joshi✏️

Are you sure you have to initialize every part of your app at the very start of your application?

One of the most common things done at the application start is initializing various resources. This can be app config, logging service, or some database connection that will be later used by the request handlers. However, not all of these need to be ready at the very start of the app, and can lead to a slow-start.

This is where lazy initialization helps us with deferring the resource initialization until the resource is needed. This can also skip the initialization entirely if the resource in question is not used at all.

In older versions, Rust did not have support for such lazy initializations in its standard library. There were several popular crates in the ecosystem which were commonly used for this functionality, such as [lazy_static](https://blog.logrocket.com/rust-lazy-static-pattern/) and once_cell . Starting from Rust 1.80, a lot of functionality provided by these crates is now available in the standard library itself, and can be used in place of these two crates.

In this post, we will see:

  • The lazy initialization pattern
  • How lazy_static and once_cell provide lazy initialization
  • Using stdlib for lazy initialization
  • Comparison between standard library, lazy_static, and once_cell

The lazy initialization pattern

Consider an example where we have an API endpoint which is infrequently used and needs to read and parse a large file from disk.

We can do the read and parse at the start of our application, however, that might block the server from serving requests until the parsing is complete. It might also be the case that the particular API endpoint is not hit at all, so the resources spent in loading the file were not useful.

Another example is if an application uses some in-memory DB such as sqlite or redis. However, not all invocation of the application actually need the DB. In such a case, loading the DB in memory and maintaining the connection overhead each time is not useful.

We can defer the initialization of such resources until they are needed, initialize them on their first use, and keep them around for later use. This pattern is known as lazy initialization.

However this presents a small problem in Rust , where we must either pass the lazily initialized resource to each function as a param, or make it a static global and use unsafe Rust to initialize it at runtime.

To avoid this, crates like lazy_static or once_cell provide safe wrappers around the unsafe operations, and we can use them to safely use lazily initialized values throughout our code.

How lazy_static and once_cell provide lazy initialization

lazy_static provides a macro to write the initialization code for the static variables, and it is used at runtime to initialize the variable at the first use. The general syntax is:

use lazy_static::lazy_static;
lazy_static!{
  static ref VAR : TYPE = {initialization code}
}

For example, setting the log level as a static variable would be something like:

use lazy_static::lazy_static;

lazy_static! {
    static ref LOG_LEVEL: String = get_log_level();
}

fn get_log_level() -> String {
    match std::env::var("LOG_LEVEL") {
        Ok(s) => s,
        Err(_) => "WARN".to_string(),
    }
}

fn main() {
    println!("{}", *LOG_LEVEL);
}

Code in the lazy_static! definition uses the get_log_level function at runtime to set the log level.

While pretty straightforward, this has some of its own quirks. We have to use static ref, which is not a valid Rust syntax, and we need to de-reference the LOG_LEVEL for use as seen in the println statement. We can do the same thing using once_cell crate as:

use once_cell::sync::OnceCell;

static LOG_LEVEL: OnceCell<String> = OnceCell::new();

fn get_log_level() -> String {
    match std::env::var("LOG_LEVEL") {
        Ok(s) => s,
        Err(_) => "WARN".to_string(),
    }
}

fn main() {
    let log_level = LOG_LEVEL.get_or_init(get_log_level);
    println!("{}", log_level);
}

Here, instead of specifying the code in the declaration, we use the get_or_init method when we want to retrieve the value.

If the value is not initialized, the given function will be used to initialize the value, and otherwise it will return the existing value. Because we get the value directly, we do not need any extra de-referencing.

While both of these have their pros and cons, one common thing is that you need to add one more external crate in your dependencies. Now that the stdlib provides types for the lazy pattern, we can directly use those instead and reduce the number of dependencies.

Using stdlib for lazy initialization

In Rust 1.70 and 1.80, types similar to lazy_static and once_cell have been stabilized in the Rust Standard Library itself. We can use them instead of any external crates to achieve similar functionality.

OnceLock type from the standard library can be used similar to once_cell crate’s OnceCell type:

use std::sync::OnceLock;

static LOG_LEVEL: OnceLock<String> = OnceLock::new();

fn get_log_level() -> String {
    match std::env::var("LOG_LEVEL") {
        Ok(s) => s,
        Err(_) => "WARN".to_string(),
    }
}

fn main() {
    let log_level = LOG_LEVEL.get_or_init(get_log_level);
    println!("{}", log_level);
}

Comparing to the once_cell example, we have replaced the OnceCell by OnceLock, but the rest of the code is still the same. OnceLock type also exposes a method called get_or_init which provides the same functionality as OnceCell's get_or_init.

Somewhat similar to the lazy_static, we can use the LazyLock type to specify the initialization function at the declaration level without having to use a macro:

use std::sync::LazyLock;

static LOG_LEVEL: LazyLock<String> = LazyLock::new(get_log_level);

fn get_log_level() -> String {
    match std::env::var("LOG_LEVEL") {
        Ok(s) => s,
        Err(_) => "WARN".to_string(),
    }
}

fn main() {
    println!("{}", *LOG_LEVEL);
}

Here we pass an initialization function to the new method of LazyLock, and when the variable’s value is accessed for the first time, the type internally calls this function to initialize the value.

Comparing lazy_static, once_cell, and native types

As mentioned in the release notes of Rust 1.70 and 1.80 , the code for the newly added native types is adopted from the once_cell crate. Thus, they provide functionality quite similar to the original implementations from the crate.

The lazy_static crate uses its own macro syntax to declare the static variables, which I still occasionally forget. Comparatively, once_cell does not need any macro or custom syntax, and does all the lazy stuff based solely on types.

Another case to note is that in case of lazy_static , the initialization code has to be directly written inside the declaration macro. If what you need is a more flexible way to initialize similar to get_or_init function, you have to use once_cell or the standard library types.

Compared to both of these crates, one big advantage of native types is not requiring any additional dependency. Even though both of the crates only have a couple of dependencies themselves, it still means your project will have a couple of more dependencies and take slightly more time for compiling.

Another advantage of native types is that they are developed and maintained directly by the official rust standard library team.

Conclusion

The introduction of lazy_static initialization types into the Rust standard library itself provides a lot of convenience for using lazy initialization into our code without having to add another crate as a dependency.

With these types, we get the power of doing expensive calculation only when needed and initializing some expensive constructs such as regular expressions exactly once without having to deal with manual initialization checks every time.

That said, existing crates like lazy_static or once_cell are still popular and well-maintained, and I don’t see them going away anytime soon.

If you are already using them or want to use them because of familiarity, you can keep using them without worry. Even if you switch your own code to use the native types, there’s a good chance that some of your dependencies use these crates, and they may not switch to native types immediately, thus still keeping these crates in your dependency tree.

However, I believe we’ll see more use of the native types as time goes on instead of the external crates in new projects that are created after this.

You can find the code examples in the Github repo here. Thanks for reading!


LogRocket: Full visibility into web frontends for Rust apps

Debugging Rust applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking performance of your Rust apps, automatically surfacing errors, and tracking slow network requests and load time, try LogRocket.

LogRocket Rust Demo

LogRocket is like a DVR for web apps, recording literally everything that happens on your Rust app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app’s performance, reporting metrics like client CPU load, client memory usage, and more.

Modernize how you debug your Rust apps — start monitoring for free.