Ref ref.
Introduction
Today I want to discuss the Ref
type defined in Base Julia.
The reason is that it is often used in practice, but it is not immediately
obvious what the design behind Ref
is.
I will focus on an entry-level introduction to the topic and leave out more advanced issues that are typically not needed when working with Julia.
This post is written under Julia 1.9.0-rc2.
Where can you see Ref
?
As a normal Julia user there are two cases, where you might encounter Ref
:
broadcasting and allowing mutation.
Broadcasting
The first case is in broadcasting when you want to store some object in a 0-dimensional container that protects its contents from being broadcasted over. Here is a typical example:
julia> x = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> y = [2, 3, 4]
3-element Vector{Int64}:
2
3
4
julia> Ref(x) .* y
3-element Vector{Vector{Int64}}:
[2, 4, 6]
[3, 6, 9]
[4, 8, 12]
Note that I wrap x
in Ref
to ensure that the whole x
vector is multiplied
by elements of y
. If I omitted Ref
I would get an elementwise product of
x
and y
:
julia> x .* y
3-element Vector{Int64}:
2
6
12
The reason why Ref
is used in such cases is that Ref
makes a minimal impact on
the type of the result of the broadcasted operation. Consider this example:
julia> z = (2, 3, 4)
(2, 3, 4)
julia> [x] .* z
3-element Vector{Vector{Int64}}:
[2, 4, 6]
[3, 6, 9]
[4, 8, 12]
julia> Ref(x) .* z
([2, 4, 6], [3, 6, 9], [4, 8, 12])
Here I protected x
when multiplying it by elements of the tuple z
.
I could protect x
by wrapping it with a vector, but, as you can see
then the result of the operation would be vector of vectors. While
wrapping x
in Ref
produces a tuple of vectors as a result.
As you can see, using Ref
made broadcasting mechanism use the type of
the other container to determine the output type, which is typically desirable.
Allowing mutation
The other use of Ref
is when we have an immutable type that we want to be able
to mutate 😄. This might sound strange, but sometimes indeed it is useful.
Let me give you a simple example:
julia> struct X
value::Int
callcount::Base.RefValue{Int}
X(x) = new(Int(x), Ref(0))
end
julia> f(x::X) = (x.callcount[] += 1; x)
f (generic function with 1 method)
julia> x = X(10)
X(10, Base.RefValue{Int64}(0))
julia> f(x)
X(10, Base.RefValue{Int64}(1))
julia> f(x)
X(10, Base.RefValue{Int64}(2))
julia> f(x)
X(10, Base.RefValue{Int64}(3))
Here I defined the X
type that stores a value, which I want to be immutable,
and an extra field callcount
that counts how many times the function f
was
called on this object. Since Int
is immutable, I needed to wrap it with Ref
to achieve the mutability of this field.
As a side note this is not the only way to get this kind of effect. For example,
I could define a mutable struct
with const
field value
. Still in some
cases Ref
is a useful because it is mutable. Note that I accessed and updated
the value stored in Ref
using empty indexing x.callcount[]
(i.e. brackets
with no value inside them).
So what is hard about Ref
?
In the last example I said I am talking about Ref
, but in the definition of
the X
type I used callcount::Base.RefValue{Int}
instead. This is the tricky
part. Ref
is a parametric abstract type. This means that no object can have
Ref
type. Ref
is a non-leaf node in the type tree in Julia. Let us check its
subtypes:
julia> subtypes(Ref)
6-element Vector{Any}:
Base.CFunction
Base.RefArray
Base.RefValue
Core.Compiler.RefValue
Core.LLVMPtr
Ptr
As you can see there are six types that are subtypes of Ref
. And here comes
why I have said that I want our post today to be entry-level. I will only talk
about RefValue
and RefArray
. I leave out other options as they are
rarely needed (unless you are doing low-level stuff in Julia, but then probably
you do not need to read this post 😄).
The tricky thing is that when we write Ref(1)
we do not get an object
whose type is Ref
, but instead a RefValue
(that is a subtype of Ref
):
julia> v1 = Ref(1)
Base.RefValue{Int64}(1)
julia> v1[]
1
Similarly we can have a reference to an element of an array. In this case
we pass an array as a first argument to Ref
and an index as a second one:
julia> v2 = Ref([2, 3, 4], 2)
Base.RefArray{Int64, Vector{Int64}, Nothing}([2, 3, 4], 2, nothing)
julia> v2[]
3
You can think of Ref
as a convenient way to handle both cases (wrapping a value
and wrapping an element of an array) in a single syntax.
There is one difference between RefValue
and RefArray
though. RefValue
indeed guarantees mutability of the container as we have seen in the example
above with the X
struct. Trying to mutate RefArray
will try to mutate
the underlying array. Therefore the following code fails:
julia> v3 = Ref(2:4, 2)
Base.RefArray{Int64, UnitRange{Int64}, Nothing}(2:4, 2, nothing)
julia> v3[] = 10
ERROR: CanonicalIndexError: setindex! not defined for UnitRange{Int64}
While this code works:
julia> a = [2, 3, 4]
3-element Vector{Int64}:
2
3
4
julia> v4 = Ref(a, 2)
Base.RefArray{Int64, Vector{Int64}, Nothing}([2, 3, 4], 2, nothing)
julia> v4[]
3
julia> v4[] = 100
100
julia> v4
Base.RefArray{Int64, Vector{Int64}, Nothing}([2, 100, 4], 2, nothing)
julia> a
3-element Vector{Int64}:
2
100
4
and we can see that the a
array was changed.
Conclusions
The major things to remember about Ref
are:
- Its main uses are in broadcasting and when we need a lightweight mutable container.
Ref
is abstract, when you writeRef(x)
you do not get aRef
instance. Instead you will get aRefValue
(which is mutable).- There are other subtypes of
Ref
than justRefValue
. You will rarely need them. Of the other options the one you might want to use most often isRefArray
, which creates a reference to a single element of the underlying array.
I hope you found this post a useful ref. for Ref
.