This type of caching is also called lazy initialization. I would go with something similar to your approach 4, but extract the lazy logic to be fully independent/reusable; something like this perhaps:
mutable struct Lazy{T}
f::Function
value::Union{Nothing, T}
calculated::Bool
Lazy{T}(f) where T = new{T}(f, nothing, false)
end
function value(z::Lazy{T}) where T
z.calculated || (z.value = z.f(); z.calculated = true)
z.value::T
end
I used a calculated
field instead of isnothing
, in case T
is Nothing
(which could make sense if the lazy function doesn’t need a return value, e.g. if modifying some global state). Note that this particular implementation is not thread safe.
Now your example can be written like this:
struct MyType{T}
v::Vector{T}
lims::Lazy{Tuple{T,T}}
MyType(v::AbstractVector{T}) where T = new{T}(v, Lazy{Tuple{T,T}}(() -> extrema(v)))
end
lims(m::MyType) = value(m.lims)
Testing it:
julia> m = MyType([11, 77, 58, 4, 25, 78, 63, 5, 97, 40]);
julia> lims(m)
(4, 97)
julia> @code_warntype lims(m)
Body::Tuple{Int64,Int64}
1 ─ %1 = (Base.getfield)(m, :lims)::Lazy{Tuple{Int64,Int64}}
│ %2 = invoke Main.value(%1::Lazy{Tuple{Int64,Int64}})::Tuple{Int64,Int64}
└── return %2