Elixir Tips and Tricks
1. Struct Update
The special syntax for updating a map, %{var | field: value}
is particularly useful with structs.
With a plain map, the following gives a runtime erorr:
saiyan = %{name: "goku"}
saiyan = %{goku | pwer: 9001}
because %{var | ...}
can only be used to update an existing field. However, with a struct, you'll get a compile-time error:
defmodule Saiyan do
defstruct [:name, :power]
...
end
goku = %Saiyan{name: "goku"}
goku = %Saiyan{goku | pwer: 9001}
** (CompileError) code.ex:17: unknown key :pwer for struct Saiyan
2 alias, import and use
Starting off, it can be a overwhelming to realize that Elixir has at least three ways of interacting with other modules. While there's a bit of overlap, they mostly serve distinct purposes.
2.a alias
alias
is the simplest to understand and the one you'll use the most. It lets you access a module via a shortened name rather than its full name:
MyApp.Service.Auth.user(email, password)
# vs
alias Myapp.Service.Auth
...
Auth.user(email, password)
2.b import
import
takes this a step further: once imported, you can access functions directly as though they're defined in the current module:
import Myapp.Service.Auth
...
user(email, password)
Personally, I think you should avoid importing. It makes things obscure: where is user
defined? Also, because you lose the context provided by the [hopefully] meaningful module name, for the sake of readability, you'll often have you re-add that context to the function name, such as auth_user
.
Why bother? First, you have to import a module if you want to use its macros. As a beginner-safe-rule, you should only import when you need to use a module's macros. You can enforce this:
import MyApp.Service.Auth, only: :macros
You can also import only functions or specific functions or macros.
I confess that we do occasionally import functions. Most notably in our tests, where a number of helpers are repeatedly needed. For example, all of our integration tests have access to an imported truncate
function which is used to wipe a database clean.
2.c use
use
isn't like alias
or import
but it's so powerful that it can be, and often is, used to inject an alias
and/or import
. When you use
a module, that module's __using__/1
macro is executed. That macro can do anything, which means the behavious of use
changes from module to module.
defmodule MyApp.Controller.Base do
# automatically called when you: use MyApp.Controller.Base
defmacro __using__(_opts) do
quote do
# Gets injected into the calling module
import MyApp.Controller.Base
# ... probably does more things
end
end
end
Above is a somewhat common pattern where a use
'd module imports itself. If you're just starting to learn elixir, use
when a library / documentation tells you to, and take the opportunity to look at that module's __using__/1
macro to learn what it's doing.
3. alias __MODULE__, as...
You can minimize the presence of __MODULE__
by aliasing it:
defmodule DBZ.Saiyan do
# Expands to alias DBZ.Saiyan, which means we can now use
# Saiyan instead of __MODULE__
alias __MODULE__
def new(name) do
%Saiyan{name: name}
end
end
We often do this in our GenServers and give it the name of State
(unless something more specific makes sense). This works well when combined with the special update syntax described in tip #1:
defmodule Dune.Harvesters do
use GenServer
alias __MODULE__, as: State
defstruct [:lookup, :dispatched]
# ...
def handle_cast({:destroyed, id}, state) do
state = %State{state | lookup: Map.delete(state.lookup, id)}
{:noreply, state}
end
end
4. With's default else behaviour
Elixir's with
is useful for dealing with more complex flows, but did you know that you can omit the else
clause? The default behaviour is to return whatever broke the flow (and would have triggered the else
).
In other words, any time you write:
with {:ok, decoded} <- Poison.decode(data),
{:ok, scores} <- extract_scores(decoded)
do
# do something with scores
else
err -> err
end
You can omit the else
block:
with {:ok, decoded} <- Poison.decode(data),
{:ok, scores} <- extract_scores(decoded)
do
# do something with scores
end
5. Atom Memory
Atom's aren't garbage collected. You should be weary of using String.to_atom/1
. Doing this on user input, for example, is a good way to run out of memory.
One option is to use String.to_existing_atom/1
which raises if the atom isn't already defined.
Another option is to leverage matching:
def planets("caladan"), do: :caladan
def planets("ix"), do: :ix
def planets("arrakis"), do: :arrakis
def planets(_), do: nil
Or less tedious and less readable:
for {value, input} <- [caladan: "caladan", ix: "ix", ...] do
def planets(unquote(input)), do: unquote(value)
end
def planets(_), do: nil
6. IO.inspect Label
IO.inspect
takes an optional label
value which is prepends to the output:
IO.inspect("#{min} - #{max}", label: "debug")
> debug: "10 - 100"
7. IO.inspect return
Speaking of IO.inspect
, it returns the parameter that you pass into it. This makes it injectable without having to change your code (say, by having to introduce a temporary variable or breaking a pipe chain):
case IO.inspect(parse_input(input)) do
...
end
# or
result = String.split(input, ",", parts: 3)
|> Enum.map(&String.to_integer/1)
|> IO.inspect()
|> ...
8. Running a Specific Test
Add the path to the mix test
command to run that specific file and optionally include :LINE
to run a specific test:
mix test test/users_test.exs
mix test test/users_test.exs:24
9. Dependencies
List outdated dependencies by running mix hex.outdated
; clean unused dependencies with mix deps.clean --unlock --unused
10. Phoenix custom action parameters
If you find yourself using the same variable from conn.assigns
, consider having it automatically injected into your actions:
# turn
def show(conn, params) do
context = conn.assigns[:context]
...
end
# into
def show(conn, params, context) do
...
end
This can be achieved by overriding action/2
within your controller, (as described in the documentation):
def action(conn, _) do
args = [conn, conn.params, conn.assigns[:context]]
apply(__MODULE__, action_name(conn), args)
end
11. Default parameters and function heads
If you're new to elixir, you might see a function parameter which includes two backslashes: opts \\ []
. This is how elixir defines a default value for a parameter.
Default values are related to another strange thing you might spot: functions with no bodies (called function heads). Consider the implementation of Enum.all/2
:
def all?(enumerable, fun \\ fn(x) -> x end) # No body?!
def all?(enumerable, fun) when is_list(enumerable) do
...
end
def all?(enumerable, fun) do
...
end
That first line is required by the compiler whenever you use default values and have multiple versions of the function. It removes any ambiguity that might arise from having default values and multiple functions.
(Function heads are also useful in more advanced cases where documenting actual implementation(s) is messy or impractical, usually related to macros).
12. Pattern matching anonymous functions
Just like normal functions, anonymous functions can also do pattern matching:
def extract_errors(results) do
Enum.reduce(results, [], fn
:ok, errors -> errors # don't do anything
{:error, err}, errors -> [err | errors]
other, errors -> -> [other | errors]
end)
end
13. Enum.reduce/3
Any function in the Enum
module can be implemented using Enum.reduce/3
. For example, Enum.map/2
is implemented as a reduce + reverse (since it preserves ordering).
You should always consider using the more readable versions, but if you're just getting started, it's a good mental exercise to consider how you'd implement each function using only reduce/3
. Also, if performance matters and you have specific needs (like wanting to map but not caring about order), doing things in reduce/3
might be faster.
14. Don't overlook Enum.reduce_while/3
Enum.reduce_while/3
is a powered-up version of reduce/3
. It behaves almost the same, including taking the same parameters, but you control when it should stop enumerating the input. This makes it a more efficient solution for implementing Enum.find/2
, Enum.take_while/2
and any custom partial enumeration behaviour you need.
In the normal reduce/3
the supplied function returns the accumulator, which is passed to the next iteration. With reduce_while/3
the function returns a tuple, either: {:cont, acc}
or {:halt, acc}
. The values :cont
and :halt
control whether iteration should continue or halt. These values are stripped from the final return.
For example, say we're dealing with user input. We're getting an array of strings. We want to convert them into integers and limit the array to 10 items:
{_count, ids} = Enum.reduce_while(param["ids"], {0, []}, fn
id, {11, ids} -> {:halt, {10, ids}} # or counter is > 10, stop processing
id, {count, ids} ->
case Integer.parse(id) do
{n, ""} -> {:cont, {count + 1, [n | ids]}}
_ -> {:cont, {count, ids}}
end
end)
15. Process Mailboxes
The most important thing to understand about processes is how they interact with their mailbox. Every process has a queue of pending messages called a mailbox. When you send a message to a process (say via send/2
, GenServer.call/2
or GenServer.cast/2
), that message is added at the back of the target process' queue.
When you receive
you dequeue the oldest message from the mailbox. For a GenServer receiving happens automatically; but the important point is that one message is processed at a time. It's not until you return from your handle_call/3
, handle_cast/2
or handle_info/2
function that the next pending message will be processed. This is why your processes state is always consistent, it's impossible to have two concurrent threads of execution within the same process overwriting each other's changes to your state.
Whether you're using GenServer.cast/2
or call/2
doesn't change anything from the target process' point of view. The difference between the two only impacts the callers behaviour. If you cast
and then call
, you're guaranteed that the handle_cast
will fully execute before the handle_call
and thus the handle_call
will see any state changes made by the handle_cast
16. GenServer's init
A failure in your GenServre's init/1
will take down your entire app. The behaviour might surprise you at first, but it isn't without benefits: it provides a strong guarantee about the state of your app. This is particularly true given that supervisors and their workers are started synchronously. In other words, if you have a supervisor tree that places WorkerB
after WorkerA
, then WorkerB
can be sure that WorkerA
has been fully initialized (and thus can call it).
If you absolutely need a database connection for your app to work, establish it in init. However, if you're able to gracefully handle an unavailble database, you should establish it outside of your init function. A common pattern is to send yourself a message:
def init(_) do
send(self(), :connect)
{:ok, nil}
end
def handle_info(:connect, state) do
...
{:noreply, new_state}
end
Because processes only handle one message at a time, you could simply block in the above handle_info/2
until a connection can be established. Any call/2
or cast/2
into this process will only be processed once the above function returns. Of course, by default, call/2
will timeout after 5 seconds (which is probably what you want: the app will startup, but trying to use the features that rely on this process will error).
17. nil
There are a couple properties of nil
that you should be aware or. First, because nil
, like true
and false
, is actually an atom, it participates in term ordering rules:
nil > 1
> true
nil > "1"
> false
nil > :dune
> true
nil > :spice
> false
Secondly the Access behaviour (var[:key]
) ignores nils. So while many languages would throw an exception on the following code, Elixir doesn't:
user = nil
user[:power]
> nil
user[:name][:last]
> nil
18. Sigils
Strings that embed quotes can be messy to write and hard to read. Sigils are special functions that begin with ~
aimed at helping developers deal with special text.
The most common is the ~s
sigil which doesn't require quotes to be escaped:
"he said \"it's over 9000!!\""
# vs
~s(he said "it's over 9000")
The difference between the lowercase s
and uppercase S
sigils is that the lowercase one allows escape characters and interpolation:
~s(he said:\n\t"it's over #{power}")
> he said:
"it's over 9000"
# vs
~S(he said:\n\t"it's over #{power}")
> he said:\n\t\"it's over \#{power}\"
The other popular sigil is ~r
to create a regular expression:
Regex.scan(~r/over\s(\d+)/, "over 9000")
> [["over 9000", "9000"]]
Finally, you can always create your own sigils.
19. [Linked] Lists
Lists in Elixir are implemented as linked lists. In the name of performance, this is something you should always be mindful of. Many operations that are O(1) in other languages, are O(N) in Elixir. The three that you'll need to be most vigilant about are: getting the count/length, getting a value at a specific index and appending a value at the end of the list. For example, if you want to know if a list is empty, use Enum.empty?/1
rather than Enum.count/1 == 0
(or match against []
code>).
While appending a value is O(N), prepending is O(1) - the opposite of what you'd see in languages with dynamic arrays. For this reason, when dealing with multiple values, you should favour prepending (+ reversing if order matters).
There are also cases where Erlang's double-ended queue might prove useful. It has O(1) operation to get and add values at both ends of the queue. Sadly, it still doesn't have an O(1) length operation, but you could easily create your own wrapper.
20. iolists
Many language us a buffered string when dealing building a string dynamically. While that isn't an option given immutable data structure, we do have a suitable alternative: iolists. An iolist is a list made of binaries (strings) or nested iolists. Here's a simple example, but keep in mind that iolists are often deeply nested:
sql = ["select ", ["id", " ,name", " ,power"], " from saiyans"]
Much of the standard library and many third party libraries can work with iolists directly:
IO.puts(["it's", " ", ["over ", ["9000", "!!!"]]])
> it's over 9000!!!
File.write!("spice", ["the", [" ", "spice", [" must", [" flow"]]]])
IO.inspect(File.read!("spice"))
> "the spice must flow"\
In some cases functions that receive an iolist will first convert it to a normal binary then process it (which is what Poison.decode/1
does if you pass it an iolist). However, in other cases, processing happens on the iolist directly.
Taking a step back, let's say we have a list of words:
colors = ["red", "blue", "green", "orange", "yellow"]
To append "grey" via the ++
operator, we need to create a new list and copy all the values into the new list. That's why we say it's O(N). Now consider what needs to happen if we do this as an iolist. The result would look like:
colors = [["red", "blue", "green", "orange", "yellow"], "grey"]
The original list doesn't change and so its values aren't copied. We do create a new list (the outer brackets). That new list references the original list at its head, and the new value at its tail. The cost of an iolist is no longer tied to the number of elements in the lists.
21. Piping
Don't do single statement piping. It's the bad sort of clever. This:
params = params |> Map.delete(:id)
Is less readable than this:
params = Map.delete(params, :id)
It's never called for.