Chapter 07: 副作用と純粋関数¶
Elixir における副作用の管理と純粋関数の分離について学びます。
概要¶
関数型プログラミングでは、副作用(I/O、状態変更、時間、乱数)を純粋なビジネスロジックから分離することが重要です。このチャプターでは、その設計パターンを紹介します。
主なトピック¶
- 純粋関数と副作用の分離
- Functional Core, Imperative Shell
- 副作用の遅延実行
- Reader パターン
- 依存性注入
純粋関数 vs 副作用¶
純粋関数¶
- 同じ入力に対して常に同じ出力
- 外部状態を変更しない
# 純粋関数の例
def add(a, b), do: a + b
def upcase(s), do: String.upcase(s)
副作用を持つ関数¶
- I/O(コンソール、ファイル、ネットワーク)
- 状態変更
- 現在時刻の取得
- 乱数生成
# 副作用を持つ関数
def current_time, do: DateTime.utc_now() # 非決定的
def random_number, do: :rand.uniform() # 非決定的
def print(msg), do: IO.puts(msg) # I/O
Functional Core, Imperative Shell¶
ビジネスロジックを純粋関数として実装し、副作用は境界層で処理します。
# Functional Core(純粋関数)
defmodule PricingCore do
def apply_discount(price, %{type: :percentage, value: pct}) do
price * (1 - pct / 100)
end
def calculate_total(products) do
products |> Enum.map(& &1.price) |> Enum.sum()
end
end
# Imperative Shell(副作用を扱う)
defmodule PricingShell do
def calculate_order_price(products, discount_fetcher, discount_code, tax_rate) do
# 副作用:外部から割引情報を取得
discount = discount_fetcher.(discount_code)
# 純粋関数でビジネスロジックを実行
PricingCore.calculate_final_price(products, discount, tax_rate)
end
end
副作用の遅延実行(Effect as Data)¶
副作用をデータとして表現し、実行を遅延させます。
defmodule Effect do
def console_log(message) do
{:effect, :console_log, message, fn _ -> :ok end}
end
def get_current_time do
{:effect, :get_time, nil, fn time -> time end}
end
# 本番用インタープリター
def run({:effect, :console_log, message, cont}) do
IO.puts(message)
cont.(:ok)
end
# テスト用インタープリター
def run_test({:effect, effect_type, _, cont}, mock_values) do
cont.(Map.get(mock_values, effect_type))
end
end
Reader パターン¶
環境(設定、依存性)を引数として渡す関数をラップします。
defmodule Reader do
def pure(value), do: fn _env -> value end
def ask, do: fn env -> env end
def asks(selector), do: fn env -> selector.(env) end
def flat_map(reader, f) do
fn env -> f.(reader.(env)).(env) end
end
def run(reader, env), do: reader.(env)
end
# 使用例
def get_db_url do
Reader.asks(fn config -> config.db_url end)
end
def build_service do
Reader.flat_map(get_db_url(), fn db_url ->
Reader.flat_map(get_api_key(), fn api_key ->
Reader.pure(%{db: db_url, key: api_key, ready: true})
end)
end)
end
時間の抽象化¶
時間依存をテスト可能にします。
defmodule TimeService do
def add_timestamp(data, time_provider) do
Map.put(data, :timestamp, time_provider.())
end
def is_expired?(expiry_date, time_provider) do
DateTime.compare(time_provider.(), expiry_date) == :gt
end
end
# テスト
test "is_expired? は期限切れを判定する" do
now = ~U[2024-01-15 12:00:00Z]
provider = fn -> now end
assert TimeService.is_expired?(~U[2024-01-10 12:00:00Z], provider) == true
assert TimeService.is_expired?(~U[2024-01-20 12:00:00Z], provider) == false
end
まとめ¶
- 純粋関数でビジネスロジックを実装
- Functional Core, Imperative Shellで副作用を分離
- 依存性注入でテスト可能な設計
- Reader パターンで環境を引き回す