rw-book-cover

Metadata#

  • Author: Ulisses Almeida
  • Full Title: Learn Functional Programming With Elixir
  • Category: #books

Literature Notes#

TABLE rows.file.link AS "Literature Note", rows.file.cday AS "Date"

FROM #note/literature AND [Learn Functional Programming With Elixir](/notes/learn-functional-programming-with-elixir/)

GROUP BY file.link

sort date ASCENDING

Summaries#

Chapter 1: Thinking Functionally#

In functional programming, functions are the basic building blocks of a program, values are immutable, and the code is declarative, rather than imperative. Functions, immutability, and declarative code are the core principles of functional programming.

Immutability is especially important when working with concurrency or parallel computation. Immutable values sidesteps the need for complex locks and mutexes commonly found in imperative languages.

Pure functions are functions with the following properties:

  • values that are immutable
  • results that are affected only by the function’s arguments
  • computation that doesn’t have side effects

Elixir features the pipe operator |> that passes the result of each expression to the next function:

def capitalize_words(title) do
  title
  |> String.splut
  |> capitalize_all
  |> join_with_whitespace
end

Chapter 2: Working with Variables and Functions#

Logical Expressions#

and, or, and not work with boolean values - left side of expression must be a boolean value &&, ||, ! allow the left side value to be truthy or falsy (nil, false)

  • && returns the right side expression result when the left value is truthy, else returns 1st left-side value
  • || returns the first truthy expression, else returns first value

Anonymous Functions#

> hello = fn name -> "Hello #{name}!" end hello is a variable that we assign the function to name is the parameter #{name} is how one does string interpolation in Elixir

> hello.("Jane")
> Hello, Jane!

Closures allow you to share variables in same lexical scope.

Named Functions#

Named functions are defined in modules, which must be named with atoms or aliases (any word starting with a capital letter, ASCII only). For named function arguments, you can omit parentheses.

defmodule Checkout do
  def total_cost(price, tax_rate) do
    price * (tax_rate + 1)
  end
end
iex> c("checkout.ex")
iex> Checkout.total_cost(100, 0.2)
120.0

Modules#

Named functions live in modules. They’re defined with defmodule and CamelCase is used to name them. The exist in a file where the filename is the same as the module name but all lower case.

Multiple module files can exist in the same directory - those modules will all belong to the same namespace which has the same name as the directory.

work_with_functions/lib/ecommerce/checkout.ex

defmodule Ecommerce.Checkout do
  def total_cost(price, tax_rate) do
    price * (tax_rate + 1)
  end
end

Module attributes start with @ and can be used for constants, documentation, and more.

Modules need to be imported with name + arity (how many arguments a function received).

defmodule TaskListWithImport do
  import File, only: [write: 3, read: 1]
  @file_name "task_list.md"

  def add(task_name) do
    task = "[ ] " <> task_name <> "\n"
    write(@file_name, task, [:append])
  end

  def show_list do
    read(@file_name)
  end
end

Named Functions as Values#

& is a shortcut for referencing a function, also known as a capture operator. You can bind it to a variable:

iex> upcase &String.upcase/1
iex> upcase.("Hello")
HELLO

Anonymous Functions as Values#

& is also used here:

iex> total_cost = &(&1 * &2)
iex> total_cost.(10, 2)
20

However, this can’t be used for 0-arity functions.

Chapter 3: Using Pattern Matching to Control the Program Flow#

Pattern Matching#

The = operator always does pattern matching, even in variable assignment. This is because if you try to match an empty variable or similar, Elixir will make them match - by assigning the value on the right to the empty variable on the left.

Destructuring (aka Unpacking)#

String#

"Auth: " <> credentials = "Auth: 793AG!" will assign "793AG!" to credentials

Tuple#

{ability_score, _} = Integer.parse(user_input) This allows us to ignore second part of user_input by assigning it to _

Lists#

[a, a, a] = [1, 2, 1] raises a Match Error [a, a, a] = [1, 1, 1] works fine

Maps#

Extract and check

