What we'll cover in this section includes:
- Strings and Atoms
- Booleans
- Comparison operators
- Lists and tuples
- Maps and structs
- Keyword lists
https://hexdocs.pm/elixir/String.html https://hexdocs.pm/elixir/Atom.html
"We like strings! They are utf-8 encoded!"
'We only use charlists when working with Erlang libraries! They are just lists of integers!'
name = "Tim"
"We can do string interpolation #{name}"
# Atoms are like symbols in other languages
:atoms_are_not_garbage_collected
# Use atoms for distinct values
:ok
:error
:"stage complete"
"we love " <> "concatenating things"
'we love ' ++ 'concatenating things'
# Some examples to understand why we might prefer strings to charlists
# --> 5 (counts graphmemes)
String.length("héllo")
# --> 6 (counts code points - é is represented by 2 code points)
length('héllo')
QUESTION! |
---|
When parsing user input is it better to parse to strings, charlists, or atoms? Why? |
# and, or --> both require boolean values as inputs
# --> false
true and false
# --> true
true or false
# --> error!!
# 1 or true
# &&, || --> can handle truthy/falsy values.
# nil and false are the only falsy values
# --> 1
1 || true
# --> true
true || 1
# --> nil
1 && nil
# Some of the stranger cases...
# --> 1
false or 1
# --> error!!
# 1 or false
# --> true
"a" == "a"
# --> true
1 == 1.0
# --> false
1 === 1.0
# --> true
5.0 <= 10
# --> true. All types can be compared. There is an overall ordering of types:
1 < :atom
# number < atom < reference < function < port < pid < tuple < map < list < bitstring
- Lists are linked lists (each element has a pointer to the next)
- they can contain any, mixed values, atoms, strings, maps or even other lists.
- Prepending elements is fast
- Looking up an element or getting the length of a list takes time proportional to the list length
- The Enum module contains useful methods for lists, maps, and ranges
- The Stream module contains similar methods, but the lazy equivalents
- The built-in List module has some helpful util methods
- Streams are lazy lists, and can be infinite
- Tuples are stored contiguously in memory
- Prepending elements or editing is slow (requires the whole tuple to be copied)
- Looking up an element or getting the length is constant in time (fast!)
- The built-in Tuple module has some helpful util methods - https://hexdocs.pm/elixir/Tuple.html
# A list
[1, 2, 3]
# --> [1, 2, 3] - Syntax for prepending to list
[1 | [2, 3]]
# --> [1, 2, 3, 4]
[1, 2] ++ [3, 4]
# A tuple
tuple = {:ok, 15, "humans"}
QUESTION! |
---|
I want a data structure to store a users name, hair colour, and height - would lists or tuples be more appropriate? |
I want a data structure to store the messages a user has sent - would lists or tuples be more appropriate? |
The pipe operator is a handy way to pipe the output of a function into another function. This helps avoid lots of nested functions.
defmodule Pipe do
def add_1(x), do: x + 1
def times_3(x), do: x * 3
def minus_n(x, n), do: x - n
end
x = 5
# If I want to add 1, then times 3 without a pipe I need to do something like this:
Pipe.times_3(Pipe.add_1(x))
# Not too bad, but if you have a lot of chained function calls it can get quite cumbersome
# Instead we can use the pipe operator
x
|> Pipe.add_1()
|> Pipe.times_3()
# If we pipe to a function which takes multiple arguments the piped value is used at the first
# argument
x
|> Pipe.add_1()
|> Pipe.times_3()
|> Pipe.minus_n(4)
# Is the same as...
Pipe.minus_n(x, 4)
- Both modules provide similar methods, but the Stream module has operations which are lazy
# This code doesn't output a list! Just a description of transformations we want to do
[1, 2, 3]
|> Stream.map(fn x -> x + 1 end)
|> Stream.map(fn x -> x + 3 end)
QUESTION! |
---|
What output would you expect from the following code and why? |
nums = [0, 10, 20]
defmodule Add do
def add_one(x) do
IO.puts(x)
x + 1
end
def enum_fn(nums) do
nums
|> Enum.map(&Add.add_one/1)
|> Enum.map(&Add.add_one/1)
|> Enum.map(&Add.add_one/1)
end
def stream_fn(nums) do
nums
|> Stream.map(&Add.add_one/1)
|> Stream.map(&Add.add_one/1)
|> Stream.map(&Add.add_one/1)
|> Enum.to_list()
end
end
IO.puts("Running Enum version")
Add.enum_fn(nums)
IO.puts("Running Stream version")
Add.stream_fn(nums)
map = %{
"my_key" => "my_value",
57 => %{"nested_map_key" => "nested_value"},
:atoms_make_good_keys => "yay!",
:tricky_case => nil
}
# What are the outputs from each of the following?
Map.get(map, "my_key") |> IO.inspect(label: "my_key")
Map.get(map, "non-existent") |> IO.inspect(label: "non-existent")
Map.get(map, "non-existent-defaulted", :my_default) |> IO.inspect(label: "non-existent-defaulted")
Map.get(map, ":tricky_case") |> IO.inspect(label: "tricky_case")
Map.has_key?(map, "non-existent") |> IO.inspect(label: "has non-existent-key")
map[:tricky_case] |> IO.inspect(label: "tricky_case with access syntax")
map.tricky_case |> IO.inspect(label: "tricky_case with dot syntax")
map[:non_existent] |> IO.inspect(label: "non_existent with access syntax")
map.non_existent |> IO.inspect(label: "non_existent with dot syntax")
# Where all the keys of a map are atoms you can use this abbreviated syntax:
abbre_syntax = %{
key_1: "value 1",
key_2: "value 2"
}
normal_syntax = %{
:key_1 => "value 1",
:key_2 => "value 2"
}
abbre_syntax == normal_syntax
defmodule Human do
@enforce_keys [:name]
defstruct name: "", age: 0, height: 0
end
# What's the output for each of these?
%Human{name: "Tim"}
%Human{name: "Tim", age: 20, height: 150}
%Human{age: 20, height: 150}
%Human{name: "Tim", species: "Human"}
Let's add some functionality to our Human module and contrast how this might look when using a struct vs a map...
defmodule HumanStruct do
@enforce_keys [:name]
defstruct name: "", age: 0, height: 0
def grow_older(%HumanStruct{age: age} = human) do
%HumanStruct{human | age: age + 1}
end
def grow_taller(%HumanStruct{height: height} = human) do
%HumanStruct{human | height: height + 10}
end
end
defmodule HumanMap do
def grow_older(%{age: age} = human) do
%{human | age: age + 1}
end
def grow_taller(%{height: height} = human) do
%{human | height: height + 10}
end
end
mike = %HumanStruct{name: "Mike", age: 25, height: 180}
mike
|> HumanStruct.grow_older()
|> HumanStruct.grow_taller()
|> IO.inspect(label: "Mike")
rachel = %{name: "Rachel", age: 48, height: 190}
rachel
|> HumanMap.grow_older()
|> HumanMap.grow_taller()
|> IO.inspect(label: "Rachel")
# What happens if we try and use a struct with a method expecting a map or vice versa?
mike |> HumanMap.grow_older()
rachel |> HumanStruct.grow_older()
DISCUSSION! |
---|
When do you think you should use maps and when should you use structs? |
- Keyword lists are just lists of 2 item tuples where the first element of each tuple is an atom
- They are often used to pass function options in order to give a neat syntax for passing them
- Keyword lists benefit from some syntactic sugar to make using them easier
- The Keyword module has some helpful utils for working with Keyword lists
- Possibly somewhat unobviously, they may contain elements with the same key more than once!
# A keyword list
[{:a, 1}, {:b, 2}]
# Shorthand for a keyword list
[a: 1, b: 2]
defmodule ListUtils do
def query(list, kw_args \\ []) do
filter_fns = Keyword.get_values(kw_args, :where)
order_by = Keyword.get(kw_args, :order_by)
filtered_list =
Enum.reduce(filter_fns, list, fn filter_fn, list ->
Enum.filter(list, filter_fn)
end)
Enum.sort(filtered_list, order_by)
end
end
my_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
res =
ListUtils.query(
my_list,
where: fn x -> rem(x, 2) == 0 end,
where: fn x -> rem(x, 3) == 0 end,
order_by: fn x, y -> x > y end
)
# This is just syntactic sugar for...
ListUtils.query(
my_list,
[
{:where, fn x -> rem(x, 2) == 0 end},
{:where, fn x -> rem(x, 3) == 0 end},
{:order_by, fn x, y -> x > y end}
]
)
IO.inspect(res, label: "Result")
EXERCISE! |
---|
Write a function that takes 2 arguments - a starting number and a keyword list which can contain 2 keywords to_add, to_takeaway. It should return the starting number with the numbers to_add and to_takeaway added or taken away.
Example usage:
MyMath.do_math(5, to_takeaway: 5, to_takeaway: 2, to_add: 8)
# Expected result ---> 6
Pattern matching can be helpful for:
- extracting values from composite data structures like maps and tuples
- conditional logic
{a, b} = {10, 20}
IO.inspect(a, label: "a")
IO.inspect(b, label: "b")
%{e: e} = %{e: 10, f: 15}
IO.inspect(e, label: "e")
[hd | tail] = [1, 2, 3]
IO.inspect(hd, label: "hd")
IO.inspect(tail, label: "tail")
[x, y, z] = [1, 2, 3]
IO.inspect(x, label: "x")
IO.inspect(y, label: "y")
IO.inspect(z, label: "z")
# When things don't match...
[a, b] = [1, 2, 3]
# You can use an underscore `_` or prefix a variable with an underscore e.g. `_tail` to help
# in situations where you only want to match parts
[a, b, _] = [1, 2, 3]
# Motivation for the pin operator
x = 10
[x, y, z] = [11, 12, 13]
IO.inspect(x, label: "x")
# Usage of the pin operator
x = 10
# --> Match Error!
[^x, y, z] = [11, 12, 13]
QUESTION! |
---|
Will the following pattern matches fail or succeed? What value will be set for x? |
x = 5
[^x, ^x, x, x] = [5, 5, 6, 7]
[a | x] = [1, 2, 3]
x
{:ok, [x, _, _]} = {:error, [3, 4, 5]}
# Also known as the robot butt...
[_ | _] = []
- Named functions
- Anonymous functions, when to use, and how to pass named functions
- Default args
- 'when' qualifier
- pattern matching with multiple function headers
# Named functions
defmodule Fn do
def add(a, b) do
a + b
end
def add_shorthand(a, b), do: a + b
def add_defaulted(a \\ 0, b \\ 0), do: a + b
end
IO.inspect(Fn.add(10, 15))
IO.inspect(Fn.add_shorthand(10, 20))
IO.inspect(Fn.add_defaulted())
# Anonymous functions
add = fn a, b -> a + b end
# Call them with a .
add.(1, 5)
# You can pass anonymous functions as arguments to other functions
# e.g.1
Enum.map([1, 2, 3], fn x -> x * 2 end) |> IO.inspect(label: "Doubled")
# e.g.2
triple = fn x -> x * 3 end
Enum.map([1, 2, 3], triple) |> IO.inspect(label: "Tripled")
# How do we pass named functions as arguments?
defmodule Fn do
def square(a), do: a * a
end
# You could use an anonymous function that calls your named function
Enum.map([1, 2, 3], fn x -> Fn.square(x) end)
# Neater to use function capture syntax
Enum.map([1, 2, 3], &Fn.square/1)
# Function capture syntax can also be used as a shorthand for defining anonymous functions
Enum.map([1, 2, 3], &(&1 * &1 * &1))
EXERCISE! |
---|
Can you write a function and_then which takes 2 functions as an argument and itself returns a function which combines them both? |
Example usage:
combined_fn = Fn.and_then(fn a -> a + 5 end, fn a -> a * 3 end)
combined_fn.(7) # --> (7 + 5) * 3 --> 36
defmodule Fn do
# ....
end
combined_fn = Fn.and_then(fn a -> a + 5 end, fn a -> a * 3 end)
combined_fn.(7)
BONUS 1! |
---|
Can you neaten up your code by using the pipe operator? |
BONUS 2! |
---|
Can you adapt your code to combine any number of function calls by taking a list of functions as the method argument? TIP: Look at the reduce function |
EXERCISE! |
---|
Can you use the capture syntax to define an anonymous function which takes 3 arguments and adds them together? |
In Elixir we can define the same function multiple times, and put conditions about which implementation to use using 2 concepts:
- Guards
- Pattern matching
defmodule Guards do
def double(a)
def double(a) when is_number(a) do
a * 2
end
# Strings are binaries in Elixir! Read more on the docs ;)
def double(a) when is_binary(a) do
a <> a
end
def double(a) when is_function(a) do
fn x -> x |> a.() |> a.() end
end
end
IO.inspect(Guards.double(5), label: "number")
IO.inspect(Guards.double("moo"), label: "string")
doubled_fn = Guards.double(fn x -> x * 2 end)
IO.inspect(doubled_fn.(2), label: "function")
defmodule PatternMatching do
def get_favourite_pet(person, pets)
def get_favourite_pet(_, []) do
"No pets :("
end
def get_favourite_pet(%{name: name} = _person, [%{name: pet_name} | _]) do
"#{pet_name} is #{name}'s favourite pet!"
end
end
PatternMatching.get_favourite_pet(%{name: "Tim"}, [])
|> IO.inspect(label: "No pets")
PatternMatching.get_favourite_pet(%{name: "Joanna"}, [%{name: "Fluffy"}, %{name: "Cuddles"}])
|> IO.inspect(label: "Fluffy and Cuddles:")
EXERCISE! |
---|
Can you create a function struct_to_map that converts any struct to a map, even if it's nested? |
Tips:
Map.from_struct()
may come in handyMap.new()
may come in handy - it can create a map from a list of tuples%_{}
enables you to pattern match on any struct- Multiple function headers are your friend
defmodule MapHelpers do
def struct_to_map() do
end
end
defmodule Name do
defstruct first_name: "", last_name: ""
end
defmodule Pet do
defstruct name: Name
end
defmodule Human do
defstruct age: 0, name: Name, pets: []
end
# Example usage
my_map =
MapHelpers.struct_to_map(%Human{
age: 35,
name: %Name{first_name: "Tim", last_name: "G"},
pets: [
%Pet{name: %Name{first_name: "Fluffy", last_name: "Fluffball"}},
%Pet{name: %Name{first_name: "Daisy", last_name: "Maisy"}}
]
})
IO.inspect(my_map)
my_map == %{
age: 35,
name: %{first_name: "Tim", last_name: "G"},
pets: [
%{name: %{first_name: "Fluffy", last_name: "Fluffball"}},
%{name: %{first_name: "Daisy", last_name: "Maisy"}}
]
}
- Mix is the build tool for Elixir, similar to npm, yarn, maven, sbt, pip, or whatever else you are used to!
- It can...
- Compile and run your project
- Create new projects
- Run your tests
- Download your dependencies
- Lots of other useful stuff...
- Mix is great! But not very interesting, we'll just cover a few useful commands here
mix new my_new_project # Create a new project with the main module being MyNewProject.
# Check the example folder for an example!
mix compile
MIX_ENV=test mix compile
iex -S mix # Start an iex session with all of your project code included!
mix deps.get
mix test
mix format
Note for some of these to work you'll need to be in a Phoenix project or have the Phoenix pre-requisites installed
mix phx.server # Start the Phoenix server
mix phx.routes # Show the routes available in the application and their controllers
mix phx.new hello_world # Create a new Phoenix project
mix ecto.migrate # Run outstanding DB migrations
mix ecto.reset # Clears DB, re-runs migrations, and puts in some seed data
You can find and run all of these examples in example/test/example_test.exs
Unfortunately they aren't setup to run in this Livebook.
defmodule ExampleTest do
use ExUnit.Case # <--- Lets ExUnit setup the module for testing
doctest Example # <--- says to run the doctests from the example module
describe "hello" do # <---- describe can be used to group similar tests
test "greets the world" do # <---- the actual test case
assert Example.hello() == :world
end
end
end
test "doesn't sound like a police officer" do
refute Example.hello() == :ello
end
- assert_raise (built-in)
- capture_io and capture_log (built-in)
- assert_unordered_list_equality (custom)
defmodule Example2 do
def greets_in_french do
IO.inspect("greeting in french")
:bonjour
end
def reverse_list(list), do: Enum.reverse(list)
end
defmodule Example2Test do
use ExUnit.Case
describe "greets_in_french/0" do
test "it greets the user in French" do
assert Example2.greets_in_french() == :bonjour
end
test "it does not greet the user in German" do
refute Example2.greets_in_french() == :hallo
end
test "it raises if the function is called with the wrong number of arguments" do
assert_raise FunctionClauseError, fn ->
Example2.greets_in_french("some argument")
end
end
test "it inspects as expected" do
assert capture_io(fn -> Example2.greets_in_french() end)
end
end
describe "reverse_list/1" do
test "it returns a list containing the same elements as the input" do
list = [1, 2, 3]
result = Example.reverse(list)
refute list == result
assert_unordered_list_equality(list, result)
end
end
end
Examples from docstrings on methods can be automatically tested!
@doc """
Hello world.
## Examples
iex> Example.hello()
:world
"""
def hello do
:world
end
- setup_all runs once for the module
- setup runs once before each test
- Both can add to a state/context map, which is then available to each test
- You can have multiple setup clauses
setup_all do
IO.puts("Running setup_all step")
[recipient: :world]
end
setup do
IO.puts("Running setup step")
on_exit(fn ->
IO.puts("This is invoked once the test is done. Process: #{inspect(self())}")
end)
# Returns extra metadata to be merged into context.
# Any of the following would also work:
#
# {:ok, %{hello: "world"}}
# {:ok, [hello: "world"]}
# %{hello: "world"}
#
[hello: "world"]
end
defmodule Example3Test do
use ExUnit.Case
setup do
:ok
end
describe "some_function_here/2" do
setup do
:ok
end
test "it behaves in a certain way" do
## assertions go here
end
test "it behaves in some other way" do
## assertions go here
end
end
describe "some_other_function/3" do
setup do
:ok
end
test "it returns what we expect" do
## assertions go here
end
test "it raises when we expect it" do
## assertions go here
end
end
test "some helper function does something" do
## assertions go here
end
end
Builders are a really big part of how we do Elixir testing at Multiverse.
A complex but highly used example of a builder is the Apprenticehip Builder, helping use build apprenticeship records quickly so we can test our code against various setups and situations. See: https://github.com/Multiverse-io/platform/blob/master/test/factories/apprenticeship_builder.ex
An example of how builders can be used can be found here: https://github.com/Multiverse-io/platform/blob/master/test/platform/flying_start_attendance/flying_start_attendance_filter_test.exs#L223
test/test_helper.exs
allows you to do some setup for all tests in your project.
defmodule OuterModule do
@moduledoc """
I'm a module that does some stuff
"""
defmodule InnerModule do
@doc """
Adds 2 numbers
"""
def add(a, b), do: a + b
end
end
defmodule OuterModule.AnotherInnerModule do
def subtract(a, b), do: a - b
end
OuterModule.InnerModule.add(1, 2)
OuterModule.AnotherInnerModule.subtract(10, 2)
- Case statement for pattern matching
- Cond statements
- If/else statements (and unless)
- Comprehensions are helpful for composing operations
- The with statement is helpful for chaining operations which might fail
db_results = {:ok, [%{name: "Tim", age: 21}]}
case db_results do
{:ok, []} -> "No data!"
{:ok, [_ | _] = data} -> data
{:error, _} -> "Oh no an error!"
end
cond do
2 + 2 == 5 -> "This will not be true"
2 * 2 == 3 -> "Nor this"
1 + 1 == 2 -> "But this will"
end
# Note there is no "else if"
if 2 + 2 == 5 do
"This will not be true"
else
"But this will"
end
unless 2 + 2 == 5 do
"The laws of maths still hold"
end
Comprehensions are useful as they allow you to do functions like Enum.map
, Enum.filter
in a
neater, more concise syntax
# A simple example
for(n <- [1, 2, 3, 4], do: n * n)
|> IO.inspect(label: "Simple example")
# Pattern matching example - only keeps elements matching the pattern!
for({:a, num} <- [a: 1, b: 2, c: 3], do: num * 2)
|> IO.inspect(label: "Pattern matching example")
# Example with a filter
for(n <- [1, 2, 3, 4, 5], n <= 3, do: n * 2)
|> IO.inspect(label: "Example with filter")
# Example with multiple lists
for a <- [1, 2, 3],
b <- [1, 2, 3] do
a * b
end
|> IO.inspect(label: "Example with multiple lists")
# Example with a different enumerable as input
for({animal, num} <- %{:monkeys => 10, :lions => 26}, do: {animal, num - 2})
|> IO.inspect(label: "Example with filter")
# Outputting to a data structure other than a list
for {key, val} <- [a: 1, b: 2, c: 3], into: %{}, do: {key, val * 2}
EXERCISE! |
---|
Use a comprehension to find the product (multiplication) of all even numbers from the first list, and all numbers less than 100 from the second list |
# We'll just want to keep the even numbers
list_1 = [1, 2, 3, 4, 5]
# We'll just want to keep the numbers less than 100
list_2 = [27, 105, 109, 32, 2, 999]
# Final output should be a list containing:
# --> [2 * 27, 4 * 27, 2 * 32, 4 * 32, 2 * 2, 4 * 2]
# --> [54, 108, 64, 128, 4, 8]
Enables you to succintly execute a sequence of commands, and return immediately if any of the commands don't meet the given pattern match.
result = with
{:ok, db_results} <- get_db_data(),
{:ok, parsed_results} <- parse_db_results(db_results) do
IO.inspect(parsed_results)
end
defmodule MaybeFailFns do
def random_fail(), do: Enum.random([true, false])
def read_file() do
case random_fail() do
true -> {:err, "file_does_not_exist"}
false -> {:ok, "file contents"}
end
end
def parse_file(_file_contents) do
case random_fail() do
true -> {:err, "cannot parse file"}
false -> {:ok, "parsed file contents"}
end
end
def write_parsed_output(_parsed_file) do
case random_fail() do
true -> {:err, "cannot write file"}
false -> {:ok, "File written successfully!"}
end
end
end
# We want to read a file, parse the output, and then write the output to another file.
# Any of these steps may fail!!! We want to return the final result or the 1st error we get
import MaybeFailFns
# Approach 1 - things we've seen so far:
case read_file() do
{:err, err} ->
{:err, err}
{:ok, file_contents} ->
case parse_file(file_contents) do
{:err, err} ->
{:err, err}
{:ok, parsed_file_contents} ->
case write_parsed_output(parsed_file_contents) do
{:err, err} -> {:err, err}
{:ok, success_msg} -> {:ok, success_msg}
end
end
end
EXERCISE! |
---|
Use the with statement to neaten things up! |
# Approach 2 - use the with to neaten things up!:
import MaybeFailFns
We can use the else block of a with statement to pattern match on errors (in the case that not all the pattern matches in the with were successful)
result = with
{:ok, db_results} <- get_db_data(),
{:ok, parsed_results} <- parse_db_results(db_results) do
IO.inspect(parsed_results)
else
{:err, :db_connection_err} -> IO.puts("Couldn't connect to the DB, please try again!")
{:err, :parse_error} -> IO.puts("We couldn't parse the results from the DB - please contact support!")
_ -> IO.puts("We've encountered an unexpected error - please contact support")
end
EXERCISE! |
---|
Can you add an else clause to the previous exercise to give more user friendly errors? |
- Aliasing lets you use a shorter reference for the module name
- Import means you can use the function names directly
defmodule Some.Long.Nested.Module do
def do_stuff(), do: "Stuff!"
end
Some.Long.Nested.Module.do_stuff()
# Or...
alias Some.Long.Nested.Module
Module.do_stuff()
# Or...
alias Some.Long.Nested.Module, as: M
M.do_stuff()
import Some.Long.Nested.Module
do_stuff()
DISCUSSION |
---|
When should we use import and when should we use alias? |
If you want to use macros from a module then you must require
that module.
defmodule Mac do
defmacro my_unless(clause, do: expression) do
quote do
if(!unquote(clause), do: unquote(expression))
end
end
end
require Mac
Mac.my_unless 1 == 2 do
IO.puts("Woohoo!!")
end
if !(1 == 2), do: IO.puts("Woohoo!!")
When you 'use' another module it lets that module inject arbitrary code into your module. Usually you will rely on the documentation to let you know what it is doing. The Phoenix framework makes heavy use of this!
defmodule Mac do
defmacro __using__(_opts) do
IO.puts("We're using the Mac module!!")
quote do
def say_hello(), do: "hello"
end
end
end
defmodule UsingMac do
use Mac
def my_method, do: say_hello()
end
UsingMac.my_method()
- Protocols
- Macros
- Elixir Processes and OTP