pic
Personal
Website

5h. In-Place Functions

PhD in Economics

Introduction

This section continues exploring approaches to mutating vectors. The emphasis is in particular on in-place functions, defined as functions that mutate at least one of their arguments.

Remarkably, many built-in functions come with in-place versions. This makes it possible to store outputs in a vector passed as argument, rather than in a new object. Another common application of in-place functions is for mutating vectors via a for-loop.

The significance of in-place functions will become apparent when discussing high performance. Essentially, by reusing the same object, in-place functions eliminate the overhead of creating new objects, thereby resulting in efficiency gains.

In-Place Functions

In-place functions, also known as mutating functions, are characterized by modifying at least one of its arguments. For instance, given a vector x, the function foo(x) is in-place when it modifies the values of x's elements.

Code

y = [0,0]

function foo(x)
    x[1] = 1
end

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

julia>
foo(y) #it mutates 'y'

julia>
y
2-element Vector{Int64}: 1 0
Remark
While functions are capable of mutating values, they can't reassign variables. This is because any attempt to redefine a function's variable will be interpreted as the definition of a new local variable. [note] Strictly speaking, it's possible to reassign a variable by using the global keyword. However, its use is discouraged, explaining why we won't cover it.

The following code illustrates this feature by redefining a function argument and a global variable. The result reflects that, when x is redefined, foo interprets x as a new local variable. This determines that x only exists within foo's scope.

x = 2

function foo(x)
    x = 3
end

Output in REPL
julia>
x
2

julia>
foo(x)

julia>
x #functions can't redefine variables globally, only mutate them
2

x = [1,2]

function foo()
    x = [0,0]
end

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

julia>
foo()

julia>
x #functions can't redefine variables globally, only mutate them
2-element Vector{Int64}: 1 2

When it comes to for-loops, the ability to mutate function arguments is crucial. Recall that for-loops should always be enclosed within functions. This practice is recommended to prevent issues with variable scope and primarily for performance reasons, as we'll discuss later on the website.

The code snippet below demonstrates this application. In particular, we consider its use for initializing and population a vector.

x = [3,4,5]

function foo(x)
    for i in 1:2
        x[i] = 0
    end
end

Output in REPL
julia>
foo(x)

julia>
x
3-element Vector{Int64}: 0 0 5

x = Vector{Float64}(undef, 3)           # initialize a vector with 3 elements

function foo(x)
    for i in eachindex(x)
        x[i] = 0
    end
end

Output in REPL
julia>
foo(x)

julia>
x
3-element Vector{Int64}: 0 0 0

x = [5,6,7]

function foo()
    for i in eachindex(x)
        x[i] = 0
    end
end

Output in REPL
julia>
foo()

julia>
x
3-element Vector{Int64}: 0 0 0

Built-In In-Place Functions

Many built-in functions taking vectors as arguments are available in two forms: a "standard" version and an in-place version. To distinguish between them, Julia's developers follow a convention that any function ending with ! corresponds to an in-place function.

Remark
Ending a function with ! does not affect the function's behavior in any way. It's simply a convention adopted by Julia's developers to emphasize the mutable nature of the operation. The goal is to prevent users from unintentionally modifying objects.

For example, let's consider the function sort. This orders the elements of a vector in increasing order, or alternatively in decreasing order through the option rev=true. Specifically, the standard version sort(x) creates a new vector containing the sorted elements, leaving the original vector x unaffected. In contrast, the in-place version sort!(x) directly modifies the original vector x to store the result.

x = [2, 1, 3] 

y = sort(x)

Output in REPL
julia>
x
3-element Vector{Int64}: 2 1 3

julia>
y
3-element Vector{Int64}: 1 2 3

x = [2, 1, 3]

sort!(x)

Output in REPL
julia>
x
3-element Vector{Int64}: 1 2 3

The example illustrates a scenario where the function argument serves as both input and output. A related usage is when we include an argument solely to storing the output. This technique enables the reuse of the same vector to store results. This can improve performance when performing multiple iterations, by reusing the output vector and therefore avoiding the creating of a new object in each iteration.

For instance, given a function foo and a vector x, the function map(foo, x) has an in-place version map!(foo, <output vector>, x).

x      = [1, 2, 3]


output = map(a -> a^2, x)

Output in REPL
julia>
x
3-element Vector{Int64}: 1 2 3

julia>
output
3-element Vector{Int64}: 1 4 9

x      = [1, 2, 3]
output = similar(x)             # we initialize `output`

map!(a -> a^2, output, x)

Output in REPL
julia>
x
3-element Vector{Int64}: 1 2 3

julia>
output
3-element Vector{Int64}: 1 4 9