© The Author(s), under exclusive license to APress Media, LLC, part of Springer Nature 2023
D. YangIntroducing ReScripthttps://doi.org/10.1007/978-1-4842-8888-7_2

2. Functions

Danny Yang1  
(1)
Mountain View, CA, USA
 

In this chapter, you will learn how to write and use functions in ReScript, and discuss topics such as recursion, pipes, and polymorphism. We’ll also introduce functional programming concepts like purity and using functions as values.

Defining and Using Functions

ReScript’s function definition syntax is very similar to the syntax for anonymous functions in JavaScript. The body of a function is a single expression or block, and the result of evaluating the body is the value that is returned from the function.

Here is a function that adds two numbers:
(x, y) => x + y
Here is a function with multiple lines in its body – it adds two numbers and prints the sum before returning it. Unlike in JavaScript, there is no return statement – the value returned from the function is the result of evaluating the last expression in the body:
(x, y) => {
 let sum = x + y
 Js.log(sum)
 sum
}

In ReScript, function definitions are values. As such, they can be bound to names using let bindings just like any other value, and they can be called using that name later.

Here’s an example of declaring and calling a named function:
let add = (x, y) => x + y
Js.log(add(1, 2))
Console output:
3
In the following example, the expressions bound to a, b, and c all evaluate to 5:
let add = (x, y) => x + y
let a = add(2, 3)
let otherAdd = add
let b = otherAdd(2, 3)
let c = ((x, y) => x + y)(2, 3)

Functions can be declared anywhere, including inside other functions, and the visibility of these declarations follows the same scoping rules as other let bindings.

This means that we can define a nested helper function inside a more complex function, without making it visible outside that function. In the following example, diff is a helper function defined inside the manhattan_distance function:
let manhattan_distance = (x1, y1, x2, y2) => {
   let diff = (a, b) => abs(a - b)
   diff(x1, x2) + diff(y1, y2)
}
Nested function definitions will inherit bindings from surrounding contexts:
let x = 5
let func1 = y => {
 let func2 = z => {
   x + y + z
 }
 func2(5)
}
Js.log(func1(5))
Console output:
15

Type Annotations

Like other values in ReScript, functions have types as well. The type of a function (also called its type signature) consists of a list of types for each parameter, followed by the type of the value returned from the function.

Function type signatures can be expressed in the form (input1, input2, ... inputN) => output. For example, the function (x, y) => x + y is also a value of type (int, int) => int. It can only be called with one or two arguments of type int, and cannot be called with anything else. That is not a typo – we can call a function that has two parameters with only a single argument. More on that later!

You may have observed from the earlier examples that we don’t need to annotate the function with a type, thanks to type inference. Annotations may be added if we want – input type annotations may be added after each parameter, and an output type annotation may be placed before the body of the function as follows:
let add = (x: int, y: int): int => x + y
We could also use the type signature to annotate the add binding (after all, add is just a value with type (int, int) => int), but it is a bit harder to read:
let add: (int, int) => int = (x, y) => x + y

Adding type annotations has two benefits: the first is to make the code clearer to other people who may be reading it, and the second is to add additional restrictions the possible values a function may be called with.

For example, let’s say we were writing a function that checks for equality between two integers, and we want to make sure that it is only called on integers, not any other value. Without type annotations, the typechecker will infer the loosest possible restrictions on the input types – in this case, the following example may be called on any two arguments, as long as they are the same type as each other:
let eqInts = (a, b) => a == b
Adding annotations will prevent the function from being called on anything other than integers:
let eqInts = (a: int, b: int) => a == b

Using Standard Library Functions and Opening Modules

In the examples from the last chapter, you saw standard library functions like Belt.Int.toString and Belt.String.trim. The standard library is organized into modules, which are groupings of values, types, and functions. We’ll go into more details about modules in a later chapter – for now, you just need to know enough to use the standard library.

Module members can be accessed using the dot notation: for example, Belt.Int.toString refers to the toString function defined inside the module called Int, which is nested inside the module called Belt.

One way to call a function in a module is to use the full name as we’ve seen previously:
let x = Belt.Int.toString(5)
Writing out the full name of the module can get very verbose. Luckily, we can use the open keyword to open a module and import all of its bindings into the current scope:
open Belt
let x = Int.toString(5)
open Belt.Int
let x = toString(5)
Since only the current scope is affected, you can safely use this in nested scopes or functions without it affecting any enclosing scope:
let x = {
 open Belt
 Int.toString(6)
}
let y = Belt.Int.toString(5)
Be careful when opening modules at the top level of a file – if two opened modules contain members with the same name, then the second one will shadow the first one. In this example, both Belt and Js contain nested Int modules. Since Js was opened after Belt, the Int.toString that is used in the program is the one from Js.Int, not Belt.Int:
open Belt
open Js
let x = Int.toString(1)
This code compiles and runs, but the compiler will yield the following warning:
[W] Line 2, column 0:
this open statement shadows the module identifier Int (which is later used)
[W] Line 1, column 0:
unused open Belt.

