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.