Skip to content

Commit 5fb39c6

Browse files
authored
Allow ScopedValue defaults to be lazily computed (#59372)
1 parent 9e5e287 commit 5fb39c6

File tree

5 files changed

+107
-37
lines changed

5 files changed

+107
-37
lines changed

NEWS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ New library features
6767
migrate now by calling `legacyscope=false` or using `macroexpand!`. This may often require
6868
fixes to the code calling `macroexpand` with `Meta.unescape` and `Meta.reescape` or by
6969
updating tests to expect `hygienic-scope` or `escape` markers might appear in the result.
70+
* `Base.ScopedValues.LazyScopedValue{T}` is introduced for scoped values that compute their default using a
71+
`OncePerProcess{T}` callback, allowing for lazy initialization of the default value. `AbstractScopedValue` is
72+
now the abstract base type for both `ScopedValue` and `LazyScopedValue`. ([#59372])
7073

7174
Standard library changes
7275
------------------------

base/scopedvalues.jl

Lines changed: 84 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,57 @@
22

33
module ScopedValues
44

5-
export ScopedValue, with, @with
5+
export ScopedValue, LazyScopedValue, with, @with
66
public get
77

8+
"""
9+
AbstractScopedValue{T}
10+
11+
Abstract base type for scoped values that propagate values across
12+
dynamic scopes. All scoped value types must extend this abstract type.
13+
14+
See also: [`ScopedValue`](@ref), [`LazyScopedValue`](@ref)
15+
16+
!!! compat "Julia 1.13"
17+
AbstractScopedValue requires Julia 1.13+.
18+
"""
19+
abstract type AbstractScopedValue{T} end
20+
21+
22+
"""
23+
LazyScopedValue{T}(f::OncePerProcess{T})
24+
25+
A scoped value that uses an `OncePerProcess{T}` to lazily compute its default value
26+
when none has been set in the current scope. Unlike `ScopedValue`, the default is
27+
not evaluated at construction time but only when first accessed.
28+
29+
# Examples
30+
31+
```julia-repl
32+
julia> using Base.ScopedValues;
33+
34+
julia> const editor = LazyScopedValue(OncePerProcess(() -> ENV["JULIA_EDITOR"]));
35+
36+
julia> editor[]
37+
"vim"
38+
39+
julia> with(editor => "emacs") do
40+
sval[]
41+
end
42+
"emacs"
43+
44+
julia> editor[]
45+
"vim"
46+
```
47+
48+
!!! compat "Julia 1.13"
49+
LazyScopedValue requires Julia 1.13+.
50+
"""
51+
mutable struct LazyScopedValue{T} <: AbstractScopedValue{T}
52+
const getdefault::OncePerProcess{T}
53+
end
54+
55+
856
"""
957
ScopedValue(x)
1058
@@ -40,17 +88,23 @@ julia> sval[]
4088
Scoped values were introduced in Julia 1.11. In Julia 1.8+ a compatible
4189
implementation is available from the package ScopedValues.jl.
4290
"""
43-
mutable struct ScopedValue{T}
91+
mutable struct ScopedValue{T} <: AbstractScopedValue{T}
4492
# NOTE this struct must be defined as mutable one since it's used as a key of
4593
# `ScopeStorage` dictionary and thus needs object identity
46-
const has_default::Bool # this field is necessary since isbitstype `default` field may be initialized with undefined value
94+
const hasdefault::Bool # this field is necessary since isbitstype `default` field may be initialized with undefined value
4795
const default::T
48-
ScopedValue{T}() where T = new(false)
96+
ScopedValue{T}() where T = new{T}(false)
4997
ScopedValue{T}(val) where T = new{T}(true, val)
5098
ScopedValue(val::T) where T = new{T}(true, val)
5199
end
52100

53-
Base.eltype(::ScopedValue{T}) where {T} = T
101+
Base.eltype(::AbstractScopedValue{T}) where {T} = T
102+
103+
hasdefault(val::ScopedValue) = val.hasdefault
104+
hasdefault(val::LazyScopedValue) = true
105+
106+
getdefault(val::ScopedValue) = val.hasdefault ? val.default : throw(KeyError(val))
107+
getdefault(val::LazyScopedValue) = val.getdefault()
54108

55109
"""
56110
isassigned(val::ScopedValue)
@@ -72,34 +126,34 @@ julia> isassigned(b)
72126
false
73127
```
74128
"""
75-
function Base.isassigned(val::ScopedValue)
76-
val.has_default && return true
129+
function Base.isassigned(val::AbstractScopedValue)
130+
hasdefault(val) && return true
77131
scope = Core.current_scope()::Union{Scope, Nothing}
78132
scope === nothing && return false
79133
return haskey((scope::Scope).values, val)
80134
end
81135

82-
const ScopeStorage = Base.PersistentDict{ScopedValue, Any}
136+
const ScopeStorage = Base.PersistentDict{AbstractScopedValue, Any}
83137

84138
struct Scope
85139
values::ScopeStorage
86140
end
87141

88142
Scope(scope::Scope) = scope
89143

90-
function Scope(parent::Union{Nothing, Scope}, key::ScopedValue{T}, value) where T
144+
function Scope(parent::Union{Nothing, Scope}, key::AbstractScopedValue{T}, value) where T
91145
val = convert(T, value)
92146
if parent === nothing
93147
return Scope(ScopeStorage(key=>val))
94148
end
95149
return Scope(ScopeStorage(parent.values, key=>val))
96150
end
97151

98-
function Scope(scope, pair::Pair{<:ScopedValue})
152+
function Scope(scope, pair::Pair{<:AbstractScopedValue})
99153
return Scope(scope, pair...)
100154
end
101155

102-
function Scope(scope, pair1::Pair{<:ScopedValue}, pair2::Pair{<:ScopedValue}, pairs::Pair{<:ScopedValue}...)
156+
function Scope(scope, pair1::Pair{<:AbstractScopedValue}, pair2::Pair{<:AbstractScopedValue}, pairs::Pair{<:AbstractScopedValue}...)
103157
# Unroll this loop through recursion to make sure that
104158
# our compiler optimization support works
105159
return Scope(Scope(scope, pair1...), pair2, pairs...)
@@ -115,19 +169,17 @@ function Base.show(io::IO, scope::Scope)
115169
else
116170
print(io, ", ")
117171
end
118-
print(io, typeof(key), "@")
172+
print(io, isa(key, ScopedValue) ? ScopedValue{eltype(key)} : typeof(key), "@")
119173
show(io, Base.objectid(key))
120174
print(io, " => ")
121175
show(IOContext(io, :typeinfo => eltype(key)), value)
122176
end
123177
print(io, ")")
124178
end
125179

126-
struct NoValue end
127-
const novalue = NoValue()
128-
129180
"""
130181
get(val::ScopedValue{T})::Union{Nothing, Some{T}}
182+
get(val::LazyScopedValue{T})::Union{Nothing, Some{T}}
131183
132184
If the scoped value isn't set and doesn't have a default value,
133185
return `nothing`. Otherwise returns `Some{T}` with the current
@@ -148,31 +200,36 @@ julia> isnothing(ScopedValues.get(b))
148200
true
149201
```
150202
"""
151-
function get(val::ScopedValue{T}) where {T}
203+
function get(val::AbstractScopedValue{T}) where {T}
152204
scope = Core.current_scope()::Union{Scope, Nothing}
153205
if scope === nothing
154-
val.has_default && return Some{T}(val.default)
155-
return nothing
206+
!hasdefault(val) && return nothing
207+
return Some{T}(getdefault(val))
156208
end
157209
scope = scope::Scope
158-
if val.has_default
159-
return Some{T}(Base.get(scope.values, val, val.default)::T)
210+
if hasdefault(val)
211+
return Some{T}(Base.get(Base.Fix1(getdefault, val), scope.values, val)::T)
160212
else
161-
v = Base.get(scope.values, val, novalue)
162-
v === novalue || return Some{T}(v::T)
213+
v = Base.KeyValue.get(scope.values, val)
214+
v === nothing && return nothing
215+
return Some{T}(only(v)::T)
163216
end
164217
return nothing
165218
end
166219

167-
function Base.getindex(val::ScopedValue{T})::T where T
220+
function Base.getindex(val::AbstractScopedValue{T})::T where T
168221
maybe = get(val)
169222
maybe === nothing && throw(KeyError(val))
170223
return something(maybe)::T
171224
end
172225

173-
function Base.show(io::IO, val::ScopedValue)
174-
print(io, ScopedValue)
175-
print(io, '{', eltype(val), '}')
226+
function Base.show(io::IO, val::AbstractScopedValue)
227+
if isa(val, ScopedValue)
228+
print(io, ScopedValue)
229+
print(io, '{', eltype(val), '}')
230+
else
231+
print(io, typeof(val))
232+
end
176233
print(io, '(')
177234
v = get(val)
178235
if v === nothing
@@ -265,7 +322,7 @@ julia> with(() -> a[] * b[], a=>3, b=>4)
265322
12
266323
```
267324
"""
268-
function with(f, pair::Pair{<:ScopedValue}, rest::Pair{<:ScopedValue}...)
325+
function with(f, pair::Pair{<:AbstractScopedValue}, rest::Pair{<:AbstractScopedValue}...)
269326
@with(pair, rest..., f())
270327
end
271328
with(@nospecialize(f)) = f()

stdlib/Test/src/Test.jl

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,7 @@ using Random: AbstractRNG, default_rng
2929
using InteractiveUtils: gen_call_with_extracted_types
3030
using Base: typesplit, remove_linenums!
3131
using Serialization: Serialization
32-
using Base.ScopedValues: ScopedValue, @with
33-
34-
const record_passes = OncePerProcess{Bool}() do
35-
return Base.get_bool_env("JULIA_TEST_RECORD_PASSES", false)
36-
end
32+
using Base.ScopedValues: LazyScopedValue, ScopedValue, @with
3733

3834
const global_fail_fast = OncePerProcess{Bool}() do
3935
return Base.get_bool_env("JULIA_TEST_FAILFAST", false)
@@ -1253,10 +1249,11 @@ struct FailFastError <: Exception end
12531249
# For a broken result, simply store the result
12541250
record(ts::DefaultTestSet, t::Broken) = ((@lock ts.results_lock push!(ts.results, t)); t)
12551251
# For a passed result, do not store the result since it uses a lot of memory, unless
1256-
# `record_passes()` is true. i.e. set env var `JULIA_TEST_RECORD_PASSES=true` before running any testsets
1252+
# `TEST_RECORD_PASSES[]` is true. i.e. overridden by scoped value or with env var
1253+
# `JULIA_TEST_RECORD_PASSES=true` set in the environment.
12571254
function record(ts::DefaultTestSet, t::Pass)
12581255
@atomic :monotonic ts.n_passed += 1
1259-
if record_passes()
1256+
if TEST_RECORD_PASSES[]
12601257
# throw away the captured data so it can be GC-ed
12611258
t_nodata = Pass(t.test_type, t.orig_expr, nothing, t.value, t.source, t.message_only)
12621259
@lock ts.results_lock push!(ts.results, t_nodata)
@@ -2103,6 +2100,9 @@ end
21032100
const CURRENT_TESTSET = ScopedValue{AbstractTestSet}(FallbackTestSet())
21042101
const TESTSET_DEPTH = ScopedValue{Int}(0)
21052102
const TESTSET_PRINT_ENABLE = ScopedValue{Bool}(true)
2103+
const TEST_RECORD_PASSES = LazyScopedValue{Bool}(OncePerProcess{Bool}() do
2104+
return Base.get_bool_env("JULIA_TEST_RECORD_PASSES", false)
2105+
end)
21062106

21072107
macro with_testset(ts, expr)
21082108
:(@with(CURRENT_TESTSET => $(esc(ts)), TESTSET_DEPTH => get_testset_depth() + 1, $(esc(expr))))

test/scopedvalues.jl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,3 +197,15 @@ nothrow_scope(Core.current_scope())
197197
push!(ts, 2)
198198
end
199199
end
200+
201+
# LazyScopedValue
202+
global lsv_ncalled = 0
203+
const lsv = LazyScopedValue{Int}(OncePerProcess(() -> (global lsv_ncalled; lsv_ncalled += 1; 1)))
204+
@testset "LazyScopedValue" begin
205+
@test (@with lsv=>2 lsv[]) == 2
206+
@test lsv_ncalled == 0
207+
@test lsv[] == 1
208+
@test lsv_ncalled == 1
209+
@test lsv[] == 1
210+
@test lsv_ncalled == 1
211+
end

test/testdefs.jl

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,10 @@ using Test, Random
55
include("buildkitetestjson.jl")
66

77
function runtests(name, path, isolate=true; seed=nothing)
8-
@Base.ScopedValues.with Test.TESTSET_PRINT_ENABLE=>false begin
8+
@Base.ScopedValues.with Test.TESTSET_PRINT_ENABLE=>false Test.TEST_RECORD_PASSES=>Base.get_bool_env("CI", false) begin
99
# remove all hint_handlers, so that errorshow tests are not changed by which packages have been loaded on this worker already
1010
# packages that call register_error_hint should also call this again, and then re-add any hooks they want to test
1111
empty!(Base.Experimental._hint_handlers)
12-
withenv("JULIA_TEST_RECORD_PASSES" => Base.get_bool_env("CI", false)) do
1312
try
1413
if isolate
1514
# Simple enough to type and random enough so that no one will hard
@@ -105,7 +104,6 @@ function runtests(name, path, isolate=true; seed=nothing)
105104
ex isa TestSetException || rethrow()
106105
return Any[ex]
107106
end
108-
end # withenv
109107
end # TESET_PRINT_ENABLE
110108
end
111109

0 commit comments

Comments
 (0)