Higher-Order Functions

Since functions are values, they can also be passed as arguments into other functions. Simple functions that operate on regular data types like strings and integers and arrays are first-order functions, while a function that takes another function as input is called a higher-order function. Using higher-order functions is one aspect of functional programming called higher-order programming.

Many other languages, including JavaScript, also support higher-order programming – for example, the JavaScript’s Array.map API takes in a function and applies it to every element of the original array, returning a new array with the results:
let arr = [1, 2, 3];
let newArr = arr.map(x => x * 2);
console.log(newArr);
Console output:
[2, 4, 6]
The equivalent in ReScript is the following:
let arr = [1, 2, 3]
let newArr = Belt.Array.map(arr, x => x * 2)
Js.log(newArr)
Console output:
[2, 4, 6]

One difference you’ll notice between the JavaScript and ReScript examples earlier is that the JavaScript API is a method call on the array, while the ReScript API is a function that takes in the array as an input.

In fact, the concept of classes and instance methods does not exist in ReScript at all! One purpose of classes in object-oriented languages is to define data types and the permitted operations on those data types.

In ReScript, complex data types can be defined without needing a class, and modules let the programmer associate a data type with its supported operations. For example, the standard library module Js.Array2 contains functions for working with arrays.

As you learn more about the standard library, you’ll probably get the feeling that using modules is a bit like using classes with only static methods, but that’s not the whole picture. Modules are actually much more powerful than that, and you’ll learn all about them in a later chapter.

Piping

In languages, methods and the builder pattern allow programmers to chain function calls to keep code clean:
let arr = [1, 2, 3];
let newArr = arr.map(x => x * 2)
               .map(x => x + 2)
               .filter(x => x > 5);
console.log(newArr);
Console output:
[6, 8]
In ReScript, we can chain function calls using the pipe operator. If we wanted to rewrite the previous example in ReScript, we’d probably come up with something like this:
let arr = [1, 2, 3]
let newArr =
 arr
 ->Js.Array2.map(x => x * 2)
 ->Js.Array2.map(x => x + 2)
 ->Js.Array2.filter(x => x > 5)
Js.log(newArr)
Console output:
[6, 8]

The pipe operator (->) takes the result of the expression on the left and passes it as an argument to the function on the right. The argument piped from the left corresponds to the first parameter of the function being called. Subsequent arguments are wrapped in parentheses like a regular function call.

For example, the expression arr->Js.Array2.map(x => x * 2) is equivalent to Js.Array2.map(arr, x => x * 2).

Unlike JavaScript’s method chaining which is limited to the available methods of the left-hand object, ReScript’s pipe operator can be used with any function as long as the types match. The next example shows that we don’t even need to declare a new variable to print the array, we can pipe it directly into the Js.log function:
[1, 2, 3]
->Js.Array2.map(x => x * 2)
->Js.Array2.map(x => x + 2)
->Js.Array2.filter(x => x > 5)
->Js.log
Console output:
[6, 8]
The expression on the left of a pipe does not have to be a function call:
let arr = [1, 2, 3]->Js.Array2.map(x => x * 2)
Console output:
[2, 4, 6]
If the function on the right only has a single parameter and its argument is being piped in, then the parentheses surrounding the arguments should be omitted:
let double = x => x * 2
let n = 100->double->double->double
Js.log(n)
Console output:
800
In the following incorrect example, there is an extra set of parentheses. The expression 100->double() is equivalent to the expression double(100)() (call the double function with 100, and then call the resulting function with ()):
let n = 100->double()
Compiler output:
This function has type int => int
It only accepts 1 argument; here, it’s called with more.

The pipe is a very powerful piece of syntax because it lets us compose and chain functions in a way that is easy to read.

With piping, reading the code from left to right or top to bottom matches the flow of the data, whereas nested function call expressions have to be read from inside out.

For example, writing an expression like 100->double->double->double makes far more sense than writing something like double(double(double(100))).

Since we can only pipe to the first argument in a function, functions where the first parameter is the same type as the output are easier to compose using pipes. However, not every function needs to be written to optimize for piping – only when it actually makes sense to compose functions together.

