関数型プログラミングとは
関数型プログラミング(Functional Programming)とは、
簡潔に言うと、
関数を中心にプログラムを組み立てるスタイルのことを指します。
具体的に述べると、
純粋関数とイミュータブルな値を組み合わせて宣言的にプログラムを書くスタイルを意味します。
この記事全体を通して、この内容を詳細に説明していきます。
関数型プログラミングの特徴
関数型プログラミングは、以下のような特徴を持ちます。
- 宣言型プログラミング
- 一つの要件を一つの関数で表す
- シグネチャ = 関数の役割
- 関数を「値」と同じよう扱う
① 宣言型プログラミング
プログラミングには「命令型」と「宣言型」の大きく分けて2つの考え方があります。
- 命令型
「どうやるか」(手順)を1つずつ指示するスタイル
const numbers = [1, 2, 3, 4, 5];
const evens = [];
// 「どうやって偶数を取り出すか」を細かく記載
for (let i = 0; i < numbers.length; i++) {
if (numbers[i] % 2 === 0) {
evens.push(numbers[i]);
}
}
メリット:コンピュータの動きに近いため、極限までパフォーマンスをチューニングしたい場合には命令的な記述が最適になる
- 宣言型
「何をしたいか」(結果)だけを書くスタイル
const numbers = [1, 2, 3, 4, 5];
// filter()「偶数を取り出す」という記載だけ
const evens = numbers.filter(n => n % 2 === 0);
メリット:コードが短くなり、意図が読み取りやすくなる
関数型プログラミングは、宣言型プログラミングであり、処理の内容を関数(何をしたいか)の組み合わせで表現します。
const numbers = [1, 2, 3, 4]
// 関数の組み合わせ
const result = numbers
.map(double)
.filter(greaterThanFive)
.reduce(sum, 0)
② 一つの要件を一つの関数で表す
関数型プログラミングでは、大きな処理を1つの関数にまとめて書くのではなく、それぞれの要件を小さな関数に分割して組み合わせるという考え方を重視します。
具体的には、
- 要件を小さな処理に分割する
- それぞれを小さな関数として実装する
- それらの関数を組み合わせて大きな機能を作る
という流れでプログラムを構築します。
例えば、以下のような要件があったとします。
- 偶数だけ取り出す
- その数値を2倍にする
上記の要件を関数プログミングで表すと、
const numbers = [1, 2, 3, 4, 5];
// 偶数かどうか判定
function isEven(n) {
return n % 2 === 0;
}
// 数値を2倍にする
function double(n) {
return n * 2;
}
// 関数を組み合わせて処理
const result = numbers
.filter(isEven)
.map(double);
console.log(result); // [4, 8]
それぞれの関数がそれぞれの要件を満たし、それを組み合わせることで、全体の機能を構築しています。
③ シグネチャ = 関数の役割
関数型プログラミングでは、関数の内部にどのような処理が書かれているかを見る前に、シグネチャ(関数名+引数の型・個数・順番)を見るだけで「その関数が何をしたいのか」を理解できることが理想とされます。
例えば、
let multiplier = 2;
function double(n: number): number { // ← シグネチャ
return n * multiplier;
}
この関数はmultiplierという外部の状態に依存しており、外部の値が2から3に変われば処理結果も変わってしまいます。シグネチャからだけではどのような処理がされるかがわかりません。
次のようなものが、純粋関数として理想です。
function double(n: number): number { // ← シグネチャ
return n * 2;
}
中身の処理を見なくともシグネチャを見るだけで、「数値を2倍して、数値を返す」という関数の役割を理解できます。
④ 関数を「値」と同じよう扱う
関数型プログラミングでは、関数も値として扱います。ここで言う「値」とは、変数に入れたり、関数に渡したり、戻り値として返したりできるものを指します。
例えば、
const double = function(n) {
return n * 2;
};
ここでは、doubleという変数に関数という値が入っています。
function apply(fn, value) {
return fn(value);
}
function double(n) {
return n * 2;
}
apply(double, 5); // 10
ここでは、doubleを関数の引数として扱っています。
このように、関数を値として扱うことを意識することで、処理を柔軟に組み合わせたり、再利用しやすくなります。
関数型プログラミングの構成要素
関数型プログラミングは、以下の2つの要素を根幹としています。
- 純粋関数
- イミュータブルな値
関数型プログラミングは、純粋関数とイミュータブルな値を使ってプログラムを組み立てることを主としています。
純粋関数
関数型プログラミングでは、純粋関数という考え方が重要になります。純粋関数とは、同じ入力に対して必ず同じ結果を返し、外部に影響を与えない関数のことです。
純粋関数の特徴・・・
- ひとつだけ値を返す
- 引数主導の計算
- 既存の値を変更しない
① ひとつだけ値を返す
純粋関数では、処理の結果として1つの値(戻り値)を返します。
function double(n) {
return n * 2;
}
double(5)
上記の関数は、入力:5に対して、出力:10というように、1つの結果だけを返しています。
※ここで重要なのは、処理結果を戻り値として返すという点です。
例えば、次のコードは純粋関数ではありません。
let result;
function double(n) {
result = n * 2;
}
この場合、結果を外部の変数に格納しているため純粋ではありません。
② 引数主導の計算
純粋関数では、戻り値が渡された引数のみに基づいて計算されます。
function add(a, b) {
return a + b;
}
この関数は、入力:2,3 → 出力:5のように、入力だけで結果が決まります。
一方で、次のような例は純粋関数ではありません。
let tax = 1.1;
function calculatePrice(price) {
return price * tax;
}
この関数は外部の変数taxに依存しているため、環境によって結果が変わる可能性があります。
③ 既存の値を変更しない
純粋関数では、外部のデータを変更しません。
悪い例(純粋関数ではない):
let total = 0;
function addPrice(price) {
total += price;
}
この関数では外部の変数totalを変更しているため、純粋関数ではありません。
良い例(純粋関数):
function addPrice(total, price) {
return total + price;
}
この場合、値を変更するのではなく、新しい値を返します。
※ 関数の内部で作った変数(ローカル変数)を変更するのは問題ありません。
function double(n) {
let result = n * 2;
return result;
}
このresultは関数の内部だけで使われるため問題ありません。
純粋関数を保つための方法・・・
純粋関数を保つための方法には以下のようなものがあります。
1. データをコピーして渡す
純粋関数では、既存のデータを変更しないことが重要です。そのため、元のデータを変更するのではなく、コピーしたデータを使います。
悪い例(元のデータを変更する):
const numbers = [1, 2, 3];
function addNumber(arr, n) {
arr.push(n);
return arr;
}
addNumber(numbers, 4);
console.log(numbers); // [1,2,3,4]
このコードでは、arr.push(n)によって元の配列が変更されています。
良い例(コピーして使う):
const numbers = [1, 2, 3];
function addNumber(arr, n) {
return [...arr, n];
}
const result = addNumber(numbers, 4);
console.log(numbers); // [1,2,3]
console.log(result); // [1,2,3,4]
ここでは、[...arr, n]によって新しい配列を作っています。
つまり、元のデータは変更せず、新しいデータを作るという考え方です。
2. 状態を引数として渡す
純粋関数では、外部の状態に依存しないことが重要です。そのため、必要な状態を引数として渡します。
悪い例(外部の状態に依存):
let tax = 1.1;
function calculatePrice(price) {
return price * tax;
}
この関数はtaxという外部の状態に依存しています。もしtaxが変更されると結果も変わります。
良い例(状態を引数として渡す):
function calculatePrice(price, tax) {
return price * tax;
}
calculatePrice(100, 1.1); // 110
この場合、入力:price = 100 tax = 1.1だけで結果が決まります。つまり、状態は関数の外に置かず、値として渡すという設計になります。
純粋関数を使うメリット・・・
純粋関数を使うメリットは主に以下3つとなります。
1. 単一責任
純粋関数は引数から戻り値を計算するだけなので、役割が自然と1つ(単一責任)になります。
例えば、
function double(n: number): number {
return n * 2;
}
この純粋関数は、「数値を2倍にする」という1つの役割だけを持っています。そのため、「理解しやすい」「再利用しやすい」コードとなっています。
もし1つの関数が複数の責任を持つと、次のようになります。
function processUser(user) {
user.age += 1;
console.log(user);
saveUser(user);
}
この関数は、
- 年齢を更新
- ログ出力
- データ保存
という複数の責任を持っています。このような関数では理解や管理が難しくなってしまいます。
2. 副作用がない
副作用とは、関数の外側の状態を変更してしまうことです。
例えば、次の関数です。
let count = 0;
function increment() {
count++;
}
この関数は、外部の変数 count を変更しているため、副作用があります。
純粋関数ではこのようなことはしません。
function increment(n: number): number {
return n + 1;
}
この関数では、入力:n 出力;n + 1だけです。外部の状態は一切変更しません。その結果、「バグが起きにくい」「動作が予測しやすい」コードとなっています。
3. 参照透過性
参照透過性とは、「同じ入力なら、必ず同じ出力になる」という性質です。
参照透過な例:
function square(n: number): number {
return n * n;
}
この関数は、
square(3) → 9
square(3) → 9
square(3) → 9
何回実行しても必ず同じ結果になります。
参照透過でない例;
let tax = 0.1;
function priceWithTax(price: number): number {
return price * (1 + tax);
}
もし途中でtax = 0.2;となると呼び出した際の結果が変わります。つまり、同じ入力でも結果が変わるため、参照透過ではありません。
参照透過な関数は、結果が入力だけで決まり、外部の状態に影響されないため、コードが理解しやすく、テストしやすく、安全に実行できるコードとなります。
イミュータブルな値
イミュータブルな値とは、一度作成したら変更できない値のことです。もし値を変更したい場合は、既存の値を変更するのではなく、新しい値を作成(コピー)します。
ミュータブルな例:
const numbers = [1, 2, 3];
numbers.push(4); // 配列の中身が変更される
この場合、numbersという配列の元の値が直接変更されています。ミュータブルな値は、どこからでも変更される可能性があります。
その結果、
- どこで変更されたのか分かりにくい
- バグの原因になる
- プログラムの動作が予測しにくくなる
という問題が起きます。
イミュータブルな例:
const numbers = [1, 2, 3];
const newNumbers = [...numbers, 4];
この場合、元のnumbersは変更されていません。代わりに、newNumbersという新しい配列を作成しています。
イミュータブルな値を使うことで次のメリットがあります。
- 状態の変更を追跡しやすい
- バグが発生しにくい
- プログラムの動作が予測しやすい
関数型プログラミングでは、イミュータブルな値を使うことが推奨されます。
高階関数
上記の二つの要素に加えて、関数型プログラミングにおいて高階関数も重要な要素となります。最初の章で、関数を値として扱うという考え方を紹介しましたが、この概念を体現するのが高階関数となります。
高階関数の特徴・・・
高階関数とは、以下のいずれかを満たす関数を指します。
- 関数を引数として受け取る
- 関数を戻り値として返す
① 関数を引数として受け取る
関数を別の関数に渡して処理を行うパターンです。
function apply(fn: (n: number) => number, value: number): number {
return fn(value);
}
function double(n: number): number {
return n * 2;
}
apply(double, 5); // 10
この例では、doubleという関数をapplyに渡しています。つまり、applyが関数を引数として受けっといるため、高階関数です。
② 関数を戻り値として返す
関数が新しい関数を作って返すこともできます。
function createMultiplier(n: number) {
return function(x: number): number {
return x * n;
};
}
const double = createMultiplier(2);
double(5); // 10
このように関数を返す関数も高階関数です。
標準ライブラリの代表的な高階関数・・・
標準ライブラリには、多くの高階関数があります。代表的なものは次の3つで、これらは関数を引数として受け取っており、高階関数となります。
map(変換)
配列の各要素を変換して、新しい配列を作ります。
const numbers = [1, 2, 3];
const result = numbers.map(n => n * 2);
console.log(result); // [2, 4, 6]
filter(抽出)
条件に合う要素だけを取り出します。
const numbers = [1, 2, 3, 4];
const result = numbers.filter(n => n % 2 === 0);
console.log(result); // [2, 4]
reduce(集約 / 畳み込み)
配列の要素を1つの値にまとめます。
const numbers = [1, 2, 3, 4];
const sum = numbers.reduce((acc, n) => acc + n, 0);
console.log(sum); // 10
高階関数のメリット・・・
高階関数を使うメリットは、以下2つです。
-
共通する「枠組み」を再利用できる
多くのプログラムには、「データの集合をループで回す」「エラーハンドリングを行う」といった共通の枠組みが存在します。高階関数を使うと、この「枠組み」だけを関数として定義し、「具体的な処理」を何度も書く必要がなくなります。 -
コードの意図が明確になる
「どうやって実現するか(How)」という手順に埋もれてしまいがちなコードの意図が、高階関数を使うことで「何をしたいか(What)」という形で浮かび上がってきます。例えば、filterを使った場合、「条件に合うものを抽出する(filter)」という単語がコードに現れるため、一目で意図が伝わる。
まとめ
ここまで、関数型プログラミングの基本的な考え方について説明してきました。
関数型プログラミングの特徴は、関数を中心にプログラムを組み立てるという点にあります。処理を大きな手続きとして書くのではなく、小さな関数に分割し、それらを組み合わせることでプログラムを構築します。
そのために重要になるのが、次の3つの考え方です。
- 純粋関数: 外部の状態に依存せず、引数から結果を計算する関数
- イミュータブルな値: 既存のデータを変更せず、新しい値を作る
- 高階関数: 関数を値として扱い、関数を組み合わせて処理を表現する
これらを組み合わせることで、プログラムは
- 動作が予測しやすい
- 副作用によるバグが起きにくい
- 小さな部品(関数)の組み合わせとして設計できる
という特徴を持つようになります。
また、関数型プログラミングでは、命令型「どのように処理するか(How)」よりも、宣言型「何をしたいか(What)」をコードで表現することを重視します。その結果、コードの意図が読み取りやすくなり、保守性の高いプログラムを書くことができます。
もちろん、すべてのプログラムを完全な関数型スタイルで書く必要はありません。実際の開発では、命令型の書き方と組み合わせながら使うことも多くあります。
しかし、
- 状態をなるべく持たない
- 値を変更するのではなく新しい値を作る
- 処理を小さな関数に分割する
といった関数型プログラミングの考え方を意識するだけでも、コードの品質は大きく向上します。
まずは、小さな関数を作ることや、map・filter・reduce などの高階関数を使うことから始めてみてください。そうした実践を通して、関数型プログラミングの考え方を自然に身につけていくことができるでしょう。
補足
関数型エラー処理
関数型プログラミングでは、エラー処理も関数の戻り値として扱うことが重要です。つまり、エラーを例外として処理するのではなく、値として扱うという考え方です。
nullを使ったエラー処理・・・
エラーが起きたときにnullを返す方法はよくありますが、関数型プログラミングではあまり好まれません。
function parseNumber(str: string): number | null {
const n = Number(str);
if (Number.isNaN(n)) {
return null;
}
return n;
}
この場合、呼び出す側は「正常な値」と「null」の2つの可能性を常に考える必要があります。
- 何が起きたのか分からない
- バグの原因になりやすい
という問題があります。
try-catchを使ったエラー処理・・・
ほとんどのプログラミング言語において、try-catchを使って例外処理を行うことができます。
function parseNumber(str: string): number {
try {
const n = JSON.parse(str);
return n;
} catch {
return 0;
}
}
しかし、try-catchは、
- 制御フローが複雑になる
- 関数の振る舞いがシグネチャから分かりにくい
という問題があります。
Option型を使ったエラー処理・・・
関数型プログラミングでは、Optionという型を使う方法があります。
Optionは次の2つの状態を持ちます。
- Some(value): 値がある
- None: 値がない
function parseNumber(str: string): Option<number> {
const n = Number(str);
if (Number.isNaN(n)) {
return None;
}
return Some(n);
}
上記の例を見てわかる通り、Optionが付いたシグネチャを見るだけで、「成功する場合がある」「失敗する場合がある」ということが分かります。つまり、エラーの可能性がシグネチャに現れるというメリットがあります。
orElseを使ったエラー処理・・・
Optionは、関数と組み合わせて処理をつなげやすいという特徴があります。
例えば、文字列を数値に変換するparseNumberという関数があるとします。
parseNumber("10") → Some(10)
parseNumber("abc") → None
"abc"のように数値に変換できない場合は、Noneが返ります。
ここでorElseを使うと、「値がない場合の代替処理」を簡単に書くことができます。
const result =
parseNumber("abc")
.orElse(() => Some(0));
上記のように、「数値に変換できればその値を使う」「変換できなければ 0 を使う」という処理をシンプルに書くことができます。
このように Option は、値がある場合とない場合の処理を関数でつなげて書けるため、
エラー処理や分岐を分かりやすく表現できるという特徴があります。
Either型を使ったエラー処理・・・
Optionの問題点として、Noneしか返せないため、どんなエラーが起きたのかが分かりません。この問題を解決するために使われるのがEitherです。
Either型は、慣習として以下のような使い方をします。
Either<L, R>
├─ Left(エラー内容)
└─ Right(成功値)
例えば、
function parseNumber(str: string): Either<string, number> {
const n = Number(str);
if (Number.isNaN(n)) {
return Left("Not a number");
}
return Right(n);
}
この場合、
Right(number) → 成功した値
Left(string) → エラー内容
になり、Optionの問題であるエラー内容がわからないという問題を解決します。
データのモデル化
データのモデル化とは、現実の問題や概念をプログラムで扱いやすいデータ構造として表現することです。
関数型プログラミングでは、データのモデル化においてデータの意味や制約を型によって表現することを重視します。
例えば、一般的にデータ定義を行う際、ユーザーを次のように定義します。
type User = {
id: number
name: string
age: number
}
このような定義は分かりやすいですが、実際のプログラムではいくつかの問題が発生することがあります。
問題1:パラメータの順序がわからない
例えば、次のような関数があるとします。
function createUser(id: number, age: number): User
呼び出すときに
createUser(10, 20)
と書いた場合
10 = id
20 = age
なのか
10 = age
20 = id
なのかすぐには分かりません。
特にパラメータが増えると、さらに分かりにくくなります。
createUser(10, 20, 1, 3000)
id?
age?
role?
salary?
のように何の値なのか判断しづらくなります
解決方法:newtype(ゼロコストラッパー)
この問題を防ぐために、意味ごとに型を分けます。
type UserId = number
type Age = number
そして関数を次のように定義します。
function createUser(id: UserId, age: Age): User
これにより
createUser(userId, age)
numberが意味のある型になるため、コードが読みやすくなります。
このように既存の型を意味のある型で包む方法をnewtype(ゼロコストラッパー)と呼びます。
問題2:特定の条件でしか存在しない値がある
データの中には、特定の条件でしか存在しない値があります。
例えば、「ログインしているユーザーのみ、メールアドレスが存在する」などです。
このような場合、単純な型では状態を表現しきれません。
解決方法:Option型
Option型を使うことで、値がある場合とない場合を表現できます。
例えば、
type User = {
name: string
email: Option<string>
}
これにより、「メールアドレスがある」「メールアドレスがない」という状態を安全に表現できます。
問題3:取り得る値の範囲が決まっている
例えば、次のような値があります。
- ステータス
- 曜日
- 支払い方法
このような値は決まった種類しか存在しません。
もし単なる文字列で定義すると、
type Status = string
次のような問題が起きます。
status = "running"
status = "abc"
つまり、無効な値が入る可能性があります。
解決方法:直和型(Union Type)
直和型は、複数の型のうちどれか1つを持つ型です。
type Status =
| "pending"
| "success"
| "failed"
この場合、
pending
success
failed
のいずれかしか値として持てません。
つまり、「事前に定義された値からしか選べない」という安全なデータ構造になります。
このように関数型プログラミングでは、
- 意味を持つ型を作る(newtype)
- 存在する/しないを型で表す(Option)
- 取り得る値を型で制限する(直和型)
データの意味や制約を型で表現し、安全なデータ構造を作ります。
追記:ADT(Algebraic Data Type / 代数的データ型)
以下を組み合わせたデータモデルを ADT(Algebraic Data Type) と呼びます。
- 直積型(Product Type):複数の値をすべて持つ型
- 直和型(Sum Type):複数の型のうち どれか1つを持つ型
例えば、
type User = {
id: number
name: string
}
type LoginState =
| { type: "Guest" }
| { type: "LoggedIn"; user: User }
構造
LoginState
├ Guest
└ LoggedIn
└ User
├ id
└ name
ここでは、
- User → 直積型
- LoginState → 直和型
これらを組み合わせてデータを表現しています。
このような「直積型 + 直和型」でデータ構造を作る方法を ADT(Algebraic Data Type) と呼びます。