Erlang and code style
Correct Erlang usage mandates you do not write any kind of defensive code. This is called intentional programming. You write code for the intentional control flow path which you expect the code to take. And you don’t write any code for the paths which you think are not possible. Furthermore, you don’t write code for data flow which was not the intention of the program.
It is an effect, silly
If an Erlang program goes wrong, it crashes. Say we are opening a file. We can guard the file open call like so:
{ok, Fd} = file:open(Filename, [raw, binary, read, read_ahead]),
What happens if the file doesn’t exist? Well the process crashes. But note we did not have to write any code for that path. The default in Erlang is to crash when a match isn’t valid. We get a badmatch error with a reason as to why we could not open the file.
A process crashing is not a problem. The program is still operating and supervision—An important fault-tolerance concept in Erlang—will make sure that we try again in a little while. Say we have introduced a race condition on the file open, by accident. If it happens rarely, the program would still run, even if the file open fails from time to time.
You will often see code that looks like:
ok = foo(...),<br>ok = bar(...),<br>ok = ...
which then asserts that each of these calls went well, making sure code crashes if the control and data flow is not what is expected.
Notice the complete lack of error handling. We don’t write
case foo(...) of
ok -> case bar(...) of ... end;
{error, Reason} -> throw({error, Reason})
end,
Nor do we fall into the trap of the Go programming language and write:
res, err := foo(...)
if err != nil {
panic(...)
}
res2, err := bar(...)
if err != nil {
panic(...)
}
because this is also plain silly, tedious and cumbersome to write.
The key is that we have a crash-effect in the Erlang interpreter which we can invoke where the default is to crash the process if something goes wrong. And have another process clean up. Good Erlang code abuses this fact as much as possible.
Intentional?
Note the word intentional. In some cases, we do expect calls to fail. So we just handle it like everyone else would, but since we can emulate sum-types in Erlang, we can do better than languages with no concept of a sum-type:
case file:open(Filename, [raw, read, binary]) of
{ok, Fd} -> ...;
{error, enoent} -> ...
end,
Here we have written down the intention that the file might not exist. However:
-
We only worry about non existence.
-
We crash on
eaccess
which means an access error due to permissions. -
Likewise for
eisdir
,enotdir
,enospc
.
Why?
Leaner code, that’s why.
We can skip lots of defensive code which often more than halves the code size of projects. There are much less code to maintain so when we refactor, we need to manipulate less code as well.
Our code is not littered with things having nothing to do with the “normal” code flow. This makes it far easier to read code and determine what is going on.
Erlang process crashes gives lots of information when something dies. For a proper OTP process, we get the State of the process before it died and what message was sent to it that triggered the crash. A dump of this is enough in about 50% of all cases and you can reproduce the error just by looking at the crash dump. In effect, this eliminates a lot of silly logging code.
Data flow defensive programming
Another common way of messing up Erlang programs is to mangle incoming data through pattern matching. Stuff like the following:
convert(I) when is_integer(I) -> I;
convert(F) when is_float(F) -> round(F);
convert(L) when is_list(L) -> list_to_integer(L).
The function will convert “anything” to an integer. Then you proceed to use it:
process(Anything) -> I = convert(Anything), ...I...
The problem here is not with the process function, but with the call-sites of the process function. Each call-site has a different opinion on what data is being passed in this code. This leads to a situation where every subsystem handles conversions like these.
There are several disguises of this anti-pattern. Here is another smell:
convert({X, Y}) -> {X, Y};
convert(B) when is_binary(B) ->
[X, Y] = binary:split(B, <<"-">>),
{X, Y}.
This is stringified programming where all data are pushed into a string and then manually deconstructed at each caller. It leads to a lot of ugly code with little provision for extension later.
Rather than trying to handle different types, enforce the invariant early on the api:
process(I) when is_integer(I) -> ...
And then never test for correctness inside your subsystem. The dialyzer is good at inferring the use of I as an integer. Littering your code with is_integer tests is not going to buy you anything. If something is wrong in your subsystem, the code will crash, and you can go handle the error.
There is something to be said about static typing here, which will force you out of this unityped world very easily. In a statically typed language, I could still obtain the same thing, but then I would have to define something along the lines of (* Standard ML code follows *)
datatype anything = INT of int
| STRING of string<br>
| REAL of real
and so on. This quickly becomes hard to write pattern matches for, so hence people only defines the anything type if they really need it. (Gilad Bracha was partially right when he identified this as a run-time check on the value, but what he omitted was the fact that the programmer has the decision to avoid a costly runtime check all the time—come again, Gilad ☺).
The scourge of undefined
Another important smell is that of the undefined
value. The story here is that
undefined is often used to program a Option/Maybe monad. That is, we have the
type
-type option(A) :: undefined | {value, A}.
(For the static typists out there: Erlang does have a type system based on success types for figuring out errors, and the above is one such type definition)
It is straightforward to define reflection/reification into an exception-effect for these. Jakob Sievers https://github.com/cannedprimates/stdlib2/blob/master/src/s2_maybe.erl stdlib2 library already does this, as well as define the monadic helper called do (Though the monad is of the Error-type rather than Option).
But I’ve seen:
-spec do_x(X) -> ty() | undefined
when X :: undefined | integer().
do_x(undefined) -> undefined;
do_x(I) -> ...I....
Which leads to complicated code. You need to be 100% in control of what values can fail and what values can not. Constructions like the above silently passes undefined on. This has its uses—but be wary when you see code like this. The <em class="markup—em markup—p-em">undefined</em> value is essentially a <em class="markup—em markup—p-em">NULL</em>. And those were C.A.R Hoare’s billion dollar mistake.
The problem is that the above code is nullable. The default in Erlang is that you never have NULL-like values. Introducing them again should be used sparingly. You will have to think long and hard because once a value is nullable, it is up to you to check this all the time. This tend to make code convoluted and complicated. It is better to test such things up front and then leave it out of the main parts of the code base as much as possible.
“Open” data representations
Whenever you have a data structure, there is a set of modules which knows about and operates on that data structure. If there is only a single module, you can emulate a common pattern from Standard ML or OCaml where the concrete data structure representation is abstract for most of the program and only a single module can operate on the abstract type.
This is not entirely true in Erlang, where anyone can introspect any data. But keeping the illusion is handy for maintainability.
The more modules that can manipulate a data structure, the harder it is to alter that data structure. Consider this when putting a record in a header file. There are two levels of possible creeping insanity:
-
You put the record definition in a header file in
src
. In this case only the application itself can see the records, so they don’t leak out. -
You put the record definition in a header file in
include
. In this case the record can leak out of the application and often will.
A good example is the HTTP server cowboy where its request object is manipulated through the cowboy_req module. This means the internal representation can change while keeping the rest of the world stable on the module API.
There are cases where it makes sense to export records. But think before doing so. If a record is manipulated by several modules, chances are that you can win a lot by re-thinking the structure of the program.
The values ‘true’ and ‘false’ are of type atom()
As a final little nod, I see too much code looking like
f(X, Y, true, false, true, true),
Which is hard to read. Since this is Erlang, you can just use a better name for the true and false values. Just pick an atom which makes sense and then produce that atom. It also has the advantage to catch more bugs early on if arguments get swapped by accident. Also note you can bind information to the result, by passing tuples. There is much to be said about the concept of boolean blindness which in typical programs means to rely too much on boolean() values. The problem is that if you get a true say, you don’t know why it was true. You want evidence as to its truth. And this can be had by passing this evidence in a tuple. As an example, we can have a function like this:
case api:resource_exists(ID) of
true -> Resource = api:fetch_resource(ID), ...;
false -> ...
end.
But we could also write it in a more direct style:
case api:fetch_resource(ID) of
{ok, Resource} -> ...;
not_found -> ...
end.
(Edit: I originally used the function name resource_exists
above but Richard Carlsson correctly points out this is
a misleading name. So I changed it to something with a better name)
which in the long run is less error prone. We can’t by accident call the fetch_resource call and if we look up the resource, we also get hold of the evidence of what the resource is. If we don’t really want to use the resource, we can just throw it away.
Closing remarks
Rules of thumb exists to be broken. So once in a while they must be broken. However, I hope you learnt something or had to stop and reflect on something if you happened to get here (unless you scrolled past all the interesting stuff).
I am also interested in Pet-peeves of yours, if I am missing some. The way to become a better programmer is to study the style of others.