Labeled and Optional Parameters

In JavaScript, function parameters are positional parameters – when a function is called, the first argument corresponds to the first parameter, and the second argument to the second parameter, etc.

In addition to positional parameters, ReScript also supports labeled parameters (or named parameters). They allow arguments to be passed in any order, but each argument needs to be labeled with which parameter it’s for. This is useful both for clarity and for giving us more flexibility, but it can be more verbose:
let labeledFun = (~arg1, ~arg2) => Js.log2(arg1, arg2)
labeledFun(~arg2=5, ~arg1=4)
Console output:
4 5
Labeled parameters can also be made optional by providing a default value:
let labeledFun = (~arg1:int=1, ~arg2=3) => Js.log2(arg1, arg2)
labeledFun()
Console output:
1 3

Labeling parameters is useful for making call sites clearer, but if the parameter names get too long, we can use as to make a shorter alias to refer to them with a different name inside the function body.

Any type annotations we add should come after the alias. In the following example, both very long labeled parameters use shorter aliases inside the function body, and the second parameter has a type annotation:
let labeledFun2 = (~veryLongParam1 as x, ~veryLongParam2 as y: int) => {
 Js.log2(x, y)
}
labeledFun2(~veryLongParam1=10, ~veryLongParam2=10)
Labeled parameters may be mixed with positional parameters in any order. When a function with labeled parameters is called, the only restriction is that any positional arguments must be passed in order, while labeled arguments may be passed in any order relative to the other arguments. To illustrate this, see the following example, where the second parameter is labeled. Each function call in the example prints the same value:
let partiallyLabeledFun = (arg1, ~arg2, arg3, arg4) => {
 Js.log4(arg1, arg2, arg3, arg4)
}
partiallyLabeledFun(1, 3, 4, ~arg2=2)
partiallyLabeledFun(~arg2=2, 1, 3, 4)
partiallyLabeledFun(1, ~arg2=2, 3, 4)
Console output:
1 2 3 4
1 2 3 4
1 2 3 4

Currying and Partial Application

Currying is a feature in functional languages that allows a function to be treated as a sequence of nested functions that take a single argument each. For example, a function with two arguments is no different from a function that takes in the first argument and outputs another function, which takes in the second argument and outputs the final result.

Functions in ReScript are curried by default, which means that they support partial application – we can call them without providing all the arguments.

For example, the following add function can be called like a regular JavaScript function, but we can also apply one argument at a time like addCurried:
let add = (x, y) => x + y
let addCurried = x => (y => x + y)
// both arguments at once
add(1, 2)->Js.log
// one argument at a time
let addOne = add(1)
addOne(2)->Js.log
// equivalent to the above
let addOne = y => add(1, y)
addOne(2)->Js.log
// just like addCurried
let addCurriedOne = addCurried(1)
addCurriedOne(2)->Js.log
Console output:
3
3
3
3
For any function with multiple arguments, we can pass in the arguments all at once, one at a time, or anything in between. Take Js.log4, which accepts four arguments; the following function calls will all print the same result:
Js.log4(1, 2, 3, 4)
Js.log4(1)(2)(3)(4)
Js.log4(1)(2, 3, 4)
Js.log4(1, 2, 3)(4)
Console output:
1 2 3 4
1 2 3 4
1 2 3 4
1 2 3 4

It is important to note that partially applying a function will not execute the function body. The function body is only executed when all the arguments are applied, and a partially applied function with only one argument missing can be reused like any other function with one parameter.

Partial application is kind of neat to think about and unlocks some flexibility in how we use functions, but it should be used very sparingly. In most cases it doesn’t add to expressiveness, and it has a runtime performance penalty.

Polymorphic Functions

ReScript supports polymorphism in functions and types. This means that type signatures are not restricted to only matching a single concrete type, and the same function can be used on multiple types of arguments. For example, we can write a function that can be called on any type of array regardless of what contents it holds.

In type signatures, polymorphic types are represented by type variables with arbitrary names prefixed with an apostrophe, like 'a, 'b, and 'c.

