pic
Personal
Website

6c. Chaining Operations

PhD in Economics

Introduction

This section introduces two approaches to computing outputs that involve multiple intermediate steps. First, we introduce the so-called let blocks, which create a new scope that returns the last line as its output. Let blocks offer a concise way to wrap a sequence of operations, making them similar to functions but with less syntactic clutter. They also help maintain a tidy namespace, as all intermediate variables will be local and therefore inaccessible outside the block.

The second approach is based on pipes, which chain a series of operations and return the final output as result. As the built-in pipe can become unwieldy beyond single-argument functions, we also present an alternative based on the Pipe package.

Let Blocks

Let blocks are particularly helpful when we need to perform a series of operations, but only care about the ultimate result. To illustrate their utility, suppose we want to compute the rounded logarithm of a's absolute value. Formally, \(\mathrm{round}\left(\ln\left(\left|a\right|\right)\right)\).

In Julia, this operation can be implemented using the expression round(log(abs(a))), where round(x) returns the closest integer to x. However, its readability isn't optimal due to the multiple parentheses, which could be exacerbated with longer names of variables and functions.

To enhance readability, we could split the operation into multiple steps: i) computing the absolute value of a, ii) computing the logarithm of the result, and iii) rounding the resulting output. Based on this, one option is to create three intermediate variables that store the output of each step. However, this approach would clutter our namespace, and additionally obscure the nested nature of the operations.

A more elegant solution is to introduce a let-block, which creates a new scope delimited by the let and end keywords. This construct allows us to wrap multiple computations and return the last calculation as the output. Remarkably, let-blocks support argument passing, which requires specifying them after the let keyword.

The following code examples compare these approaches, with the final output stored in the variable output.

a = -2

output = round(log(abs(a)))
Output in REPL
julia>
output
1.0
a = -2

temp1  = abs(a)
temp2  = log(temp1)
output = round(temp2)
Output in REPL
julia>
output
1.0
a = -2

output = let b = a         # 'b' is a local variable having the value of 'a' 
   temp1 = abs(b)
   temp2 = log(temp1)
   round(temp2)
end
Output in REPL
julia>
output
1.0

julia>
temp1 #local to let-block (same as temp2)
ERROR: UndefVarError: `temp1` not defined
a = -2

output = let a = a         # the 'a' on the left still refers to a local variable
   temp1 = abs(a)
   temp2 = log(temp1)
   round(temp2)
end
Output in REPL
julia>
output
1.0

julia>
temp1 #local to let-block (same as temp2)
ERROR: UndefVarError: `temp1` not defined

Let Blocks Can Mutate Variables
Let blocks behave like functions regarding assignments and mutation. This means that you can mutate their arguments, but can't reassign variables.

x = [2,2,2]

output = let x = x
   x[1] = 0
end
Output in REPL
julia>
x
3-element Vector{Int64}:
 0
 2
 2
x = [2,2,2]

output = let x = x
   x = 0
end
Output in REPL
julia>
x
3-element Vector{Int64}:
 2
 2
 2

Since mutations are possible, it's essential to exercise caution to prevent unintended changes in the global scope.

Pipes

Pipes offer an alternative for streamlining outputs that involve several intermediate steps. Unlike let-blocks, pipes are specifically designed to chain together operations, where each step takes the output of the previous step as its input. This makes them particularly well-suited for single-argument functions, whose results are nested using the |> keyword.

To illustrate the application of pipes, let's revisit the example presented for let blocks.

a = -2

output = round(log(abs(a)))
Output in REPL
julia>
output
1.0
a = -2

output = a |> abs |> log |> round
Output in REPL
julia>
output
1.0

Let Blocks and Pipes For Long Names
Let blocks and pipes also enable the creation of temporary aliases for variables with lengthy names. In this way, users can give meaningful names to variables, while preserving code readability.

variable_with_a_long_name = 2

output = variable_with_a_long_name - log(variable_with_a_long_name) / abs(variable_with_a_long_name)
Output in REPL
julia>
output
1.6534264097200273
variable_with_a_long_name = 2

temp   = variable_with_a_long_name
output = temp - log(temp) / abs(temp)
Output in REPL
julia>
output
1.6534264097200273
variable_with_a_long_name = 2

output = variable_with_a_long_name       |>
         a -> a - log(a) / abs(a)
Output in REPL
julia>
output
1.6534264097200273
variable_with_a_long_name = 2 ; using Pipe

output = @pipe variable_with_a_long_name |>
               _ - log(_) / abs(_)
Output in REPL
julia>
output
1.6534264097200273
variable_with_a_long_name = 2

output = let x = variable_with_a_long_name
    x - log(x) / abs(x)
end
Output in REPL
julia>
output
1.6534264097200273

Broadcasting Pipes

Just like any other operator, pipes can be broadcasted by prefixing them with a dot ., as in .|>. In this way, the subsequent operation is applied element-wise to the preceding output.

To demonstrate its use, consider the following operation over x. First, we transform its elements by taking the logarithm of its absolute values, and then sum its resulting elements.

x = [-1,2,3]