iex> %{intelligence: 10, dexterity: dexterity_value} = abilities
iex> dexterity_value
    12

In this example, abilities must have key intelligence with value 10, then check if abilities has dexterity key then load that into dexterity_value. This doesn’t create a new map.

Structs#

Same as maps

Sigils are shortcuts to quickly create values. For example, a word-list: ~w(c, a, be) gives ["c", "a", "be"].

Flow Control#

Function clauses are functions with the same name but are used with pattern matching in the parameters to control flow.

defmodule NumberCompare do
  def greater(number, other_number) do
    check(number >= other_number, number, other_number)
  end

  defp check(true, number, _), do: number
  defp check(false, _, other_number), do: other_number
end

In the above example, the private (defp) check functions are function clauses.

You can define default values for parameters in function signature with \\. Elixir will create multiple functions - one with a value-less argument, the other with no argument where Elixir will call the first version and pass in the default value.

defmodule Checkout do
  def total_cost(price, quantity \\ 10), do: price * quantity
end

Guard clauses allow you to provide boolean statements that must be true for the function to be called, using when.

defmodule NumberCompare do
  def greater(number, other_number) when number >= other_number, do: number
  def greater(_, other_number), do: other_number
end

The first version of greater is only called if the first argument is greater than or equal to the second one.

defguard is a directive allows you to create macro functions to be used in guard clauses.

defmodule Checkout do
  defguard is_rate(value) when is_float(value) and value >= 0 and value <= 1
  defguard is_cents(value) when is_integeer(value) and value >= 0

  def total_cost(price, tax_rate) when is_cents(price) and is_rate(tax_rate) do
    price + tax_cost(price, tax_rate)
  end

  def tax_cost(price, tax_rate) when is_cents(price) and is_rate(tax_rate) do
    price * tax_rate
  end
end

case lets you check against multiple pattern match clauses.

user_input = IO.gets "Write your ability score:\n"
case Integer.parse(user_input) do
  :error -> IO.puts "Invalid ability score: #{user_input}"
  {ability_score, _} ->
    ability_modifier = (ability_score - 10) / 2
    IO.puts "Your ability modifier is #{ability_modifier}"
end

This handles 2 scenarios: user input is a valid integer, and user input is not a valid integer. patterh matching expression comes before the -> and the expression after it is evaluated.

cond allows you to check against logical expressions

{age, _} = Integer.parse IO.gets("Person's age:\n")

result = cond do
  age < 13 -> "kid"
  age <= 18 -> "teen"
  age > 18 -> "adult"
end

IO.puts "Result: #{result}"

Notice the similarity in structure to case.

if and unless are the same as in other languages.

Chapter 4: Diving into Recursion#

Bounded recursion - a type of recursive function in which successive calls to itself have an end, and is the most common type of recursive function. Boundary clauses in elixir should always be defined before the clauses that can be repeated.

defmodule Sum do
  def up_to(0), do: 0
  def up_to(n), do: n + up_to(n - 1)
end

Handling Lists#

For lists, you can recursively process them with this syntax: [head | tail], which saves the first item in the list to head and the rest to tail. Here’s a simple example:

defmodule Math do
  def sum([]), do: 0
  def sum([head | tail]), do : head + sum(tail)
end

[head | tail] is also useful for making new lists, which prepends an element to the original list (creating a new list, since data is immutable) and is faster than appending with ++.

@enchanter_name "Edwin"

def enchant_for_sale([]), do: []
def enchant_for_sale([item | incoming_items]) do
  new_item = %{
    title: "#{@enchanter_name}'s #{item.title}"
    price: item.price * 3,
    magic: true
  }
  [new_item | enchant_for_sale(incoming_items)]
end

In the recursive step, we use pattern matching to retrieve the head of the input list (item), then create a new item by applying transformation rules to it. Then we build a new list where the head is new_item and the rest of it is a recursive call to enchant_for_sale/1.

You can also use filter function clauses to use pattern matching and prevent a transformation from happening. Using the above example, we want to avoid enchanting an item that is already enchanted, and we’ll do that by checking that magic is already true (L4-6):

