Skip to content

Instantly share code, notes, and snippets.

@sebfisch
Last active July 12, 2024 17:55
Show Gist options
  • Save sebfisch/2ec0e7a357c367f63cdc85473648d067 to your computer and use it in GitHub Desktop.
Save sebfisch/2ec0e7a357c367f63cdc85473648d067 to your computer and use it in GitHub Desktop.
Exploring function calls in Roc

Exploring function calls in Roc

This is a systematic in-depth exploration of different ways of calling functions in Roc.

Lambda expressions and function calls

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

Callbacks

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.

Partial applications and Currying

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.

Summary

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 and uncurry.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment