Zulip Chat Archive

Stream: lean4

Topic: Lazy initialization


Eric Wieser (Feb 14 2025 at 12:08):

Does something like this already exist?

/-- Wrapper for a lazily-initializer reference. -/
structure LazilyInitialized (α : Type) : Type where
  private ref : IO.Ref (Option α)
  private mutex : Std.BaseMutex
  private init : IO α
deriving Nonempty

def LazilyInitialized.new {α} (init : IO α) : IO (LazilyInitialized α) := do
  return { ref :=  IO.mkRef none, mutex :=  Std.BaseMutex.new, init := init}

/-- Obtain the value, constructing it in a thread-safe way if necessary. -/
def LazilyInitialized.get {α} (l : LazilyInitialized α) : IO α := do
  if let .some a  l.ref.get then
    return a
  try
    l.mutex.lock
    if let .some a  l.ref.get then
      return a
    let a  l.init
    l.ref.set (some a)
    return a
  finally
    l.mutex.unlock

initialize foo : LazilyInitialized Foo  LazilyInitialized.new do
  someExpensiveIOCall

Eric Wieser (Feb 14 2025 at 12:31):

(context: nomeata/loogle#26)

Joachim Breitner (Feb 14 2025 at 13:11):

Looks like an IO version of docs#Thunk

Joachim Breitner (Feb 14 2025 at 13:13):

(I’ll watch this discussion in case something new comes up before merging your PR)

Eric Wieser (Feb 14 2025 at 14:25):

Ah indeed, though Thunk uses atomics behind the scenes rather than a mutex

Eric Wieser (Feb 14 2025 at 14:25):

Maybe this warrants a ThunkT monad transformer, with Thunk := ThunkT Id?

Eric Wieser (Feb 17 2025 at 12:11):

So something like

import Std
import Lean

/-- A monadic thunk, that memorizes its result after the first execution.

Can also be thought of as a monadic lazy initializer-/
structure ThunkM (m : Type  Type) [Monad m] [MonadLiftT BaseIO m] [MonadFinally m] (α : Type) : Type where
  private ctor ::
    private ref : IO.Ref (Option α)
    private mutex : Std.BaseMutex
    private init : m α

variable {m : Type  Type} {α} [Monad m] [MonadLiftT BaseIO m] [MonadFinally m]

instance [Nonempty (m α)] : Nonempty (ThunkM m α) :=
  let ref := inferInstanceAs <| Nonempty (IO.Ref (Option α))
  let mutex := inferInstanceAs <| Nonempty Std.BaseMutex
  let init := inferInstanceAs <| Nonempty (m α)
  {ref, mutex, init}

/-- Create a monadic thunk from an initializer.

When `.get` is called and the initializer succeeds, its result is cached. -/
def ThunkM.mk (init : m α) : BaseIO (ThunkM m α) := do
  return { ref :=  IO.mkRef none, mutex :=  Std.BaseMutex.new, init := init}

/-- Obtain the value, constructing it in a thread-safe way if necessary. -/
def ThunkM.get (l : ThunkM m α) : m α := do
  if let .some a  (l.ref.get : BaseIO (Option α)) then
    return a
  try
    l.mutex.lock
    if let .some a  (l.ref.get : BaseIO (Option α)) then
      return a
    let a  l.init
    (l.ref.set (some a) : BaseIO Unit)
    return a
  finally
    l.mutex.unlock

/-- info: ran initializer once -/
#guard_msgs in
run_meta do
  let lazy1 : ThunkM Lean.MetaM Nat  ThunkM.mk <| do
    Lean.logInfo "ran initializer once"
    return 37
  lazy1.get
  lazy1.get

Joachim Breitner (Feb 17 2025 at 13:04):

Remind me why do you want to lift it out of IO? I guess now m can do “things”, but since you don't really know when the first get it will be invoked, it seems like it can be somewhat confusing and dangerous to have the effects of m appear there.

Eric Wieser (Feb 17 2025 at 13:08):

I don't have a good motivation for that besides trying to generalize. Indeed, it's hard to think of a monad where this lift wouldn't be confusing and dangerous

Joachim Breitner (Feb 17 2025 at 13:08):

Ok, that’s what I thought :-)

Eric Wieser (Feb 17 2025 at 13:09):

Maybe a version that cached the monad state and context at the time of initialization and used that in .get would be sane

Joachim Breitner (Feb 17 2025 at 13:10):

Yes, possibly, using docs#MonadControlT or so. But even then it may be confusing that any side-effects are dropped.

Eric Wieser (Feb 17 2025 at 13:10):

But it's more than I need, so maybe the original with name IO.Thunk is best?

Joachim Breitner (Feb 17 2025 at 13:11):

If in doubt, don’t generalize. (It’s code, not math)

Eric Wieser (Feb 17 2025 at 13:12):

I guess ExceptT BonusEx IO would be a place where the generality is useful

Joachim Breitner (Feb 17 2025 at 13:13):

Really? In that case the action would be thrown upon each .get and never cached (based on your code above)

Eric Wieser (Feb 17 2025 at 13:13):

That's already what it does with io errors

Joachim Breitner (Feb 17 2025 at 13:14):

Hmm, fair point. Maybe it should live in BaseIO then :-)

Joachim Breitner (Feb 17 2025 at 13:16):

Probably that’s why docs#BaseIO.asTask is also in BaseIO

Eric Wieser (Feb 17 2025 at 13:17):

Joachim Breitner said:

Hmm, fair point. Maybe it should live in BaseIO then :-)

Or there should be both BaseIO.Thunk and IO.Thunk?

Eric Wieser (Feb 17 2025 at 13:22):

For what it's worth, Python's @functools.cache has the behavior of not caching errors, like IO.Thunk would (https://github.com/python/cpython/issues/65736)

Joachim Breitner (Feb 17 2025 at 14:20):

Maybe BaseIO.Thunk is enough. IO.Thunk sounds dangerous, unless it does store the exception (i.e. is a thin wrapper around BaseIO.Thunk)

Eric Wieser (Feb 17 2025 at 15:36):

Would you like me to make such a change to the loogle PR, or is it blocked until this discussion is resolved anyway?

Joachim Breitner (Feb 17 2025 at 15:39):

I guess I can merge this change (with a BaseIO-typed-thunk) to unblock your experiments using Loogle locally.


Last updated: May 02 2025 at 03:31 UTC