Chapter 18 Functional Elixir
18.1 Objectives
- the reasoning behind the functional approach
- simplifying by splitting
- abstracting the “pure” logic
- dealing with dirty code
- making dirty code testable
- the power
with
-in - do or not to do
- final thoughts
18.2 The reasoning behind the functional approach
Across the last 17 chapters, we focused on learning OTP by building a trading system. On the way, we omitted (for a very good reason - a clear focus on OTP) the conversation about functional programming.
But wait, what? We are already using concepts like higher-order functions - isn’t that enough?
We indeed use some functional patterns, but we never dug deeper into what Elixir developers should know(and apply) and, most importantly, why.
In a nutshell, the selling point of functional programming is that applying it will make your code easier to reason about and test. Tests dramatically improve software quality. The easier they are to write, there’s less excuse not to write them - as simple as that.
We will start from the basics and look into different ways of implementing functional concepts, considering Elixir’s strengths and weaknesses.
18.3 Simplifying by splitting
Note: This section could appear to be a bit “random”, but I added it to aid continuity of refactoring steps(refactoring those callbacks later would cause a fair amount of complexity).
Let’s look at our strategy inside the Naive.Trader
module. In this section, we will focus on its (handle_info/2
) callback function.
We are looking for clauses that do more than “one thing” to split them into multiple clauses:
- The first callback places a buy order - it has a single responsibility and is easy to follow.
- The second callback takes care of race conditions - the same story, easy to understand.
- The third callback is the one we will focus on. It branches using the
if
statement, and we could describe it as “fetch and maybe place a sell order” function. The “and” in the description clearly indicates that it’s really two functions glued together. We will split it into “fetch buy order” and “place a sell order” functions(below code replaces the 3rdhandle_info/2
callback):
# /apps/naive/lib/naive/trader.ex
def handle_info(
%TradeEvent{},
%State{
id: id,
symbol: symbol,
buy_order: %Binance.OrderResponse{
price: buy_price,
orig_qty: quantity,
status: "FILLED"
},
sell_order: nil,
profit_interval: profit_interval,
tick_size: tick_size
} = state
) do
sell_price = calculate_sell_price(buy_price, profit_interval, tick_size)
@logger.info(
"The trader(#{id}) is placing a SELL order for " <>
"#{symbol} @ #{sell_price}, quantity: #{quantity}."
)
{:ok, %Binance.OrderResponse{} = order} =
@binance_client.order_limit_sell(symbol, quantity, sell_price, "GTC")
:ok = broadcast_order(order)
new_state = %{state | sell_order: order}
@leader.notify(:trader_state_updated, new_state)
{:noreply, new_state}
end
def handle_info(
%TradeEvent{
buyer_order_id: order_id
},
%State{
id: id,
symbol: symbol,
buy_order:
%Binance.OrderResponse{
order_id: order_id,
transact_time: timestamp
} = buy_order
} = state
) do
@logger.info("Trader's(#{id} #{symbol} buy order got partially filled")
{:ok, %Binance.Order{} = current_buy_order} =
@binance_client.get_order(
symbol,
timestamp,
order_id
)
:ok = broadcast_order(current_buy_order)
buy_order = %{buy_order | status: current_buy_order.status}
new_state = %{state | buy_order: buy_order}
@leader.notify(:trader_state_updated, new_state)
{:noreply, new_state}
end
The first function takes care of placing a sell order. The second one fetches the buy order.
- We can now move to the next clause, similar to the last one we could describe as “fetch the sell order and maybe terminate the trader”. We will split it into two callbacks: “fetch the sell order” and “terminate trader”.
The code below replaces the 5th handle_info/2
callback:
# /apps/naive/lib/naive/trader.ex
def handle_info(
%TradeEvent{},
%State{
id: id,
symbol: symbol,
sell_order: %Binance.OrderResponse{
status: "FILLED"
}
} = state
) do
@logger.info("Trader(#{id}) finished trade cycle for #{symbol}")
{:stop, :normal, state}
end
def handle_info(
%TradeEvent{
seller_order_id: order_id
},
%State{
id: id,
symbol: symbol,
sell_order:
%Binance.OrderResponse{
order_id: order_id,
transact_time: timestamp
} = sell_order
} = state
) do
@logger.info("Trader's(#{id} #{symbol} SELL order got partially filled")
{:ok, %Binance.Order{} = current_sell_order} =
@binance_client.get_order(
symbol,
timestamp,
order_id
)
:ok = broadcast_order(current_sell_order)
sell_order = %{sell_order | status: current_sell_order.status}
new_state = %{state | sell_order: sell_order}
@leader.notify(:trader_state_updated, new_state)
{:noreply, new_state}
end
The first function takes care of terminating the trader. The second function is fetching the sell order.
That finishes our first refactoring round, but I need to admit that our change has impacted the behaviour of our strategy. Each time a buy or sell order gets filled, we will fetch that order from Binance, but we won’t immediately place a sell order nor terminate as it was happening before. Instead, only when another event arrives will the trader place a sell order or terminate.
Changes like this require approval from the business in a work situation, but it’s a good showcase of the situation where we can propose a solution that will simplify the code(the benefits will become evident in the following sections).
We can confirm that we have broken our tests by running our integration testsuite:
$ MIX_ENV=integration mix test.integration
...
1) test Naive trader full trade(buy + sell) test (NaiveTest)
apps/naive/test/naive_test.exs:12
** (MatchError) no match of right hand side value: [["0.43070000", "BUY", "FILLED"]...
code: [buy_1, sell_1, buy_2] = DataWarehouse.Repo.all(query)
stacktrace:
test/naive_test.exs:83: (test)
(Subject to acceptance by the business) we will fix the integration test in the following way:
# /apps/naive/test/naive_test.exs
test "Naive trader full trade(buy + sell) test" do
...
# Step 4 - Broadcast 10 events # <= updated comment
[
...
generate_event(8, "0.43205", "345.14235000"),
# this one should trigger buy order for a new trader process
generate_event(9, "0.43205", "345.14235000"), # <= added line
generate_event(10, "0.43210", "3201.86480000") # <= updated id
]
We added an event at the same price(as the sell order’s price) that will trigger placing a buy order by the new trader and make our test green again.
18.4 Abstracting the “pure” logic
In our adventure to make our code more functional, we should strive to separate(as much as possible) pure business logic from side effects and boilerplate.
The Naive.Trader
module is a GenServer that receives trade events via messages. Based on them and the current state, using pattern-matching, it decides what action should be performed(place a buy order, fetch a buy order, place a sell order, fetch sell order, terminate trader, trigger rebuy or ignore event).
Each of the pattern-matches inside the callback functions’ headers is a strategy specific business logic that got mixed with the fact that it’s executed by a GenServer that receives messages.
We will create a new file called strategy.exs
inside the apps/naive/lib/naive/
directory, where we will copy all of the handle_info/2
callback functions from the Naive.Trader
module:
# /apps/naive/lib/naive/strategy.ex
defmodule Naive.Strategy do
def handle_info(...) do
... # <= place a buy order logic
end
def handle_info(...) do
... # <= race condition fix logic
end
def handle_info(...) do
... # <= place a sell order logic
end
def handle_info(...) do
... # <= fetch the buy order logic
end
def handle_info(...) do
... # <= terminate trader logic
end
def handle_info(...) do
... # <= fetch the sell order logic
end
def handle_info(...) do
... # <= trigger rebuy order logic
end
def handle_info(...) do
... # <= ignore trade event logic
end
First, we will rename all of the handle_info/2
functions inside the Naive.Strategy
module to generate_decision/2
. Next, we will go through them one by one, leaving the pure parts and limiting them to returning the decision.
18.4.1 Place a buy order rules
The first function decides should the trader place a buy order. We can see that price and quantity calculations are pure functions based on the incoming data. We will remove everything below those two as it’s causing side effects.
As now we are dealing with a function generating a decision, we will return a tuple with data that, together with state, will be used to place a buy order.
After removing some of the pattern-matching that we used to retrieve data(no longer needed), our first function should look like this:
# /apps/naive/lib/naive/strategy.ex
# the first clause
def generate_decision(
%TradeEvent{price: price},
%State{
budget: budget,
buy_order: nil,
buy_down_interval: buy_down_interval,
tick_size: tick_size,
step_size: step_size
}
) do
price = calculate_buy_price(price, buy_down_interval, tick_size)
quantity = calculate_quantity(budget, price, step_size)
{:place_buy_order, price, quantity}
end
18.4.2 Race condition rules
The second function deals with the race condition when multiple transactions fill the buy order. The original callback ignores those trade events, so the generate_decision/2
function should return the same “decision”:
18.4.3 Fetch the buy order rules
For the 3th clause, we will return only an atom as there’s no pure logic besides the pattern-match in the header itself:
18.4.4 Place a sell order rules
We will follow the same logic for the 4th clause of the generate_decision/2
function. We will leave only the sell price calculation as it’s pure and return a tuple together with the decision:
# /apps/naive/lib/naive/strategy.ex
# the fourth clause
def generate_decision(
%TradeEvent{},
%State{
buy_order: %Binance.OrderResponse{
status: "FILLED",
price: buy_price
},
sell_order: nil,
profit_interval: profit_interval,
tick_size: tick_size
}
) do
sell_price = calculate_sell_price(buy_price, profit_interval, tick_size)
{:place_sell_order, sell_price}
end
18.4.6 Fetch the sell order rules
For the 6th clause, we will indicate that trader needs to fetch the sell order:
18.4.7 Trigger rebuy rules
Inside the 7th clause, we are dealing with triggering the rebuy. Here, we can decide whether rebuy should be triggered and get rid of conditional logic inside further steps. We couldn’t refactor this function by splitting it (as we’ve done in the first section) as we need to call the trigger_rebuy?/3
function to check should rebuy be triggered. The functions that we refactored in the first section of this chapter were splittable as they relied on pattern-matching in the function headers where calling local functions is not allowed):
# /apps/naive/lib/naive/strategy.ex
# the seventh clause
def generate_decision(
%TradeEvent{
price: current_price
},
%State{
buy_order: %Binance.OrderResponse{
price: buy_price
},
rebuy_interval: rebuy_interval,
rebuy_notified: false
}
) do
if trigger_rebuy?(buy_price, current_price, rebuy_interval) do
:rebuy
else
:skip
end
end
18.4.8 The final clause rules
The final (8th) clause will just ignore the trade event as it’s of no interest:
# /apps/naive/lib/naive/strategy.ex
# the final(8th) clause
def generate_decision(%TradeEvent{}, %State{}) do
:skip
end
This finishes the changes to the generate_decision/2
clauses. We extracted a fair amount of logic into an easily testable pure function. We now need to use it inside the Naive.Trader
module.
18.4.9 Changes to the Naive.Trader
module
We will start by moving all of the calculation functions to the Naive.Strategy
module as we are using them from the generate_decision/2
function. Those will be:
calculate_sell_price/3
calculate_buy_price/3
calculate_quantity/3
trigger_rebuy?/3
They can now be changed to public functions as they are pure and fit the “interface” of the Naive.Strategy
module(it feels ok[and it’s safe as they are pure] to “expose” them to be called from other modules).
We need to remember about moving the Decimal
alias into the Naive.Strategy
module together with a copy of the TradeEvent
struct alias and add the alias for the Naive.Trader.State
struct:
# /apps/naive/lib/naive/strategy.ex
alias Decimal, as: D
alias Core.Struct.TradeEvent
alias Naive.Trader.State
The next step will be to rename all the handle_info/2
callback functions inside the Naive.Trader
module to execute_decision/2
, which we will get back to in a moment.
First, we need to add a single handle_info/2
callback under the init/1
function that will pattern match only the fact that the received message contains the TradeEvent
struct and the state is the correct State
struct:
# /apps/naive/lib/naive/trader.ex
# add after the `init/1` function
def handle_info(%TradeEvent{} = trade_event, %State{} = state) do
Naive.Strategy.generate_decision(trade_event, state)
|> execute_decision(state)
end
So, the Naive.Strategy
module will decide what the trader server should do based on its pure business logic. That decision will be passed forward with the state to the execute_decision/2
function (at this moment, it’s just the old handle_info/2
function renamed, but we will update it next).
18.4.10 Naive.Trader
- place buy order
We will update the execute_decision/2
function to take a decision + state and execute the correct action based on pattern-match of the decision. Starting with the 1st clause, we need to pattern match a tuple:
# /apps/naive/lib/naive/trader.ex
# the first execute clause
def execute_decision(
{:place_buy_order, price, quantity},
%State{
id: id,
symbol: symbol
} = state
) do
@logger.info(
"The trader(#{id}) is placing a BUY order " <>
"for #{symbol} @ #{price}, quantity: #{quantity}"
)
{:ok, %Binance.OrderResponse{} = order} =
@binance_client.order_limit_buy(symbol, quantity, price, "GTC")
:ok = broadcast_order(order)
new_state = %{state | buy_order: order}
@leader.notify(:trader_state_updated, new_state)
{:noreply, new_state}
end
The amount of pattern matching will be much smaller as part of the original callback has been moved inside the Naive.Strategy
’s logic(to calculate the price and quantity).
18.4.11 Naive.Trader
- Race condition clause
As we are using the :skip
“decision” for both the race condition events and the “non interesting” events, we can safely remove this clause as we will implement skipping as the last clause.
18.4.12 Naive.Trader
- Place a sell order
In case of placing a sell order, we will pattern match on a tuple containing the :place_sell_order
atom and slim down on pattern matching:
# /apps/naive/lib/naive/trader.ex
def execute_decision(
{:place_sell_order, sell_price},
%State{
id: id,
symbol: symbol,
buy_order: %Binance.OrderResponse{
orig_qty: quantity
}
} = state
) do
@logger.info(
"The trader(#{id}) is placing a SELL order for " <>
"#{symbol} @ #{sell_price}, quantity: #{quantity}."
)
{:ok, %Binance.OrderResponse{} = order} =
@binance_client.order_limit_sell(symbol, quantity, sell_price, "GTC")
:ok = broadcast_order(order)
new_state = %{state | sell_order: order}
@leader.notify(:trader_state_updated, new_state)
{:noreply, new_state}
end
18.4.13 Naive.Trader
- Fetch the buy order
In case of fetching the buy order, we will pattern match on a :fetch_buy_order
atom and slim down on pattern matching:
# /apps/naive/lib/naive/trader.ex
def execute_decision(
:fetch_buy_order,
%State{
id: id,
symbol: symbol,
buy_order:
%Binance.OrderResponse{
order_id: order_id,
transact_time: timestamp
} = buy_order
} = state
) do
@logger.info("Trader's(#{id} #{symbol} buy order got partially filled")
{:ok, %Binance.Order{} = current_buy_order} =
@binance_client.get_order(
symbol,
timestamp,
order_id
)
:ok = broadcast_order(current_buy_order)
buy_order = %{buy_order | status: current_buy_order.status}
new_state = %{state | buy_order: buy_order}
@leader.notify(:trader_state_updated, new_state)
{:noreply, new_state}
end
18.4.14 Naive.Trader
- Terminate the trader
In case of terminating the trader, we will pattern match on a :exit
atom and slim down on pattern matching:
18.4.15 Naive.Trader
- Fetch the sell order
In case of fetching the sell order, we will pattern match on a :fetch_sell_order
atom and slim down on pattern matching:
# /apps/naive/lib/naive/trader.ex
def execute_decision(
:fetch_sell_order,
%State{
id: id,
symbol: symbol,
sell_order:
%Binance.OrderResponse{
order_id: order_id,
transact_time: timestamp
} = sell_order
} = state
) do
@logger.info("Trader's(#{id} #{symbol} SELL order got partially filled")
{:ok, %Binance.Order{} = current_sell_order} =
@binance_client.get_order(
symbol,
timestamp,
order_id
)
:ok = broadcast_order(current_sell_order)
sell_order = %{sell_order | status: current_sell_order.status}
new_state = %{state | sell_order: sell_order}
@leader.notify(:trader_state_updated, new_state)
{:noreply, new_state}
end
18.4.16 Naive.Trader
- Triggering rebuy
In case of triggering the rebuy procedure, we will pattern match on a :rebuy
atom, slim down on pattern matching and simplify the function a fair bit(no branching required anymore - yay!):
18.4.17 Naive.Trader
- The final ignore clause
The final ignore clause will :skip
all events:
The ignore clause finishes our current refactoring round, which showcased that sometimes abstracting pattern matching into a separate function is a valid strategy to increase the amount of pure code.
Note: The fact that we could abstract the logic from pattern matches is quite a unique situation to our application. I would not advise abstracting GenServer pattern matching into a separate module if dealing with different structs/actions(in our case, all our pattern matches were “making a trading decision”, it’s a single “action”, that’s why we abstracted them).
18.5 Dealing with dirty code
In the last section, we’ve split the handle_info/2
clauses into the generate_decision/2
and execute_decision/2
functions. That’s excellent progress, but we still have the strategy logic inside the Naive.Trader
module.
Let’s move the execute_decision/2
function(together with all the code that it depends on, like the broadcast_order/1
and convert_to_order/1
functions as well as a copy of the require Logger
) from the Naive.Trader
module to the Naive.Strategy
module.
As the generate_decision/2
function is causing side effects, we don’t want it to be called directly from the outside of the module, so we will need to make it private.
Changing the execute_decision/2
function(now inside the Naive.Strategy
module) to private will cause a problem with the handle_info/2
callback function inside the Naive.Trader
module as it relies on the execute_decision/2
function to be public. The fact that our strategy makes a decision and then executes code based on it is an implementation detail that we shouldn’t share with the Naive.Trader
module. That’s why we will move the body of the handle_info/2
callback function into a new function called execute/2
inside the Naive.Strategy
module:
# /apps/naive/lib/naive/strategy.ex
def execute(%TradeEvent{} = trade_event, %State{} = state) do
generate_decision(trade_event, state)
|> execute_decision(state)
end
Before updating the Naive.Trader
module to use the execute/2
function, we need to address another issue that moving the execute_decision/2
caused. At this moment, all of the clauses return GenServer specific tuples. What we really need to return to the trader is an atom indicating should it continue or terminate together with the updated state:
# /apps/naive/lib/naive/strategy.ex
# last lines inside the `execute_decision/2` clauses
{:ok, new_state} # <= previously {:noreply, new_state} (5 times)
{:ok, state} # <= previously {:noreply, state} (once)
:exit # <= previously {:stop, :normal, state} + remove `state` pattern match (once)
We can now update the handle_info/2
callback function to call the new “interface” of the Naive.Strategy
module that we just created and act accordingly to the result:
# /apps/naive/lib/naive/trader.ex
def handle_info(%TradeEvent{} = trade_event, %State{} = state) do
case Naive.Strategy.execute(trade_event, state) do
{:ok, new_state} -> {:noreply, new_state}
:exit -> {:stop, :normal, state}
end
end
At this moment, we could just copy/move module attributes from the Naive.Trader
module to the Naive.Strategy
module and our code would start to work again. Still, before we will do that, we will use this opportunity to look into how to make our dirty code testable.
18.6 Making dirty code testable
Making dirty code testable is very closely linked to injecting dependencies. In the testing environment, we would like to use dummy implementations instead of executing the side-effect-causing code to simplify the tests. We will look into the different ways that we can pass side-effect-causing “code” into impure functions.
18.6.1 Passing functions arguments
Functions are first-class citizens in Elixir, which means that we can pass them as arguments to functions. This way, we can pass side-effect causing functions into our Naive.Strategy
module.
Let’s look at how this would look in practice. We need to look into the execute_decision/2
function, as it’s where the place side effects happen. Looking at the 1st clause(responsible for placing a buy order), we can see that it’s calling the Logger.info/1
, Binance.order_limit_buy/4
, PubSub.broadcast/3
(via the broadcast_order/1
function) and Leader.notify/2
functions. To make our code easily testable, we would need to be able to pass dummy implementations for all of those.
As we aren’t calling the execute_decision/2
directly, we need to pass all of the above functions as arguments to the execute/2
function, which will pass them onward to the execute_decision/2
.
We can see that even with default values pointing to the “real” implementation, that’s still a lot of noise to make testing easier. It will negatively impact the maintenance of the code - here’s an example of what this would look like(don’t bother typing it):
# /apps/naive/lib/naive/strategy.ex
# injecting dummy implementation, fallback to real implementation
def execute(
%TradeEvent{} = trade_event,
%State{} = state,
logger_info \\ &Logger.info/1, # <= function injected
order_limit_buy \\ &Binance.order_limit_buy/4, # <= function injected
pubsub_broadcast \\ &PubSub.broadcast/3, # <= function injected
notify_leader \\ &Leader.notify/2 # <= function injected
) do
...
There are already four functions, and we only took care of side-effects causing functions from the first execute_decision/2
clause. We can easily see how this very quickly becomes just unmanageable as there would be 10+ “injected” arguments going from the execute/2
to execute_decision/2
, and only some of them would be used in each clause.
Additional downsides:
- when passing a function as an argument, we need to specify the arity, so when we would like to use more than one arity, we need to pass the function multiple times with different arities. An example could be passing
Logger.info/1
andLogger.info/2
- we need to give a name to every passed function, sometimes multiple arities (again, how should variables for
Logger.info/1
andLogger.info/2
be called?logger_info_2
?) - share amount of arguments negatively impacts code readability
We can see that passing functions as arguments is just a bad idea in case of making our code testable. It will have the opposite effect, decreasing readability making our code difficult to maintain and follow.
Important note: Passing functions as arguments is not always bad! A good example could be when different actions need to be performed based on runtime data.
18.6.2 Passing grouped functions as a context
The natural next step would be to put all of those functions into some structure like Map or Keyword list. Whichever we would choose, we will end up with the same problems of naming keys(this time inside the map/keyword list), multiple functions because of different arity but also default values inside each clause of the execute_decision/2
function:
# /apps/naive/lib/naive/strategy.ex
defp execute_decision(
{:place_buy_order, price, quantity},
%State{
id: id,
symbol: symbol
} = state,
%{} = context # <= context added
) do
# vvv fetch from context vvv
logger_info = Map.get(context, :logger_info, &Logger.info/1)
order_limit_buy = Map.get(context, :order_limit_buy, &Binance.order_limit_buy/4)
leader_notify = Map.get(context, :leader_notify, &Leader.notify/2)
Again this looks like a bad idea. It’s probably marginally better than just sending functions one by one, but not much.
18.6.3 Passing grouped modules as a context
The significant advantage of passing modules as arguments instead of functions is that we no longer have a problem with naming keys or caring about different functions’ arities. There will also be substantially fewer modules used in comparison to functions.
Sadly we still need to use the Map
function to get the modules out of "context"
:
# /apps/naive/lib/naive/strategy.ex
defp execute_decision(
{:place_buy_order, price, quantity},
%State{
id: id,
symbol: symbol
} = state,
%{} = context # <= context added
) do
logger = Map.get(context, :logger, &Logger) # <= fetch from context
binance = Map.get(context, :binance, Binance) # <= fetch from context
leader = Map.get(context, :leader, &Leader) # <= fetch from context
This is much better, but we will still need to do a fair amount of additional work to get the modules out. Also, our code will be full of the “default” modules(as each of the clauses retrieving them from the context will need to specify defaults).
18.6.4 Injecting modules to module’s attributes based on the configuration
And we finally got there - we have come a full circle. This is the approach that we previously used inside the Naive.Trader
module(you can go ahead and add them to the Naive.Strategy
module):
# /apps/naive/lib/naive/strategy.ex
defmodule Naive.Strategy do
...
@binance_client Application.compile_env(:naive, :binance_client)
@leader Application.compile_env(:naive, :leader)
@logger Application.compile_env(:core, :logger)
@pubsub_client Application.compile_env(:core, :pubsub_client)
We looked into different ways to inject dependencies to understand their downsides.
Sometimes, injecting values(like modules) as module attributes can feel like a “global state”, “singleton”, or similar antipattern. We need to understand that each programming language provides different ways to solve common programming problems. Dependency injection is one of those common concerns that every language needs to solve, and Elixir solves it by using compile-time modules’ attributes.
As long as you are using module attributes to be able to inject compile-time dependencies to test your code, there’s just no better way to do it in Elixir, and now we know why(based on the issues with the alternative approaches).
Together with the module attributes, our code should be now fully functional.
18.7 The power with
-in
In the last section, we looked into different ways to inject modules’ dependencies to avoid side-effects causing functions inside the tests. Besides side-effect causing functions, in functional programming, error handling is also done in a specific manner.
Many languages introduced concepts like Either
, which is a struct that can be either Left
(error result) or Right
(success result). Those quite nicely fit to the standard Elixir results like {:error, reason}
and {:ok, result}
. Further, those languages provide multiple functions to work with the Either
, like map
.
safeDivide(2, 0) # <= returns Left("Dividing error")
|> then(Either.map(&(&1 * 2))) # <= still Left("Dividing error")
safeDivide(2, 1) # <= returns Right(2)
|> then(Either.map(&(&1 * 2))) # <= returns Right(4)
The above code will use hypothetical Left('Dividing error')
or Right(result)
. The Either.map/2
is a special map
function that runs the passed function if it’s Right
or completely ignores it when it’s Left
- it could be visualized as:
def map(%Either.Left{} = left, _fun), do: left
def map(%Either.Right{result: v}, fun), do: %Either.Right{result: fun.(v)}
This is nice and great, but what if the function inside the Either.map/2
returns another Either
? Like:
safeDivide(2, 1) # <= returns Right(2)
|> then(Either.map(&(safeDivide(&1, 1)))) # <= now Right(Right(2))!?
Now we need to understand those abstractions to be able to decide should we map
or flatMap
(that’s the function that will not wrap the function result into the Right
):
safeDivide(2, 1) # <= returns Right(2)
|> then(Either.flatMap(&(safeDivide(&1, 1)))) <= still Right(2)
And that is just the beginning of the complexities that those abstractions bring.
Furthermore, let’s say that inside the first Either.map/2
callback, we will have some variable(s) that we would like to use later on. We are now deep inside closures world like the following:
safeDivide(2, 1) # <= returns Right(2)
|> then(Either.flatMap(fn res ->
# x = some data generated here
safeDivide(2, 1)
|> then(Either.map(&(&1 * 2)))
|> then(Either.map(&(&1 * x))) # <= a clause to have access to x
end))
The above example is obviously simplified and silly but should give us a gist of what sort of complexity we will very soon get involved in. And, again, we just scratched the surface - there are so many more functions that the Either
provides. Besides, writing code in this fashion in Elixir would cause a lot of friction in the team as it’s difficult to find any advantages of using it.
18.7.1 Idiomatic error handling
To achieve the same results, Elixir provides the with
statement:
with {:ok, divide_result} <- safeDiv(2,1),
{:ok, divide_result_2} <- safeDivide(2, 1)
do
divide_result_2 * 2 * divide_result
else
err -> err
end
The above code provides the same functionality as the one before with Either
. We can clearly understand it without any knowledge about how Either
works, mapping
, flatMapping
etc. It’s just standard Elixir.
Again, as in the case of modules’ attributes. Elixir provides a pragmatic way of dealing with errors - just return a tuple with an :error
atom. It also provides utility functions like with
to deal with errors in an idiomatic way. There’s no reason to introduce concepts like Either
as language has built-in concepts/patterns taking care of error situations.
18.8 Do or not to do
In the last section, we discussed wrapping the results in the Either
structs to be able to map, flatMap on them regardless of the function result. What if we could apply the same principle to avoid executing any code(side effects) at all?
That’s the basic idea behind all the category theory related abstractions like the infamous IO Monad.
I won’t go into a vast amount of details. Still, we can think about it as every time we are calling a special map
or flatMap
, instead of executing anything, it would just wrap whatever was passed to it inside another function and return it like:
def map(acc, function) do
fn ->
case acc.() do
{:ok, data} -> {:ok, function.(data)}
{:error, error} -> {:error, error}
end
end
end
In a nutshell, what we would end up with is a function containing a function containing a function… At this moment, I find it very difficult to find any practical reason why somebody would want to do something like this in a dynamically typed language.
In statically typed languages, there’s an argument that instead of a function of function etc., we could have a typed object which would indicate what actions can be performed on that future result. This is very often praised as a compile-time guarantee of side effectfull code.
In the Elixir, without strong typing and with a massive impact on how the code is written and how easy it is to understand, there’s just no practical reason to use those concepts beyond toy programs. The resulting function would be an untestable blob without introspection support from the BEAM VM.
18.9 Final thoughts
Every programming language needs to provide tools to handle common concerns like error handling or dependency injection.
Elixir provides excellent tools to handle both of those concerns using the with
statement and modules’ attributes.
Without a type system, there’s no practical reason why anybody would introduce category theory-based abstractions like Monads. The resulting code will be complicated to deal with, and as in the case of many other functional programming languages like Ocaml or Clojure, the pragmatic way is to execute side effects.
It’s the developers’ responsibility to design code in a way that maximizes the amount of pure code and push side effects to “the edge”. The typical pattern would be to “prepare” (group all logic before side effects) or to “post process” the results of multiple side effects (group pure logic after side effects).
That’s all in regards to functional programming in Elixir. In the next chapter, we will look into what the idiomatic Elixir code looks like.
[Note] Please remember to run the mix format
to keep things nice and tidy.
The source code for this chapter can be found on GitHub