Powered by AppSignal & Oban Pro

Functions & Modules Practice

05-functions-modules.livemd

Functions & Modules Practice

Mix.install([])

Introduction

Master functions and modules - the building blocks of Elixir applications. Learn anonymous functions, named functions, pattern matching in functions, and module organization.

Part 1: Anonymous Functions

Basic Anonymous Functions

# Exercise 1.1: Create and use anonymous functions
add = fn a, b -> a + b end
multiply = fn a, b -> a * b end

IO.puts("5 + 3 = #{add.(5, 3)}")
IO.puts("5 * 3 = #{multiply.(5, 3)}")

# Note the dot notation: add.(5, 3)
# Exercise 1.2: Capture operator shorthand
add_short = &(&1 + &2)
multiply_short = &(&1 * &2)
double = &(&1 * 2)

IO.puts("Using capture: #{add_short.(5, 3)}")
IO.puts("Double: #{double.(21)}")
# Exercise 1.3: Multi-clause anonymous functions
handle_response = fn
  {:ok, data} -> "Success: #{data}"
  {:error, :timeout} -> "Request timed out"
  {:error, :not_found} -> "Resource not found"
  {:error, reason} -> "Error: #{reason}"
end

# Test it:
IO.puts(handle_response.({:ok, "deployed"}))
IO.puts(handle_response.({:error, :timeout}))
IO.puts(handle_response.({:error, "connection refused"}))
# Exercise 1.4: Functions as arguments
numbers = [1, 2, 3, 4, 5]

# Pass functions to Enum
doubled = Enum.map(numbers, &(&1 * 2))
evens = Enum.filter(numbers, &(rem(&1, 2) == 0))

IO.inspect(doubled, label: "Doubled")
IO.inspect(evens, label: "Evens")

# Custom function
custom_transform = fn x -> x * 3 + 1 end
transformed = Enum.map(numbers, custom_transform)
IO.inspect(transformed, label: "Custom transform")

Part 2: Named Functions in Modules

Basic Module Structure

defmodule Calculator do
  # Public functions
  def add(a, b), do: a + b
  def subtract(a, b), do: a - b

  def multiply(a, b) do
    a * b
  end

  def divide(a, 0), do: {:error, :division_by_zero}
  def divide(a, b), do: {:ok, a / b}
end

# Test it:
IO.puts("10 + 5 = #{Calculator.add(10, 5)}")
IO.puts("10 - 5 = #{Calculator.subtract(10, 5)}")

case Calculator.divide(10, 0) do
  {:ok, result} -> IO.puts("Result: #{result}")
  {:error, :division_by_zero} -> IO.puts("Cannot divide by zero!")
end

Pattern Matching in Functions

defmodule ServerManager do
  # Pattern match on different inputs
  def start_server(%{name: name, port: port}) do
    IO.puts("Starting #{name} on port #{port}")
    {:ok, :started}
  end

  def stop_server(%{name: name, pid: pid}) when is_pid(pid) do
    IO.puts("Stopping #{name} (PID: #{inspect(pid)})")
    {:ok, :stopped}
  end

  def check_status(:running), do: "Server is running"
  def check_status(:stopped), do: "Server is stopped"
  def check_status(:degraded), do: "Server is degraded"
  def check_status(_), do: "Unknown status"
end

# Test it:
ServerManager.start_server(%{name: "api", port: 8080})
ServerManager.stop_server(%{name: "api", pid: self()})
IO.puts(ServerManager.check_status(:running))

Guards in Functions

defmodule PortValidator do
  def validate(port) when port < 1 do
    {:error, "Port must be positive"}
  end

  def validate(port) when port > 65535 do
    {:error, "Port must be <= 65535"}
  end

  def validate(port) when port < 1024 do
    {:warning, "Port #{port} is privileged"}
  end

  def validate(port) when is_integer(port) do
    {:ok, port}
  end

  def validate(_) do
    {:error, "Port must be an integer"}
  end
end

# Test it:
test_ports = [0, 80, 8080, 70000, "8080", 8080.5]

Enum.each(test_ports, fn port ->
  result = PortValidator.validate(port)
  IO.puts("#{inspect(port)} -> #{inspect(result)}")
end)

Default Arguments

defmodule HTTPClient do
  def get(url, timeout \\ 5000, retries \\ 3) do
    IO.puts("GET #{url} (timeout: #{timeout}ms, retries: #{retries})")
    {:ok, "response"}
  end

  def post(url, body, headers \\ [], timeout \\ 5000) do
    IO.puts("POST #{url}")
    IO.puts("Body: #{body}")
    IO.puts("Headers: #{inspect(headers)}")
    IO.puts("Timeout: #{timeout}ms")
    {:ok, "response"}
  end
