Engee documentation

Values with limited scope

The page is in the process of being translated.

The values with limited scope represent the implementation of dynamic scopes in Julia.

!!! note "Lexical scoping vs dynamic scoping" Lexical scoping is the default behavior in Julia. In the lexical definition of areas, the area of a variable is determined by the lexical (textual) the structure of the program. When dynamically defining scopes, the variable is bound to the last assigned value during program execution.

The state of a value with a limited scope depends on the execution path of the program. This means that there can be several different values for a single value at the same time.

Compatibility: Julia 1.11

Limited scope values appeared in Julia 1.11. In Julia version 1.8 and later, a compatible implementation is available in the ScopedValues.jl package.

In its simplest form 'ScopedValue` can be created with a default value, and then used with or @with to add a new dynamic area. The new scope inherits all values from the parent scope (and recursively from all external scopes), with the provided limited-scope value taking precedence over the previous definitions.

First, let’s look at an example of a lexical domain. The 'let` operator starts a new lexical domain in which the external definition of x is obscured by its internal definition.

x = 1
let x = 5
    @show x # 5
end
@show x # 1

In the following example, since Julia uses a lexical scope, the variable x in the body of the function f refers to the variable x defined in the global scope, and adding the scope let does not change the value observed by the function f.

x = 1
f() = @show x
let x = 5
    f() # 1
end
f() # 1

Now, using `ScopedValue', you can use a dynamic scope.

using Base.ScopedValues

x = ScopedValue(1)
f() = @show x[]
with(x=>5) do
    f() # 5
end
f() # 1

Note that the observed value of ScopedValue depends on the program execution path.

It often makes sense to use the const variable to indicate a value with a limited scope, and you can set the value of several scopedvalues by calling with in one go.

using Base.ScopedValues

f() = @show a[]
g() = @show b[]

const a = ScopedValue(1)
const b = ScopedValue(2)

f() # a[] = 1
g() # b[] = 2

# Добавляем новую динамическую область и задаем значение.
with(a => 3) do
    f() # a[] = 3
    g() # b[] = 2
    with(a => 4, b => 5) do
        f() # a[] = 4
        g() # b[] = 5
    end
    f() # a[] = 3
    g() # b[] = 2
end

f() # a[] = 1
g() # b[] = 2

'ScopedValues` provides a macro version for with'. The expression `+@with var⇒val expr+ calculates expr in a new dynamic area with the variable var, which is set to val. @with var=>val expr is equivalent to with(var=>val) do expr end. However, with requires a closure or a function with a null argument, which results in an additional call frame. For example, consider the following function f:

using Base.ScopedValues
const a = ScopedValue(1)
f(x) = a[] + x

To run the function f in the dynamic range with a with the value 2, use with:

with(() -> f(10), a=>2)

However, this requires enclosing the function f in a function with a null argument. To avoid the appearance of an additional call frame, you can use the macro @with:

@with a=>2 f(10)

Dynamic areas are inherited by tasks Task at the time of the task creation. Dynamic regions are not distributed through Distributed.jl operations.

In the example below, we open a new dynamic area before starting a task. The parent task and two child tasks simultaneously observe independent values of the same value with a limited scope.

using Base.ScopedValues
import Base.Threads: @spawn

const scoped_val = ScopedValue(1)
@sync begin
    with(scoped_val => 2)
        @spawn @show scoped_val[] # 2
    end
    with(scoped_val => 3)
        @spawn @show scoped_val[] # 3
    end
    @show scoped_val[] # 1
end

Values with a limited area are constant throughout the entire area, but a mutable state can be stored in such a value. Remember that in the context of parallel programming, there are the usual caveats regarding global variables.

Care must also be taken when storing references to mutable state in values with limited scope. Perhaps, when adding a new dynamic area, you will want to explicitly cancel shared access to the changeable state.

using Base.ScopedValues
import Base.Threads: @spawn

const sval_dict = ScopedValue(Dict())

# Пример неправильного использования изменяемого значения
@sync begin
    # `Dict`не является потокобезопасным, приведенное ниже использование является недопустимым
    @spawn (sval_dict[][:a] = 3)
    @spawn (sval_dict[][:b] = 3)
end

@sync begin
    # Если вместо этого мы передадим каждой задаче уникальный словарь,
    # то сможем обращаться к словарям без гонок.
    with(sval_dict => Dict()) do
        @spawn (sval_dict[][:a] = 3)
    end
    with(sval_dict => Dict()) do
        @spawn (sval_dict[][:b] = 3)
    end
end

Example

