The primary function of PreludeDicts.jl is modify! which is a very flexible API (e.g., all
Base APIs can be implemented based on modify! efficiently) and also extensible (e.g.,
lock-free dictionaries can
support this API).
PreludeDicts.jl also has functions tryset!, trysetwith!, tryget and tryinsert! using
efficient and debuggable error handling API
Try.jl.
See the Documentation for API reference.
modify!(f, dict. key) provides a very powerful API for accessing and manipulating the
value associated with a key. It takes a single-argument function f that takes
nothingifkeydoes not exist- a key-value pair if
keyhas a value
as an argument and then can return
nothingto delete the valueSome(value)to insert thevalue
The function f can also return, as an optimization:
Delete(data)to delete the value but also propagatingdatato the callerKeep(data)to not change the value but propagatingdatato the caller
For example, modify! can be used to insert or set a value
julia> using PreludeDicts
julia> dict = Dict(:a => 0);
julia> modify!(Returns(Some(111)), dict, :a)
Some(111)
julia> dict[:a]
111or delete a value
julia> dict = Dict(:a => 0);
julia> modify!(Returns(nothing), dict, :a)
julia> haskey(dict, :a)
falseor access the value
julia> dict = Dict(:a => 111);
julia> getvalue(x) = x === nothing ? nothing : Some(last(x));
julia> modify!(getvalue, dict, :a)
Some(111)
julia> modify!(getvalue, dict, :b) === nothing
truemodify! is powerful because above operations can be dynamically controlled using the
function f:
julia> inc!(dict, key) = modify!(dict, key) do slot
if slot === nothing
Some(1)
else
Some(last(slot) + 1)
end
end;
julia> dict = Dict(:a => 111);
julia> inc!(dict, :a)
Some(112)
julia> inc!(dict, :b)
Some(1)
julia> dict == Dict(:a => 112, :b => 1)
trueIf modify! is directly implemented (which is the case for Dict for some Julia versions),
it is more efficient than, e.g., dict[key] = get(dict, key, 0) + 1 which requires two hash
function calls and probings.
Note that modify!(getvalue, dict, :a) above is not maximally efficient since it re-inserts
the existing value. As a (rather micro) optimization, Keep can be used instead
julia> dict = Dict(:a => 111);
julia> getvalue2(x) = x === nothing ? nothing : Keep(last(x));
julia> y = modify!(getvalue2, dict, :a)
Keep(111)
julia> y[] # get the wrapped data
111
julia> modify!(getvalue2, dict, :b) === nothing
trueTo help implementing functions like pop! efficiently, modify also supports Delete that
signals the deletion like nothing but can have a data payload.
julia> y = modify!(Delete, dict, :a)
Delete(:a => 111)
julia> y[] # get the wrapped data
:a => 111
julia> haskey(dict, :a)
falsetrysetwith!(f, dict, key) is like get!(f, dict, key) but the returned value esncode
if the value returned by f is inserted or not.
julia> dict = Dict(:a => 111);
julia> trysetwith!(Returns(222), dict, :a)
Try.Err: :a => 111
julia> trysetwith!(Returns(222), dict, :b)
Try.Ok: :b => 222
julia> dict == Dict(:a => 111, :b => 222)
truetryset!(dict, key, value) is like get!(dict, key, value) but the returned value encodes
if the value is inserted or not.
julia> dict = Dict(:a => 111);
julia> tryset!(dict, :a, 222)
Try.Err: :a => 111
julia> tryset!(dict, :b, 222)
Try.Ok: :b => 222
julia> dict == Dict(:a => 111, :b => 222)
truetryget(dict, key) is similar to dict[key] but the returned value encodes if the key
exists or not, instead of throwing.
julia> dict = Dict(:a => 111);
julia> tryget(dict, :a)
Try.Ok: 111
julia> tryget(dict, :b)
Try.Err: TypedKeyError: key :b not foundtryinsert!(set, x) is like push!(set, x) but the the returned value encodes if the item
x is inserted or not.
julia> set = Set([111]);
julia> tryinsert!(set, 111)
Try.Err: 111
julia> tryinsert!(set, 222)
Try.Ok: 222
julia> set == Set([111, 222])
truemodify!-based implementations show 40% to 50% performance improvements in some benchmarks:
julia> using PreludeDictsBenchmarks
julia> suite = PreludeDictsBenchmarks.setup();
julia> results = run(suite)
2-element BenchmarkTools.BenchmarkGroup:
tags: []
"TrySet" => 2-element BenchmarkTools.BenchmarkGroup:
tags: []
"impl=:tryset!" => Trial(27.639 μs)
"impl=:tryset_generic!" => Trial(39.650 μs)
"Increments" => 2-element BenchmarkTools.BenchmarkGroup:
tags: []
"impl=:modify!" => Trial(1.001 ms)
"impl=:modify_generic!" => Trial(1.587 ms)where the implementations (impl) with _generic! suffix uses the generic implementation
that is not written in terms of the direct implementation of modify! that touches the
dictionary internals.
See benchmark/PreludeDictsBenchmarks for benchmark
code.
Various efficient AbstractDict API implementations can be derived from modify!, showing
that this is a powerful API basis:
julia> function getindex′(dict, key)
y = modify!(Keep, dict, key)
y === nothing && throw(KeyError(key))
return last(y[])
end;
julia> getindex′(Dict(:a => 111), :a)
111
julia> setindex′!(dict, value, key) = modify!(Returns(Some(value)), dict, key);
julia> dict = Dict();
julia> setindex′!(dict, 111, :a);
julia> dict[:a]
111
julia> function pop′!(dict, key)
pair = modify!(Delete, dict, key)[]
pair === nothing && throw(KeyError(key))
return last(pair)
end;
julia> pop′!(dict, :a)
111
julia> dict == Dict()
true
julia> function get′!(f, dict, key)
y = modify!(dict, key) do pair
if pair === nothing
Some(f())
else
Keep(last(pair))
end
end
return y isa Keep ? y[] : something(y)
end;
julia> get′!(() -> 222, dict, :a)
222
julia> dict[:a]
222
julia> get′!(() -> 333, dict, :a)
222
julia> dict[:a]
222Other dictionary interfaces have been explored.
Dictionaries.jl has a token-based API to
avoid repeatedly calling hash function and probings. Other languages have similar
mechanism. For example, Rust's HashMap has the Entry
API that achieves the
same effect. However, since Julia has coroutine (Task), the token system is "isomorphic"
to modify! in the sense that one can be implemented in terms of another (see below).
More importantly, modify! gives more freedom to the dictionary implementer in how the
function f passed by the user is called. For example, in lock-free dictionaries, it may
be possible that f is called multiple times if multiple tasks try to update the same slot
concurrently. Indeed,
ConcurrentCollections uses a
similar API to manipulate ConcurrentDict. (TODO: Use PreludeDicts.jl in
ConcurrentCollections.jl.)
struct Token # not specializing for fields for simplicity
task::Task
key::Any
state::Union{Nothing,Pair}
end
function gettoken(dict, key)
parent = current_task()
task = @task begin
y = modify!(dict, key) do state
yieldto(parent, state)
end
yieldto(parent, y)
end
return Token(task, key, yieldto(task))
end
function Base.getindex(token::Token)
state = token.state
state === nothing && throw(KeyError(token.key))
return last(state)
end
Base.setindex!(token::Token, value) = yieldto(token.task, Some(value))
dict = Dict()
tk = gettoken(dict, :a)
tk[] = 111
tk = gettoken(dict, :a)
tk[]
# output
111