end

# Test with different argument combinations:
HTTPClient.get("https://api.example.com")
HTTPClient.get("https://api.example.com", 10_000)
HTTPClient.get("https://api.example.com", 10_000, 5)

HTTPClient.post("https://api.example.com", "data")
HTTPClient.post("https://api.example.com", "data", [{"Content-Type", "application/json"}])

Part 3: Private Functions

defmodule DeploymentManager do
  # Public API
  def deploy(service, version) do
    with :ok <- validate_version(version),
         :ok <- check_permissions(),
         {:ok, _} <- perform_deployment(service, version) do
      notify_success(service, version)
      {:ok, :deployed}
    else
      {:error, reason} -> {:error, reason}
    end
  end

  # Private helper functions
  defp validate_version(version) do
    if String.match?(version, ~r/^\d+\.\d+\.\d+$/), do: :ok, else: {:error, :invalid_version}
  end

  defp check_permissions do
    # Simulate permission check
    :ok
  end

  defp perform_deployment(service, version) do
    IO.puts("Deploying #{service} v#{version}...")
    {:ok, :done}
  end

  defp notify_success(service, version) do
    IO.puts("✓ Successfully deployed #{service} v#{version}")
  end
end

# Can only call public function:
DeploymentManager.deploy("api", "1.2.3")

# This would fail:
# DeploymentManager.validate_version("1.0.0")  # ** (UndefinedFunctionError)

Part 4: Pipe Operator with Functions

defmodule LogProcessor do
  def parse(line) do
    String.split(line, " ", parts: 3)
  end

  def extract_level([_timestamp, level, _message]) do
    String.trim(level, ":")
  end

  def extract_message([_timestamp, _level, message]) do
    message
  end

  def classify_severity("ERROR"), do: :critical
  def classify_severity("WARN"), do: :warning
  def classify_severity("INFO"), do: :info
  def classify_severity(_), do: :unknown
end

# Process a log line with pipe operator:
log_line = "2024-11-01 ERROR: Database connection failed"

severity =
  log_line
  |> LogProcessor.parse()
  |> LogProcessor.extract_level()
  |> LogProcessor.classify_severity()

IO.puts("Severity: #{severity}")

message =
  log_line
  |> LogProcessor.parse()
  |> LogProcessor.extract_message()

IO.puts("Message: #{message}")

Challenge Exercises

Challenge 1: Service Health Checker

defmodule HealthChecker do
  def check(url, max_retries \\ 3)

  def check(_url, 0) do
    {:error, :max_retries_exceeded}
  end

  def check(url, retries) do
    # Simulate health check
    case :rand.uniform(3) do
      1 ->
        {:ok, :healthy}

      2 ->
        {:error, :degraded}

      3 ->
        IO.puts("Retry #{4 - retries}...")
        Process.sleep(100)
        check(url, retries - 1)
    end
  end

  def check_multiple(urls) do
    Enum.map(urls, fn url ->
      {url, check(url)}
    end)
  end
end

# Test it:
result = HealthChecker.check("http://api.example.com")
IO.inspect(result, label: "Single check")

results = HealthChecker.check_multiple(["http://api1.com", "http://api2.com"])
IO.inspect(results, label: "Multiple checks")

Challenge 2: Configuration Builder

defmodule ConfigBuilder do
  defstruct database: %{}, cache: %{}, api: %{}

  def new do
    %__MODULE__{}
  end

  def with_database(config, opts \\ []) do
    db_config = %{
      host: Keyword.get(opts, :host, "localhost"),
      port: Keyword.get(opts, :port, 5432),
      pool_size: Keyword.get(opts, :pool_size, 10)
    }

    %{config | database: db_config}
  end

  def with_cache(config, opts \\ []) do
    cache_config = %{
      host: Keyword.get(opts, :host, "localhost"),
      port: Keyword.get(opts, :port, 6379)
    }

    %{config | cache: cache_config}
  end

  def with_api(config, opts \\ []) do
    api_config = %{
      port: Keyword.get(opts, :port, 4000),
      timeout: Keyword.get(opts, :timeout, 5000)
    }

    %{config | api: api_config}
  end

  def build(config) do
    IO.puts("Building configuration...")
    IO.inspect(config, label: "Final config")
    {:ok, config}
  end
end

