michie (sounds like Mickey) — an attribute macro that adds memoization to a function.
- Features
- Non-features
- key_expr
- store_type
- store_init
- Type requirements
- Generic functions
- Functions that take no input
- How it works
- Why must key_expr be provided
- Support and feedback
- Supports
- Plain functions
- Generic functions
- Functions in
impl
blocks - Functions in trait implementation blocks
- Functions that are default trait implementations
- Thread safe
- Expansion depends on only
std
- Hygienic
- Supports recursive functions
- Bring your own store
- Caching features: this crate does not provide a caching mechanism other than some trivial implementations. It allows you to bring your own.
- "Blazingly fast": this crate aims to provide a simple and easy-to-use means of memoizing a function. If you actually really require micro-optimized memoization then you'd most likely have to implement it yourself.
In each invocation a key is obtained.
It is used to query the function's cache store for a possible hit.
An expression that evaluates into a key must be provided via the key_expr
argument.
The expression may use bindings from the function's parameters.
In the following example the key_expr
is simply the name of the only parameter.
use michie::memoized;
use std::collections::HashMap;
#[memoized(key_expr = input, store_type = HashMap<usize, usize>)]
fn f(input: usize) -> usize {
// expensive calculation
# unimplemented!()
}
A concrete store type must be either provided via the store_type
argument or inferred from the store_init
(next section).
The provided type must implement [MemoizationStore
].
Implementations of [MemoizationStore
] for [BTreeMap
] and [HashMap
] are provided.
In the following example, [BTreeMap
] is provided as the store:
use michie::memoized;
use std::collections::BTreeMap;
#[memoized(key_expr = input, store_type = BTreeMap<usize, usize>)]
fn f(input: usize) -> usize {
// expensive calculation
# unimplemented!()
}
By default, the store is initialized via [Default::default()
].
Different initialization may be provided via an expression to store_init
:
use michie::memoized;
use std::collections::HashMap;
#[memoized(key_expr = input, store_init = HashMap::<usize, usize>::with_capacity(500))]
fn f(input: usize) -> usize {
// expensive calculation
# unimplemented!()
}
Bounds apply to the key type and the function's return type.
Some are from the general instrumentation and others are via the store type's implementation of [MemoizationStore
].
The following apply to the key type and to the function's return type:
- [
Sized
]: for one, the instrumentation stores the key in alet
binding. 'static
: key and return values are owned by a store which is owned by a static.- [
Send
] and [Sync
]: for parallel access.
Another source of bounds on the key type and the return type is the implementation of [MemoizationStore
] for the store type.
Be mindful of the type requirements when using on a generic function:
use michie::memoized;
use std::hash::Hash;
use std::collections::HashMap;
#[memoized(key_expr = input.clone(), store_type = HashMap<A, B>)]
fn f<A, B>(input: A) -> B
where
A: 'static + Send + Sync // bounds from instrumentation
+ Eq + Hash // store-specific bounds
+ Clone, // used in this function's `key_expr`
B: 'static + Send + Sync + Clone // bounds from instrumentation
+ From<A>, // used in this function's body
{
input.into()
}
Functions that take no input are good candidates for compile-time evaluation,
which is usually preferred over runtime caching (such as this crate provides).
Nonetheless, some functions cannot be evaluated at compile time.
A reasonable key_expr
for a function that takes no input is ()
:
use michie::memoized;
use std::collections::HashMap;
#[memoized(key_expr = (), store_type = HashMap<(), f64>)]
fn f() -> f64 {
// expensive calculation
# unimplemented!()
}
The original function expands into something similar to this:
fn f(input: Input) -> Output {
static STORE = Mutex::new(#store_init);
let key = #key_expr;
let store_mutex_guard = STORE.lock().unwrap();
let attempt = store_mutex_guard.get(&key);
drop(store_mutex_guard);
if let Some(hit) = attempt {
return hit;
} else {
let miss = #original_fn_body;
let miss = STORE.lock().unwrap().insert(key, miss);
return miss;
};
}
The only conceivable default key_expr
is the entire input.
For example, for a function signature:
fn f(a: usize, _b: usize) -> usize
the default key_expr
would be (a, _b)
.
Two potential problems: some parameters might not satisfy bounds on the key type.
Also, the resulting key might be a supervalue of the input of the actual calculation.
To explain the latter problem, here is an example:
use michie::memoized;
use std::collections::HashMap;
// pretend that `key_expr` is omitted and that this is the default
#[memoized(key_expr = (a, _b), store_type = HashMap<(usize, usize), usize>)]
fn f(a: usize, _b: usize) -> usize {
// the actual calculation uses a subvalue of the input — only `a`
# a
}
f(0, 0); // expected miss because it's the first invocation
f(0, 1); // avoidable miss!
If an accurate key_expr = a
had been provided, the second execution would have been a hit.
To summarize, key_expr
is mandatory in order to encourage proper consideration of it.