LazyCell for any arbitrary lazy evaluation

I think we should broaden possible applications of the LazyCell type. I have come across this sentiment that LazyCell is only useful for initializing values at runtime that cannot be evaluated at compile-time. I think this is because of the use of LazyStatic crate, but LazyCell can potentially be used for a lot more. I think LazyCell can be used for any arbitrary lazy evaluation. Right now LazyCell only applies to FnOnce() -> T. This is a good start and allows for closures in addition to functions, but it has limits. As an example, I want to provide the users of my crate with structs (LotsOfCalcs) that can calculate material properties using the struct's implemented functions (cheap_prop_calc_X), but to evaluate these properties I need inputs from the user and I need to calculate intermediate values (expensive_calcs) that are used to calculate the final properties. I only want the intermediate values to be evaluated if and when the user needs them because they are expensive. The code below tries to illustrate this, the only problem is that LazyCell does not implement my struct (ExpensiveCalc), but I think it could with a small change.

struct ExpensiveCalc {
    inputs: (f64, f64),
    expensive_func: fn(f64, f64) -> f64
}

pub struct LotsOfCalcs {
    expensive_calcs: [LazyCell<f64, ExpensiveCalc>; 5]
}

impl LotsOfCalcs {
    pub fn cheap_prop_calc_1(&self) -> MadeFromExpensiveCalcs;
    pub fn cheap_prop_calc_2(&self) -> MadeFromExpensiveCalcs;
    pub fn cheap_prop_calc_3(&self) -> MadeFromExpensiveCalcs;
}

Now there is a way around this so that I could still use LazyCell as-is, but it requires dynamic dispatch because closures are "un-nameable". I mean you could do it with generics, but you would be juggling around 5 different generics, one for each LazyCell. I have also heard people suggest using a HashMap to store cached values instead of LazyCells or using OnceCell somehow, but LazyCell really is the best fit for this problem. The code below works, but the heap allocation and vtable lookups are going to slow it down and I would like this to be suitable for a hot loop.

pub struct LotsOfCalcs {
    expensive_calcs: [LazyCell<f64, Box<dyn FnOnce() -> f64>>; 5]
}

impl LotsOfCalcs {
    fn new(inputs: (f64, f64)) -> LotsOfCalcs {
        let expensive_calcs = [
            LazyCell::new(|| expensive_func_1(inputs)),
            ...
        ];
        LotsOfCalcs {expensive_calcs}
    }
    ...
}

Instead I want to use the former code, but I need a way for the LazyCell to support the ExpensiveCalc type. I propose a simple CallOnce public trait in the std::cell module, see below. This would be the same as the FnOnce() -> T trait, except it could be implemented by structs or enums. Instead of LazyCell being implemented for <T: FnOnce() -> T> it would be implemented for <T: CallOnce>.

pub trait CallOnce{
    type Output;
    fn call_once(self) -> Self::Output;
}

I think it would be trivial to implement this trait for all functions and closures, see below.

impl<T, F: FnOnce() -> T> CallOnce for F {
    type Output = T;
    fn call_once(self) -> Self::Output {
        self()
    }
}

Similarly, CallOnce could be implemented for my ExpensiveCalc as follows:

impl CallOnce for ExpensiveCalc {
    type Output = f64;
    fn call_once(self) -> Self::Output {
        (self.expensive_func)(self.inputs.0, self.inputs.1)
    }
}

The final option would be to make a bespoke type for my application; however, it would literally be an exact copy for LazyCell with the aforementioned addition.

I think the fn_traits feature will work for your use-case.

2 Likes

You are absolutely right! I guess I am a little worried because it is not stable. I looked through the issue on GitHub, but I couldn't figure out if it is actually being worked on or if stabilization is feasible.

Because closure types are per location (per generic instantiation if relevant), you can make this work with stable LazyCell by having an into_closure method on your helper struct. Here’s a tiny example (the playground is not fun to use from a phone, so I just faked it):

EDIT: Ah, but this doesn’t help you name the type. Which you might be able to work around with more generics, but I can see how that’s annoying.

1 Like

The current std solution would be to fall down to using OnceCell instead, e.g. (ExpensiveCalc, OnceCell<f64>) with it.1.get_or_init(|| (it.0.expensive_func)(it.0.inputs.0, it.1.inputs.1)).

I don't really see std providing an "Invoke" trait that's effectively just an alias for FnOnce<()> just to make nullary FnOnce stable to implement without needing to address that it wants for vararg support. However, lazy computation with LazyCell does serve as an additional motivator to address that subset.

If provided in std, I expect the impl direction would be Invoke -> Fn rather than Fn -> Invoke (as would be required outside of std). This enables us to deprecate Invoke once Fn is eventually stable to implement.

2 Likes

Thank you. This will work well for my application. LazyCell would be nice, because I can pass the closure to it once during initialization and LazyCell implements Deref.

LazyCell also allows you to send it to a user and they can treat it as the inner type without worrying about when or how it get initialized. Hopeful fn_traits gets stabilized someday.