# Use it with pipe operator:
{:ok, config} =
  ConfigBuilder.new()
  |> ConfigBuilder.with_database(host: "db.example.com", pool_size: 20)
  |> ConfigBuilder.with_cache(port: 6380)
  |> ConfigBuilder.with_api(port: 8080, timeout: 10_000)
  |> ConfigBuilder.build()

Challenge 3: Deployment State Machine

defmodule DeploymentStateMachine do
  def transition(:pending, :start), do: {:ok, :building}
  def transition(:building, :build_complete), do: {:ok, :testing}
  def transition(:testing, :tests_pass), do: {:ok, :deploying}
  def transition(:deploying, :deploy_complete), do: {:ok, :verifying}
  def transition(:verifying, :health_check_pass), do: {:ok, :complete}

  def transition(:building, :build_fail), do: {:ok, :failed}
  def transition(:testing, :tests_fail), do: {:ok, :failed}
  def transition(:deploying, :deploy_fail), do: {:ok, :rollback}
  def transition(:verifying, :health_check_fail), do: {:ok, :rollback}

  def transition(state, _event), do: {:error, "Invalid transition from #{state}"}

  def execute_workflow(events, initial_state \\ :pending) do
    Enum.reduce_while(events, initial_state, fn event, state ->
      case transition(state, event) do
        {:ok, new_state} ->
          IO.puts("#{state} --#{event}--> #{new_state}")
          {:cont, new_state}

        {:error, reason} ->
          IO.puts("Error: #{reason}")
          {:halt, state}
      end
    end)
  end
end

# Test successful deployment:
success_events = [:start, :build_complete, :tests_pass, :deploy_complete, :health_check_pass]
final_state = DeploymentStateMachine.execute_workflow(success_events)
IO.puts("\nFinal state: #{final_state}\n")

# Test failed deployment:
failure_events = [:start, :build_complete, :tests_fail]
failed_state = DeploymentStateMachine.execute_workflow(failure_events)
IO.puts("\nFinal state: #{failed_state}")

Practice Problems

Problem 1: Retry with Exponential Backoff

defmodule RetryHelper do
  def with_backoff(fun, max_attempts \\ 3, initial_delay \\ 100) do
    do_retry(fun, 1, max_attempts, initial_delay)
  end

  defp do_retry(fun, attempt, max_attempts, _delay) when attempt > max_attempts do
    {:error, :max_retries_exceeded}
  end

  defp do_retry(fun, attempt, max_attempts, delay) do
    case fun.() do
      {:ok, result} ->
        {:ok, result}

      {:error, _reason} when attempt < max_attempts ->
        IO.puts("Attempt #{attempt} failed, retrying in #{delay}ms...")
        Process.sleep(delay)
        do_retry(fun, attempt + 1, max_attempts, delay * 2)

      {:error, reason} ->
        {:error, reason}
    end
  end
end

# Test with simulated flaky function:
flaky_operation = fn ->
  if :rand.uniform(2) == 1 do
    {:ok, "success"}
  else
    {:error, :temporary_failure}
  end
end

result = RetryHelper.with_backoff(flaky_operation, 5, 50)
IO.inspect(result, label: "Retry result")

Problem 2: Function Composition

defmodule FunctionComposer do
  def compose(f, g) do
    fn x -> f.(g.(x)) end
  end

  def pipe_functions(value, functions) do
    Enum.reduce(functions, value, fn fun, acc ->
      fun.(acc)
    end)
  end
end

# Test composition:
double = &amp;(&amp;1 * 2)
add_ten = &amp;(&amp;1 + 10)

composed = FunctionComposer.compose(double, add_ten)
result = composed.(5)
IO.puts("(5 + 10) * 2 = #{result}")

# Test piping:
functions = [
  &amp;(&amp;1 * 2),
  &amp;(&amp;1 + 10),
  &amp;(&amp;1 / 2)
]

piped_result = FunctionComposer.pipe_functions(5, functions)
IO.puts("Piped: #{piped_result}")

Key Takeaways

Anonymous functions: Use fn or &() syntax ✓ Named functions: Define in modules with defPattern matching: Multiple function clauses ✓ Guards: Add conditions with whenPrivate functions: Use defp for internal use ✓ Pipe operator: Chain function calls elegantly ✓ Default arguments: Provide sensible defaults ✓ Recursion: The Elixir way to loop

Next Steps

  1. Build a complete module for a real problem
  2. Practice function composition
  3. Master recursion patterns
  4. Learn about higher-order functions

Excellent work! You’re ready to build real applications! 🚀