output = sum(log.(abs.(x)))
Output in REPL
julia>
output
1.791759469228055
x = [-1,2,3]

temp1  = abs.(x)
temp2  = log.(temp1)
output = sum(temp2)
Output in REPL
julia>
output
1.791759469228055
x = [-1,2,3]

output = x .|> abs .|> log |> sum
Output in REPL
julia>
output
1.791759469228055

Pipes with More Complex Operations

So far, our examples featuring pipes have adhered to a simple pattern, where each function was taking a single argument. Nonetheless, this approach imposes a significant constraint, as it precludes the application of pipes to operations and functions that require multiple arguments. Consequently, it becomes impossible to incorporate a function like foo(x,y) or an operation like 2 * x.

The issue arises because these cases require specifying how the preceding output should be integrated into the subsequent operation. Fortunately, the limitation can be circumvented by combining pipes with anonymous functions. This enables the preceding output to be treated as an argument of the anonymous function. As shown below, the technique greatly expands the utility of pipes.

a = -2

output = round(2 * abs(a))
a = -2

temp1  = abs(a)
temp2  = 2 * temp1
output = round(temp2)
a = -2

output = a |> abs |> (x -> 2 * x) |> round

#equivalent, but more readable
output = a              |>
         abs            |>
         x -> 2 * x     |>
         round

Package Pipe

While combining pipes and anonymous functions is a viable approach, it can quickly degenerate into cumbersome code. If this occurs, the use of pipes would undermine the very goal of writing clean and readable code.

A solution for this is offered by the Pipe package through the @pipe macro. This allows us to maintain an identical syntax for single-argument operations, while eliminating the need for anonymous functions in multi-argument operations. The functionality is achieved by introducing the _ symbol as way to reference the preceding output.

For the illustration, we revisit the last example.

#
a = -2

output = a |> abs |> (x -> 2 * x) |> round

#equivalent, but more readable
output =       a            |>
               abs          |>
               x -> 2 * x   |>
               round
using Pipe
a = -2

output = @pipe a |> abs |> 2 * _ |> round

#equivalent, but more readable
output = @pipe a            |>
               abs          |>
               2 * _        |>
               round

Function Composition (OPTIONAL)

An alternative approach to nest functions is through the composition operator ∘. This can be inserted by Tab Completion through \circ, and its functionality is the same as in Mathematics. Specifically, (f ∘ g)(x) provides an identical output to f(g(x)), for some functions f and g.

The operator ∘ also acts as an alternative to piping, providing the same output as x |> f |> g. Moreover, ∘ is also available as a function, where ∘(f,g)(x) is equivalent to (f ∘ g)(x).

The following examples show applications with built-in and user-defined functions.

a        = -1



# all `output` are equivalent
output   = log(abs(a))
output   = a |> abs |> log
output   = (log ∘ abs)(a)
output   = ∘(log, abs)(a)
Output in REPL
julia>
output
0.0
a        = 2
outer(a) = a + 2
inner(a) = a / 2

# all `output` are equivalent
output   = (a / 2) + 2
output   = outer(inner(a))
output   = a |> inner |> outer
output   = (outer ∘ inner)(a)
output   = ∘(outer, inner)(a)
Output in REPL
julia>
output
3.0

Function composition can also be employed jointly with broadcasting. The notation for this purpose is easier to understand by thinking of the composition as a new function \(h:=f\circ g\), such that \(h\left(x\right):=\left(f\circ g\right)\left(x\right)\) and therefore \(h\left(x\right):=\left(f\circ g\right)\left(x\right)=f\left[g\left(x\right)\right]\). Considering this, broadcasting h would require h.(x), which is equivalent to (f ∘ g).(x) or ∘(f,g).(x).

x        = [1, 2, 3]



# all `output` are equivalent
output   = log.(abs.(x))
output   = x .|> abs .|> log
output   = (log ∘ abs).(x)
output   = ∘(log, abs).(x)
Output in REPL
julia>
output
3-element Vector{Float64}:
 0.0
 0.6931471805599453
 1.0986122886681098
x        = [1, 2, 3]
outer(a) = a + 2
inner(a) = a / 2

# all `output` are equivalent
output   = (x ./ 2) .+ 2
output   = outer.(inner.(x))
output   = x .|> inner .|> outer
output   = (outer ∘ inner).(x)
output   = ∘(outer, inner).(x)
Output in REPL
julia>
output
3-element Vector{Float64}:
 2.5
 3.0
 3.5

Lastly, it's possible to broadcast the composition operator ∘ itself, allowing us to apply multiple functions to the same object. For instance, the following example ensures that each function takes the absolute value of its argument, previous to apply the function of interest.

Broadcasting ∘
a            = -1

inners       = abs
outers       = [log, sqrt]
compositions = outers .∘ inners

# all `output` are equivalent
output       = [log(abs(a)), sqrt(abs(a))]
output       = [foo(a) for foo in compositions]
Output in REPL
julia>
compositions
2-element Vector{ComposedFunction{O, typeof(abs)} where O}:
 log ∘ abs
 sqrt ∘ abs

julia>
output
2-element Vector{Float64}:
 0.0
 1.0