はじめに#
「関数型プログラミング」という言葉を聞いたことはあるでしょうか。近年のJavaScript開発では、ReactやReduxなどのライブラリ・フレームワークが関数型プログラミングの考え方を採用しており、その重要性が高まっています。
関数型プログラミングは、データの変更を避け、純粋な関数を組み合わせてプログラムを構築するパラダイムです。このアプローチを取り入れることで、バグが少なく、テストしやすく、保守性の高いコードを書けるようになります。
本記事では、以下の内容を初心者向けにわかりやすく解説します。
- 関数型プログラミングとは何か
- イミュータブル(不変性)の考え方
- 純粋関数と副作用
- 高階関数とmap/filter/reduceの活用
- 関数合成とポイントフリースタイル
- カリー化と部分適用
- 実践的な活用パターン
関数型プログラミングとは#
命令型と宣言型の違い#
プログラミングのパラダイムには、大きく分けて「命令型」と「宣言型」があります。関数型プログラミングは宣言型の一種です。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
const numbers = [1, 2, 3, 4, 5];
// 命令型:「どうやって」処理するかを記述
const doubledImperative = [];
for (let i = 0; i < numbers.length; i++) {
doubledImperative.push(numbers[i] * 2);
}
// 宣言型(関数型):「何を」したいかを記述
const doubledDeclarative = numbers.map((n) => n * 2);
console.log(doubledImperative); // [2, 4, 6, 8, 10]
console.log(doubledDeclarative); // [2, 4, 6, 8, 10]
|
命令型のコードは、ループカウンタの初期化、条件判定、インクリメントなど「処理の手順」を細かく記述します。一方、宣言型のコードは「各要素を2倍にする」という意図を直接表現しています。
関数型プログラミングの主要な特徴#
関数型プログラミングには、以下の主要な特徴があります。
| 特徴 |
説明 |
| 純粋関数 |
同じ入力に対して常に同じ出力を返し、副作用を持たない |
| イミュータブル |
データを変更せず、新しいデータを作成する |
| 高階関数 |
関数を引数として受け取り、関数を返す |
| 関数合成 |
小さな関数を組み合わせて大きな処理を構築する |
| 宣言的な記述 |
「何を」したいかを明確に表現する |
これらの特徴を理解し実践することで、より堅牢なコードを書けるようになります。
関数型プログラミングのメリット#
関数型プログラミングを採用することで、以下のようなメリットが得られます。
| メリット |
理由 |
| バグの減少 |
データを変更しないため、意図しない状態変化が起きにくい |
| テストしやすさ |
純粋関数は入出力のテストだけで検証できる |
| 並行処理の安全性 |
共有状態を変更しないため、競合が発生しにくい |
| コードの再利用性 |
小さな関数を組み合わせるため、再利用しやすい |
| 可読性の向上 |
宣言的な記述により、意図が明確になる |
イミュータブル(不変性)の考え方#
ミュータブルとイミュータブル#
イミュータブル(Immutable)とは、一度作成したデータを変更しないという考え方です。逆に、データを直接変更できることをミュータブル(Mutable)と呼びます。
1
2
3
4
5
6
7
8
9
10
|
// ミュータブル(変更可能)な操作
const mutableArray = [1, 2, 3];
mutableArray.push(4); // 元の配列を変更
console.log(mutableArray); // [1, 2, 3, 4]
// イミュータブル(不変)な操作
const immutableArray = [1, 2, 3];
const newArray = [...immutableArray, 4]; // 新しい配列を作成
console.log(immutableArray); // [1, 2, 3] (元の配列は変わらない)
console.log(newArray); // [1, 2, 3, 4]
|
なぜイミュータブルが重要なのか#
データを直接変更すると、以下のような問題が発生しやすくなります。
1
2
3
4
5
6
7
8
9
10
11
|
// 問題のあるコード例
function addDiscount(cart) {
cart.total = cart.total * 0.9; // 元のオブジェクトを変更
return cart;
}
const originalCart = { items: ["商品A", "商品B"], total: 1000 };
const discountedCart = addDiscount(originalCart);
console.log(originalCart.total); // 900(意図せず変更されている)
console.log(discountedCart.total); // 900
|
上記の例では、addDiscount関数が元のオブジェクトを直接変更してしまうため、関数を呼び出す側が意図しない形でデータが書き換わってしまいます。
イミュータブルな操作を実現する方法#
JavaScriptでイミュータブルな操作を実現するには、スプレッド構文やイミュータブルな配列メソッドを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
// オブジェクトのイミュータブルな更新
function addDiscountImmutable(cart) {
return {
...cart,
total: cart.total * 0.9 // 新しいオブジェクトを作成
};
}
const originalCart = { items: ["商品A", "商品B"], total: 1000 };
const discountedCart = addDiscountImmutable(originalCart);
console.log(originalCart.total); // 1000(元のオブジェクトは変わらない)
console.log(discountedCart.total); // 900
// 配列のイミュータブルな操作
const numbers = [1, 2, 3];
// 要素の追加
const added = [...numbers, 4]; // [1, 2, 3, 4]
// 要素の削除
const removed = numbers.filter((n) => n !== 2); // [1, 3]
// 要素の更新
const updated = numbers.map((n) => (n === 2 ? 20 : n)); // [1, 20, 3]
console.log(numbers); // [1, 2, 3](元の配列は変わらない)
|
ミュータブルなメソッドとイミュータブルなメソッド#
JavaScriptの配列メソッドには、元の配列を変更するものと、新しい配列を返すものがあります。
| 操作 |
ミュータブル(避ける) |
イミュータブル(推奨) |
| 末尾に追加 |
push() |
[...array, item] |
| 先頭に追加 |
unshift() |
[item, ...array] |
| 削除 |
splice() |
filter() |
| ソート |
sort() |
[...array].sort() |
| 反転 |
reverse() |
[...array].reverse() |
| 更新 |
直接代入 |
map() |
1
2
3
4
5
6
7
8
9
10
11
|
// ソートをイミュータブルに行う
const scores = [85, 92, 78, 95, 88];
// ミュータブル(元の配列を変更)
// scores.sort((a, b) => b - a); // 元の配列が変わってしまう
// イミュータブル(新しい配列を作成)
const sortedScores = [...scores].sort((a, b) => b - a);
console.log(scores); // [85, 92, 78, 95, 88](変わらない)
console.log(sortedScores); // [95, 92, 88, 85, 78]
|
純粋関数と副作用#
純粋関数とは#
純粋関数(Pure Function)とは、以下の2つの条件を満たす関数です。
- 同じ入力に対して常に同じ出力を返す(参照透過性)
- 副作用を持たない(外部の状態を変更しない)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 純粋関数の例
function add(a, b) {
return a + b;
}
console.log(add(2, 3)); // 常に5を返す
console.log(add(2, 3)); // 何度呼んでも5を返す
// 純粋関数:配列の合計を計算
function sum(numbers) {
return numbers.reduce((acc, n) => acc + n, 0);
}
console.log(sum([1, 2, 3])); // 常に6を返す
|
副作用とは#
副作用(Side Effect)とは、関数が外部の状態を変更したり、外部の状態に依存したりすることです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// 副作用のある関数の例
let counter = 0;
function incrementCounter() {
counter++; // 外部変数を変更している(副作用)
return counter;
}
console.log(incrementCounter()); // 1
console.log(incrementCounter()); // 2(同じ呼び出しでも結果が異なる)
// 外部状態に依存する関数
let taxRate = 0.1;
function calculateTax(price) {
return price * taxRate; // 外部変数に依存している
}
taxRate = 0.08;
console.log(calculateTax(1000)); // 80(税率の変更で結果が変わる)
|
純粋関数に書き換える#
副作用のある関数を純粋関数に書き換えることで、予測可能で信頼性の高いコードになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// 純粋関数に書き換え:カウンター
function incrementCounter(counter) {
return counter + 1; // 新しい値を返す
}
let count = 0;
count = incrementCounter(count); // 1
count = incrementCounter(count); // 2
// 純粋関数に書き換え:税金計算
function calculateTax(price, taxRate) {
return price * taxRate; // 必要な値を引数で受け取る
}
console.log(calculateTax(1000, 0.1)); // 100
console.log(calculateTax(1000, 0.08)); // 80
|
副作用を完全に排除することはできない#
実際のアプリケーションでは、副作用を完全に排除することはできません。以下のような操作は必ず副作用を伴います。
- DOM操作
- API通信
- ファイルの読み書き
- コンソールへの出力
- データベースへのアクセス
関数型プログラミングでは、副作用を排除するのではなく、副作用を局所化して管理することが重要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 副作用を局所化する例
// 純粋な関数(ビジネスロジック)
function formatUserData(user) {
return {
displayName: `${user.lastName} ${user.firstName}`,
age: calculateAge(user.birthDate)
};
}
// 副作用を含む関数(外部とのインタラクション)
async function displayUserInfo(userId) {
const user = await fetchUser(userId); // 副作用:API通信
const formatted = formatUserData(user); // 純粋関数を使用
console.log(formatted); // 副作用:コンソール出力
}
|
高階関数とmap/filter/reduceの活用#
関数型プログラミングでは、高階関数(Higher-Order Function)を多用します。高階関数とは、関数を引数として受け取る関数、または関数を戻り値として返す関数のことです。
map - 変換#
mapは配列の各要素を変換し、新しい配列を返します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
const users = [
{ name: "田中", score: 85 },
{ name: "佐藤", score: 92 },
{ name: "鈴木", score: 78 }
];
// 各ユーザーの名前だけを抽出
const names = users.map((user) => user.name);
console.log(names); // ["田中", "佐藤", "鈴木"]
// スコアを10点加算
const bonusScores = users.map((user) => ({
...user,
score: user.score + 10
}));
console.log(bonusScores);
// [
// { name: "田中", score: 95 },
// { name: "佐藤", score: 102 },
// { name: "鈴木", score: 88 }
// ]
|
filter - 抽出#
filterは条件に一致する要素だけを抽出し、新しい配列を返します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
const products = [
{ name: "商品A", price: 500, inStock: true },
{ name: "商品B", price: 1200, inStock: false },
{ name: "商品C", price: 800, inStock: true },
{ name: "商品D", price: 2000, inStock: true }
];
// 在庫がある商品だけを抽出
const availableProducts = products.filter((product) => product.inStock);
// 1000円以下の商品を抽出
const affordableProducts = products.filter((product) => product.price <= 1000);
console.log(availableProducts);
// [{ name: "商品A", ... }, { name: "商品C", ... }, { name: "商品D", ... }]
console.log(affordableProducts);
// [{ name: "商品A", ... }, { name: "商品C", ... }]
|
reduce - 集約#
reduceは配列の全要素を処理し、単一の値(または新しい構造)を返します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
const orders = [
{ product: "商品A", quantity: 2, unitPrice: 500 },
{ product: "商品B", quantity: 1, unitPrice: 1200 },
{ product: "商品C", quantity: 3, unitPrice: 300 }
];
// 合計金額を計算
const totalAmount = orders.reduce((sum, order) => {
return sum + order.quantity * order.unitPrice;
}, 0);
console.log(totalAmount); // 2600
// オブジェクトへの変換
const productMap = orders.reduce((map, order) => {
map[order.product] = order.quantity * order.unitPrice;
return map;
}, {});
console.log(productMap);
// { "商品A": 1000, "商品B": 1200, "商品C": 900 }
|
メソッドチェーン#
map、filter、reduceはメソッドチェーンで組み合わせることができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
const transactions = [
{ type: "income", amount: 5000 },
{ type: "expense", amount: 1200 },
{ type: "income", amount: 3000 },
{ type: "expense", amount: 800 },
{ type: "income", amount: 2000 }
];
// 収入の合計を計算
const totalIncome = transactions
.filter((t) => t.type === "income") // 収入だけを抽出
.map((t) => t.amount) // 金額だけを取り出す
.reduce((sum, amount) => sum + amount, 0); // 合計を計算
console.log(totalIncome); // 10000
|
関数合成とポイントフリースタイル#
関数合成とは#
関数合成(Function Composition)とは、複数の関数を組み合わせて新しい関数を作ることです。数学の合成関数 $f(g(x))$ と同じ考え方です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// 個別の関数を定義
const double = (x) => x * 2;
const addTen = (x) => x + 10;
const square = (x) => x * x;
// 手動で合成
const result = square(addTen(double(5)));
console.log(result); // 400(5 → 10 → 20 → 400)
// compose関数を作成
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
// 関数を合成して新しい関数を作成
const calculate = compose(square, addTen, double);
console.log(calculate(5)); // 400
|
pipe関数#
composeは右から左へ関数を適用しますが、pipeは左から右へ適用します。データの流れが直感的に理解しやすくなります。
1
2
3
4
5
6
7
8
9
10
11
12
|
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
// 左から右へ処理が流れる
const processData = pipe(
(str) => str.trim(),
(str) => str.toLowerCase(),
(str) => str.split(" "),
(arr) => arr.filter((word) => word.length > 3)
);
const result = processData(" Hello World JavaScript ");
console.log(result); // ["hello", "world", "javascript"]
|
ポイントフリースタイル#
ポイントフリースタイル(Point-Free Style)とは、関数の引数を明示的に書かないスタイルのことです。関数合成と相性が良く、コードをより抽象的に表現できます。
1
2
3
4
5
6
7
8
9
10
11
12
|
// 通常のスタイル(引数を明示)
const doubleAll = (numbers) => numbers.map((n) => n * 2);
// ポイントフリースタイル
const double = (n) => n * 2;
const doubleAllPointFree = (numbers) => numbers.map(double);
// さらにポイントフリーに
const map = (fn) => (arr) => arr.map(fn);
const doubleAllFull = map(double);
console.log(doubleAllFull([1, 2, 3])); // [2, 4, 6]
|
カリー化と部分適用#
カリー化とは#
カリー化(Currying)とは、複数の引数を取る関数を、1つの引数を取る関数の連鎖に変換することです。名前は数学者のハスケル・カリーに由来します。
1
2
3
4
5
6
7
|
// 通常の関数(複数の引数を一度に受け取る)
const add = (a, b, c) => a + b + c;
console.log(add(1, 2, 3)); // 6
// カリー化された関数(1つずつ引数を受け取る)
const curriedAdd = (a) => (b) => (c) => a + b + c;
console.log(curriedAdd(1)(2)(3)); // 6
|
カリー化のメリット#
カリー化を使うと、関数の再利用性が高まり、部分適用が容易になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// カリー化された関数
const multiply = (a) => (b) => a * b;
// 部分適用:特定の値を固定した新しい関数を作成
const double = multiply(2);
const triple = multiply(3);
console.log(double(5)); // 10
console.log(triple(5)); // 15
// 配列操作での活用
const numbers = [1, 2, 3, 4, 5];
console.log(numbers.map(double)); // [2, 4, 6, 8, 10]
console.log(numbers.map(triple)); // [3, 6, 9, 12, 15]
|
実践的なカリー化の例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// フィルタ条件をカリー化
const filterBy = (key) => (value) => (arr) =>
arr.filter((item) => item[key] === value);
const products = [
{ name: "商品A", category: "電化製品", status: "available" },
{ name: "商品B", category: "衣類", status: "soldout" },
{ name: "商品C", category: "電化製品", status: "available" },
{ name: "商品D", category: "衣類", status: "available" }
];
// 部分適用で再利用可能なフィルタを作成
const filterByCategory = filterBy("category");
const filterByElectronics = filterByCategory("電化製品");
const filterByStatus = filterBy("status");
const filterByAvailable = filterByStatus("available");
console.log(filterByElectronics(products));
// [{ name: "商品A", ... }, { name: "商品C", ... }]
console.log(filterByAvailable(products));
// [{ name: "商品A", ... }, { name: "商品C", ... }, { name: "商品D", ... }]
|
汎用的なカリー化ユーティリティ#
任意の関数をカリー化するユーティリティ関数を作成することもできます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
// 汎用的なカリー化関数
const curry = (fn) => {
return function curried(...args) {
if (args.length >= fn.length) {
return fn.apply(this, args);
}
return (...nextArgs) => curried(...args, ...nextArgs);
};
};
// 使用例
const sum = (a, b, c) => a + b + c;
const curriedSum = curry(sum);
console.log(curriedSum(1)(2)(3)); // 6
console.log(curriedSum(1, 2)(3)); // 6
console.log(curriedSum(1)(2, 3)); // 6
console.log(curriedSum(1, 2, 3)); // 6
|
実践的な活用パターン#
パターン1: データ変換パイプライン#
APIから取得したデータを加工する際に、関数型のパターンが活躍します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
// APIレスポンスのデータ変換
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
// 各処理を関数として定義
const filterActiveUsers = (users) =>
users.filter((user) => user.status === "active");
const sortByRegistration = (users) =>
[...users].sort((a, b) => new Date(b.registeredAt) - new Date(a.registeredAt));
const takeFirst = (n) => (users) => users.slice(0, n);
const formatForDisplay = (users) =>
users.map((user) => ({
id: user.id,
displayName: `${user.lastName} ${user.firstName}`,
email: user.email
}));
// パイプラインを構築
const processUsers = pipe(
filterActiveUsers,
sortByRegistration,
takeFirst(10),
formatForDisplay
);
// 使用例
const rawUsers = [
{ id: 1, firstName: "太郎", lastName: "田中", status: "active", email: "tanaka@example.com", registeredAt: "2024-01-15" },
{ id: 2, firstName: "花子", lastName: "佐藤", status: "inactive", email: "sato@example.com", registeredAt: "2024-03-20" },
{ id: 3, firstName: "次郎", lastName: "鈴木", status: "active", email: "suzuki@example.com", registeredAt: "2024-02-10" }
];
const displayUsers = processUsers(rawUsers);
console.log(displayUsers);
// [
// { id: 3, displayName: "鈴木 次郎", email: "suzuki@example.com" },
// { id: 1, displayName: "田中 太郎", email: "tanaka@example.com" }
// ]
|
パターン2: バリデーション#
複数のバリデーションルールを合成するパターンです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
// バリデーション関数を作成
const createValidator = (predicate, errorMessage) => (value) =>
predicate(value) ? { valid: true } : { valid: false, error: errorMessage };
// 個別のバリデータ
const isRequired = createValidator(
(value) => value !== null && value !== undefined && value !== "",
"必須項目です"
);
const minLength = (min) =>
createValidator(
(value) => value.length >= min,
`${min}文字以上で入力してください`
);
const isEmail = createValidator(
(value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
"有効なメールアドレスを入力してください"
);
// バリデータを合成
const composeValidators = (...validators) => (value) => {
for (const validate of validators) {
const result = validate(value);
if (!result.valid) {
return result;
}
}
return { valid: true };
};
// 使用例
const validateEmail = composeValidators(
isRequired,
minLength(5),
isEmail
);
console.log(validateEmail("")); // { valid: false, error: "必須項目です" }
console.log(validateEmail("abc")); // { valid: false, error: "5文字以上で入力してください" }
console.log(validateEmail("invalid")); // { valid: false, error: "有効なメールアドレスを入力してください" }
console.log(validateEmail("test@example.com")); // { valid: true }
|
パターン3: イベントハンドラの抽象化#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
// イベントハンドラを関数型で抽象化
const preventDefault = (fn) => (event) => {
event.preventDefault();
return fn(event);
};
const stopPropagation = (fn) => (event) => {
event.stopPropagation();
return fn(event);
};
const getTargetValue = (fn) => (event) => fn(event.target.value);
const debounce = (delay) => (fn) => {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
};
// 使用例
const handleSearch = pipe(
getTargetValue,
debounce(300),
)((query) => {
console.log(`検索: ${query}`);
});
// フォーム送信ハンドラ
const handleSubmit = preventDefault((event) => {
const formData = new FormData(event.target);
console.log("送信データ:", Object.fromEntries(formData));
});
|
関数型プログラミングを学ぶ際の注意点#
過度な抽象化を避ける#
関数型プログラミングのパターンは強力ですが、過度に使用するとかえって可読性が下がることがあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 過度に抽象化された例(読みにくい)
const result = pipe(
filter(propEq("status", "active")),
map(pick(["id", "name"])),
sortBy(prop("name")),
take(5)
)(users);
// 適度な抽象化(読みやすい)
const activeUsers = users.filter((user) => user.status === "active");
const formatted = activeUsers.map(({ id, name }) => ({ id, name }));
const sorted = [...formatted].sort((a, b) => a.name.localeCompare(b.name));
const topFive = sorted.slice(0, 5);
|
パフォーマンスへの配慮#
メソッドチェーンは可読性を高めますが、大量のデータを処理する場合は中間配列が生成されるため、パフォーマンスに影響することがあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 中間配列が3つ生成される
const result = largeArray
.filter((item) => item.active) // 中間配列1
.map((item) => item.value) // 中間配列2
.filter((value) => value > 100); // 中間配列3
// reduceで1回のループにまとめる
const resultOptimized = largeArray.reduce((acc, item) => {
if (item.active && item.value > 100) {
acc.push(item.value);
}
return acc;
}, []);
|
チームでの導入#
関数型プログラミングのパターンをチームで導入する場合は、段階的に進めることをおすすめします。
- まず
map、filter、reduceの基本をマスターする
- イミュータブルな操作を意識してコードを書く
- 純粋関数を意識して副作用を局所化する
- 必要に応じて関数合成やカリー化を導入する
まとめ#
本記事では、JavaScriptにおける関数型プログラミングの基礎を解説しました。
- イミュータブル: データを変更せず、新しいデータを作成することで予期しないバグを防ぐ
- 純粋関数: 同じ入力に対して同じ出力を返し、副作用を持たない関数を書く
- 高階関数:
map、filter、reduceを活用して宣言的なコードを書く
- 関数合成: 小さな関数を組み合わせて複雑な処理を構築する
- カリー化: 関数の再利用性を高め、部分適用を可能にする
関数型プログラミングは、すべてを一度にマスターする必要はありません。まずはイミュータブルな操作と純粋関数を意識することから始め、徐々にスキルを広げていくことをおすすめします。
これらの概念を実践することで、より堅牢でテストしやすく、保守性の高いJavaScriptコードを書けるようになるでしょう。
参考リンク#