
- Thinking in Recursion
- Why Recursion?
- Working with Lists
- Tail Recursion - The Efficient Way
- List Comprehensions - Elegant Data Transformation
- Basic Comprehensions
- Adding Filters
- Multiple Generators
- Pattern Matching in Comprehensions
- Error Handling - The Elixir Way
- When Things Go Wrong
- Using Pattern Matching
- The `with` Expression
- Exceptions for Exceptional Cases
- Try-Rescue for External Code
- Working with Files
- Reading Files
- Writing Files
- Processes - Elixir's Concurrency Model
- Spawning Processes
- Message Passing
- Stateful Processes
- Mix - Your Project Manager
- Creating a New Project
- Project Structure
- Configuring Your Project
- Common Mix Commands
- Testing Your Code
- Writing Tests
- Test Setup
- Documenting Your Code
- Module Documentation
- What's Next?
- Practice Exercises
- Key Points
Advanced Concepts and Mix Projects - Learn Elixir Part 3
Now that you're comfortable with Elixir's basics, let's tackle some more advanced concepts. This article covers recursion, error handling, file operations, and project management. These topics will help you write more robust and maintainable Elixir code.
Thinking in Recursion
In functional programming, we don't use traditional loops. Instead, we use recursion - functions that call themselves. This might seem strange at first, but it's actually quite elegant once you get the hang of it.
Why Recursion?
Recursion fits naturally with functional programming because:
- It works well with immutable data
- It's easier to reason about than loops with changing state
- It can be optimized by the compiler
Let's start with a simple example - counting down:
defmodule Recursion do
  def countdown(0), do: IO.puts("Blast off!")
  def countdown(n) do
    IO.puts(n)
    countdown(n - 1)
  end
end
Here's what happens when you call countdown(3):
- Prints "3"
- Calls countdown(2)
- Prints "2"
- Calls countdown(1)
- Prints "1"
- Calls countdown(0)
- Prints "Blast off!"
The key is the base case (countdown(0)) - it stops the recursion.
Working with Lists
Lists are perfect for recursion because they're built from a head and tail:
defmodule ListOperations do
  # Sum all numbers in a list
  def sum([]), do: 0  # Base case: empty list
  def sum([head | tail]) do
    head + sum(tail)  # Add head to sum of tail
  end
  # Find the length of a list
  def length([]), do: 0
  def length([_head | tail]) do
    1 + length(tail)  # Count this element plus rest
  end
end
The [head | tail] pattern is crucial here. It splits the list into the first element and everything else.
Tail Recursion - The Efficient Way
Regular recursion can cause stack overflow with large data. Tail recursion fixes this by making the recursive call the last operation:
defmodule TailRecursion do
  # Regular factorial (can cause stack overflow)
  def factorial(0), do: 1
  def factorial(n) when n > 0 do
    n * factorial(n - 1)  # Multiplication happens after recursive call
  end
  # Tail recursive factorial (optimized)
  def factorial_tail(n), do: factorial_tail(n, 1)
  
  def factorial_tail(0, acc), do: acc
  def factorial_tail(n, acc) when n > 0 do
    factorial_tail(n - 1, n * acc)  # Recursive call is last operation
  end
