本記事では、関数型プログラミングの入り口として、純粋関数とイミュータブルをやさしく解説します。
手続き型との違いをシンプルな例と一緒に紹介し、日々のコードに少しずつ取り入れる方法まで、段階的に理解できるようにまとめました。
関数型プログラミングの基本とは
初心者向けの関数型プログラミング入門
関数型プログラミングは、データを入力して結果を出す関数を組み合わせてプログラムを作る考え方です。
関数はできるだけ周辺の状態に依存せず、同じ入力なら同じ出力を返すことを大切にします。
これにより、コードの挙動が予想しやすくなり、小さく安全に変更できます。
特に重要な土台が純粋関数とイミュータブル(不変)です。
純粋関数は入力と出力の関係が明確で、イミュータブルはデータを書き換えず新しい値として扱います。
まずはこの2つから理解すると全体像がつかみやすくなります。
手続き型との違い
手続き型は「手順の列」を書き、途中で変数を書き換えながらゴールに向かいます。
関数型は「何を計算したいか」を関数の組み合わせで表し、なるべく値を変えずに進めます。
以下に違いを整理します。
| 観点 | 手続き型 | 関数型 |
|---|---|---|
| 考え方 | 手順を順に実行する | 入力から出力への変換を組み合わせる |
| 状態 | 変数を更新することが多い | なるべく更新せず新しい値を作る |
| 関数 | 手続きの塊(副作用を持ちやすい) | 純粋関数を中心にする |
| デバッグ | 状態の追跡が必要 | 関数の入出力に注目できる |
| テスト | 事前準備が多くなりがち | 入力と期待出力だけで検証しやすい |
使うメリット
関数型の考え方を取り入れると、コードの見通しが良くなります。
同じ入力から同じ出力が得られる関数が増えるほど、原因調査やテストが簡単になります。
また、データの変更が局所化されるため、思わぬ影響が起きにくくなります。
- 変更に強い: 影響範囲が絞られ、バグが広がりにくくなります。
- テストが容易: 入出力を比べれば動作確認できます。
- 再利用しやすい: 小さな純粋関数は用途を変えても使い回せます。
キーワード整理
よく出てくる用語を短くまとめます。
ここでは意味をイメージしやすくすることを優先します。
| キーワード | かんたん説明 |
|---|---|
| 純粋関数 | 同じ入力に対して常に同じ出力。外の状態を変えない。 |
| イミュータブル(不変) | 値を上書きせず、新しい値を作って扱う考え。 |
| 副作用 | 画面表示やファイル書き込み、外部変数の変更など、関数の外へ影響すること。 |
| 参照透明性 | 式をその結果で置き換えても意味が変わらない性質。純粋関数が持つ。 |
純粋関数とは
定義
純粋関数は「同じ入力なら必ず同じ結果を返し、副作用がない関数」です。
ここでいう副作用とは、関数の外に影響を与える動作のことです(例: グローバル変数の更新、現在時刻の読み取り、乱数取得、ファイルやネットワークへの書き込みなど)。
純粋関数は参照透明性を持ち、式を計算結果に置き換えてもプログラム全体の意味が変わりません。
これにより、読解やテストがとても楽になります。
非純粋な例と注意点
以下はJavaScriptでの非純粋な例です。
どれも関数の外に影響したり、外部に依存したりしています。
// 外部の変数を書き換える(副作用)
let total = 0;
function addToTotal(x) {
total += x; // 外部状態に依存・更新
return total;
}
// 現在時刻に依存する(毎回結果が変わる)
function greet() {
const h = new Date().getHours(); // 時間に依存
return h < 12 ? "Good morning" : "Hello";
}
// ランダムに依存する(再現できない)
function rollDice() {
return Math.floor(Math.random() * 6) + 1; // 乱数依存
}
非純粋な処理すべてが悪いわけではありません。
画面表示や保存などは必要です。
ただし、それらを計算(純粋)と分離し、境界(入出力の直前)に集めると安全です。
書き方のコツ
純粋関数に近づけるための実践的なポイントを挙げます。
- 入力は引数で受け取り、関数の外を読まないようにします。
- 既存の引数や外部データを直接書き換えないようにします(イミュータブル)。
- 結果は新しい値を返すようにします。
- 日時や乱数が必要な場合は、関数に注入(引数として渡す)して、テスト時に固定値を渡せるようにします。
- 計算部分と副作用部分(ログ、保存、通信など)を分けてファイルや関数を設計します。
かんたん例
いくつか純粋関数の例を見てみます。
// 税込み価格を返す(純粋)
function withTax(price, taxRate) {
return Math.round(price * (1 + taxRate));
}
// 配列の合計(純粋)
function sum(numbers) {
return numbers.reduce((acc, n) => acc + n, 0);
}
// 文字列をタイトルケースに(純粋)
function toTitleCase(s) {
return s
.split(" ")
.map(w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
.join(" ");
}
日時や乱数の注入例です。
テストでは固定の関数を渡せます。
function greeting(now) { // nowは日時を返す関数
const hour = now().getHours();
return hour < 12 ? "Good morning" : "Hello";
}
// 実運用
greeting(() => new Date());
// テスト時(常に9時として評価)
greeting(() => new Date("2020-01-01T09:00:00Z"));
純粋関数のメリット
予測可能・テスト容易・再利用しやすいの3点が特に効きます。
純粋関数は入力と出力だけを見ればよいので、ユニットテストが簡単です。
再利用では、ロジックを小さく切り出すことで、別のプロジェクトでも使い回せます。
さらに、同じ入力なら結果が同じため、メモ化(結果のキャッシュ)の効果も出やすく、性能改善につながることもあります。
イミュータブルとは
基本
イミュータブル(不変)とは、既存の値を上書きせず、新しい値として扱う考え方です。
この考え方により、どの時点の値を使っているかが明確になり、途中で書き換えられてバグになる問題を避けられます。
破壊的変更との違い
配列やオブジェクトを「その場で書き換える」操作は破壊的変更と呼ばれます。
イミュータブルでは、元の値を変えずに新しい配列やオブジェクトを作るようにします。
代表的な違いをまとめます(JavaScript)。
| 対象 | 破壊的(避けたい) | 安全な代替(新しい値を返す) |
|---|---|---|
| 配列の追加 | arr.push(x) | [...arr, x] または arr.concat([x]) |
| 配列の削除 | arr.splice(i, 1) | arr.filter((_, idx) => idx !== i) |
| 配列の更新 | arr[i] = v | arr.map((n, idx) => idx === i ? v : n) |
| オブジェクトの更新 | obj.k = v | { ...obj, k: v } |
| オブジェクトの削除 | delete obj.k | (({ k, ...rest }) => rest)(obj) |
pushやspliceは元の配列を変えるため注意です。
代わりにスプレッド構文やmap/filterなどを使います。
配列とオブジェクトの安全な更新
安全な更新は「コピーして変更」を基本にします。
// 配列に要素を追加
const arr = [1, 2, 3];
const arr2 = [...arr, 4]; // arrは変わらない
// 特定の要素だけ更新
const scores = [10, 20, 30];
const scores2 = scores.map((s, i) => (i === 1 ? s + 5 : s));
// オブジェクトのフィールドを更新
const user = { id: 1, name: "Taro", age: 20 };
const updatedUser = { ...user, age: 21 };
// ネストが浅いときの安全な更新
const state = { profile: { name: "Taro", city: "Tokyo" } };
const state2 = {
...state,
profile: {
...state.profile,
city: "Osaka"
}
};
ネストが深い場合は、更新箇所まで段階的にスプレッドでコピーします。
学習中は無理に複雑な入れ子を作らず、データ構造をシンプルに保つのがコツです。
開発中だけObject.freezeで予期せぬ書き換えに早めに気づく方法もあります(後述)。
イミュータブルのメリット
「いつ、どこで値が変わったのか」を追う必要が減ることが最大の利点です。
以前の値がそのまま残るため、比較や差分、取り消し操作(Undo)が簡単になります。
さらに、関数に渡したデータが途中で書き換わらないため、関数が予想通りに動きやすくなります。
メモリ使用量がやや増える場面はありますが、現代のアプリでは利点が上回ることが多いです。
大きなデータを扱うときは、作成回数や構造を見直すとよいでしょう。
小さなテクニック
- 変数宣言は
constを基本にします。constは「再代入禁止」ですが、オブジェクトの中身の変更までは防げません。中身まで守りたいときは開発中にObject.freeze(obj)を併用します。 - 破壊的なAPIかどうかドキュメントで確認し、破壊的なら代替を覚えます(例:
push→スプレッド、sort→コピーしてからsortなど)。 - コピーが必要か迷ったら、まずは「元の値を変えない」を優先します。最適化はあとで行っても間に合います。
はじめ方と実践のコツ
小さな関数に分ける
大きな関数は混乱のもとです。
1つの関数は1つの役割を目指し、名前から何をしているか分かるようにします。
計算(純粋)と入出力(副作用)を分けると保守が楽になります。
// 計算(純粋)
function calcTotal(items) { // items: {price, qty}[]
return items.reduce((sum, it) => sum + it.price * it.qty, 0);
}
// 入出力(副作用)
function showTotal(total) {
console.log(`Total: ${total}円`);
}
状態を持たない設計
できるだけ関数の外部状態に頼らず、必要なデータは引数で受け取ります。
関数は引数だけ見れば理解できる状態が理想です。
アプリ全体では、状態を扱う場所を限定し、その周辺以外は純粋関数で組み立てると見通しがよくなります。
日時や乱数の扱い
日時や乱数は非純粋になりやすい代表です。
関数に「現在時刻関数」や「乱数関数」を注入して、テスト時に固定の値を渡せるようにします。
function makeId(randomInt) { // randomInt: 上限を受け取り0..上限-1を返す関数
return `id_${randomInt(100000)}`;
}
// 実運用
makeId((max) => Math.floor(Math.random() * max));
// テスト(常に42を返す)
makeId(() => 42); // => "id_42"
テストしやすい関数にする
テストしやすさは純粋関数の大きな強みです。
入力と期待値を並べるだけでテストが書けるようになります。
複雑な準備やモックが必要な場合は、どこかで副作用や状態に依存しているサインです。
計算部分を切り出すとテストが軽くなります。
// 期待: 価格と税率から税込みを計算
console.assert(withTax(100, 0.1) === 110);
console.assert(withTax(200, 0.08) === 216);
練習課題
学んだ内容を小さく試してみましょう。
難しく考えず、手を動かすことが大切です。
- 文字列の配列から、長さ3以上だけを小文字にして返す純粋関数を作る(元の配列は変えない)。
- ユーザー配列
[{id, name}]から、特定idのユーザー名だけを変更した新配列を返す(スプレッドとmapを使う)。 - 税込み計算関数
withTax(price, rate)を作り、いくつかの入力で期待値をテストする。 - 現在時刻に応じて挨拶する関数に、
now()関数を注入してテスト可能にする。 - サイコロ関数
roll(randomInt)を作り、randomIntを注入して結果を固定してテストする。
まとめ
関数型プログラミングの入り口は、純粋関数とイミュータブルを身につけることです。
純粋関数は「同じ入力→同じ出力」で副作用がなく、イミュータブルは「元の値を変えない」姿勢です。
この2つを意識すると、コードの予測可能性が高まり、デバッグやテストが大きく楽になります。
まずは小さな関数から始め、計算と副作用を分け、日時や乱数は関数に注入して扱いましょう。
今日の1ファイル、1関数だけでも十分です。
少しずつ純粋に、少しずつ不変に。
その積み重ねが、読みやすく壊れにくいコードにつながっていきます。
