Ref ref.

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2023/04/28/ref.html

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 write Ref(x) you do not get a Ref instance. Instead
    you will get a RefValue (which is mutable).
  • There are other subtypes of Ref than just RefValue. You will rarely need them.
    Of the other options the one you might want to use most often is RefArray, which
    creates a reference to a single element of the underlying array.

I hope you found this post a useful ref. for Ref.