Introduction to Elixir

Updated: 03 May 2024

Notes from the Elixir Programming Introduction YouTube Video

Installation and Setup

Installation

  1. Follow the installation instructions as per the Elixir Docs for your operating system
  2. Install the Elixir Language Server for VSCode (JakeBecker.elixir-ls)
  3. Install the Elixirt Formatter for VSCode (saratravi.elixir-formatter)

You can text the installation by opening the Elixir repl using iex (interactive elixir) in your terminal

Running Code

To start writing some code, we just need to create a file wih an exs extension

intro.exs
1
IO.puts("Hello World")

You can then run it using:

Terminal window
1
elixir intro.exs

In general, we use exs for code that we will run using the interpreter and ex for code we will compile

Mix

Mix is a tool for working with Elixir code. We can use the mix command to create and manage Elixir projects

Programming in Elixir

Creating a Project

We can create an example project with some code in it by using mix new, we can create a project like so:

Terminal window
1
mix new example

This should show the following output:

Terminal window
1
* creating README.md
2
* creating .formatter.exs
3
* creating .gitignore
4
* creating mix.exs
5
* creating lib
6
* creating lib/example.ex
7
* creating test
8
* creating test/test_helper.exs
9
* creating test/example_test.exs
10
11
Your Mix project was created successfully.
12
You can use "mix" to compile it, test it, and more:
13
14
cd example
15
mix test
16
17
Run "mix help" for more commands.

For our sake,. we’ll be working in the lib/example.ex file which defines the module for our application

A module is effectively a mainspace which is within the do...end block. We also have a hello function in our module. The generated file can be seen below:

lib/example.ex
1
defmodule Example do
2
@moduledoc """
3
Documentation for `Example`.
4
"""
5
6
@doc """
7
Hello world.
8
9
## Examples
10
11
iex> Example.hello()
12
:world
13
14
"""
15
def hello do
16
:world
17
end
18
end

Interacting with a Module

We can compile the project using:

Terminal window
1
mix compile

Then, we can interact with this code interactively using iex

Terminal window
1
iex -S mix

Thereafter we will find ourself with the module loaded into the interactive session, we can interact with the code that we loaded via the module like:

Terminal window
1
# within the Elixir REPL
2
iex(1)> Example.hello

Next, you can run the function using mix

Terminal window
1
mix run -e "Example.hello"

In the case when we use mix the result of the function will not be output since it is nit printed using IO.puts

Running a Project

Something important to know - code outside of the module definition is executed when the code is compiled, not during runtime

We can define an entrypoint at our application configuration level - this is done in the mix.exs file:

mix.exs
1
# ... rest of file
2
def application do
3
[
4
# define our entry module
5
mod: {Example, []},
6
extra_applications: [:logger]
7
]
8
end
9
# ... rest of file

Next, we need to provide a start method in our module that will be called when the app is run. We can clear out our Example module and will just have the following:

lib/example.ex
1
defmodule Example do
2
use Application
3
4
# `mix run` looks for the `start` function in the module
5
def start(_type, _args) do
6
IO.puts("App Starting")
7
Supervisor.start_link([], strategy: :one_for_one)
8
end
9
end

In the above, we use the _ prefix to denote that we’re not using those parameters - if we remove these we will get warnings when using mix

We can run the above code using either mix or mix run:

Terminal window
1
# `mix` is shorthand for `mix run`
2
mix run

The above line with Supervisor.start_link isn’t really doing anything as yet - but it is needed to satisfy the requirement of Elixir that the app returns a supervision tree

Dependencies

Dependencies in Elixir can be installed using hex which is Elixir’s package manager. We can set this up by using:

Terminal window
1
mix local.hex

We can then look for packages on Hex Website. For the sake of example we’re going to install the uuid package. We do this by adding it to the mix.exs file:

mix.exs
1
defp deps do
2
[
3
{:uuid, "~> 1.1"}
4
# {:dep_from_hexpm, "~> 0.3.0"},
5
# {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"}
6
]
7
end

Therafter, run the following command to install the dependencies:

Terminal window
1
mix deps.get

We can then use the package we installed in our code:

lib/example.ex
1
defmodule Example do
2
use Application
3
alias UUID
4
5
# `mix run` looks for the `start` function in the module
6
def start(_type, _args) do
7
IO.puts(UUID.uuid4())
8
9
Supervisor.start_link([], strategy: :one_for_one)
10
end
11
end

Syntax

Defining Variable Bindings

1
def main() do
2
# binding a variable
3
x = 5
4
IO.puts(x)
5
6
# can re-bind a variable
7
x=10
8
IO.puts(x)
9
end

You can also implement constants at the module level by using @ as follows:

