Julia encourages programmers to put as much code as possible inside functions that can be JIT-compiled, and creating many small functions is, by design, performant.
That tends to leave many small, simple functions, which need to be combined to carry out non-trivial tasks.
One obvious approach is to nest function calls. The following example is very contrived, but illustrates the point.
julia> first.(titlecase.(reverse.(["my", "test", "strings"])))
3-element Vector{Char}:
'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
The disadvantage of this approach is that readability drops rapidly as nesting gets deeper.
We need a simpler and more flexible approach.
This is the technique beloved of mathematicians, and Julia copies the mathematical syntax.
An arbitrary number of functions can be composed
together with ∘
operators (entered as \circ
then tab).
The result can be used as a single function.
julia> compfunc = first ∘ titlecase ∘ reverse
first ∘ titlecase ∘ reverse
julia> compfunc.(["my", "test", "strings"])
3-element Vector{Char}:
'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
# alternative syntax, giving the same result
julia> (first ∘ titlecase ∘ reverse).(["my", "test", "strings"])
A couple of points to note:
An alternative might be thought of as the programmers' approach, rather than the mathematicians'.
Pipelines
have long been used in Unix shell scripts, and more recently became popular in mainstream programming languages (F# is sometimes credited with pioneering their adoption).
The basic concept is to start with some data, then pipe it through a sequence of functions to get the result.
The pipe operator is |>
(as in F# and recent versions of R), though Julia also has a broadcast version .|>
.
julia> ["my", "test", "strings"] .|> reverse .|> titlecase .|> first
3-element Vector{Char}:
'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
'T': ASCII/Unicode U+0054 (category Lu: Letter, uppercase)
'S': ASCII/Unicode U+0053 (category Lu: Letter, uppercase)
Execution is now strictly left-to-right, with the output of each function flowing in the direction of the arrow to become the input for the next function.
It is no coincidence that the functions used to illustrate composition and pipelining all take a single argument.
Some purely-functional languages, pipe the first argument into a function but allow others to be included.
In contrast, Julia only expects function names (or something equivalent) in a pipeline, without any additional arguments.
There are important technical reasons for this (related to the fact that currying
is not a standard part of the language design).
The many people who have no understanding of currying should merely accept that this limitation is not a careless oversight, and is not likely to change in future Julia versions.
We need single-arguments functions that do whatever is needed. Fortunately, defining new functions in Julia is easy.
Most simply, we could use an anonymous function
.
For example, if we have a single input string and we want to split on underscores:
julia> "my_test_strings" |> (s -> split(s, '_'))
3-element Vector{SubString{String}}:
"my"
"test"
"strings"
That vector could then be piped to other functions, as before.
Enclosing the anonymous function in parentheses is optional in this case, but more generally is a useful way to reduce ambiguity.
Equally, we could create a named function, earlier in the program, and reuse it as needed.
Closures
are beyond the scope of this Concept, but anyone familiar with them from other languages will recognise that they offer a more flexible way to create single-argument functions.
julia> function makesplit(sep)
fs(str) = split(str, sep)
fs
end
makesplit (generic function with 1 method)
julia> f_us = makesplit('_')
(::var"#fs#32"{Char}) (generic function with 1 method)
julia> "my_test_strings" |> f_us
3-element Vector{SubString{String}}:
"my"
"test"
"strings"
# alternatively:
julia> "my_test_strings" |> makesplit('_')
3-element Vector{SubString{String}}:
"my"
"test"
"strings"
Once makesplit()
is defined, it can be used to work with any separator.
Note that makesplit('_')
is a function call that evaluates to another function, which in turn receives input from the pipe.
If this seems confusing, that is normal at first (but it becomes clearer with practice).
There has been a long discussion about making pipes more versatile in base Julia, but the various suggestions are mutually incompatible and no agreement has been reached.
Meanwhile, users have taken the usual approach of creating various installable packages that address specific needs. None will work within Exercism, but take a look at these if you are interested: