Why, how, and when of ∘
Introduction
New users of Julia often ask me about the use of the ∘
operator. In this post
I discuss the most important things one should know about it.
In this the post I use Julia 1.6.3.
Getting started with ∘
Let us start with the basics. Before even understanding what ∘
does you
probably wonder how you can type it. Fortunately it is easy to check in Julia’s
help:
help?> ∘
"∘" can be typed by \circ<tab>
search: ∘
f ∘ g
Compose functions: i.e. (f ∘ g)(args...) means f(g(args...)). The ∘ symbol can
be entered in the Julia REPL (and most editors, appropriately configured) by
typing \circ<tab>.
Function composition also works in prefix form: ∘(f, g) is the same as f ∘ g.
The prefix form supports composition of multiple functions:
∘(f, g, h) = f ∘ g ∘ h and splatting ∘(fs...) for composing an iterable
collection of functions.
As you can see you can easily write in the Julia REPL by typing \circ
and
pressing the <tab>
key. Also most editors that support Julia allow use of
this key sequence.
We can also check the Unicode code of ∘
:
julia> '∘'
'∘': Unicode U+2218 (category Sm: Symbol, math)
and its UTF-8 representation:
julia> codeunits("∘")
3-element Base.CodeUnits{UInt8, String}:
0xe2
0x88
0x98
Understanding ∘
When f
and g
are functions then writing f ∘ g
creates a new callable
object that takes the same arguments as the g
function and passes the value
returned by calling g
to the f
function. This operation is called function
composition.
Let us check this on some example functions:
julia> c = abs ∘ sin
abs ∘ sin
julia> c(-1)
0.8414709848078965
julia> abs(sin(-1))
0.8414709848078965
Additionally, once the composed object is created it can be easily inspected:
julia> typeof(c)
ComposedFunction{typeof(abs), typeof(sin)}
julia> fieldnames(typeof(c))
(:outer, :inner)
julia> c.outer
abs (generic function with 13 methods)
julia> c.inner
sin (generic function with 13 methods)
As you can see you can:
- easily identify that some object is a composed function (as it has
ComposedFunction
type); - easily recover the functions that were used in composition.
In some usage scenarios these two properties can be quite useful.
One important feature one has to keep in mind is that you have to put the
f ∘ g
expression in parentheses if you want to immediately call it:
julia> abs∘sin(-1) # incorrect
abs ∘ -0.8414709848078965
julia> (abs∘sin)(-1) # correct
0.8414709848078965
Rationale for ∘
You might wonder why someone would want to write (f ∘ g)(x)
if you can write
f(g(x))
or x |> g |> f
to get the same. The reason is that f ∘ g
is an
object that can be passed around. Clearly you could have created an anonymous
function e.g. via x -> f(g(x))
or x -> x |> g |> f
. However, these
approaches are more visually noisy, less explicit, and create a new anonymous
function each time they used (which means triggering compilation).
The f ∘ g
object is most commonly passed as an argument to higher order
functions. Here are some examples:
julia> map(uppercase∘strip, [" a", "b ", " c "])
3-element Vector{String}:
"A"
"B"
"C"
julia> sum(sqrt∘abs, -10:10)
44.936556372408205
Before I finish let me explain the compilation issue. Start with a fresh Julia session:
julia> x = 1:100;
julia> @time sum(abs∘sin, x);
0.146191 seconds (446.61 k allocations: 25.602 MiB, 4.85% gc time, 99.78% compilation time)
julia> @time sum(abs∘sin, x);
0.000011 seconds (3 allocations: 80 bytes)
Now open a new fresh Julia session again:
julia> x = 1:100;
julia> @time sum(x -> abs(sin(x)), x);
0.150860 seconds (445.16 k allocations: 25.541 MiB, 8.25% gc time, 97.47% compilation time)
julia> @time sum(x -> abs(sin(x)), x);
0.034172 seconds (54.68 k allocations: 3.256 MiB, 99.82% compilation time)
As you can see in the first case compilation happened only once. The reason is
that the type of abs∘sin
is always the same (it is
ComposedFunction{typeof(abs), typeof(sin)}
). On the other hand if you define
an anonymous function it gets a distinct type each time it is created:
julia> x -> abs(sin(x))
#5 (generic function with 1 method)
julia> x -> abs(sin(x))
#7 (generic function with 1 method)
Such differences matter most if the higher order function to which you pass the composed function is complex and thus expensive to compile.
Conclusions
Hopefully after reading this post you understand why, how, and when the ∘
operator can be used.