Introduction to Elixir
Updated: 03 May 2024
Notes from the Elixir Programming Introduction YouTube Video
Installation and Setup
Installation
- Follow the installation instructions as per the Elixir Docs for your operating system
- Install the Elixir Language Server for VSCode (
JakeBecker.elixir-ls
) - 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
1IO.puts("Hello World")
You can then run it using:
1elixir intro.exs
In general, we use
exs
for code that we will run using the interpreter andex
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:
1mix new example
This should show the following output:
1* creating README.md2* creating .formatter.exs3* creating .gitignore4* creating mix.exs5* creating lib6* creating lib/example.ex7* creating test8* creating test/test_helper.exs9* creating test/example_test.exs10
11Your Mix project was created successfully.12You can use "mix" to compile it, test it, and more:13
14 cd example15 mix test16
17Run "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:
1defmodule Example do2 @moduledoc """3 Documentation for `Example`.4 """5
6 @doc """7 Hello world.8
9 ## Examples10
11 iex> Example.hello()12 :world13
14 """15 def hello do16 :world17 end18end
Interacting with a Module
We can compile the project using:
1mix compile
Then, we can interact with this code interactively using iex
1iex -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:
1# within the Elixir REPL2iex(1)> Example.hello
Next, you can run the function using mix
1mix 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:
1 # ... rest of file2 def application do3 [4 # define our entry module5 mod: {Example, []},6 extra_applications: [:logger]7 ]8 end9 # ... 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:
1defmodule Example do2 use Application3
4 # `mix run` looks for the `start` function in the module5 def start(_type, _args) do6 IO.puts("App Starting")7 Supervisor.start_link([], strategy: :one_for_one)8 end9end
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
:
1# `mix` is shorthand for `mix run`2mix 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:
1mix 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:
1 defp deps do2 [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:
1mix deps.get
We can then use the package we installed in our code:
1defmodule Example do2 use Application3 alias UUID4
5 # `mix run` looks for the `start` function in the module6 def start(_type, _args) do7 IO.puts(UUID.uuid4())8
9 Supervisor.start_link([], strategy: :one_for_one)10 end11end
Syntax
Defining Variable Bindings
1def main() do2 # binding a variable3 x = 54 IO.puts(x)5
6 # can re-bind a variable7 x=108 IO.puts(x)9end
You can also implement constants at the module level by using @
as follows:
1@y 152
3def main() do4 IO.puts(@y)5end
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
1str = "Hello World"2atm = :hello_world3atm = :"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:
1status = Enum.random([:gold, :silver, :bronze])2
3if status === :gold do4 IO.puts("First Place")5else6 IO.puts("You Lose")7end
We can do equality checking using ===
or ==
which is less strict (a bit like javascript)
Case Statements
1status = Enum.random([:gold, :silver, :bronze, :something_else])2
3case status do4 :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?")9end
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
1name = "Bob Smith"2message = "Our new Employee is:\n - #{name}"3
4IO.puts(message)
Numbers
1# integer2x = 53
4# float5y= 3.06
7# if all inputs are int then z is int, otherwise it will be a float8z = 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
:
1x = 0.12: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:
1x = 0.12342r = 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:
1time = Time.new(16,30,0,0)2IO.inspect(time)
Dates and Times
We can create dates and times using their respective constructors:
1time = Time.new(16,30,0,0)2date = 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:
1time = Time.new!(16,30,0,0)2date = Date.new!(2024, 1, 1)3
4dt = 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:
1time = Time.new!(16,30,0,0)2date = Date.new!(2024, 1, 1)3
4dt = DateTime.new!(date, time)5IO.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 {}
1bob = {"Bob Smith", 55}
We can also create a new tuple to which we append new values using Tuple.append
1bob = {"Bob Smith", 55}2bob = Tuple.append(bob, :active)3
4IO.inspect(bob)
We can also do math on tuples, for example as seen below:
1prices = {20,50, 10}2avg = Tuple.sum(prices)/tuple_size(prices)3
4IO.inspect(avg)
We can get individual properties out of a tuple using the elem
method with the index:
1prices = {20,50, 10}2avg = Tuple.sum(prices)/tuple_size(prices)3
4IO.puts("Average of: #{elem(prices,0)}, #{elem(prices,1)}, #{elem(prices,2)} is #{avg}")
Or we can descructure the individual values:
1bob = {"Bob Smith", 55}2bob = Tuple.append(bob, :active)3
4{name, age, status} = bob5
6IO.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 []
1user1 = {"Bob Smith", 44}2user2 = {"Alice Smith", 55}3user3 = {"Jack Smith", 66}4
5users = [6 user1,7 user2,8 user39]10
11Enum.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 %{}
1user1 = {"Bob Smith", 44}2user2 = {"Alice Smith", 55}3user3 = {"Jack Smith", 66}4
5members = %{6 bob: user1,7 alice: user2,8 jack: user39}10
11{name, age}= members.alice12IO.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:
1defmodule User do2 defstruct [:name, :age]3end
And we can create an instance of the struct using the %Name{}
syntax
1user = %User{name: "Bob Smith", age: 55}
We can access properties of the struct using dot notation:
1user = %User{name: "Bob Smith", age: 55}2
3IO.puts(user.name)
Random
We can get a random int using the following:
1random = :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:
1guess = 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:
1case guess do2 {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")7end
If we want to ignore the partial error case, we can use an _
:
1case guess do2 {result, _}-> IO.puts("Parsed: #{result}")3
4 :error -> IO.puts("Failed to parse")5end
Combining the above, we can create a small guessing game:
1correct = :rand.uniform(11) - 12
3guess = IO.gets("Guess a number between 0 and 10: ") |> String.trim() |> Integer.parse()4
5IO.inspect(guess)6
7case guess do8 {result, _} ->9 if result === correct do10 IO.puts("You guessed correctly!")11 else12 IO.puts("The correct answer is #{correct}, you said #{result}")13 end14
15 :error ->16 IO.puts("Failed to parse")17end
List Comprehension
List comprehension is used for doing some operation for each item in a list, the syntax is as follows:
1values = [25, 50, 75, 100]2
3for n <- values, do: IO.puts("value: #{n}")
We can also assign the result of the do
to a new array
1values = [25, 50, 75, 100]2
3double_values = for n <- values, do: n * 2
We can also combine a comprehension with a condition:
1values = [25, 50, 75, 100]2
3evens = 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:
1values = [25, 50, 75, 100]2
3added_values = values ++ [101, 102]
Prepending to a List
We can use the |
operator to prepend to a list using the following syntax:
1values = [25, 50, 75, 100]2
3added_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`2a = String.split("hello world, how are you?")3IO.inspect(a)4
5# split by comma `String.split/2`6a = String.split("hello world, how are you?", ",")7IO.inspect(a)
Passing Functions as Parameters
We can pass anonymous functions to other functions:
1values = [25, 50, 75, 100]2
3result = Enum.map(values, fn n -> Integer.to_string(n) end)
We can pass fuctions to other functions. For example, equivalent to the above:
1values = [25, 50, 75, 100]2
3result = 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:
1def sum(a,b) do2 a + b3end
And we can use this:
1result = sum(5,32)2
3values = [25, 50, 75, 100]4result = Enum.reduce(values, &Example.sum/2)
Another small example is a function for printing out a list of numbers:
1def print_numbers(lst) do2 lst |> Enum.join(" | ") |> IO.puts()3end