In the example below, a limited scope value is used to implement access permission verification in a web application. After defining the query permissions, a new dynamic scope is added and a value with a limited scope of `LEVEL' is set. Other parts of the application can request a value with a limited scope and receive the corresponding value. Other alternatives, such as task-local storage and global variables, are not well suited for this kind of distribution. Our only alternative would be to run the value through the entire call chain.

using Base.ScopedValues

const LEVEL = ScopedValue(:GUEST)

function serve(request, response)
    level = isAdmin(request) ? :ADMIN : :GUEST
    with(LEVEL => level) do
        Threads.@spawn handle(request, response)
    end
end

function open(connection::Database)
    level = LEVEL[]
    if level !== :ADMIN
        error("Access disallowed")
    end
    # ... открыть подключение
end

function handle(request, response)
    # ...
    open(Database(#=...=#))
    # ...
end

Idioms

Revoking shared access to a changeable state

using Base.ScopedValues
import Base.Threads: @spawn

const sval_dict = ScopedValue(Dict())

# Если вы хотите добавить новые значения в словарь, а не заменять
# их, явным образом отмените общий доступ к значениям. В этом примере мы используем `merge`
# для отмены общего доступа к состоянию словаря в родительской области.
@sync begin
    with(sval_dict => merge(sval_dict[], Dict(:a => 10))) do
        @spawn @show sval_dict[][:a]
    end
    @spawn sval_dict[][:a] = 3 # Гонка отсутствует, поскольку общий доступ отменен.
end

Values with limited scope as global variables

To access the meaning of a limited-scope value, the limited-scope value itself must be in the (lexical) scope. This means that most often you will use values with limited scope as permanent global variables.

using Base.ScopedValues
const sval = ScopedValue(1)

In fact, values with limited scope can be considered as hidden arguments to functions.

This does not exclude their use as non-global variables.

using Base.ScopedValues
import Base.Threads: @spawn

function main()
    role = ScopedValue(:client)

    function launch()
        #...
        role[]
    end

    @with role => :server @spawn launch()
    launch()
end

But perhaps in such cases it would be easier to just directly pass the argument to the function.

Too many ScopedValue values

If you create a lot ScopedValue for a single module, it might be better to use a special structure to store them.

using Base.ScopedValues

Base.@kwdef struct Configuration
    color::Bool = false
    verbose::Bool = false
end

const CONFIG = ScopedValue(Configuration(color=true))

@with CONFIG => Configuration(color=CONFIG[].color, verbose=true) begin
    @show CONFIG[].color # true
    @show CONFIG[].verbose # true
end

API Documentation

ScopedValue(x)

Create a container that distributes values across dynamic areas. Use with to create a dynamic area and enter it.

The values can only be set when entering a new dynamic area, and they will be constant throughout the lifetime of this area.

Dynamic areas apply to all tasks.

Examples

julia> using Base.ScopedValues;

julia> const sval = ScopedValue(1);

julia> sval[]
1

julia> with(sval => 2) do
           sval[]
       end
2

julia> sval[]
1
Compatibility: Julia 1.11

Limited scope values appeared in Julia 1.11. In Julia version 1.8 and later, a compatible implementation is available in the ScopedValues.jl package.

with(f, (var::ScopedValue{T} => val)...)

Executes f in a new dynamic area with the variable var, which has been assigned the value val'. `val is converted to type `T'.

Examples

julia> using Base.ScopedValues

julia> a = ScopedValue(1);

julia> f(x) = a[] + x;

julia> f(10)
11

julia> with(a=>2) do
           f(10)
       end
12

julia> f(10)
11

julia> b = ScopedValue(2);

julia> g(x) = a[] + b[] + x;

julia> with(a=>10, b=>20) do
           g(30)
       end
60

julia> with(() -> a[] * b[], a=>3, b=>4)
12
@with (var::ScopedValue{T} => val)... expr

The with version as a macro. The expression @with var=>val expr calculates expr in a new dynamic area with the variable var, which is set to val. val is converted to type T'. `+@with var⇒val expr+ is equivalent to with(var=>val) do expr end, but @with avoids creating a closure.

Examples

julia> using Base.ScopedValues

julia> const a = ScopedValue(1);

julia> f(x) = a[] + x;

julia> @with a=>2 f(10)
12

julia> @with a=>3 begin
           x = 100
           f(x)
       end
103
isassigned(val::ScopedValue)

Checks whether `ScopedValue' has been assigned a value.

Examples

julia> using Base.ScopedValues

julia> a = ScopedValue(1); b = ScopedValue{Int}();

julia> isassigned(a)
true

julia> isassigned(b)
false
get(val::ScopedValue{T})::Union{Nothing, Some{T}}

If a value with a limited area is not specified and has no default value, returns nothing'. Otherwise, it returns `+Some{T}+ with the current value.

Examples

julia> using Base.ScopedValues

julia> a = ScopedValue(42); b = ScopedValue{Int}();

julia> ScopedValues.get(a)
Some(42)

julia> isnothing(ScopedValues.get(b))
true

Implementation notes and performance

Scope uses a permanent dictionary. The search and insertion are performed using O(log(32, n)). When entering a dynamic area, a small amount of data is copied, and the unchanged data is distributed among other areas.

The object itself Scope is not intended for users and may be changed in a future version of Julia.

Ideas for development

This development was heavily influenced by the document https://openjdk.org/jeps/429 [JEPS-429], which in turn relied on free variables in the dynamic domain in many Lisp dialects. In particular, in Interlisp-D and its deep linking strategy.

The design of context variables was discussed earlier https://peps.python.org/pep-0567 /[PEPS-567], implemented in Julia as https://github.com/tkf/ContextVariablesX .jl[ContextVariablesX].