We continue our research about Erlang in 2019, you can read the first chapter from https://medium.com/@iqdeveloper/erlangcosc4-281c6db607a2
Control Abstraction
Control abstraction is fundamental to high-level languages, allowing us to hide procedural data by disregarding the low-level details sparing the programmer from complicated code. By defining our own control constructs programmers can accurately perform well-defined operations such as subroutines, loops, and branch instructions.
In Erlang higher-order functions or just funs are the basic unit of abstractions, they are used to create your own control abstraction. That way they can can do exactly what you want them to do. Higher-order functions are simply functions that manipulate functions. Funs can be used in various ways, but here will show you how they can be used to create your own control abstraction. We will start by defining a simple fun and assign it to a variable:
Double = fun(X) -> 3*X end
After defining the fun we can only apply it to an argument, like this:
Double(1).
3
Now that we have a basic understanding of funs we can start creating our own for statements, if statements, switch statements, or while statements. In Erlang there are no for loops so we have to create our own with funs and a concept called recursion. Creating a for loop is easy in Erlang and it allows us to explicitly define the for loop so we don’t have to rely on a set of predefined set of control abstractions.
for(0, _) ->
[ ];
for (N, Term) when N > 0 ->
io: fwrite(“Hello World!~n”),
[Term| for(N-1, Term)].
start() ->
for(5, 1).
Here we defined a recursive function which simulates the implementation of a for loop. We applied a guard to ensure the N or limit is a positive value. We then recursively call the for function and reduce the value of N after each iteration.
While statement that outputs:
0
1
2
3
Here we defined a recursive function called while to simulate the implementation of a while statement. We input a list X of integers to our while function. The while functions takes each list value and stores it in the variable Acc. Finally the while loop is called recursively for each value in the list X.
We compare the variables A and B. The -> operator follows the expression and the ; operator follows statement 1. The following -> operator follows the true expression.
Data Types
Data types are an essential part of any programming language, and it’s best to have a thorough understanding of them before utilizing the language. Erlang is dynamically typed so there is no need for explicitly stating your data type, but it is strongly typed so it requires explicit conversion between data types. Below is a list of each data type along with its description and examples of use, after which is a few examples of explicit type conversion.
Built-in Data Types
- Number: there are two types of numeric literals:
We have two types of number data types. Integer and Float. Below are examples.
2. Atom: a literal (a constant with a name) can be any combination of characters but if it does not start with a lowercase letter or contains characters that aren’t alphanumeric, underscore (_), or at (@) then it must be enclosed in single quotes (‘)
Examples:
3. Bit String: used to store an area of un-typed memory
Example of multiple ways to create a bit string of size 3 bits:
4. Tuple: a compound data type with a fixed number of terms where each term ‘Term’ is an element and the number of elements is the size of the tuple. You can create a tuple by giving it a name and using curly brackets, and separating each element with a comma, Tuple_Name = {element1, element2,…, elementn}.
Example of creating and using a tuple:
Line 1 creates a tuple named ‘T’, lines 2–4 are examples of getting an element of a tuple by its index (note that the index of a tuple starts at 1 and not 0) using the element() built-in function, line 5 is an example of resetting an element in a tuple (if wanted, you can keep the same name ‘T’ instead of ‘T2’), and line 6 and 7 are examples or using the tuple_size() built-in function in Erlang to find the size of a tuple.
5. Map: a compound data type with a variable number of terms made up of key-value pairs, where each key-value pair is an element and the number of elements is the size of the map. You can create a map by giving it a name, using a pound symbol followed by curly brackets, using the notation key=>value for each element, and separating each element with a comma, Map_Name = #{key1=>value1,…,keyn=>valuen}.
Example of creating and using a map:
Line 1 creates the map named ‘M’, lines 2–4 show how to get a value of an element from its key using the maps:get() built-in function, line 5 shows how to update a value in the map based on its key using the maps:update() built-in function, and lines 6 and 7 show how to determine the size of a map using the map_size() built-in function.
6. List: a compound data type with a variable number of terms where each term is an element and the number of elements in the list is the length of the list. The list itself is either an empty list ‘[]’ or ‘[H|T]’ which consists of a head (first element) and a tail (remaining elements) which is also a list if this is a proper list. To Create a list, name it and contain the elements in square brackets and separate the elements by commas.
Example of creating and using a list:
Line 1 is the creation of a list named ‘L’, line 2 is the separation of the head and tail of L into an element (H) and a list (T), line 3–4 are the contents of the head and tail, line 5 is the creation of a new list ‘L2’ with a new head and the tail from L, line 6 is the creation of another new list ‘L3’ with the head from L and a new tail, and lines 7 and 8 are how you find the length of a list using the length() built-in function.
Other Data Types:
- Boolean: there is no ‘Boolean’ data type in Erlang, however there are two reserved atoms ‘true’ and ‘false’ which have the typical usability of a boolean.
Examples of the use of booleans:
2. String: there is no ‘String’ data type in Erlang, however you can enclose a string of characters in double quotes (“) which is shorthand for a list of characters, so for example the string “hello” is actually the list [$h, $e, $l, $l, $o] which is equivalent to [104, 101, 108, 111] which is the list of ASCII values of these characters. Also, two adjacent string literals will be concatenated into one during compilation.
Line 2 shows how you can use the length() built-in function from the list data type and apply it to the ‘string’ data type, and line 3 shows the concatenation of multiple ‘strings’.
Built-In Type Conversion Functions
For each of the built-in data types there are a number of built-in functions that allows for explicit type conversion. Following are these built-in functions:
Data Abstraction in the Language
While simple programs are easy to manage and manipulate, larger and complex programs can easily be cluttered and inefficient. Data abstraction, such as modules, classes, and functions make the program less time consuming, easier to manage, and more secure. Procedural abstraction is the most basic examples of data abstraction. In procedural abstraction, code is used to implement basic methods without needing to understand how the method is implemented. These are a few examples of procedural abstraction where they are understood print a certain response but the implementation is not needed to understand.
Another key example is Modular abstractions. With modular abstractions, modular code-level functional abstractions, rely on the dialyzer to create them for you like in the queue example from above.
The date() is given to produce the current date. This particular code could be lengthy due to the method of getting the information from the computer but thankfully the method is already built in and not needed nor important. There is many other basic procedural abstraction that makes it much easier and user friendly to code such as: merge(), add(), List.to_string(), and lists:concat().
Another key example of data abstraction is modular abstractions.A module is a unit of compilation, where each module is named and contains functions that can be accessed from other modules.
This is an example of two modules named main and print. Main is used to create the instances of print and call the message function. Thus whenever M1 is called the value of Name is Humpty and the value of M2 is Dumpty. A feature known as “dynamic code replacement” allows a new version of a module to be loaded at any time. Modules make it much easier and less risky to make changes without affecting the rest of the program.
Functions are a major contribution to programming. Whether it be a simple adding function or a complex function handling and manipulating large amounts of data, they are able to help make the code shorter and easier to manage. A function that can accept other functions transported around is named a higher order function.
This is an example of a higher order function. In this example, double() and add_one() are predefined function that are being used in map(). The function-to-function aspect of this code allows for easy manipulation of a certain function without risking the integrity of the other functions.
Procedural abstraction can be used to write several different functions that have a similar structure, but are slightly different.There are instances in programming where a higher order function is needed for two different functions that are very similar. While these are not exactly the same, they still have separate roles and thus without having to execute each separately, another function may be used to use either or both.
print_list(Stream, [H|T]) ->
io:format(Stream, “~p~n”, [H]),
print_list(Stream, T);
print_list(Stream, []) ->
true.
broadcast(Msg, [Pid|Pids]) ->
Pid ! Msg,
broadcast(Msg, Pids);
broadcast(_, []) ->
true.
foreach(F, [H|T]) ->
F(H),
foreach(F, T);
foreach(F, []) ->
ok.
foreach(fun(H) -> io:format(S, “~p~n”,[H]) end, L)
foreach(fun(Pid) -> Pid ! M end, L)
In this program, print_list() and broadcast() are examples that are similar but slightly different. The function foreach() uses both functions just differently.
Programs of a certain size are complex. As long as the program is written by a single programmer and is fairly small, say under 1000 lines of code, then everything is easy. The programmer can keep the whole program in the head and it is easy to do stuff with that program. If on the other hand the program grows in size or we add more programmers, then we can’t rely on the singular knowledge of a programmer.
The only way to solve this problem is to build in abstractions in your programs. We will review two such methods in Erlang. The idea of abstraction, informally, is that we will hide certain details and only provide a clean interface through which to manipulate stuff. Erlang is a “Mutually Consenting Adult Language” (read: dynamically typed with full term introspection — or more violently — unityped crap with everything in one big union type). So this abstraction is not possible in reality. On the other hand, the dialyzer can provide us with much of the necessary tooling for abstraction.
As an example of so-called modular abstraction, let us consider a small toy module:
-module(myq). -export([push/2,
empty/0,
pop/1]).
-type t() :: {fifo, [integer()], [integer()]}.
-export_type([t/0]). -spec empty() -> t().
-spec push(integer(), t()) -> t().
-spec pop(t()) -> 'empty' | {'value', integer(), t()}.
These are the definitions and specs of the module we are implementing. We are writing a simple queue module for a FIFO queue, based on two lists that are kept back-to-back. Using a Standard ML / Ocaml trick here by calling the canonical type it operates on for ‘t’. The operations push/2 and pop/1 are used to push and pop elements to and from the queue respectively. Note we are prefixing queues by the atom ‘fifo’ to discriminate them from other tuples. The implementation of the queue is equally simple:
empty() -> {fifo, [], []}. push(E, {fifo, Front, Back}) -> {fifo, Front, [E | Back]}. pop({fifo, [E | N], Back}) -> {value, E, {fifo, N, Back}};
pop({fifo, [], []}) -> empty;
pop({fifo, [], Back}) -> pop({fifo, lists:reverse(Back), []}).
We always push to the back list and always pop from the front list. If the front list ever becomes empty, we reverse the back list to the front. Not used persistently, this queue has amortized O(1) run-time and is as such pretty fast.
The neat thing is that all operations are local to the myq module when you want to operate on queues. This abstracts away details about queues when you are using them via this module. There can much code inside such a module which is never exposed to the outside and thus we have an easier time managing the program.
There is a problem with this though, which is that the implementation of the queue is transparent. A user of the myq module can, when handed a queue, Q, of type myq:t() we can discriminate on it like this user:
-module(u). -compile(export_all). -spec f(myq:t()) -> myq:t().
f(Q) ->
case Q of
{fifo, [], []} ->
myq:push(7, Q);
_Otherwise ->
Q
end.
Note how we match on the queue and manipulate it. This is bad practice! If the myq module defined the representation of the queue it ought to be the only module that manipulate the internal representation of a queue. Otherwise we might lose the modularity since the representation has bled all over the place. Now, since Erlang is for mutually consenting adults, you need to make sure this data structural representation leak doesn’t happen yourself. It is especially important with records. If you want modular code, avoid putting records in included header files if possible unless you are dead sure the representation won’t change all of a sudden. Otherwise the record will bleed all over your code and make it harder to change stuff later on. Also changes are not module-local but in several modules. This hurts the reusability of code.
However, the dialyzer has a neat trick! If we instead of
-type t() :: {fifo, [integer()], [integer()]}.
had defined the type as opaque
-opaque t() :: {fifo, [integer()], [integer()]}.
Then the dialyzer will report the following when run on the code:
u.erl:9: The call myq:push(7,Q::{'fifo',[],[]}) does not have an
opaque term of type myq:t() as 2nd argument
which is a warning that we are breaking the opaqueness abstraction of the myq:t() type.
The Other kind of abstraction in Erlang
Languages like Haskell or ML has these kind of tricks up their sleeve in the type system. You can enforce a type to be opaque and get type errors if a user tries to dig into the structure of the representation. Since the dialyzer came later in Erlang one might wonder why one could write programs larger than a million lines of code in Erlang and get away with it when there was no enforcement of opaqueness. The answer is subtle and peculiar. Part of the answer is naturally the functional heritage of Erlang. Functional languages tend to have excellent reusability properties because the task of handling state is diminished. Also, functional code tend to be easier to maintain since it is much more data-flow oriented than control-flow oriented. But Erlang has another kind of abstraction which is pretty unique to it, namely that of a process:
If we create a process, then its internal state is not observable from the outside. The only thing we can do is to communicate with the process by protocol: we can send it a message and we can await messages from it. This makes the process abstract when viewed from the outside. The internal representation is not visible and you could completely substitute the process for another one without the caller knowing. In Erlang this principle of process isolation is key to the abstractions facilities.
What does this mean really?
Erlang has not one, but two kinds of ways to handle large applications: You can use modules, exports of types and opaqueness constraints to hide representations. While you can break the abstraction, the dialyzer will warn you when you are doing so. This is a compile-time and program-code abstraction facility. Orthogonally to this, a process is a runtime isolation abstraction. It enforces a given protocol at run time which you must abide. It can hide the internal representation of a process. It provides an abstraction facility as well. It is also the base of fault tolerance. If a process dies, only its internal state can be directly affected. Other processes not logically bound to it can still run. It is my hunch that these two tools together is invaluable when it comes to building large Erlang programs, several hundred thousand lines of code — and get away with it!
So in conclusion: To create modular code-level functional abstractions, rely on the dialyzer to create them for you like in the queue example from above. To create a modular runtime, split your program into processes, where each process handles a concurrent task.
Expressions
In this section, all valid Erlang expressions are listed. When writing Erlang programs, it is also allowed to use macro- and record expressions. However, these expressions are expanded during compilation and are in that sense not true Erlang expressions.
- Expression Evaluation
All subexpressions are evaluated before an expression itself is evaluated, unless explicitly stated otherwise. For example, consider the expression:
Expr1 + Expr2
Expr1 and Expr2, which are also expressions, are evaluated first — in any order — before the addition is performed.
Many of the operators can only be applied to arguments of a certain type. For example, arithmetic operators can only be applied to numbers. An argument of the wrong type causes a badarg runtime error.
2. Terms
The simplest form of expression is a term, that is an integer, float, atom, string, list, map, or tuple. The return value is the term itself.
3. Variables
A variable is an expression. If a variable is bound to a value, the return value is this value. Unbound variables are only allowed in patterns.
Variables start with an uppercase letter or underscore (_). Variables can contain alphanumeric characters, underscore and @.
Examples:
X
Name1
PhoneNumber
Phone_number
_
_Height
Variables are bound to values using pattern matching. Erlang uses single assignment, that is, a variable can only be bound once.
The anonymous variable is denoted by underscore (_) and can be used when a variable is required but its value can be ignored.
Example:
[H|_] = [1,2,3]
Variables starting with underscore (_), for example, _Height, are normal variables, not anonymous. They are however ignored by the compiler in the sense that they do not generate any warnings for unused variables.
Example:
The following code:
member(_, []) ->
[].
can be rewritten to be more readable:
member(Elem, []) ->
[].
This causes a warning for an unused variable, Elem, if the code is compiled with the flagwarn_unused_vars set. Instead, the code can be rewritten to:
member(_Elem, []) ->
[].
Notice that since variables starting with an underscore are not anonymous, this matches:
{_,_} = {1,2}
But this fails:
{_N,_N} = {1,2}
The scope for a variable is its function clause. Variables bound in a branch of an if, case, or receive expression must be bound in all branches to have a value outside the expression. Otherwise they are regarded as ‘unsafe’ outside the expression.
For the try expression variable scoping is limited so that variables bound in the expression are always ‘unsafe’ outside the expression.
4. Patterns
A pattern has the same structure as a term but can contain unbound variables.
Example:
Name1
[H|T]
{error,Reason}
Patterns are allowed in clause heads, case and receive expressions, and match expressions.
Match Operator = in Patterns
If Pattern1 and Pattern2 are valid patterns, the following is also a valid pattern:
Pattern1 = Pattern2
When matched against a term, both Pattern1 and Pattern2 are matched against the term. The idea behind this feature is to avoid reconstruction of terms.
Example:
f({connect,From,To,Number,Options}, To) ->
Signal = {connect,From,To,Number,Options},
...;
f(Signal, To) ->
ignore.
can instead be written as
f({connect,_,To,_,_} = Signal, To) ->
...;
f(Signal, To) ->
ignore.
String Prefix in Patterns
When matching strings, the following is a valid pattern:
f("prefix" ++ Str) -> ...
This is syntactic sugar for the equivalent, but harder to read:
f([$p,$r,$e,$f,$i,$x | Str]) -> ...
Expressions in Patterns
An arithmetic expression can be used within a pattern if it meets both of the following two conditions:
- It uses only numeric or bitwise operators.
- Its value can be evaluated to a constant when complied.
Example:
case {Value, Result} of
{?THRESHOLD+1, ok} -> ...
5. Function Calls
ExprF(Expr1,...,ExprN)
ExprM:ExprF(Expr1,...,ExprN)
In the first form of function calls, ExprM:ExprF(Expr1,...,ExprN), each of ExprMand ExprF must be an atom or an expression that evaluates to an atom. The function is said to be called by using the fully qualified function name. This is often referred to as a remote or external function call.
Example:
lists:keysearch(Name, 1, List)
In the second form of function calls, ExprF(Expr1,...,ExprN), ExprF must be an atom or evaluate to a fun.
If ExprF is an atom, the function is said to be called by using the implicitly qualified function name. If the function ExprF is locally defined, it is called. Alternatively, if ExprF is explicitly imported from the M module, M:ExprF(Expr1,...,ExprN) is called. If ExprFis neither declared locally nor explicitly imported, ExprF must be the name of an automatically imported BIF.
Examples:
handle(Msg, State)
spawn(m, init, [])
Examples where ExprF is a fun:
1> Fun1 = fun(X) -> X+1 end,
Fun1(3).
4
2> fun lists:append/2([1,2], [3,4]).
[1,2,3,4]
3>
Notice that when calling a local function, there is a difference between using the implicitly or fully qualified function name. The latter always refers to the latest version of the module. See Compilation and Code Loading and Function Evaluation.
Local Function Names Clashing With Auto-Imported BIFs
If a local function has the same name as an auto-imported BIF, the semantics is that implicitly qualified function calls are directed to the locally defined function, not to the BIF. To avoid confusion, there is a compiler directive available, -compile({no_auto_import,[F/A]}), that makes a BIF not being auto-imported. In certain situations, such a compile-directive is mandatory.
6. if
if
GuardSeq1 ->
Body1;
...;
GuardSeqN ->
BodyN
end
The branches of an if-expression are scanned sequentially until a guard sequence GuardSeq that evaluates to true is found. Then the corresponding Body (sequence of expressions separated by ',') is evaluated.
The return value of Body is the return value of the if expression.
If no guard sequence is evaluated as true, an if_clause run-time error occurs. If necessary, the guard expression true can be used in the last branch, as that guard sequence is always true.
Example:
is_greater_than(X, Y) ->
if
X>Y ->
true;
true -> % works as an 'else' branch
false
end
7. Arithmetic Expressions
op Expr
Expr1 op Expr2
Examples:
1> +1.
1
2> -1.
-1
3> 1+1.
2
4> 4/2.
2.0
5> 5 div 2.
2
6> 5 rem 2.
1
7> 2#10 band 2#01.
0
8> 2#10 bor 2#01.
3
9> a + 10.
** exception error: an error occurred when evaluating an arithmetic expression
in operator +/2
called as a + 10
10> 1 bsl (1 bsl 64).
** exception error: a system limit has been reached
in operator bsl/2
called as 1 bsl 18446744073709551616
8. Boolean Expressions
Examples:
1> not true.
false
2> true and false.
false
3> true xor false.
true
4> true or garbage.
** exception error: bad argument
in operator or/2
called as true or garbage
9. Catch and Throw
catch Expr
Returns the value of Expr unless an exception occurs during the evaluation. In that case, the exception is caught.
For exceptions of class error, that is, run-time errors, {'EXIT',{Reason,Stack}} is returned.
For exceptions of class exit, that is, the code called exit(Term), {'EXIT',Term} is returned.
For exceptions of class throw, that is the code called throw(Term), Term is returned.
Reason depends on the type of error that occurred, and Stack is the stack of recent function calls, see Exit Reasons.
Examples:
1> catch 1+2.
3
2> catch 1+a.
{'EXIT',{badarith,[...]}}
Notice that catch has low precedence and catch subexpressions often needs to be enclosed in a block expression or in parentheses:
3> A = catch 1+2.
** 1: syntax error before: 'catch' **
4> A = (catch 1+2).
3
The BIF throw(Any) can be used for non-local return from a function. It must be evaluated within a catch, which returns the value Any.
Example:
5> catch throw(hello).
hello
If throw/1 is not evaluated within a catch, a nocatch run-time error occurs.
10. TRY
try Exprs
catch
Class1:ExceptionPattern1[:Stacktrace] [when ExceptionGuardSeq1] ->
ExceptionBody1;
ClassN:ExceptionPatternN[:Stacktrace] [when ExceptionGuardSeqN] ->
ExceptionBodyN
end
This is an enhancement of catch. It gives the possibility to:
- Distinguish between different exception classes.
- Choose to handle only the desired ones.
- Passing the others on to an enclosing try or catch, or to default error handling.
Notice that although the keyword catch is used in the try expression, there is not a catch expression within the try expression.
It returns the value of Exprs (a sequence of expressions Expr1, ..., ExprN) unless an exception occurs during the evaluation. In that case the exception is caught and the patterns ExceptionPattern with the right exception class Class are sequentially matched against the caught exception. If a match succeeds and the optional guard sequence ExceptionGuardSeq is true, the corresponding ExceptionBody is evaluated to become the return value.
Stacktrace, if specified, must be the name of a variable (not a pattern). The stack trace is bound to the variable when the corresponding ExceptionPattern matches.
If an exception occurs during evaluation of Exprs but there is no matching ExceptionPattern of the right Class with a true guard sequence, the exception is passed on as if Exprs had not been enclosed in a try expression.
If an exception occurs during evaluation of ExceptionBody, it is not caught.
It is allowed to omit Class and Stacktrace. An omitted Class is shorthand for throw:
try Exprs
catch
ExceptionPattern1 [when ExceptionGuardSeq1] ->
ExceptionBody1;
ExceptionPatternN [when ExceptionGuardSeqN] ->
ExceptionBodyN
end
The try expression can have an of section:
try Exprs of
Pattern1 [when GuardSeq1] ->
Body1;
...;
PatternN [when GuardSeqN] ->
BodyN
catch
Class1:ExceptionPattern1[:Stacktrace] [when ExceptionGuardSeq1] ->
ExceptionBody1;
...;
ClassN:ExceptionPatternN[:Stacktrace] [when ExceptionGuardSeqN] ->
ExceptionBodyN
end
If the evaluation of Exprs succeeds without an exception, the patterns Pattern are sequentially matched against the result in the same way as for a case expression, except that if the matching fails, a try_clause run-time error occurs.
An exception occurring during the evaluation of Body is not caught.
The try expression can also be augmented with an after section, intended to be used for cleanup with side effects:
try Exprs of
Pattern1 [when GuardSeq1] ->
Body1;
...;
PatternN [when GuardSeqN] ->
BodyN
catch
Class1:ExceptionPattern1[:Stacktrace] [when ExceptionGuardSeq1] ->
ExceptionBody1;
...;
ClassN:ExceptionPatternN[:Stacktrace] [when ExceptionGuardSeqN] ->
ExceptionBodyN
after
AfterBody
end
AfterBody is evaluated after either Body or ExceptionBody, no matter which one. The evaluated value of AfterBody is lost; the return value of the try expression is the same with an after section as without.
Even if an exception occurs during evaluation of Body or ExceptionBody, AfterBody is evaluated. In this case the exception is passed on after AfterBody has been evaluated, so the exception from the try expression is the same with an after section as without.
If an exception occurs during evaluation of AfterBody itself, it is not caught. So if AfterBody is evaluated after an exception in Exprs, Body, or ExceptionBody, that exception is lost and masked by the exception in AfterBody.
The of, catch, and after sections are all optional, as long as there is at least a catchor an after section. So the following are valid try expressions:
try Exprs of
Pattern when GuardSeq ->
Body
after
AfterBody
end
try Exprs
catch
ExpressionPattern ->
ExpressionBody
after
AfterBody
end
try Exprs after AfterBody end
Next is an example of using after. This closes the file, even in the event of exceptions in file:read/2 or in binary_to_term/1. The exceptions are the same as without the try...after...end expression:
termize_file(Name) ->
{ok,F} = file:open(Name, [read,binary]),
try
{ok,Bin} = file:read(F, 1024*1024),
binary_to_term(Bin)
after
file:close(F)
end.
Next is an example of using try to emulate catch Expr:
try Expr
catch
throw:Term -> Term;
exit:Reason -> {'EXIT',Reason}
error:Reason:Stk -> {'EXIT',{Reason,Stk}}
end
11. Block Expressions
begin
Expr1,
...,
ExprN
end
Block expressions provide a way to group a sequence of expressions, similar to a clause body. The return value is the value of the last expression ExprN.
For the rest of the expressions, you can check official Erlang Reference Manual
Authors: Group 10