Expand description
A thread-safe, insert-once type map that hands out plain references.
OnceTypeMap stores at most one value per type. Think of it as a
OnceLock per type, for types that are not known when the owning struct
is written. Values are set through &self and returned as plain &T
references tied to the borrow of the map — the caller never holds a lock
guard.
This lets an object embed one OnceTypeMap and let other code attach
state to it (caches, adapters, implementers of extra traits) while the
object keeps exposing ordinary fn foo(&self) -> &Foo accessors.
use once_type_map::OnceTypeMap;
struct Client {
extensions: OnceTypeMap,
}
impl Client {
fn extensions(&self) -> &OnceTypeMap {
&self.extensions
}
}
struct Metrics {
name: &'static str,
}
let client = Client { extensions: OnceTypeMap::new() };
let metrics: &Metrics = client.extensions().get_or_init(|| Metrics { name: "requests" });
assert_eq!(metrics.name, "requests");§Why the references outlive the internal lock
Each type gets its own heap-allocated OnceLock<T> slot. Through &self
a slot can only be created and filled once; it is never moved, replaced or
dropped. The internal hash map may reallocate when other types are added,
but that moves only the pointer to a slot, not the slot itself. A &T
therefore stays valid for as long as the map is borrowed.
The map owns each slot through an Arc that is never cloned, rather
than a Box. Whether moving a Box invalidates references into its
contents is still an open question in Rust’s aliasing rules, and the map
moves its slot pointers while such references are alive. An Arc holds a
plain pointer with no uniqueness claim, so moving it is just copying an
address, and the argument above doesn’t depend on how that is settled.
Mutating or removing values requires &mut self (get_mut, remove,
clear). The borrow checker allows that only when no &T handed out by
the map is alive — the same reasoning that lets OnceLock::get_mut and
OnceLock::take exist.
§What a live reference allows
While a reference obtained from the map is alive, the map is shared, not
frozen. Anything that takes &self still works, including adding new
types, from this thread or others:
use once_type_map::OnceTypeMap;
let map = OnceTypeMap::new();
let name: &String = map.get_or_init(|| "first".to_string());
map.get_or_init(|| 42i32);
map.insert(2.5f64).unwrap();
std::thread::scope(|s| {
s.spawn(|| map.get_or_init(|| 7u8));
});
assert_eq!(map.len(), 4);
assert_eq!(name, "first");Anything that takes &mut self — get_mut, remove, clear — is
rejected by the borrow checker while the reference is alive:
use once_type_map::OnceTypeMap;
let mut map = OnceTypeMap::new();
let name: &String = map.get_or_init(|| "first".to_string());
map.clear(); // error: `map` is already borrowed as immutable
println!("{name}");So is moving or dropping the map, which would free the value:
use once_type_map::OnceTypeMap;
let map = OnceTypeMap::new();
let name: &String = map.get_or_init(|| "first".to_string());
drop(map); // error: cannot move out of `map` because it is borrowed
println!("{name}");Once the reference is no longer used, the borrow ends and mutation is allowed again:
use once_type_map::OnceTypeMap;
let mut map = OnceTypeMap::new();
let count: &i32 = map.get_or_init(|| 1);
assert_eq!(*count, 1);
*map.get_mut::<i32>().unwrap() += 1;
assert_eq!(map.remove::<i32>(), Some(2));§Initialization
get_or_init runs the initializer outside the map’s lock, so a slow
initializer for one type does not block other types, and an initializer may
itself use the map for other types.
When many threads race to initialize the same type, only one initializer
runs; the others block until the value is ready, then return it. Once an
initializer returns, no other initializer for that type runs until the
value is removed. If the initializer panics, the slot stays empty and the
next call runs its own initializer. So an initializer can run more than
once over the life of the map: after a panic, and after remove or
clear.
§Deadlocks
Calling get_or_init::<T> or
insert::<T> from inside the initializer for the
same T is a reentrant OnceLock initialization. The standard library
leaves the outcome unspecified, and today it deadlocks.
Initializers that depend on each other in a cycle deadlock too. If the
initializer for A uses B and the initializer for B uses A, then:
- on one thread, initializing
AinitializesB, which re-entersA’s initialization (the reentrant case above); - on two threads, one starting with
Aand the other withB, each thread blocks forever waiting for the slot the other is initializing.
Neither case panics or reports an error. Keep the dependencies between initializers acyclic.
§Empty slots
The map creates a type’s slot the first time get_or_init or
insert is called for it, before the value exists. If the initializer
panics, the empty slot stays in the map. Nothing called through &self
removes it, though a later successful initialization reuses it. This costs
at most one small allocation per distinct type, but len and
is_empty scan empty slots along with full ones. remove::<T> and
clear free empty slots too.
§Limitations
- One value per type. Use newtypes to store several values of the same underlying type.
- Initializers are synchronous and can’t fail. See below for how to handle setup that can fail or is async.
§Fallible and async initialization
std’s OnceLock::get_or_try_init is not stable yet, so the map has no
get_or_try_init. What to store instead depends on what a failure means:
-
The failure is final. Store the
Resultitself. Every caller sees the same outcome, and setup runs once:struct Port(Result<u16, std::num::ParseIntError>); let port = &map.get_or_init(|| Port("80a".parse())).0; assert!(port.is_err()); -
The failure is temporary. Store a cell that supports a fallible initializer, such as
once_cell::sync::OnceCell, and call itsget_or_try_init. A failed attempt stores nothing, so the next call tries again, and only one attempt runs at a time. -
Setup is async. Store an async cell, such as
tokio::sync::OnceCell. The map’sget_or_initonly creates the empty cell, and the cell’s asyncget_or_init(orget_or_try_init) does the work without blocking the executor. Don’t run slow or blocking setup in the map’s ownget_or_initfrom async code: callers that wait on it block their thread.
The fallible_extensions and async_extensions examples in the
repository show these patterns. Once std stabilizes
OnceLock::get_or_try_init, the map will gain a matching method.
§Cost
A lookup takes a shared read lock and does one hash map probe keyed by
TypeId. Since the returned reference is not tied to a guard, callers on
hot paths can look a value up once and keep the &T.
Structs§
- Once
Type Map - A thread-safe map holding at most one value per type, set once through
&selfand read through plain references.