This simple function that checks for structural equality between two values (x, y) => x == y has the signature ('a, 'a) => bool. It is polymorphic because we can pass in values of any type to the function, as long as both arguments have the same type (note that both arguments in the type signature are annotated with the same type name, 'a).

The logging function Js.log2 has the signature ('a, 'b) => unit. Unlike the previous example, the arguments to Js.log2 can be different types since they have different type variables.

Polymorphism is important and useful, but you usually don’t need to think about it too hard when programming. The compiler can automatically infer the types of polymorphic functions, and using polymorphic functions is the same as using regular functions.

One place where we’ll encounter many polymorphic functions is the standard library for container data types. Many operations on containers like arrays are polymorphic because they do not care about the type of contents the container holds. We’ll go into depth on containers and collections later, but for now we’ll just look at Belt.Array.getExn as an example.

The Belt.Array.getExn function takes an array and an index, and returns the value at that index. It has the signature (array<'a>, int) => 'a. The name 'a is used as a type parameter for the array type, and it’s also used as the type of the returned value. This means that the type that is returned from the function is the same type as the contents of the array that the function is called with.

Another important use for polymorphism is binding to JavaScript functions, many of which are polymorphic due to JavaScript’s dynamic nature. We’ll discuss JavaScript bindings in more detail in a later chapter.

Pure Functions

Recall the discussion in the previous chapter about expressions and side effects – a function call is an expression, and like every other expression they evaluate to a single value and may have possible side effects. There is a concept called purity in functional programming which may be used to classify functions.

When a function does not depend on any external state besides its inputs and evaluating it has no side effects, then it is considered a pure function. Pure functions have the nice property that every time we evaluate it with the same inputs, we know it will yield the exact same results.

On the other hand, functions that have side effects or depend on external state are considered impure functions. A good example of this is a function that adds a row to a database and returns the number of rows in the database. That function depends on external state (the contents of the database) and also mutates that state. If we call it with the same arguments multiple times in a row, it will yield different results each time.

While it’s impossible to write real-world software with only pure functions, there are good reasons to try to keep side effects under control. Pure functions are easier to reason about because their behavior is consistent, and they are easier to test as well – no need to set up external states and dependencies, just call the function with the inputs we want to test. We don’t need functions to be perfectly pure, but in general the fewer side effects and dependencies on global state a function has, the easier it is to test and maintain.

In JavaScript, there’s very little to stop an undisciplined programmer from writing code that has tons of side effects and is a nightmare to test. Purity is not built into ReScript either, in the sense that it’s not possible to mark or infer a ReScript function as pure or impure.

In ReScript, immutability is the default and mutation generally needs to be explicit. This means there are fewer ways to interact with global state, and as you’ll learn later, there are patterns of programming which can help avoid exceptions. In this way, ReScript makes it easier to keep side effects relatively minimal while still allowing the programmer to write stateful code as needed.

Although the ideas of side effects and purity aren’t explicitly language features in ReScript, the reason I introduce this concept here is because it’s a useful way to think about functions. Keeping this in mind going forward will help you think about software differently and write cleaner and more testable code.

Ignoring Return Values

In JavaScript, we can use any expression as a statement and ignore the value it yields – this applies to function calls as well. For example, here’s a function in JavaScript that increments a counter and returns the new value:
let counter = 0;
let increment = () => {
   counter++;
   return counter;
}
If we want to increment the counter and do not care about the return value, in JavaScript we can call the increment function as a statement which implicitly throws away the return value:
increment();
increment();
increment();
console.log(increment());
Console output:
4

In ReScript, values have to be used or ignored explicitly. If we want to perform a computation solely for its side effects and ignore the return value, one way to do it is to pass the value to the ignore function, which throws away the result of the computation wrapped inside and evaluates to ().

Here is how we would implement the equivalent of the previous example in ReScript with ignore:
let counter = ref(0)
let increment = () => {
 counter := counter.contents + 1
 counter.contents
}
increment()->ignore
increment()->ignore
increment()->ignore
Js.log(increment())
Console output:
4

As shown in the example, this is useful when we are calling a function and only care about the side effects, but not the value that it returns. If we wrap a pure function with ignore, it effectively wastes the entire computation, because no state is changed and the returned value is thrown away.

Another way to discard returned values is to use them on the right hand side of a let binding without a name. Which one we use is up to personal preference:
let counter = ref(0)
let increment = () => {
 counter := counter.contents + 1
 counter.contents
}
let _ = increment()
let _ = increment()
let _ = increment()
Js.log(increment())

Recursion

Syntax

By default, functions in ReScript are not recursive – if we want them to be able to call themselves, they need to be explicitly marked as recursive using the keyword rec, like in this following example which prints a countdown:
let rec countdown = x => {
 Js.log(x)
 if x > 0 {
   countdown(x - 1)
 }
}
countdown(10)

What if we want to write two mutually recursive functions (where the first function calls the second and vice versa)?

Recall that let bindings are only visible after the binding is declared. If we declare the two functions with separate let bindings, the compiler will disallow cases where the first function calls the second function, because the second function’s declaration is not visible to the first function:
let rec isEven = x => {
 if x == 0 {
   true
 } else {
   isOdd(abs(x) - 1)
 }
}
let rec isOdd = x => {
 if x == 0 {
   false
 } else {
   isEven(abs(x) - 1)
 }
}
Compiler output:
The value isOdd can’t be found

To declare mutually recursive functions, we can declare both functions with the same let binding, separated by and. The keywords let and rec do not need to be repeated for the second declaration.

Here’s the working version of the previous example:
let rec isEven = x => {
 if x == 0 {
     true
 } else {
     isOdd(abs(x) - 1)
 }
} and isOdd = x => {
 if x == 0 {
     false
 } else {
     isEven(abs(x) - 1)
 }
}
Js.log(isOdd(100))
Js.log(isEven(20))
Console output:
false
true

How to Use Recursion

Recursion is a very powerful and versatile tool in a programmer’s tool belt. In ReScript, higher-order functions and recursion can help simplify logic that would otherwise need a for-loop or while-loop. Recursion is also a natural choice when traversing data types with a recursive structure, such as linked lists and trees (more on that in the next chapter).

Let’s say we were writing a function to calculate the factorial of a number. If we’re programming in an imperative style, we might implement it using iteration and mutable state. If we’re programming in a functional style, we could implement it using recursion. I’ll present four examples of the factorial function, implemented iteratively and recursively in both ReScript and JavaScript.

In JavaScript, an imperative implementation of factorial might look like this:
let factorial = x => {
   var result = 1
   for (var i = 1; i <= x; i++) {
       result = result * i;
   }
   return result;
}
The equivalent imperative implementation in ReScript looks basically identical:
let factorial = x => {
 let result = ref(1)
 for i in 1 to x {
   result := result.contents * i
 }
 result.contents
}
Here’s the factorial function implemented recursively in JavaScript:
let factorial = x => {
   if (x == 0) {
       return 1;
   } else {
       return x * factorial(x - 1)
   }
}
The equivalent recursive implementation in ReScript – also virtually identical:
let rec factorial = x => {
 if x == 0 {
   1
 } else {
   x * factorial(x - 1)
 }
}

In many cases, using recursion can help us write cleaner and safer code compared to using loops and mutable state.

When expressing complex logic in a loop, we may end up tracking and updating many different variables outside the loop – collecting results, marking and checking various boolean flags, etc. With a recursive implementation, a lot of that mental overhead is eliminated – not having to worry about the state outside of the loop body means that all we need to think about are the inputs to our recursive function and what the stopping condition is.

However, there are some situations when we would want to choose loops over recursion for performance reasons. Deeply nested recursion (think thousands of recursive calls) have worse performance than loops, and in extreme cases can lead to the program crashing when it hits the call stack limit in the JavaScript runtime – try calling the isEven or isOdd example on max_int!

The ReScript compiler has a nice feature called unrolling that automatically turns some recursive functions into loops to improve performance. For example, the following recursive function does NOT compile into a recursive function in JavaScript:
let rec f = x => {
 if x == 0 {
   ()
 } else {
   Js.log(x)
   f(x - 1)
 }
}
The compiler automatically turns it into a loop for better performance:
function f(_x) {
 while(true) {
   var x = _x;
   if (x === 0) {
     return ;
   }
   console.log(x);
   _x = x - 1 | 0;
   continue ;
 };
}

It’s important to emphasize that unrolling happens automatically, and it doesn’t work for very complex cases. We can easily inspect the compiled output to see whether or not a recursive function was unrolled, but we cannot force the compiler to unroll a particular function. For complex and performance-sensitive computations, the only way to guarantee that our code will be compiled into a loop is to write it as a loop in the first place.

In general, the aforementioned drawbacks only come into play when writing software that is performance-sensitive or needs to handle extreme inputs. In most everyday use cases, recursion is a very useful technique for simplifying your programs.

Final Thoughts

At the syntax level, ReScript’s functions should look and feel familiar to anyone with experience in JavaScript. However, ReScript offers a lot more beyond what is possible in JavaScript. Features like piping unlock a lot of flexibility in how we use functions and help keep our code clean and readable.

In this chapter we also introduced the concept of purity as a way to classify functions. While it’s impossible to completely eliminate side effects from any real software application, keeping these ideas in mind can help us write code that is more self-contained and therefore easier to understand and test.

With a grasp of how functions work in ReScript, we can now begin to write more interesting programs. Until now we’ve mostly worked with basic data types like integers and booleans and strings. In the next few chapters, we’ll discuss more complex data types built into the language, and how we can define our own custom data types.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset