pic
Personal
Website

5e. Slices: Copies vs Views

PhD in Economics

Introduction

This section concludes the coverage of preliminary concepts for mutations, focusing on the behavior of a vector's slice. A slice is defined as a subset of a vector's elements, and is formally represented as x[<indices>].

Importantly, slices act differently depending on how they're included in a statement. Specifically, slices can act as either:

  • copies of the original vector, thus creating a new object at a new memory address.

  • views of the original vector, where the original object and the slice share the same memory address.

In the following, we explain in detail this distinction. Understanding it is crucial for mutating slices, as mutations can only occur when the slice references the original object. Formally, we say that the slice must act as a view, so that any modifications made to the slice will affect the parent object. In contrast, if a slice acts as a copy, the parent object and the slice are unrelated, and changes to the slice have no impact on the original object.

Slices and the = Operator

Vector mutation requires modifying slices through the operator =. For this to be possible, the slice needs to reference the original object. Nonetheless, the behavior of slices in assignments varies, depending on their placement within the expression.

Specifically, slices on the left-hand (LHS) side of = act as views. This means that these slices reference the original elements, thus allowing for the mutation of its parent object. In contrast, slices on the right-hand side (RHS) of = create a copy, and therefore point to a new object. Consequently, any modification made to the slice won't affect the original object.

The following code snippet demonstrates each behavior.

x    = [4,5]


x[1] = 0        # 'x[1]' is a view and mutates 'x'

Output in REPL
julia>
x
2-element Vector{Int64}: 0 5

x    = [4,5]
y    = x[1]     # 'y' is unrelated to 'x' because 'x[1]' is a copy

x[1] = 0        # it mutates 'x' but does NOT modify 'y'

Output in REPL
julia>
y
4

Aliasing vs Copy
Objects on the RHS of = are only treated as copies when it comes to slices, such as in statements y = x[<indices>]. Instead, if we insert the whole object x on the RHS of =, as in y = x, the operation creates an alias. In this case, y and x will reference the same object, and so any modification made to y will also be reflected in x.

x = [4,5]
y = x           # the whole object (a view)

x[1] = 0        # it DOES modify 'y'

Output in REPL
julia>
y
2-element Vector{Int64}: 4 5

x = [4,5]
y = x[:]        # a slice of the whole object (a copy)

x[1] = 0        # it does NOT modify 'y'

Output in REPL
julia>
y
2-element Vector{Int64}: 0 5

The Function "view"

Identifying when slices act as copies or views will take significance when exploring high performance, as views avoid the overhead of memory allocations. Considering this, it's important to distinguish between copies and slices beyond assignments. Broadly speaking, slices typically default to creating copies. This is the case when, for instance, a slice is passed as a function argument or when used within an expression. These scenarios are illustrated below.

Slices as Copies

x = [3,4,5]

#the following slices are all copies
log.(x[1:2])

x[1:2] .+ 2

[sum(x[:]) * a for a in 1:3]

(sum(x[1:2]) > 0) && true

In all these cases, transforming slices into views requires explicit indicating it. This can be achieved via the function view, whose syntax is view(x, <indices>), where <indices> represent the subset of indices defining the slice. To demonstrate its usage, we revisit and compare the previous code snippet.

x = [3,4,5]

#we make explicit that we want views
log.(view(x,1:2))

view(x,1:2) .+ 2

[sum(view(x,:)) * a for a in 1:3]

(sum(view(x,:)) > 0) && true

x = [3,4,5]

#the following slices are all copies
log.(x[1:2])

x[1:2] .+ 2

[sum(x[:]) * a for a in 1:3]

(sum(x[1:2]) > 0) && true

The previous examples highlight the verbosity of the view function, particularly when multiple slices are involved. To address this issue, Julia provides the macros @view and @views.

The @view macro simplifies notation by converting expressions like view(x, 1:2) into @view x[1:2]. However, its advantages are somewhat limited: it saves only a few characters and requires parentheses when multiple slices are used (e.g., @view(x[1:2]) .+ @view(x[2:3])). In contrast, the @views macro significantly streamlines notation, by automatically converting every slice within an expression into a view.

Code

x = [4,5,6]

# the following are all equivalent
y = view(x, 1:2) .+ view(x, 2:3)

y = @view(x[1:2]) .+ @view(x[2:3])

@views y = x[1:2] .+ x[2:3]

One of the most notable applications of @views is in function definitions. By placing @views at the beginning, you can automatically convert every slice within the function body into a view.

@views function foo(x)
  y = x[1:2] .+ x[2:3]
  z = sum(x[:]) .+ sum(y)

  return z
end

function foo(x)
  y = @view(x[1:2]) .+ @view(x[2:3])
  z = sum(@view x[:]) .+ sum(y)

  return z
end