@enchanter_name "Edwin"

def enchant_for_sale([]), do: []
def enchant_for_sale([item = %{magic: true} | incoming_items]) do
  [item | enchant_for_sale(incoming_items)]
end
def enchant_for_sale([item | incoming_items]) do
  new_item = %{
    title: "#{@enchanter_name}'s #{item.title}"
    price: item.price * 3,
    magic: true
  }
  [new_item | enchant_for_sale(incoming_items)]
end

Conquering Recursion#

Decrease and conquer is a technique for solving problems using recursion by reducing a problem to its simplest form (the base case) and solving it incrementally. Here’s an example starting with a hardcoded factorial solution and breaking it down to the recursive form starting with the base case.

defmodule Factorial do
  def of(0), do: 1
  def of(1), do: 1
  def of(2), do: 2 * 1
  def of(3), do: 3 * 2 * 1
  def of(4), do: 4 * 3 * 2 * 1
end

This is hardcoded, but not generalized. Factorial.of(5) won’t work. But we can start simplifying this. Looking at the patterns above, we can see that calling the function with n - 1 will get us the bulk of the result for n, so we can replace those with function calls:

defmodule Factorial do
  def of(0), do: 1
  def of(1), do: 1 * of(0)
  def of(2), do: 2 * of(1)
  def of(3), do: 3 * of(2)
  def of(4), do: 4 * of(3)
end

The pattern is more clear now, and we can collapse and generalize it like this:

defmodule Factorial do
  def of(0), do: 1
  def of(n) when n > 0, do: n * of(n-1)
end

Divide and conquer is another recursive technique that is about separating the problem into 2 or more parts that can be processed on their own and then combined later. Consider sorting a list: in functional programming, data is immutable so we can’t change the order of items in a list directly. Instead we can keep dividing the list in half until we have a bunch of one-item lists (which are by definition sorted), then we have to merge them in a sorted way.

To divide a list in half recursively, we will make use of Enum.split/2, Enum.count/1 to calculate the size of the list, and Kernel.div/2 to do integer division to get the split point.

defmodule Sort do
  def ascending([]), do: []
  def ascending([a]), do: [a]
  def ascending(list) do
    half_size = div(Enum.count(list), 2)
    {list_a, list_b} = Enum.splut(list, half_size)
    # We need to sort list_a and list_b
    # ascending(list_a)
    # ascending(list_b)
    # And merge them using some strategy
  end
end

For the merge, we need a function that will unify two lists by putting the smallest elements at the beginning. We’d want something to look like:

merge([5, 9], [1, 4, 5])
[1 | merge([5, 9], [4, 5])]
[1, 4 | merge([5, 9], [5])]
[1, 4, 5 | merge([9], [5])]
[1, 4, 5, 5, | merge([9])]
[1, 4, 5, 5, 9]

Here’s a merge function that could do this:

defp merge([], list_b), do: list_b
defp merge(list_a, []), do: list_a
defp merge([head_a | tail_a], list_b = [head_b | _]) when head_a <= head_b do [head_a | merge(tail_a, list_b)]
defp merge(list_a = [head_a | _], [head_b | tail_b]) when head_a > head_b do [head_b | merge(list_a, tail_b)]
end

The two main versions simply take the smallest head item and then merge the remaining ones. Now it just needs to be called in the ascending function.

defmodule Sort do
  def ascending([]), do: []
  def ascending([a]), do: [a]
  def ascending(list) do
    half_size = div(Enum.count(list), 2)
    {list_a, list_b} = Enum.splut(list, half_size)
    merge(
      ascending(list_a),
      ascending(list_b)
    )
  end
end

The recursive calls for ascending can happen in parallel because they’re completely independent.

Tail-Call Optimization#

Tail-call optimization is a common compiler feature in functional languages that reduces functions in memory without allocating any more. To use it, you need to ensure that the last expression in a function is a function call. If it is, then the current function’s return is the return of the new function call, so the current function doesn’t need to stay in memory.

