This is a systematic in-depth exploration of different ways of calling functions in Roc.
In Roc, functions are values that are defined using lambda-expressions. Here are two single-argument functions on integers, one that increments a given number and another that doubles it.
inc : I64 -> I64
inc = \n -> 1 + n
dbl : I64 -> I64
dbl = \n -> 2 * n
Functions are called by writing argument expressions after the name of a function, separated by spaces. Parentheses are sometimes necessary, when arguments are themselves complex expressions. The following example demonstrates three function calls in a row.
nested : Str
nested = Str.fromInt (dbl (inc 20))
In nested function calls, the functions are called inside-out. Here, the pre-defined function Str.fromInt
is called after dbl
which is called after inc
. In this example, the argument of a function called later is the result of the function called directly before it.
When passing the results of functions as arguments to others, it is customary to use the pipe operator to write the calls with fewer parentheses and the functions in the order they are called in:
piped : Str
piped =
20
|> inc
|> dbl
|> Str.fromInt
The pipe operator is syntax sugar, which means that it is translated by Roc's compiler into normal function calls. The expression defining piped
desugars to the expression above defining nested
.
There is another way to write the same example by defining names for intermediate results:
stepByStep : Str
stepByStep =
x = 20
y = inc x
z = dbl y
Str.fromInt z
Here, the functions are still written in the order they are called in. Naming intermediate results is unnecessary when passing them only from one function to the next like in this example. However, explicit names provide additional flexibility because they allow us to use intermediate results more than once or in a later step like in the following example.
reusingNames : Str
reusingNames =
x = 20
y = inc x
z = x + inc y
Str.fromInt z
Some APIs require callbacks to be passed to functions. In order to explore Roc's syntax sugar related to callbacks, we simulate such an API with the following pass
function which takes two arguments.
pass : a, (a -> b) -> b
pass = \x, f -> f x
Note that in the function type as well as the lambda expression the two arguments are separated by a comma. When called, pass
passes its first argument x
to the (single-argument) callback function f
given as second argument.
We can rewrite our example in callback style using pass
. No commas are written in calls of multi-argument functions.
callbacks : Str
callbacks =
pass 20 \x ->
pass (inc x) \y ->
pass (dbl y) \z ->
Str.fromInt z
Callbacks may be nested deeply when calling multiple functions with callbacks in a row. To avoid deep nesting, Roc provides backpassing syntax sugar for writing the callbacks backwards as follows.
backpassing : Str
backpassing =
x <- pass 20
y <- pass (inc x)
z <- pass (dbl y)
Str.fromInt z
This expression desugars to the callbacks
expression and resembles the stepByStep
version defined above. For binding variables it is using a backwards arrow <-
and pass
instead of the equals sign.
As backpassing introduces names for intermediate results, we can use them more flexibly like in reusingNames
above.
backpassingReuse : Str
backpassingReuse =
x <- pass 20
y <- pass (inc x)
z <- pass (x + inc y)
Str.fromInt z
Also similar to above, we do not need to introduce names in examples (like the original one) where intermediate results are only passed to the next function. We can restructure the callbacks
expression as follows to avoid introducing intermediate names.
callbacksWithoutNames : Str
callbacksWithoutNames = Str.fromInt (pass (pass 20 inc) dbl)
This version resembles the original nested
expression but uses pass
instead of calling inc
and dbl
directly. However, the order in which the different functions are written has diverted even more from the order they are called in. The inc
function is called first but is written between the function Str.fromInt
and dbl
.
We can again use the pipe operator to write the callbacks in the order they are called.
callbackPipe : Str
callbackPipe =
20
|> pass inc
|> pass dbl
|> Str.fromInt
This expression desugars to the expression callbacksWithoutNames
above because the pipe operator passes its left argument as first argument to the given function even when used with multi-argument functions like pass
.
All our examples use the single-argument functions inc
and dbl
which are defined in terms of the arithmetic operators +
(which desugars to the two-argument function Num.add
) and *
(which desugars to the two-argument function Num.mul
.) We could write all of the examples using Num.add
and Num.mul
instead of using inc
and dbl
. In those cases where inc
and dbl
appear without an explicit argument, we can write corresponding lambda expressions:
piped2 : Str
piped2 =
20
|> (\n -> Num.add 1 n)
|> (\n -> Num.mul 2 n)
|> Str.fromInt
callbacksWithoutNames2 : Str
callbacksWithoutNames2 =
Str.fromInt (pass (pass 20 (\n -> Num.add 1 n)) (\n -> Num.mul 2 n))
callbackPipe2 : Str
callbackPipe2 =
20
|> pass (\n -> Num.add 1 n)
|> pass (\n -> Num.mul 2 n)
|> Str.fromInt
In each example, inc
and dbl
have been replaced by lambda expressions with a single argument. These lambda expressions (as well as inc
and dbl
) can be seen as partial applications of Num.add
and Num.mul
because they apply those functions to a fixed first argument and represent a single argument function supplying the second.
Writing the examples like this defeats their purpose, which is to hide names for intermediate results. In piped2
we could get away with writing Num.add 1
and Num.mul 2
next to the pipe operator. Strictly speaking, their arguments would be flipped if we did that. But we know that in this case the result would be the same because addition and multiplication return the same result regardless of argument order.
In languages like Elm and Haskell we could replace all lambda expressions with Num.add 1
or Num.mul 2
without affecting argument order. Elm and Haskell do not support multi argument functions like Roc. Instead, multi-argument functions are syntax sugar for curried functions in Elm and Haskell where all functions take a single argument.
Currying is a transformation of multi-argument functions into an equivalent sequence of nested single-argument functions. For example, in Elm and Haskell \x y -> x + y
is syntax sugar for \x -> \y -> x + y
. As a consequence, we can create partial applications without introducing new names for the remaining parameters. Curried functions are always applied to a single argument and return another function. A function call with multiple arguments like add 1 n
is syntax sugar for (add 1) n
in Elm and Haskell.
Unlike Elm and Haskell, Roc suppoorts multi-argument functions. They are not treated as their curried counterparts automatically. As a consequence, Num.add 1
is not a valid expression on its own, because the function expects two arguments, not one. Like every language that treats functions as values, Roc supports curried functions (which are just single-argument functions returning another function.) For example, we can define a curried addition function in Roc as follows.
curryAdd : I64 -> (I64 -> I64)
curryAdd = \x -> \y -> Num.add x y
While this style is unusual and seldom beneficial, it is possible in Roc. The expression curryAdd 1
is now a valid Roc expression and equivalent to \n -> Num.add 1 n
, the partial application of addition to the number 1. Note the parentheses in the result type of curryAdd
which correspond to parentheses in a call like (curryAdd 1) 2
. The parentheses in this call are required in Roc because curryAdd
is not a two-argument function.
IF we want (that's a big IF,) we can even define curry
and uncurry
functions that translate between two-argument functions and their curried counterparts:
curry : (a, b -> c) -> (a -> (b -> c))
curry = \f -> \x -> \y -> f x y
uncurry : (a -> (b -> c)) -> (a, b -> c)
uncurry = \f -> \x, y -> (f x) y
Using these definitions, curry Num.add
is a valid expression (equivalent to curryAdd
,) and uncurry curryAdd
is a valid expression (equivalent to Num.add
.) We can use curry
to define new versions of our examples with partial applications.
callbacksWithoutNames3 : Str
callbacksWithoutNames3 =
Str.fromInt (pass (pass 20 ((curry Num.add) 1)) ((curry Num.mul) 2))
callbackPipe3 : Str
callbackPipe3 =
20
|> pass ((curry Num.add) 1)
|> pass ((curry Num.mul) 2)
|> Str.fromInt
As a final note, the following definition is not valid:
piped3 : Str
piped3 =
20
|> ((curry Num.add) 1)
|> ((curry Num.mul) 2)
|> Str.fromInt
Here, the pipe operator would introduce an additional argument to curry Num.add
and curry Num.mul
which only accept a single argument.
This exploration demonstrates different ways of calling functions in Roc.
- The pipe operator can be used to call multiple functions in a row, applying each function to the result of the previous call. When using the pipe operator we can write the functions in the order they are called in without having to introduce names for intermediate results.
- Backpassing can be used to chain multiple calls of functions that expect a callback. Resulting expressions resemble those with nested definitions, binding intermediate results to introduced variables.
- The pipe operator can be used with APIs based on callbacks. As a consequence, it is possible to avoid introducing names for intermediate results (in some cases) even when programming with callbacks.
- Multi-argument functions are not curried automatically in Roc, but it is possible to define curried functions by hand and to use higher-order combinators like
curry
anduncurry
.
When deciding which style to use, we can pick the clearest one among different options Roc provides. Hopefully, this exploration helps with such decisions by comparing the different options in a systematic way. Presumably, Rocsters will be hard-pressed for good reasons to use the pipe operator with callbacks or to write curried functions. More often than not, using backpassing with callbacks and writing lambda expressions for partial applications will be the clearer choice.
I am Sebastian Fischer. I have a research background related to functional programming but work as a freelancer developing software and teaching for different organizations for more than ten years. I am interested in the design of functional programming languages as well as functional patterns in conventional programming languages such as Java.
This work is licensed under a Creative Commons Attribution 4.0 International License. © 2021