Powered by AppSignal & Oban Pro

Collections & Data Structures

03-collections-exercises.livemd

Collections & Data Structures

Mix.install([])

Introduction

Master Elixir’s data structures through hands-on exercises. Learn lists, tuples, maps, and how to manipulate them efficiently.

Part 1: Lists

Basic List Operations

# Exercise 1.1: Create and manipulate lists
servers = ["web-1", "web-2", "web-3"]

# TODO: Add a new server at the front (prepend)
servers = ["web-0" | servers]
IO.inspect(servers, label: "After prepend")

# TODO: Add servers at the end (append)
servers = servers ++ ["web-4", "web-5"]
IO.inspect(servers, label: "After append")

# TODO: Remove "web-2" from the list
servers = servers -- ["web-2"]
IO.inspect(servers, label: "After removal")
# Exercise 1.2: List head and tail
numbers = [1, 2, 3, 4, 5]

# TODO: Get first element and rest
[head | tail] = numbers
IO.puts("Head: #{head}")
IO.inspect(tail, label: "Tail")

# TODO: Get first two elements
[first, second | rest] = numbers
IO.puts("First: #{first}, Second: #{second}")
IO.inspect(rest, label: "Rest")
# Exercise 1.3: Check membership
ports = [80, 443, 8080, 8443, 3000]

# TODO: Check if port 8080 is in the list
has_8080 = 8080 in ports
IO.puts("Has 8080: #{has_8080}")

# TODO: Check if port 9000 is in the list
has_9000 = 9000 in ports
IO.puts("Has 9000: #{has_9000}")

Part 2: Tuples

# Exercise 2.1: Working with tuples
response = {:ok, 200, "Success", %{data: "some data"}}

# TODO: Get the status code (2nd element)
status_code = elem(response, 1)
IO.puts("Status code: #{status_code}")

# TODO: Get tuple size
size = tuple_size(response)
IO.puts("Tuple size: #{size}")

# TODO: Update the status code to 201
response = put_elem(response, 1, 201)
IO.inspect(response, label: "Updated response")
# Exercise 2.2: Common tuple patterns
defmodule ResponseHandler do
  # TODO: Handle different response tuples
  def handle({:ok, data}), do: "Success: #{data}"
  def handle({:error, reason}), do: "Error: #{reason}"
  def handle({:ok, status, data}), do: "Success #{status}: #{data}"
  def handle(_), do: "Unknown response"
end

# Test it:
ResponseHandler.handle({:ok, "deployed"}) |> IO.puts()
ResponseHandler.handle({:error, "timeout"}) |> IO.puts()
ResponseHandler.handle({:ok, 200, "OK"}) |> IO.puts()

Part 3: Maps

# Exercise 3.1: Creating and accessing maps
server = %{
  name: "web-1",
  ip: "10.0.0.1",
  port: 8080,
  status: :running
}

# TODO: Access values using different methods
name1 = server[:name]
name2 = server.name
IO.puts("Name (bracket): #{name1}, Name (dot): #{name2}")

# TODO: Get value with default
memory = Map.get(server, :memory, "Unknown")
IO.puts("Memory: #{memory}")
# Exercise 3.2: Updating maps
server = %{name: "web-1", port: 8080, status: :running}

# TODO: Update existing key
server = %{server | status: :stopped}
IO.inspect(server, label: "After status update")

# TODO: Add new key (must use Map.put)
server = Map.put(server, :memory_mb, 512)
IO.inspect(server, label: "After adding memory")

# TODO: Update with function
server =
  Map.update(server, :port, 8080, fn current_port ->
    current_port + 1
  end)

IO.inspect(server, label: "After port increment")
# Exercise 3.3: Merging maps
default_config = %{
  timeout: 5000,
  retries: 3,
  pool_size: 10
}

user_config = %{
  timeout: 10_000,
  pool_size: 20
}

# TODO: Merge configs (user_config overwrites defaults)
final_config = Map.merge(default_config, user_config)
IO.inspect(final_config, label: "Merged config")
# Exercise 3.4: Map operations
services = %{
  "api" => :healthy,
  "web" => :degraded,
  "db" => :healthy,
  "cache" => :healthy
}

# TODO: Get all keys
keys = Map.keys(services)
IO.inspect(keys, label: "Services")

