はじめに
こんにちは。ヴェネツィアの街中に流れる川でクロールすることが将来の夢です。トコロテンです。
本記事は、Boushitsu Advent Calendar 2020の8日目の記事になります。
最近、状態管理に対する負の感情が溜まってきたので手始めに純粋関数について学びました。
本記事では、自分が理解した範囲で一般的な関数の概念から純粋関数の概念まで実装を交えた説明を共有いたします。
本記事の目的は、読者の方々に純粋関数を厳密な理解よりも素早くふわっと理解してもらうことです。
そのため、厳密には正しくない説明が一部含まれます。ご了承ください。
関数
関数という概念
関数は、もともと数学に存在していた概念です。
しかし、今日ではプログラミングにも関数が導入されたことを始め、より一般的な概念となりました。
関数を簡潔に述べるなら、入力に対応する出力を行う何かであると言えます。(本記事ではこれを基本的な関数の定義とする)
直感的なイメージとして、関数は以下のような図で説明される場合が多いと思います。
以下の図のような構造を持った関数を基本的な関数と本記事では表記します。
定義から分かるように関数は本当に抽象度の高い概念です。
その気になれば世の中の殆どのものを入力と出力を対応させる関数とみなすことができます。
解釈次第では、私も読者の方々も関数であると言えます。
数学における関数
数学における関数は、基本的な関数に制約を付け加えたものであると考えることができます。
構造的には、基本的な関数と何も変わらないため、図も基本的な関数と同じになります。
定義は以下の通りです。
ここで重要なのは、関数が参照透過(右一意的)であるといった性質です。
これは、後に純粋関数といった概念を理解する上で必要なものになります。
定義
集合X, Yの二項関係fが右一意的かつ左全域的であるとき、fは関数です。
右一意的
入力が同じであれば出力も必ず同じものになる性質(参照透過性)
\forall x\in X, \forall y \in Y, \forall z \in Y, ((x, y) \in f \land (x, z) \in f \Rightarrow y = z)
左全域的
取りうる全ての入力において出力が定義されている性質
\forall x\in X, \exists y \in Y, (x, y) \in f
プログラミング言語における関数
プログラミング言語における関数は、基本的な関数の概念を拡張したものであると考えることができます。
具体的には、以下のような点が基本的な関数と異なります。
- 入力や出力が存在しない場合がある(
void
)- 関数というよりプロシージャである
- 出力が状態に依存する場合がある
- 副作用を持つ場合がある
参照透過性
プログラミングにおける関数は、数学的な関数と異なり、参照透過性を持つとは限りません。
実際、以下のgetCurrentYear()
関数は参照透過性を持ちません。
function getCurrentYear(): number {
return new Date().getFullYear();
}
上記の関数は、内部でDate
のインスタンスのgetFullYear()
メソッドの返り値を利用しています。
Date
型のコンストラクタに何も渡さなかった場合、インスタンスの状態(時刻)は実行時刻に依存し、getFullYear()
メソッドの返り値も実行時刻に依存するようになります。
結果として、getCurrentYear()
関数を実行したタイミングによって異なる返り値が得られます。
一方、以下のisLeapYear()
関数は参照透過性を持ちます。
function isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
上記の関数を見て分かる通り、返り値が引数year
にのみ依存しているため、引数として同じyear
の値が与えられれば、常に同じ返り値を返します。
関数の主作用と副作用
プログラミング言語における関数の特徴として、副作用が存在します。
副作用が存在するということはもちろん主作用も存在します。
関数の主作用とは、まさに定義通り、入力を変換して出力を行うことです。
プログラムにおいては、関数が引数を受け取って値を返すことに相当します。
- 主作用: 引数を受け取って値を返すこと
- 副作用: 主作用以外のコンピュータの状態等を変化させる作用
主作用は非破壊的な操作、副作用は破壊的な操作の言い換えであると考えることもできます。
主作用は限定的ですが、副作用は具体的なイメージが湧きづらいと思うので以下に副作用を持つaddToCart()
関数を示します。
let addedToCartCount = 0;
function addToCart(customer: Customer, item: Item): void {
customer.cart.push(item); // 副作用
addedToCartCount++;
console.log(`Added to cart: ${customer.id}, ${item.id}`);
return;
}
addToCart()
関数が副作用を含む理由は、以下の3つです。
- 引数
customer
の状態(cart
プロパティ)を変化させている - グローバル変数
addedToCartCount
の状態を変化させている - I/Oの状態を変化させている(標準出力への書き込み)
副作用を含む関数の直感的イメージは以下の図の通りになります。
副作用が含まれたことによって関数の構造にある決定的な違いが生まれました。
それは、副作用を含まない関数ではデータ及び操作の流れが一方向であることに対し、副作用を含む関数では双方向になるということです。
副作用を含む場合、外部への影響を考えながら関数を作成する必要が出てきます。
純粋関数と不純関数
純粋関数
以下の2つの条件を満たす関数を純粋関数と呼びます。
純粋関数単体の構造は、定義から察するように基本的な関数と同一です。
- 参照透過性を持つ
- 副作用を持たない
上記の制約によって純粋関数は以下の素晴らしい性質を持ちます。
- メモ化が行える
- 参照透過性により、マップを用意すれば関数の計算結果の再利用が行える
- スレッドセーフである
- 副作用が無いため、データの競合が発生しない
- ユニットテストが容易である
- 参照透過であり副作用が無いことから、状態を気にせず関数の入出力の組だけをテストすれば良い
- シグネチャから計算に利用するデータが一目瞭然である
- 参照透過性により、引数の組を見るだけで何が計算に利用されるか分かる
以下に、純粋関数を組み合わせて作成されたプログラムの構造の直感的なイメージを示します。
Fのノードが関数を表現しており、エッジが関数間のデータの入出力を表現しています。
純粋関数を組み合わせて作成されたプログラムは、それぞれの関数の干渉を考える必要が殆ど無くなります。
このプログラム中で考えなければならないことは、各関数に正しい引数が渡されているかの一点のみになります。
なぜならば、全ての関数は引数のみに出力が依存しており、外部の状態を一切変更しないためです。
不純な関数
純粋関数ではない関数を不純な関数と呼びます。
不純な関数は、出力が外部の状態に依存していたり、外部の状態を変更したりする場合があります。
以下に、不純な関数を組み合わせて作成されたプログラムの構造の直感的なイメージを示します。
Fのノードが関数を表現しており、エッジが関数間のデータの入出力を表現しています。
不純な関数を組み合わせて作成されたプログラムは、それぞれの関数の干渉を十分に考える必要があります。
純粋関数と異なり、不純な関数は、外部状態への依存または変更、呼び出し元の引数の変更が行われるため、状態の管理をしっかり行わなければバグの温床になります。
不純な関数から純粋関数への変換
不純な関数から純粋関数へ変換するためには、純粋関数の定義より以下の操作を行えば良いです。
- 参照透過性を持たせる
- 外部状態への依存を排除する
- 副作用を排除する
- 外部状態への変更を排除する
上記の2つの操作から分かるように、関数が純粋関数であるということは状態に一切関係しないということであり、関数を状態から完全に切り離すことで純粋関数化が行えます。
実際に、不純な関数から純粋関数へ変換していく例を示します。
外部状態への依存の排除
以下のimpureGreeting()
関数は不純な関数であり、それを純粋関数に変換したものがpureGreeting()
関数になります。
impureGreeting()
関数はローカル変数h
が外部状態(時刻)に依存しており、h
によって返り値が決定します。
このように外部状態に依存する場合は、その状態に該当するものを関数の引数に持たせることで解決します。
// 不純な関数
function impureGreeting(): string {
// 外部の状態(時刻)に依存している(参照透過でない)
const h = new Date().getHours();
if (h >= 6 && h < 12) {
return "Good morning.";
} else if (h >= 12 && h < 18) {
return "Good afternoon.";
} else {
return "Good evening.";
}
}
// 純粋関数
function pureGreeting(h: number): string {
if (h >= 6 && h < 12) {
return "Good morning.";
} else if (h >= 12 && h < 18) {
return "Good afternoon.";
} else {
return "Good evening.";
}
}
外部状態への変更の排除
以下のimpureMovePoint3()
関数は不純な関数であり、それを純粋関数に変換したものがpureMovePoint3()
関数になります。
impureMovePoint3()
関数は引数point
の状態を変更しており、これが副作用になります。
このように外部状態を変更する場合は、その状態を含むオブジェクトのコピーに変更を加えたものを新たに作成し、返り値とすることで外部状態の変更を排除することができます。
interface Vector3 {
x: number;
y: number;
z: number;
}
interface Point3 {
id: number;
coord: Vector3;
}
// 不純な関数
function impureMovePoint3(point: Point3, diff: Vector3): void {
// 引数のオブジェクトの状態を変更している(副作用)
point.coord.x += diff.x;
point.coord.y += diff.y;
point.coord.z += diff.z;
}
// 純粋関数
function pureMovePoint3(point: Point3, diff: Vector3): Point3 {
return {
...point,
coord: {
x: point.coord.x + diff.x,
y: point.coord.y + diff.y,
z: point.coord.z + diff.z,
}
};
}
I/Oの副作用
簡単な副作用を排除する方法をこれまでに示しましたが、I/Oの副作用の場合どのようにすればよいのでしょうか。
I/Oを持たないプログラムは全く意味が無い場合が殆どであり、世の中の多くのプログラムはI/Oを利用しています。
しかし、I/Oの副作用を取り除くということはI/Oを全く使わないことと同義であるかのように思えます。
例えば、以下のimpureLogging()
関数は、標準出力に文字列を書き込んでI/Oの状態を変化させる副作用があります。
function impureLogging(f: Function): void {
const res = f();
// I/Oの状態変更(標準出力への書き込み)
console.log("Result: " + res);
}
このようなI/Oの副作用を取り除くためにモナドという概念がしばしば利用されます。
モナドについて本記事でしっかり説明しようとするととても文量が多くなってしまうため、モナドについて知りたい方は以下の記事を読むことをおすすめします。
最後に
ここまで読んだ読者の中で一人でも純粋関数を理解し、状態に依存せず副作用を持たないような関数を書けるようになった方がいるなら嬉しい限りです。
プログラム中において状態という概念が存在することによって考えることが多くなります。
純粋関数の本質はそのような状態という概念との断絶にあります。
さようなら、状態管理。こんにちは、純粋関数。
晴れ時々純粋関数。