Day 4 of 5
⏱ ~60 minutes
Elixir in 5 Days — Day 4

Functional Patterns & Metaprogramming

Elixir inherits Erlang's functional patterns and adds powerful metaprogramming via macros. Today covers Enum and Stream for data processing, protocols for polymorphism, and macros that extend the language itself.

Enum and Stream

Enum processes collections eagerly — all at once. Stream processes lazily — one element at a time, on demand. Enum.map/filter/reduce/sort/group_by cover most data processing needs. For large collections or infinite streams, Stream.map/filter/take work the same way but only pull elements when needed. 'Stream.cycle([1,2,3]) |> Enum.take(10)' produces [1,2,3,1,2,3,1,2,3,1] without ever creating an infinite list in memory.

Protocols: Polymorphism

A Protocol defines an interface that different data types implement. Protocol.derive/2 can auto-derive implementations. Built-in protocols: Enumerable (makes a type work with Enum), Collectable (can receive elements), Inspect (custom iex display), String.Chars (to_string conversion). Define: 'defprotocol Serializable do; def serialize(term); end'. Implement: 'defimpl Serializable, for: MyStruct do...'.

Macros

Elixir macros run at compile time and generate AST nodes. defmacro defines a macro. quote/2 captures code as AST. unquote/1 injects values into quoted expressions. Macros are why Elixir can implement features like defmodule, def, if, and use as library code, not built-in syntax. Rule: use functions when possible; reach for macros only when you need to transform syntax or generate repetitive code.

elixir
# Enum vs Stream comparison
# Enum: eager, processes all at once
1..1_000_000
|> Enum.map(&(&1 * 2))
|> Enum.filter(&(rem(&1, 3) == 0))
|> Enum.take(5)
# Creates 1M intermediate list

# Stream: lazy, only computes what's needed
1..1_000_000
|> Stream.map(&(&1 * 2))
|> Stream.filter(&(rem(&1, 3) == 0))
|> Enum.take(5)  # Triggers evaluation
# [6, 12, 18, 24, 30] -- O(n) not O(n^2)

# Protocol example
defprotocol Area do
  def calculate(shape)
end

defimpl Area, for: %{type: :circle} do
  def calculate(%{r: r}), do: :math.pi() * r * r
end

# Macro example: unless (inverse of if)
defmacro unless(condition, do: block) do
  quote do
    if !unquote(condition), do: unquote(block)
  end
end

unless 1 == 2 do
  IO.puts "1 is not 2"
end
💡
Prefer Stream over Enum when: (1) the collection is large (100K+ elements), (2) you only need part of the result (take/N), or (3) you are reading from a file or network. For small collections, Enum is simpler and fast enough.
📝 Day 4 Exercise
Process a Large Dataset with Stream
  1. Generate a CSV file with 500,000 rows using a script
  2. Read it lazily with File.stream! and pipe through Stream.map/filter
  3. Compute the sum and average of a numeric column without loading all rows into memory
  4. Compare memory usage with Enum.to_list (eager) vs Stream (lazy) using :erlang.memory/0
  5. Define a protocol Describable with a describe/1 function and implement it for 3 custom structs

Day 4 Summary

  • Enum is eager (processes all at once); Stream is lazy (on demand)
  • Stream.map/filter chain with Enum.take avoids creating large intermediate lists
  • Protocols define polymorphic interfaces across different data types
  • Macros run at compile time and generate AST — use sparingly
  • File.stream! reads files lazily — essential for processing files larger than RAM
Challenge

Process a 1GB CSV log file using Stream: parse each line, filter for ERROR entries, extract the timestamp and message, group by hour, and write a summary report. Measure peak memory usage.

Finished this lesson?