Since mutations are possible, it's essential to exercise caution to prevent unintended changes in the global scope.
<function>
or <operator>
).
This is just notation, and the symbols <
and >
should not be misconstrued as Julia's syntax.
Action | Keyboard Shortcut |
---|---|
Previous Section | Ctrl + ð |
Next Section | Ctrl + ð |
List of Sections | Ctrl + z |
List of Subsections | Ctrl + x |
Close Any Popped Up Window (like this one) | Esc |
Open All Codes and Outputs in a Post | Alt + ð |
Close All Codes and Outputs in a Post | Alt + ð |
Unit | Acronym | Measure in Seconds |
---|---|---|
Seconds | s | 1 |
Milliseconds | ms | 10-3 |
Microseconds | Ξs | 10-6 |
Nanoseconds | ns | 10-9 |
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 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
1.0
a = -2
temp1 = abs(a)
temp2 = log(temp1)
output = round(temp2)
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
1.0
temp1
#local to let-block (same as temp2
)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
1.0
temp1
#local to let-block (same as temp2
)
x = [2,2,2]
output = let x = x
x[1] = 0
end
x
3-element Vector{Int64}:
0
2
2
x = [2,2,2]
output = let x = x
x = 0
end
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 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
1.0
a = -2
output = a |> abs |> log |> round
output
1.0
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
1.6534264097200273
variable_with_a_long_name = 2
temp = variable_with_a_long_name
output = temp - log(temp) / abs(temp)
output
1.6534264097200273
variable_with_a_long_name = 2
output = variable_with_a_long_name |>
a -> a - log(a) / abs(a)
output
1.6534264097200273
variable_with_a_long_name = 2 ; using Pipe
output = @pipe variable_with_a_long_name |>
_ - log(_) / abs(_)
output
1.6534264097200273
variable_with_a_long_name = 2
output = let x = variable_with_a_long_name
x - log(x) / abs(x)
end
output
1.6534264097200273
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
1.791759469228055
x = [-1,2,3]
temp1 = abs.(x)
temp2 = log.(temp1)
output = sum(temp2)
output
1.791759469228055
x = [-1,2,3]
output = x .|> abs .|> log |> sum
output
1.791759469228055
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
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
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
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
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
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
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.
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]
compositions
2-element Vector{ComposedFunction{O, typeof(abs)} where O}:
log â abs
sqrt â abs
output
2-element Vector{Float64}:
0.0
1.0