
- 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
with
expression 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