iex> scream = fn word -> String.upcase("#{word}!!!") end
iex> scream.("help")
"HELP!!!"

The result of scream.("help") is the same as String.upcase("help!!!") so the compiler can remove scream.("help") from the function call stack.

The previously seen factorial example is body recursive - it doesn’t end with a function call and so can’t take advantage of tail-call optimization. Calling it with a large number will result in heavy memory usage.

The typical way to create tail-recursive functions is to replace the use of the function result with an extra argument that accumulates the results of each iteration:

defmodule TRFactorial do
  def of(n), do: factorial_of(n, 1)
  defp factorial_of(0, acc), do: acc
  defp factorial_of(n, acc) when n > 0, do: factorial_of(n - 1, n * acc)
end

Functions Without Borders#

Unbounded recursion is when you can’t predict the number of repetitions for a recursive function, such as a web crawler. You can add stop clauses (such as for a directory crawler, an empty directory, or a maximum depth) to create an arbitrary or user-defined boundary.

defmodule Navigator do
  def navigate(dir) do
    expanded_dir = Path.expand(dir)
    go_through([expanded_dir])
  end

  defp go_through([]), do: nil
  defp go_through([content | rest]) do
    print_and_navigate(content, File.dir?(content))
    go_through(rest)
  end

  defp print_and_navigate(_dir, false), do: nil # underscore indicates the parameter won't be used
  defp print_and_navigate(dir, true) do
    IO.puts dir
    children_dirs = File.ls!(dir)
    go_through(expand_dirs(children_dirs, dir))
  end

  defp expand_dirs([], _relative_to), do: []
  defp expand_dirs([dir | dirs], relative_to) do
    expanded_dir = Path.expand(dir, relative_to)
    [expanded_dir | expand_dirs(dirs, relative_to)]
  end
end

To add something like a max depth restriction, you would create a module attribute (@max_depth 2),

defmodule DepthNavigator do
  @max_depth 2

  def navigate(dir) do
    expanded_dir = Path.expand(dir)
    go_through([expanded_dir], 0) # add initial value for depth from entry point
  end

  defp go_through([], _current_depth), do: nil
  defp go_through(_dirs, current_depth) when current_depth > @max_depth, do: nil # This is the boundary check
  defp go_through([content | rest], current_depth) do
    print_and_navigate(content, File.dir?(content))
    go_through(rest, current_depth)
  end

  defp print_and_navigate(_dir, false), do: nil # underscore indicates the parameter won't be used
  defp print_and_navigate(dir, true) do
    IO.puts dir
    children_dirs = File.ls!(dir)
    go_through(expand_dirs(children_dirs, dir), current_depth + 1)
  end
end

In this, we created a module attribute of max_depth, then updated go_through to take a current_depth parameter, a guard clause for if the max depth has been exceeded, and then recursive calls to go_through increment the current_depth.

In this kind of recursion, you’ll also want to avoid infinite loops. For file navigation, this can happen with symbolic links, or symlinks. We can avoid following symlinks by checking if an entity detected in the file system is a directory or not:

def dir?(dir) do
  {:ok, %{type: type}} = File.lstat(dir)
  type == :directory
end

Using Recursion with Anonymous Functions#

Calling a function recursively is more difficult when it is anonymous. To solve this, you can wrap the anonymous function in another function and delay recursive call execution.

What doesn’t work:

iex> factorial = fn
  0 -> 1
  x when x > 0 -> x * factorial.(x - 1)
end
** (CompileError) undefined function factorial/0

Wrapping solution:

iex> fact_gen = fn me ->
  fn
    0 -> 1
    x when x > 0 -> x * me.(me).(x - 1)
  end
end
iex> factorial = fact_gen.(fact_gen)
iex> factorial.(5)
120
iex> factorial.(10)
3628800

This is less expressive and more complex - but doable.

Using the capturing feature to use named function references like anon functions

iex> c(“factorial.ex”) iex> factorial = &Factorial.of/1 iex> factorial.(5) 120

Chapter 5: Using Higher-Order Functions#