1
@y 15
2
3
def main() do
4
IO.puts(@y)
5
end

Atoms and Strings

Atoms are kind of like an alternative to a string. These values have the same name and value and are constant - these cannot be randomly defined by users

We prefer to use atoms for static values since these have better performance since certain things like comparing values can be done based on memory location for atoms instead of value for strings

1
str = "Hello World"
2
atm = :hello_world
3
atm = :"Hello World"

We can also have spaces and special characters in atoms provided we enclose it in quotes

Conditional Statements

Conditions use the following syntax:

1
status = Enum.random([:gold, :silver, :bronze])
2
3
if status === :gold do
4
IO.puts("First Place")
5
else
6
IO.puts("You Lose")
7
end

We can do equality checking using === or == which is less strict (a bit like javascript)

Case Statements

1
status = Enum.random([:gold, :silver, :bronze, :something_else])
2
3
case status do
4
:gold -> IO.puts("Winner")
5
:silver -> IO.puts("Scond Place")
6
:bronze -> IO.puts("You Lose")
7
8
_ -> IO.puts("What are you doing here?")
9
end

In the above, we use the _ as a default case

Strings

IO.puts prints a string and adds a newline at the end. Strings can contain special characters and expressions as well as as various other things like unicode character codes

1
name = "Bob Smith"
2
message = "Our new Employee is:\n - #{name}"
3
4
IO.puts(message)

Numbers

1
# integer
2
x = 5
3
4
# float
5
y= 3.0
6
7
# if all inputs are int then z is int, otherwise it will be a float
8
z = x + y

Elixir is dynamically typed so it doesn’t care too much about how we work with these kinds of data types

The float type is 64 bit in Elixir and there is no double type

We can also format and print numbers using :io.format:

1
x = 0.1
2
:io.format("~.20f", [x])

Formatting the above also shows us that we don’t have very high precision using floats as normally using floating points values

There are also numerous other methods for working with numbers contained in the Float namespace. For example the ceil method:

1
x = 0.1234
2
r = Float.ceil(x,2)

The same goes for integers, their methods are located in the Integer namespace

Compound Types

Compound types are types that consist of many other values

For these compound types we can’t use IO.puts to print them out since they’re not directly stringable - we can instead use IO.inspect which will print them out in some way that makes sense for the data type as in the following example:

1
time = Time.new(16,30,0,0)
2
IO.inspect(time)

Dates and Times

We can create dates and times using their respective constructors:

1
time = Time.new(16,30,0,0)
2
date = Date.new(2024, 1, 1)

In the above code the new functions return a result, if we want to unwrap this we can add a ! in our function call:

1
time = Time.new!(16,30,0,0)
2
date = Date.new!(2024, 1, 1)
3
4
dt = DateTime.new(date, time)

Unwrapping the values lets us use them directly if we want to, we can do this along with different functions for working with dates, e.g. converting it to a string:

1
time = Time.new!(16,30,0,0)
2
date = Date.new!(2024, 1, 1)
3
4
dt = DateTime.new!(date, time)
5
IO.puts(DateTime.to_string(dt))

Tuples

Tuples allow us to store multiple values in a single variable. Tuples have a fixed number of elements and they can be different data types. Tuples use {}

1
bob = {"Bob Smith", 55}

We can also create a new tuple to which we append new values using Tuple.append

1
bob = {"Bob Smith", 55}
2
bob = Tuple.append(bob, :active)
3
4
IO.inspect(bob)

We can also do math on tuples, for example as seen below:

1
prices = {20,50, 10}
2
avg = Tuple.sum(prices)/tuple_size(prices)
3
4
IO.inspect(avg)

We can get individual properties out of a tuple using the elem method with the index:

1
prices = {20,50, 10}
2
avg = Tuple.sum(prices)/tuple_size(prices)
3
4
IO.puts("Average of: #{elem(prices,0)}, #{elem(prices,1)}, #{elem(prices,2)} is #{avg}")

Or we can descructure the individual values:

1
bob = {"Bob Smith", 55}
2
bob = Tuple.append(bob, :active)
3
4
{name, age, status} = bob
5
6
IO.puts("#{name} #{age} #{status}")

Lists

Lists are used when we have a list of elements but we don’t know how many elements we will have. Lists use []

1
user1 = {"Bob Smith", 44}
2
user2 = {"Alice Smith", 55}
3
user3 = {"Jack Smith", 66}
4
5
users = [
6
user1,
7
user2,
8
user3
9
]
10
11
Enum.each(users, fn {name, age} ->
12
IO.puts("#{name} #{age}")
13
end)

We can also use the Enum.each method to iterate over these values as we can see above

Maps

