関数型プログラミング
目次
- 概要
- 基本要素
- 純粋関数
- 参照透過性
- 副作用の境界
- 副作用を型で追う発想
- 不変データ
- 遅延評価と正格評価
- map / filter / reduce
- foldで考える
- パターンマッチと代数的データ型
- エラーを値として扱う
- OOPとの関係
- 関数型とアーキテクチャ
- 実務での効きどころ
- 実務での取り入れ方
- よくある誤解
- 関数型プログラミングの実践パターン
- 関数型プログラミングのアンチパターン
- 関数型プログラミングと並行処理
- 関数型ライブラリの活用
- 関数型言語での型安全性
- 実務での関数型言語採用
- Lazy Evaluation のトレードオフ
- Functional Programming Language の選択
- 関数型プログラミングの応用領域
- 関数型の現在地
- 関数型プログラミングの実装パターン
- 関数合成とポイントフリースタイル
- 遅延評価と無限リスト
- 型システムと型クラス
- まとめ
- 参考文献
概要
変更しにくい状態を減らし、変換を組み合わせる
関数型プログラミングは、すべてを数学のように扱うための特殊な流派ではありません。副作用と状態変更をできるだけ制御し、入力から出力への変換を組み合わせやすくする考え方です。
関数型の価値は、純粋性そのものよりも、コードの局所性と推論しやすさにあります。状態変更を減らすと、変更の影響が追いやすくなります。
この章で重視すること
基本要素
- 純粋関数 同じ入力なら同じ出力
- 不変性 既存値を破壊せず新しい値を作る
- 高階関数 関数を値として渡す
- 合成 小さな変換を連結する
純粋関数
純粋関数は、同じ入力なら同じ出力を返し、外部状態を変えません。純粋関数を増やすと、テストが簡単になり、並列化もしやすくなります。
const total = (items) =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0);
この関数はDB、時刻、乱数、グローバル変数に依存していないため、入力だけを見れば結果が決まります。
純粋関数の価値は、数学的に美しいことだけではありません。入力と出力だけを見ればよいため、レビュー、テスト、再利用が簡単になります。逆に、時刻、乱数、DB、HTTP、global stateに直接触る関数は、呼び出すたびに結果や副作用が変わります。
| 関数 | 扱いやすさ |
|---|---|
price(items, coupon) |
入力だけで結果を確認できる |
priceFromCurrentCart() |
global stateに依存する |
saveAndCalculate() |
計算と保存が混ざる |
実務では、すべてを純粋にするのではなく、業務ロジックの中心を純粋関数に寄せ、副作用を境界へ追い出します。
参照透過性
参照透過性とは、式をその結果の値で置き換えてもプログラムの意味が変わらない性質です。純粋関数が扱いやすいのは、この性質により局所的な推論ができるからです。
const tax = (price) => price * 0.1;
const total = (price) => price + tax(price);
tax(100) は常に 10 なので、置き換えても意味が変わりません。一方、現在時刻、乱数、DB、グローバル変数に依存する関数は、呼び出すたびに結果が変わる可能性があります。
副作用の境界
実用的なプログラムでは副作用を完全には消せません。ファイル、ネットワーク、DB、ログ、時刻、乱数は必要です。大事なのは副作用を端に寄せ、コアロジックを純粋に保つことです。
この形にすると、中心部分をテストしやすくなります。
副作用の境界を分けると、アプリケーションの構造も見えやすくなります。
この形にすると、Core は単体テストしやすく、Effect はmockやintegration testで確認できます。副作用を中心へ混ぜると、簡単な計算でもDBや外部APIを用意しないと試せなくなります。
副作用を型で追う発想
Haskellの IO や、Scala/ZIO、Cats Effectのようなライブラリでは、副作用を値として表し、いつ実行するかを制御します。これは副作用を消すためではなく、実行境界を明示するための設計です。
実務ではそこまで厳密にしなくても、外部I/Oをinterfaceの外側へ集めるだけで同じ方向の効果があります。
不変データ
不変データは、値を直接変更せず新しい値を作る考え方です。
const updated = users.map((user) =>
user.id === targetId ? { ...user, active: false } : user
);
変更前後の値が残るため、履歴、差分、再試行、並行処理と相性がよくなります。
不変データは、履歴や差分を扱う場面でも効きます。元の値を壊さず新しい値を作るため、「変更前」と「変更後」を比較しやすくなります。ReactやReduxのようなUI設計、イベントソーシング、設定管理、テストデータ生成でも同じ考え方が使われます。
| mutableな考え方 | immutableな考え方 |
|---|---|
| 同じobjectを書き換える | 新しいobjectを返す |
| 変更箇所を追いにくい | 差分を比較しやすい |
| 共有時に競合する | 共有しても壊れにくい |
| defensive copyが必要 | 値として渡しやすい |
ただし、すべてをコピーすると性能問題になることがあります。永続データ構造やcopy-on-writeのように、見かけ上は不変でも内部で共有する仕組みが使われます。
遅延評価と正格評価
関数型言語には、必要になるまで計算しない遅延評価を持つものがあります。Haskellが代表です。一方、多くの言語は式をすぐ評価する正格評価です。
| 評価戦略 | 利点 | 注意点 |
|---|---|---|
| 遅延評価 | 無限列、必要な分だけ計算 | メモリ使用が読みにくい |
| 正格評価 | 実行順序が分かりやすい | 不要な計算も先に行うことがある |
JavaScriptやPythonでもgeneratorやlazy sequenceを使えば、部分的に遅延評価の利点を取り入れられます。
map / filter / reduce
関数型の基本的なデータ処理は、コレクション変換として表せます。
map各要素を変換するfilter条件に合う要素だけ残すreduce1つの値に畳み込む
これらを使うと、ループの制御変数よりも「何をしたいか」が前に出ます。
foldで考える
reduce や fold は、リストから1つの値を作る一般形です。合計、最大値、集計、グルーピング、状態遷移などを同じ形で表せます。
type CartItem = { price: number; quantity: number };
const total = (items: CartItem[]) =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0);
foldを使うと、途中でどの状態を持っているかが明示されます。複雑な集計では、accumulatorの型を先に設計すると読みやすくなります。
パターンマッチと代数的データ型
関数型言語では、状態を複数のケースとして表すことがあります。TypeScriptやRustでもunionやenumとして近い考え方を使えます。
type Result =
| { type: "success"; value: string }
| { type: "failure"; reason: string };
この形にすると、成功と失敗をnullや例外だけで表すより、呼び出し側が扱うべき分岐を明示できます。
エラーを値として扱う
関数型では、例外だけでなく Result や Either のような型で失敗を表すことがあります。
type Result<T> =
| { ok: true; value: T }
| { ok: false; error: string };
この形にすると、呼び出し側は成功と失敗を明示的に分岐します。すべての失敗を値にすべきではありませんが、業務上想定される失敗は型として表すと扱いやすくなります。
OOPとの関係
OOPと関数型は対立するものではありません。OOPで責務の境界を作り、内部の計算を純粋関数として書くと、かなり扱いやすくなります。
たとえばservice classがI/Oを担当し、価格計算やvalidationは純粋関数として切り出す、という分け方ができます。
関数型とアーキテクチャ
関数型の考え方は、アーキテクチャにも使えます。
外側:
HTTP、DB、queue、clock、random
内側:
validation、pricing、policy、state transition
内側を純粋な計算に寄せると、テストが速くなり、I/Oなしで多くの仕様を確認できます。外側はadapterとして薄く保ち、失敗、retry、transactionを担当させます。
実務での効きどころ
関数型の考え方はHaskellやF# だけでなく、JavaScript、Python、Rust、C#、Javaにも部分的に入っています。特に次の場面で効きます。
- データ変換パイプライン
- 並列化しやすい処理
- テストしやすいコアロジック
実務での取り入れ方
- まずはpure functionを増やす
- 入力と出力の型を明確にする
- 配列変換をmap / filter / reduceで表す
- 共有mutable stateを減らす
- 外部I/Oは境界に集める
よくある誤解
- 関数型は難解な数学だけではない
- 副作用を完全に消す必要はない
- classを使ったら関数型ではない、という話ではない
- すべてをpoint-freeにすると読みやすくなるわけではない
テストとの相性
関数型の設計は、テストの粒度を小さくしやすいです。純粋関数は同じ入力に同じ出力を返すので、DB、時刻、network、乱数を用意しなくても仕様を確認できます。
特に相性がよいのは次のようなテストです。
| テスト | 向いている対象 |
|---|---|
| example-based test | 代表的な入力と期待出力 |
| property-based test | 並び替え、変換、正規化 |
| snapshot test | 表示用データ構造の変換 |
| golden test | compiler、formatter、parser |
たとえば「sortした結果は常に昇順で、要素数は変わらない」のような性質は、個別の入力例よりpropertyとして表す方が強い検証になります。副作用を境界へ寄せると、このような性質を業務ロジックにも適用しやすくなります。
関数型プログラミングの実践パターン
関数型プログラミングの原則を実務で活かすための具体的なパターンと、各言語での実装例を見てみます。
パターン1:パイプライン処理
データを一連の純粋関数を通して変換する。
Haskell での例:
-- 数値のリストを処理
process :: [Int] -> Int
process xs =
xs
|> map (* 2) -- 各要素を2倍
|> filter (> 10) -- 10より大きいものだけ
|> sum -- 合計
-- process [1, 5, 10, 15] = (10+20+30) = 60
パイプ演算子(|>)により、データフローが左から右へ明示的。
F# での例:
let numbers = [1; 5; 10; 15]
let result =
numbers
|> List.map (fun x -> x * 2) -- 2倍
|> List.filter (fun x -> x > 10) -- フィルタ
|> List.sum -- 合計
Python での同等物(lambdaとリスト内包):
numbers = [1, 5, 10, 15]
result = sum(
x * 2
for x in numbers
if x * 2 > 10
)
パターン2:Higher-Order Functions
関数を引数にとったり、戻り値にしたりする関数。
Haskell:関数型言語の標準
-- apply_twice : 関数 f と値 x を受け取り、f を2回適用
apply_twice :: (a -> a) -> a -> a
apply_twice f x = f (f x)
-- 使用例
double x = x * 2
result = apply_twice double 5 -- (5 * 2) * 2 = 20
JavaScript での例:
// map: 配列の各要素に関数を適用
const numbers = [1, 2, 3, 4];
const doubled = numbers.map(x => x * 2); // [2, 4, 6, 8]
// filter: 条件を満たす要素をフィルタ
const evens = numbers.filter(x => x % 2 === 0); // [2, 4]
// reduce: 配列を1つの値に畳み込む
const sum = numbers.reduce((acc, x) => acc + x, 0); // 10
利点:
- コードの再利用性が高い
- 抽象度が上がり、意図が明確
- テストが容易
パターン3:イミュータビリティ
データ構造を変更せず、新しい構造を作成。
悪い例(命令型):
# リストを破壊的に変更
users = [{"id": 1, "name": "Alice"}]
users[0]["name"] = "Bob" # 元のリストが変更される
問題:
- 他の場所でこのリストを参照していると、予期しない変更が起こる
- テストや並行処理で予測不可能
良い例(関数型):
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class User:
id: int
name: str
users = [User(id=1, name="Alice")]
# 新しいリストを作成
new_users = [replace(user, name="Bob") if user.id == 1 else user for user in users]
# 元の users は変わらない
Java での例(Record クラス):
record User(int id, String name) {}
User alice = new User(1, "Alice");
User bob = new User(alice.id(), "Bob"); // 新しいインスタンス
// alice は変わらない
パターン4:関数合成(Function Composition)
小さな関数を組み合わせて、大きな関数を構築。
Haskell:関数合成演算子
-- (.) は関数合成:(f . g) x = f (g x)
double x = x * 2
addOne x = x + 1
-- 合成
doubleAndAddOne = addOne . double
result = doubleAndAddOne 5 -- addOne(double(5)) = 11
Lodash(JavaScript ライブラリ):
const _ = require('lodash/fp');
const getAdultUserNames = _.compose(
_.sortBy(x => x.length),
_.map(user => user.name),
_.filter(user => user.age >= 18)
);
const users = [
{name: "Alice", age: 25},
{name: "Bob", age: 17},
{name: "Charlie", age: 30}
];
const result = getAdultUserNames(users);
// ["Alice", "Charlie"] (ソート済み)
メリット:
- 処理が宣言的
- テストが容易(各関数が独立)
- 再利用可能
パターン5:モナド(Monad)
計算の文脈を抽象化し、合成を容易にする。
代表的なモナド:Option / Maybe
-- Just(値あり)か Nothing(値なし)
data Maybe a = Just a | Nothing
-- >>= : bind 演算子
(Just 5) >>= (\x -> Just (x * 2)) -- Just 10
Nothing >>= (\x -> Just (x * 2)) -- Nothing
実装的な意味:
-- divide: ゼロで割れない場合は Nothing
divide :: Int -> Int -> Maybe Int
divide x 0 = Nothing
divide x y = Just (x `div` y)
-- chaining
result = Just 10 >>= \x -> divide x 2 -- Just 5
>>= \x -> divide x 0 -- Nothing
データベースクエリの NULL 値処理に似ている。
Rust の Option
fn divide(x: i32, y: i32) -> Option<i32> {
if y == 0 { None } else { Some(x / y) }
}
let result = Some(10)
.and_then(|x| divide(x, 2)) // Some(5)
.and_then(|x| divide(x, 0)) // None
.unwrap_or(-1); // -1
関数型プログラミングのアンチパターン
アンチパターン1:純粋性の過信
すべてを純粋関数で書こうとすると、実務でのパフォーマンスが落ちることがあります。
-- 純粋:毎回新しいリストを作成
append :: [a] -> [a] -> [a]
append [] ys = ys
append (x:xs) ys = x : append xs ys
-- 1000要素のリストを append すると O(n) のコピー
実務的なアプローチ:
-- 大規模データは異なるデータ構造を使用
import Data.Sequence (Seq, (|>))
-- O(1) の append
bigData |> newElement
アンチパターン2:過度な抽象化
関数型概念を無理に適用すると、可読性が落ちることがあります。
-- 過度に抽象化された例
processData :: Monad m => m a -> (a -> m b) -> m c
processData = ... -- 何をしているのか不明確
パターン3:命令型言語での部分的な関数型
完全に関数型に移行できない場合、戦略的に関数型を取り入れることが重要です。
# 良いアプローチ:ビジネスロジックは関数型、I/O は命令型
def calculate_price(items: List[Item]) -> float:
"""純粋関数:副作用なし"""
return sum(item.quantity * item.unit_price for item in items)
def process_order(order_id: int) -> None:
"""命令型:副作用を伴う"""
order = fetch_from_db(order_id) # I/O
total = calculate_price(order.items) # 純粋関数
update_order_total(order_id, total) # I/O
関数型プログラミングと並行処理
関数型プログラミングの不変性は、並行処理での安全性を大幅に向上させます。
Immutability による data race 排除
// 悪い例:可変オブジェクトの共有
class Counter {
int value;
void increment() {
this.value++; // thread-safe でない
}
}
// 良い例:イミュータブルな関数型
record Counter(int value) {
Counter increment() {
return new Counter(value + 1); // 新しいインスタンス
}
}
複数スレッドから安全にアクセス可能。
関数型エラーハンドリング
// Rust の Result<T, E>
fn risky_operation() -> Result<String, Error> {
Ok("success".to_string())
}
// Error を伝播させる場合
fn caller() -> Result<String, Error> {
let result = risky_operation()?; // ? で自動伝播
Ok(format!("Got: {}", result))
}
例外よりも明示的で、実行パスが明確。
関数型ライブラリの活用
Java:Vavr(旧 Javaslang)
import io.vavr.collection.List;
import io.vavr.control.Option;
List<Integer> numbers = List.of(1, 2, 3, 4);
Option<Integer> max = numbers.max(); // Option(4)
// チェーンメソッド
List<Integer> result = numbers
.map(n -> n * 2)
.filter(n -> n > 5)
.sortBy(n -> -n);
Python:Toolz, Cytoolz
from toolz import compose, pipe
# Function composition
double = lambda x: x * 2
add_one = lambda x: x + 1
combined = compose(add_one, double)
result = combined(5) # (5 * 2) + 1 = 11
# Pipe
result = pipe(
[1, 2, 3, 4],
lambda x: map(lambda n: n * 2, x),
list,
sum
) # 20
関数型言語での型安全性
Haskell や Scala のような関数型言語は、強力な型システムで多くのバグを防ぎます。
Haskell のきつい型チェック
-- parse 関数は Maybe を返す可能性を型で表現
parseInt :: String -> Maybe Int
-- 呼び出し側は必ず Maybe を処理しなければいけない
result = case parseInt "42" of
Just n -> n * 2
Nothing -> 0
型システムが NULL チェックを強制。
Scala のパターンマッチ
sealed trait Result[+A]
case class Success[A](value: A) extends Result[A]
case class Failure[A](error: String) extends Result[A]
def processResult[A](r: Result[A]): Unit = r match {
case Success(value) => println(s"Got: $value")
case Failure(error) => println(s"Error: $error")
}
// コンパイラが全ケースを処理したか確認
実務での関数型言語採用
Scala、Kotlin、F# などで部分的に関数型を導入。Pure function で business logic、I/O は命令型で記述するハイブリッドアプローチが実務的。
Lazy Evaluation のトレードオフ
Haskell の lazy evaluation は powerful ですが、メモリリークのリスクがあります。
Thunk Accumulation
-- 危険:thunk が蓄積
sum [1..1000000]
-- 理由:lazy evaluation により、すべての計算が遅延
-- 最後に sum されるまで、中間値がメモリに蓄積
対策:strict evaluation を明示:
import Data.List
sum' [] = 0
sum' (x:xs) = let s = sum' xs in seq s (x + s) -- strict
Streaming and Resource Management
無限リストを表現可能なのは elegant ですが、実装注意:
-- 無限リスト
numbers = [1..]
-- take で head を取得
take 10 numbers -- [1..10] のみ評価
Performance Tuning for Lazy Languages
Haskell のコードを最適化するには、lazy evaluation を理解が必須。
Core language を読むことで、実際の計算量が見える場合があります。
Functional Programming Language の選択
| 言語 | スタイル | 学習曲線 | 実務性 |
|---|---|---|---|
| Haskell | 純粋関数型 | 急 | 低 |
| Scala | 関数型 + OOP | 中 | 高 |
| Clojure | Lisp方言 | 中 | 中 |
| Elm | Front-end特化 | 緩 | 高(Web) |
| F# | .NET上 | 中 | 中 |
実務では Scala か F# がバランス。
関数型プログラミングの応用領域
金融システム
immutability + type safety で, bugs を減らせます。
Jane Street(quant trading firm)は OCaml(関数型言語)で取引システム構築。
コンパイラ設計
AST(Abstract Syntax Tree)操作は関数型に向いています。
data Expr = Add Expr Expr
| Mul Expr Expr
| Const Int
eval :: Expr -> Int
eval (Add a b) = eval a + eval b
eval (Mul a b) = eval a * eval b
eval (Const n) = n
パターンマッチで各ケースを処理。
Data Transformation Pipelines
ETL(Extract, Transform, Load)では、composable transformations が有利:
data
.filter(_ > 0)
.map(_ * 2)
.groupBy(_.category)
.mapValues(_.sum)
.toMap
各段階が小さな関数 → test 容易。
関数型の現在地
関数型プログラミングは特殊な言語だけの考え方ではなく、多くの主流言語に取り込まれています。
- JavaScript: map、filter、reduce
- Java: Stream API、ラムダ式
- Python: リスト内包表記、lambda
部分的でも関数型の考え方を導入すると、コード品質が向上することが多いです。
関数型プログラミングの実装パターン
モナドのHaskell実装と型クラス
モナド(Monad)は計算の文脈を管理するための抽象化です。Haskellでは型クラスMonadで定義されます。
class Monad m where
return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b
(>>) :: m a -> m b -> m b
fail :: String -> m a
Maybe モナドは失敗可能な計算を表します。
instance Monad Maybe where
return x = Just x
Nothing >>= f = Nothing
Just x >>= f = f x
-- 使用例: 数値の逆数を計算
reciprocal :: Double -> Maybe Double
reciprocal 0 = Nothing
reciprocal x = Just (1/x)
-- Monad チェーン
example = do
x <- Just 2
y <- reciprocal x -- Just 0.5
z <- reciprocal y -- Just 2.0
return (x + y + z) -- Just 4.5
do記法は(>>=)のシンタックスシュガーです。コンパイラは自動的にネストされた(>>=)呼び出しに変換します。
IO モナドと副作用管理
IO モナドはHaskellで副作用(ファイルI/Oやコンソール出力)を型安全に扱うための仕組みです。
main :: IO ()
main = do
putStrLn "Enter your name:"
name <- getLine -- IO モナドから値を抽出
putStrLn ("Hello, " ++ name)
-- IO 操作のシーケンス
processFile :: FilePath -> IO ()
processFile path = do
content <- readFile path
let lineCount = length (lines content)
putStrLn $ "File has " ++ show lineCount ++ " lines"
return ()
IO モナドは純粋な関数型コードと副作用のある処理を明確に分離します。これにより、プログラム全体の参照透過性の大部分が保たれます。
リスト モナドと非決定計算
リスト モナドは非決定的な計算を表します。複数の可能な値を扱うことができます。
instance Monad [] where
return x = [x]
xs >>= f = concat (map f xs)
-- 使用例: ピタゴラス数の生成
pythagoras = do
x <- [1..20]
y <- [x..20]
z <- [y..20]
guard (x*x + y*y == z*z)
return (x, y, z)
-- 結果: [(3,4,5), (5,12,13), (8,15,17), (12,35,37), ...]
guardは条件を満たさない場合に空リストを返します。これにより、フィルタリングが自然に表現されます。
Reader モナドと環境
Reader モナドはを環境から値を読む計算を表します。依存性注入(Dependency Injection)の関数型実装です。
newtype Reader r a = Reader (r -> a)
instance Monad (Reader r) where
return a = Reader (\_ -> a)
m >>= k = Reader (\r -> runReader (k (runReader m r)) r)
-- 設定を使用した計算
data Config = Config { dbPath :: String, logLevel :: String }
getDbPath :: Reader Config String
getDbPath = Reader dbPath
readConfig :: String -> Reader Config String
readConfig key = do
dbPath <- getDbPath
return $ "Config: " ++ dbPath ++ " with key=" ++ key
Reader モナドを使うことで、暗黙のパラメータ渡しをより構造化して扱えます。
関数合成とポイントフリースタイル
関数合成演算子
Haskellの(.)演算子は関数合成を表します。f . gは g の出力を f の入力に渡します。
-- 関数の定義
double x = x * 2
addOne x = x + 1
square x = x * x
-- ポイントフリーな合成
transform = double . addOne . square
-- 評価順序: square(5) -> addOne(25) -> double(26) -> 52
result = transform 5 -- 52
-- パイプライン的な合成(左から右)
(|>) :: a -> (a -> b) -> b
x |> f = f x
-- 別の書き方
result' = 5 |> square |> addOne |> double -- 52
ポイントフリースタイルは引数を明示せずに関数を定義することで、プログラムを簡潔にします。ただし過度に使うと可読性が低下します。
カリー化と部分適用
Haskellではすべての関数は基本的にカリー化されています。複数の引数を持つ関数は、実際には1つの引数を取って関数を返す関数です。
-- 明示的な定義(複数引数の見た目)
add :: Int -> Int -> Int
add x y = x + y
-- 実際の型(カリー化)
-- add :: Int -> (Int -> Int)
-- 部分適用
add5 = add 5 -- (Int -> Int) 型の関数
result = add5 3 -- 8
-- フォルド操作での活用
numbers = [1, 2, 3, 4]
summed = foldl (+) 0 numbers -- 10
doubled = map (*2) numbers -- [2, 4, 6, 8]
カリー化により、関数を値として扱い、高階関数との組み合わせが容易になります。
遅延評価と無限リスト
遅延評価の仕組み
Haskellは遅延評価により、必要になるまで計算を遅延させます。これにより無限リストを定義できます。
-- 無限リスト: 正の整数すべて
positives = [1..]
-- フィボナッチ数列(無限)
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
-- 最初の10項を取得
first10 = take 10 fibs -- [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
-- 無限リストのフィルタリング
primes = sieve [2..]
where
sieve (p:xs) = p : sieve [x | x <- xs, x `mod` p /= 0]
-- 最初の1000個の素数
first1000Primes = take 1000 primes
遅延評価により、プログラムのメモリ効率と表現力が向上します。ただし、いつ計算が実行されるかが非自明になる場合があります。
遅延評価のパフォーマンス
遅延評価は計算を遅延させるため、スタック領域(Thunk)が蓄積される可能性があります。この現象をスペースリークと呼びます。
-- スペースリークの例
sumLazy :: [Int] -> Int
sumLazy [] = 0
sumLazy (x:xs) = x + sumLazy xs
-- 大きなリストで中間結果がメモリに溜まる
-- 改善版(strict accumulator)
sumStrict :: [Int] -> Int
sumStrict xs = go 0 xs
where
go acc [] = acc
go acc (x:xs) = go (acc + x) xs -- BangPattern で厳密評価
BangPattern(!)を使って、特定の計算を厳密(eager)に評価させることでメモリ効率を改善できます。
型システムと型クラス
Eq 型クラスの実装
Eq型クラスは等価性の判定を定義します。
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
-- 自動導出
data Color = Red | Green | Blue deriving (Eq)
-- 手動実装
instance Eq Color where
Red == Red = True
Green == Green = True
Blue == Blue = True
_ == _ = False
x /= y = not (x == y)
型クラスを使うことで、異なる型に対して統一されたインターフェースを提供できます。
Ord 型クラスと順序付け
Ord型クラスは順序比較を定義します。Eqをスーパークラスとして持ちます。
class Eq a => Ord a where
compare :: a -> a -> Ordering -- LT, EQ, GT
(<) :: a -> a -> Bool
(<=) :: a -> a -> Bool
(>) :: a -> a -> Bool
(>=) :: a -> a -> Bool
max :: a -> a -> a
min :: a -> a -> a
instance Ord Color where
compare Red Green = LT
compare Green Blue = LT
compare Blue Red = GT
compare x y = if x == y then EQ else error "incomparable"
Ordを実装することで、ソート関数(sort, sortBy)が使用可能になります。
まとめ
関数型プログラミングは、言語選択というより設計姿勢として捉えると取り入れやすいです。副作用の境界を意識し、変換を小さく組み合わせるだけでもコードの見通しは大きく改善します。