# TODO: Get all values
values = Map.values(services)
IO.inspect(values, label: "Statuses")

# TODO: Filter map to only healthy services
healthy_services =
  services
  |> Enum.filter(fn {_service, status} -> status == :healthy end)
  |> Map.new()

IO.inspect(healthy_services, label: "Healthy services")

Part 4: Keyword Lists

# Exercise 4.1: Keyword list basics
options = [host: "localhost", port: 5432, timeout: 5000, pool_size: 10]

# TODO: Access value
port = options[:port]
IO.puts("Port: #{port}")

# TODO: Get value with default
username = Keyword.get(options, :username, "postgres")
IO.puts("Username: #{username}")

# TODO: Add new value
options = Keyword.put(options, :password, "secret")
IO.inspect(options, label: "With password")
# Exercise 4.2: Keyword lists allow duplicates
config = [debug: true, debug: false, timeout: 5000]

# TODO: Get first value
first_debug = Keyword.get(config, :debug)
IO.puts("First debug: #{first_debug}")

# TODO: Get all values for a key
all_debug = Keyword.get_values(config, :debug)
IO.inspect(all_debug, label: "All debug values")

Part 5: Nested Data Structures

# Exercise 5.1: Working with nested maps
cluster = %{
  name: "prod-cluster",
  region: "us-east-1",
  nodes: [
    %{id: "node-1", ip: "10.0.0.1", status: :healthy},
    %{id: "node-2", ip: "10.0.0.2", status: :healthy},
    %{id: "node-3", ip: "10.0.0.3", status: :degraded}
  ]
}

# TODO: Get the IP of the first node
first_node_ip = cluster.nodes |> List.first() |> Map.get(:ip)
IO.puts("First node IP: #{first_node_ip}")

# TODO: Count healthy nodes
healthy_count =
  cluster.nodes
  |> Enum.count(fn node -> node.status == :healthy end)

IO.puts("Healthy nodes: #{healthy_count}")

# TODO: Get all node IPs
node_ips =
  cluster.nodes
  |> Enum.map(& &1.ip)

IO.inspect(node_ips, label: "Node IPs")
# Exercise 5.2: Update nested structures
deployment = %{
  service: "api",
  version: "1.0.0",
  config: %{
    replicas: 3,
    resources: %{
      cpu: "500m",
      memory: "256Mi"
    }
  }
}

# TODO: Update memory using put_in
deployment = put_in(deployment, [:config, :resources, :memory], "512Mi")
IO.inspect(deployment.config.resources.memory, label: "Updated memory")

# TODO: Update replicas using update_in
deployment =
  update_in(deployment, [:config, :replicas], fn replicas ->
    replicas + 2
  end)

IO.inspect(deployment.config.replicas, label: "Updated replicas")

Challenge Exercises

Challenge 1: Server Inventory

defmodule ServerInventory do
  # Create a module to manage server inventory

  def new do
    %{servers: []}
  end

  def add_server(inventory, server) do
    %{inventory | servers: [server | inventory.servers]}
  end

  def list_servers(inventory) do
    inventory.servers
  end

  def find_by_name(inventory, name) do
    Enum.find(inventory.servers, fn server -> server.name == name end)
  end

  def count_by_status(inventory, status) do
    Enum.count(inventory.servers, fn server -> server.status == status end)
  end

  def total_memory(inventory) do
    Enum.reduce(inventory.servers, 0, fn server, acc ->
      acc + Map.get(server, :memory_mb, 0)
    end)
  end
end

# Test it:
inventory = ServerInventory.new()

inventory =
  inventory
  |> ServerInventory.add_server(%{name: "web-1", status: :healthy, memory_mb: 512})
  |> ServerInventory.add_server(%{name: "web-2", status: :healthy, memory_mb: 512})
  |> ServerInventory.add_server(%{name: "db-1", status: :degraded, memory_mb: 2048})

IO.inspect(ServerInventory.list_servers(inventory), label: "All servers")
IO.puts("Healthy servers: #{ServerInventory.count_by_status(inventory, :healthy)}")
IO.puts("Total memory: #{ServerInventory.total_memory(inventory)}MB")

Challenge 2: Configuration Manager

