by Oleg Tarasenko
This blog suggests an alternative means by which you can structure your programs, inspired by the Elixir pipe macro ‘|>’ but without making use of the dreaded parse transforms using my tiny epipe library, which I have recently written. Epipe itself is inspired by this article published by Scott Wlaschin.
Let’s perform a small practical task which will demonstrate this railway approach to functional programming.
Consider the case where we’re building a POP3 email client using Erlang. Our goal is to implement a control flow for establishing connections with a POP server.
This diagram illustrates the steps needed to accomplish this action:
First, let’s build a function implementing the connection functionality:
connect(Addr, Port, ConnOptions, User, Password) -> {ok, Socket} = ssl:connect(Addr, Port, ConnOptions), ok = receive_greetings(Socket), ok = send_user(Socket, User), ok = send_password(Socket, Password).
The code above is very beautiful, only four lines of code and we’re done! But wait… the implementation above is very much a best case scenario. Obviously we need to add some error handling in order to to deal with edge cases :(. I mean, “what could possibly go wrong”?
Let’s summarise all possible edge cases on the diagram below:
Let’s add the error handling code, and see how it looks now!
Spoiler: The example below is trivial and can be beautified by splitting the operations into separate functions but the nested case statements are unavoidable.
connect(Addr, Port, ConnOptions, User, Password) -> case ssl:connect(Addr, Port, ConnOptions) of {ok, Socket} -> case receive_greetings(Socket) of ok -> case send_user(Socket, User) of ok -> case send_password(Socket, Password) of ok -> ok; _Err -> error_logger:error_msg("Auth error") end; _Err -> error_logger:error_msg("Unknown user") end; Err -> error_logger:error_msg("Could not receive_greetings") end; _Error -> error_logger:error_msg("Could not connect") end.
Wow. Now we have added all of the error code. And wow, the size of the code has increased by 400%… with a commensurate decrease in readability. Ouch!
Perhaps there is a cleaner way to implement this?
The idea behind the railway approach is to decompose “step” functional blocks, using railway switches as an analogue:
* Image source: Scott Wlaschin
Which could be translated into the following Erlang code:
switch_component(Input) -> case some_action() of {ok, Response} -> {ok, Response}; % Green track Error -> {error, Error} % Red track end.
Once you have created two way (ok/error) switches for all required operations, you can combine them as elegant as it’s done on the railroad:
* Image source: Scott Wlaschin
So, to recap, what exactly happens is:
In the case of the success scenario, all functions (“railway switches”) are executed sequentially, and we travel along the “Success track”. Otherwise, our train switches to the “Error track” and travels along that route that way, bypassing all other steps:
* Image source: Scott Wlaschin
We have released a tiny erlang library, which simplifies railway decomposition for Erlang. So, given the above example, let’s take a look at how to implement our use case using Epipe:
-record(connection, { socket, user, addr, port, passwd}).connect(Addr, Port, User, Password) -> Connection = #connection{ user = User, passwd = Password, add = Addr, port = Port }, % Defining list of railway switches to follow ConnectionSteps = [ {get_socket, fun get_socket/1}, {recv_greetings, fun recv_greetings/1}, {send_user, fun send_user/1}, {send_passwd, fun send_passwd/1} ], % Running through switches case epipe:run(ConnectionSteps, Connection) of {error, Step, Reason, _State} -> error_logger:error_msg("Failed to establish connection. Reason: ~p", [Step]), {error, Reason}; {ok, _Conn} = Success -> Success end.% Building blocks. Note that every function can return either {ok, Connection} or {error, Reason}get_socket(Connection) -> case ssl:connect(Addr, Port, ExtraOptions) of {ok, Socket} -> {ok, Connection#connection{socket = Socket}}; Error -> {error, Error} end.recv_greetings(Connection) -> case recv(Connection) of {ok, <<"+OK", _Rest/binary>>} -> {ok, Connection}; {ok, <<"-ERR ", Error/binary>>} -> {error, Error}; Err -> {error, Err} end.send_user(Connection = #connection{user = User}) -> Msg = list_to_binary(User), send(Connection, <<"USER ", Msg/binary>>), case recv(Connection) of {ok, <<"+OK", _Rest/binary>>} -> {ok, Connection}; {ok, <<"-ERR ", Error/binary>>} -> {error, Error}; Err -> {error, Err} end.send_passwd(Connection = #connection{passwd = Passwd}) -> Msg = list_to_binary(Passwd), send(Connection, <<"PASS ", Msg/binary>>), case recv(Connection) of {ok, <<"+OK", _Rest/binary>>} -> {ok, Connection}; {ok, <<"-ERR ", Error/binary>>} -> {error, Error}; Err -> {error, Err} end.
The resulting code is not smaller in terms of lines of code when compared to nested case statements implementation, but it is certainly a lot more readable, making it much easier to debug and support.
If you’d like to see a real world implementation, please take a look at this refactoring example performed using the railway approach.
Originally published at www.erlang-solutions.com on June 13, 2018.