Maps are key-value pairs. Maps use %{}

1
user1 = {"Bob Smith", 44}
2
user2 = {"Alice Smith", 55}
3
user3 = {"Jack Smith", 66}
4
5
members = %{
6
bob: user1,
7
alice: user2,
8
jack: user3
9
}
10
11
{name, age}= members.alice
12
IO.puts("#{name} #{age}")

Maps are expecially useful since we will also get autocomplete for the fields that are in the map.

Structs

Structs are used for defining types that have got defined structures

Structs can be defined within a module using the defstruct keyword. This is the struct that the module operates with:

1
defmodule User do
2
defstruct [:name, :age]
3
end

And we can create an instance of the struct using the %Name{} syntax

1
user = %User{name: "Bob Smith", age: 55}

We can access properties of the struct using dot notation:

1
user = %User{name: "Bob Smith", age: 55}
2
3
IO.puts(user.name)

Random

We can get a random int using the following:

1
random = :rand.uniform(11) -1

Whenever we use the :name syntax for accessing a namespace it means we are referring to some Erlang based code

Piping

We can get user input using the IO.gets method:

1
guess = IO.gets("Guess a number between 1 and 10: ") |> String.trim() |> Integer.parse()

In the above example we also use function piping to trim and parse the input string

Pattern Matching

The above leads to the result being either a value or an error, we can use pattern matching to interpret this value:

1
case guess do
2
{result, ""} -> IO.puts("Fully parsed: #{result}")
3
4
{result, other} -> IO.puts("Partially parsed: #{result} with #{other}")
5
6
:error -> IO.puts("Failed to parse")
7
end

If we want to ignore the partial error case, we can use an _:

1
case guess do
2
{result, _}-> IO.puts("Parsed: #{result}")
3
4
:error -> IO.puts("Failed to parse")
5
end

Combining the above, we can create a small guessing game:

1
correct = :rand.uniform(11) - 1
2
3
guess = IO.gets("Guess a number between 0 and 10: ") |> String.trim() |> Integer.parse()
4
5
IO.inspect(guess)
6
7
case guess do
8
{result, _} ->
9
if result === correct do
10
IO.puts("You guessed correctly!")
11
else
12
IO.puts("The correct answer is #{correct}, you said #{result}")
13
end
14
15
:error ->
16
IO.puts("Failed to parse")
17
end

List Comprehension

List comprehension is used for doing some operation for each item in a list, the syntax is as follows:

1
values = [25, 50, 75, 100]
2
3
for n <- values, do: IO.puts("value: #{n}")

We can also assign the result of the do to a new array

1
values = [25, 50, 75, 100]
2
3
double_values = for n <- values, do: n * 2

We can also combine a comprehension with a condition:

1
values = [25, 50, 75, 100]
2
3
evens = for n <- values, n > 50, do: "Value is: #{n}"

The condition in the above example is n > 50 but this can be anything that evaluates to a boolean

Appending to Lists

If we want to append to a list we can use the ++ operator:

1
values = [25, 50, 75, 100]
2
3
added_values = values ++ [101, 102]

Prepending to a List

We can use the | operator to prepend to a list using the following syntax:

1
values = [25, 50, 75, 100]
2
3
added_values = [101, 102 | values]

Function Arity

The arity of a function refers to how many parameters the fuction takes. In elixir functions use the / t ndicate the arity. This is because we may have multiple functions with the same name in a module that each have a different arity. For example, there are two versions of String.split which take one and two parameters. We refer to these as String.split/1 and String.split/2 respectively:

1
# split by whitespace `String.split/1`
2
a = String.split("hello world, how are you?")
3
IO.inspect(a)
4
5
# split by comma `String.split/2`
6
a = String.split("hello world, how are you?", ",")
7
IO.inspect(a)

Passing Functions as Parameters

We can pass anonymous functions to other functions:

1
values = [25, 50, 75, 100]
2
3
result = Enum.map(values, fn n -> Integer.to_string(n) end)

We can pass fuctions to other functions. For example, equivalent to the above:

1
values = [25, 50, 75, 100]
2
3
result = Enum.map(values, &Integer.to_string/1)

The & operator converts a function to an anonymous function. This requires that we also specify the arity of a function so that we can pass the correct instance as a callback

Defining Functions

We can define functions using the def name do ... end syntax:

1
def sum(a,b) do
2
a + b
3
end

And we can use this:

1
result = sum(5,32)
2
3
values = [25, 50, 75, 100]
4
result = Enum.reduce(values, &Example.sum/2)

Another small example is a function for printing out a list of numbers:

1
def print_numbers(lst) do
2
lst |> Enum.join(" | ") |> IO.puts()
3
end