end
The difference is subtle but important. In tail recursion, we carry an accumulator (acc) that builds up the result, and the recursive call is the final operation. This lets the compiler optimize it into a loop.
List Comprehensions - Elegant Data Transformation
List comprehensions give you a concise way to transform and filter data. Think of them as a more readable alternative to nested loops.
Basic Comprehensions
numbers = [1, 2, 3, 4, 5]
# Double each number
doubled = for n <- numbers, do: n * 2
# Result: [2, 4, 6, 8, 10]
The for keyword creates a new list by applying the expression after do: to each element.
Adding Filters
You can add conditions to filter elements:
# Get only even numbers
evens = for n <- numbers, rem(n, 2) == 0, do: n
# Result: [2, 4]
The rem(n, 2) == 0 part is a filter - only elements that make this true are included.
Multiple Generators
You can combine multiple lists:
# Create all combinations
pairs = for x <- [1, 2], y <- [3, 4], do: {x, y}
# Result: [{1, 3}, {1, 4}, {2, 3}, {2, 4}]
This is like nested loops - for each value of x, it goes through all values of y.
Pattern Matching in Comprehensions
You can use pattern matching to extract specific parts:
users = [%{name: "Alice", age: 30}, %{name: "Bob", age: 25}]
names = for %{name: name} <- users, do: name
# Result: ["Alice", "Bob"]
This extracts just the name field from each user map.
Error Handling - The Elixir Way
Elixir has a "let it crash" philosophy, but that doesn't mean you ignore errors. Instead, you handle them at the right level.
When Things Go Wrong
Elixir provides several ways to handle errors:
- Pattern matching - Check return values
- Exceptions - For truly exceptional cases
- Supervisors - Let processes crash and restart (we'll cover this later)
Using Pattern Matching
Most Elixir functions return {:ok, result} or {:error, reason}:
defmodule SafeOperations do
  def divide(a, b) when b == 0, do: {:error, "Cannot divide by zero"}
  def divide(a, b), do: {:ok, a / b}
  def read_file(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, content}
      {:error, reason} -> {:error, "Failed to read file: #{reason}"}
    end
  end
end
This approach is preferred because it's explicit and forces you to handle both success and failure cases.
The with Expression
When you have multiple operations that might fail, with is your friend:
def process_user_data(user_id) do
  with {:ok, user} <- get_user(user_id),
       {:ok, profile} <- get_profile(user.profile_id),
       {:ok, preferences} <- get_preferences(user.preferences_id) do
    {:ok, %{user: user, profile: profile, preferences: preferences}}
  else
    {:error, reason} -> {:error, reason}
  end
end
The with expression tries each operation in sequence. If any fails, it stops and executes the else block. If all succeed, it executes the main block.
Exceptions for Exceptional Cases
Exceptions should be rare in Elixir. Use them for things that shouldn't happen:
defmodule Validation do
  defmodule ValidationError do
    defexception message: "Validation failed"
  end
  def validate_age(age) when age < 0 do
    raise ValidationError, message: "Age cannot be negative"
  end
  
  def validate_age(age) when age > 150 do
    raise ValidationError, message: "Age seems unrealistic"
  end
  
  def validate_age(_age), do: :ok
end
Try-Rescue for External Code
Use try-rescue when calling external libraries or code you don't control:
def safe_divide(a, b) do
  try do
    result = a / b
    {:ok, result}
  rescue
    ArithmeticError -> {:error, "Division by zero"}
    e in RuntimeError -> {:error, e.message}
    _ -> {:error, "Unknown error occurred"}
  end
end
The rescue block catches exceptions and converts them to your preferred error format.
Working with Files
Elixir makes file operations straightforward and safe.
Reading Files
defmodule FileReader do
  def read_entire_file(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, content}
      {:error, reason} -> {:error, "Failed to read file: #{reason}"}
    end
  end
  def read_file_lines(path) do
    File.stream!(path)
    |> Stream.map(&String.trim/1)
    |> Stream.filter(&(&1 != ""))
    |> Enum.to_list()
  end
end
The File.stream! function is great for large files because it reads line by line instead of loading everything into memory.
Writing Files
def write_log_entry(message) do
  timestamp = DateTime.utc_now() |> DateTime.to_iso8601()
  log_entry = "[#{timestamp}] #{message}\n"
  
  case File.write("app.log", log_entry, [:append]) do
    :ok -> {:ok, "Log entry written"}
    {:error, reason} -> {:error, "Failed to write log: #{reason}"}
  end
end
The [:append] option adds to the file instead of overwriting it.
Processes - Elixir's Concurrency Model
Elixir's processes are lightweight and isolated. They're not OS processes - they're managed by the BEAM virtual machine.
Spawning Processes
defmodule ProcessBasics do
  def spawn_example do
    pid = spawn(fn -> 
      IO.puts("Hello from process #{inspect(self())}")
    end)
    
    IO.puts("Spawned process: #{inspect(pid)}")
  end
end
The spawn function creates a new process that runs the given function. The process runs independently and can crash without affecting others.
Message Passing
Processes communicate by sending messages:
defmodule MessageExample do
  def start_receiver do
    spawn(fn -> 
      receive do
        {:hello, name} -> 
          IO.puts("Hello, #{name}!")
        {:goodbye, name} -> 
          IO.puts("Goodbye, #{name}!")
        _ -> 
          IO.puts("Unknown message")
      end
    end)
  end
  def send_messages do
    pid = start_receiver()
    send(pid, {:hello, "Alice"})
    send(pid, {:goodbye, "Alice"})
  end
end
The receive block waits for messages and pattern matches on them. The send function sends a message to a process.
Stateful Processes
Processes can maintain state by calling themselves recursively:
defmodule Counter do
  def start do
    spawn(fn -> counter_loop(0) end)
  end
  defp counter_loop(count) do
    receive do
      :increment -> 
        counter_loop(count + 1)
      {:get, caller} -> 
        send(caller, {:count, count})
        counter_loop(count)
      :stop -> 
        :ok
    end
  end
  def increment(pid) do
    send(pid, :increment)
  end
  def get_count(pid) do
    send(pid, {:get, self()})
    receive do
      {:count, value} -> value
    end
  end
end
This counter maintains its state by passing the count as a parameter to the recursive counter_loop function.
Mix - Your Project Manager
Mix is Elixir's build tool. It handles dependencies, compilation, testing, and more.
Creating a New Project
# Basic project
mix new my_app
# Project with supervision tree
mix new my_app --sup
# Umbrella project (multiple apps)
mix new my_umbrella --umbrella
The --sup flag creates a supervision tree, which we'll cover in the next article.
Project Structure
A typical Mix project looks like this:
my_app/
├── lib/                    # Your source code
│   ├── my_app.ex          # Main application module
│   └── my_app/            # Other modules
├── test/                   # Tests
│   ├── my_app_test.exs
│   └── test_helper.exs
├── mix.exs                 # Project configuration
├── mix.lock                # Locked dependency versions
└── README.md
Configuring Your Project
The mix.exs file is where you configure your project:
defmodule MyApp.MixProject do
  use Mix.Project
  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.14",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end
  def application do
    [
      extra_applications: [:logger],
      mod: {MyApp.Application, []}
    ]
  end
  defp deps do
    [
      # Add your dependencies here
      {:httpoison, "~> 2.0"},
      {:jason, "~> 1.4"}
    ]
  end
end
The project/0 function defines build settings, application/0 defines how your app starts, and deps/0 lists your dependencies.
Common Mix Commands
# Install dependencies
mix deps.get
# Compile your code
mix compile
# Run tests
mix test
# Start interactive shell with your app loaded
iex -S mix
# Format your code
mix format
# Generate documentation
mix docs
Testing Your Code
Elixir comes with excellent testing tools built-in.
Writing Tests
defmodule MyAppTest do
  use ExUnit.Case
  doctest MyApp
  test "greeting returns hello" do
    assert MyApp.greeting() == "Hello, World!"
  end
  test "greeting with name" do
    assert MyApp.greeting("Alice") == "Hello, Alice!"
  end
  test "division by zero returns error" do
    assert {:error, _} = MyApp.divide(10, 0)
  end
end
The test macro defines a test case. The assert macro checks that something is true and fails the test if it's not.
Test Setup
You can set up data that multiple tests need:
defmodule UserTest do
  use ExUnit.Case
  setup do
    user = %{name: "Alice", age: 30}
    {:ok, user: user}
  end
  test "user validation", %{user: user} do
    assert MyApp.validate_user(user) == :ok
  end
  test "user with invalid age", %{user: user} do
    invalid_user = %{user | age: -5}
    assert {:error, _} = MyApp.validate_user(invalid_user)
  end
end
The setup block runs before each test and provides data to the test function.
Documenting Your Code
Elixir makes it easy to write good documentation.
Module Documentation
defmodule MyApp do
  @moduledoc """
  A sample Elixir application.
  
  This module provides basic functionality for greeting users
  and performing mathematical operations.
  """
  @doc """
  Returns a greeting message.
  
  ## Examples
      
      iex> MyApp.greeting()
      "Hello, World!"
      
      iex> MyApp.greeting("Alice")
      "Hello, Alice!"
  """
  def greeting(name \\ "World") do
    "Hello, #{name}!"
  end
  @doc """
  Divides two numbers safely.
  
  Returns `{:ok, result}` on success or `{:error, reason}` on failure.
  """
  def divide(a, b) when b == 0, do: {:error, "Cannot divide by zero"}
  def divide(a, b), do: {:ok, a / b}
end
The @moduledoc documents the entire module, while @doc documents individual functions. The examples in the documentation can be run as tests with mix test.
What's Next?
You're getting comfortable with Elixir's advanced features! In the next article, we'll explore OTP (Open Telecom Platform), which is where Elixir really shines. We'll cover:
- GenServer for stateful processes
- Supervisors for fault tolerance
- Building web applications with Phoenix
- Working with databases using Ecto
Practice Exercises
Try these exercises to reinforce what you've learned:
- Recursion Practice: Write a function that finds the maximum value in a list using recursion
- List Comprehensions: Use list comprehensions to find all prime numbers up to 100
- Error Handling: Create a function that safely reads a JSON file and parses it
- Process Communication: Build a simple chat system using processes and message passing
Key Points
- Recursion is the primary way to handle loops in functional programming
- Tail recursion is more efficient and prevents stack overflow
- List comprehensions provide elegant data transformation
- Pattern matching is preferred over exceptions for error handling
- The withexpression chains operations that might fail
- Processes provide lightweight concurrency
- Mix manages your project's dependencies and build process
- ExUnit provides comprehensive testing tools
- Good documentation includes examples that can be run as tests
- Thinking in Recursion
- Why Recursion?
- Working with Lists
- Tail Recursion - The Efficient Way
- List Comprehensions - Elegant Data Transformation
- Basic Comprehensions
- Adding Filters
- Multiple Generators
- Pattern Matching in Comprehensions
- Error Handling - The Elixir Way
- When Things Go Wrong
- Using Pattern Matching
- The `with` Expression
- Exceptions for Exceptional Cases
- Try-Rescue for External Code
- Working with Files
- Reading Files
- Writing Files
- Processes - Elixir's Concurrency Model
- Spawning Processes
- Message Passing
- Stateful Processes
- Mix - Your Project Manager
- Creating a New Project
- Project Structure
- Configuring Your Project
- Common Mix Commands
- Testing Your Code
- Writing Tests
- Test Setup
- Documenting Your Code
- Module Documentation
- What's Next?
- Practice Exercises
- Key Points