defmodule ConfigManager do
  # Manage application configuration with defaults

  def load(overrides \\ %{}) do
    defaults()
    |> deep_merge(overrides)
  end

  defp defaults do
    %{
      database: %{
        host: "localhost",
        port: 5432,
        pool_size: 10
      },
      cache: %{
        host: "localhost",
        port: 6379,
        ttl: 3600
      },
      api: %{
        timeout: 5000,
        retries: 3
      }
    }
  end

  defp deep_merge(left, right) do
    Map.merge(left, right, fn _key, left_val, right_val ->
      case {left_val, right_val} do
        {%{} = l, %{} = r} -> deep_merge(l, r)
        {_, r} -> r
      end
    end)
  end
end

# Test it:
config =
  ConfigManager.load(%{
    database: %{pool_size: 20},
    api: %{timeout: 10_000}
  })

IO.inspect(config, label: "Final config")

Challenge 3: Log Aggregator

defmodule LogAggregator do
  # Aggregate logs by level and service

  def aggregate(logs) do
    logs
    |> Enum.group_by(& &1.level)
    |> Map.new(fn {level, level_logs} ->
      by_service =
        level_logs
        |> Enum.group_by(& &1.service)
        |> Map.new(fn {service, service_logs} ->
          {service, length(service_logs)}
        end)

      {level, by_service}
    end)
  end

  def top_errors(logs, n \\ 5) do
    logs
    |> Enum.filter(&(&1.level == :error))
    |> Enum.group_by(& &1.service)
    |> Enum.map(fn {service, errors} -> {service, length(errors)} end)
    |> Enum.sort_by(fn {_service, count} -> count end, :desc)
    |> Enum.take(n)
  end
end

# Test data:
logs = [
  %{level: :info, service: "api", message: "Request processed"},
  %{level: :error, service: "api", message: "Connection failed"},
  %{level: :error, service: "api", message: "Timeout"},
  %{level: :warn, service: "db", message: "Slow query"},
  %{level: :error, service: "db", message: "Connection lost"},
  %{level: :info, service: "web", message: "Page served"}
]

aggregated = LogAggregator.aggregate(logs)
IO.inspect(aggregated, label: "Aggregated logs")

top_errors = LogAggregator.top_errors(logs, 2)
IO.inspect(top_errors, label: "Top error services")

Practice Problems

Solve these on your own:

Problem 1: Deployment Tracker

defmodule DeploymentTracker do
  # Track deployments with history
  # Each deployment: %{service: ..., version: ..., timestamp: ..., status: ...}

  def new, do: %{deployments: []}

  def add_deployment(tracker, deployment) do
    deployment = Map.put(deployment, :timestamp, DateTime.utc_now())
    %{tracker | deployments: [deployment | tracker.deployments]}
  end

  def latest_for_service(tracker, service) do
    tracker.deployments
    |> Enum.filter(&(&1.service == service))
    |> Enum.sort_by(& &1.timestamp, {:desc, DateTime})
    |> List.first()
  end

  def success_rate(tracker, service) do
    service_deployments =
      tracker.deployments
      |> Enum.filter(&(&1.service == service))

    total = length(service_deployments)
    successful = Enum.count(service_deployments, &(&1.status == :success))

    if total > 0 do
      Float.round(successful / total * 100, 2)
    else
      0.0
    end
  end
end

# Test it:
tracker = DeploymentTracker.new()

tracker =
  tracker
  |> DeploymentTracker.add_deployment(%{service: "api", version: "1.0", status: :success})
  |> DeploymentTracker.add_deployment(%{service: "api", version: "1.1", status: :failed})
  |> DeploymentTracker.add_deployment(%{service: "api", version: "1.2", status: :success})

latest = DeploymentTracker.latest_for_service(tracker, "api")
IO.inspect(latest, label: "Latest API deployment")

rate = DeploymentTracker.success_rate(tracker, "api")
IO.puts("API success rate: #{rate}%")

Key Takeaways

Lists: Fast prepend, sequential access ✓ Tuples: Fixed size, fast random access ✓ Maps: Key-value, fast lookup ✓ Keyword Lists: Options, allow duplicates ✓ Nested structures: Use get_in, put_in, update_in

Next Steps

Continue with:

  1. Enum Module Deep Dive
  2. Functions & Modules Practice
  3. Real-world data structure challenges

Excellent work! 🚀