関数型プログラミングのシンプルな3つの原則

2023/02/04に公開

これは関数型プログラミングの入門記事です。今日から実践できる3つの原則を紹介します。

関数型プログラミングとは、数学のルールを制約として取り入れたプログラミングスタイルです。数学の制約をスタイルとして守ることでコードをシンプルにします。

ここで「数学」というだけで抵抗を感じる人もいるかもしれません。関数型プログラミングに登場する様々な概念は数学に由来するので、概念をそのまま引用するだけでは学術的な雰囲気のままになりがちです。

しかし安心してください。この記事では高校1年生の数学を知っていれば大丈夫です。本当にシンプルに解説するのできっと理解してもらえると思います。

関数型プログラミングを実践できる言語はいくつかありますが、この記事ではJavaScript / TypeScriptで説明します。

関数型プログラミングのメリット

堅牢なコードが書ける

ここでは私が関数型プログラミングに取り組むときのイメージをお伝えします。

私は小さな関数とその単体テストを書くのが習慣です。先に実装イメージが浮かぶ場合は実装から書きます。複雑な関数を実装する場合は先にテストを書いて関数の仕様を検討・整理することが、実装を書く前の助走になります。

関数型プログラミングの実践は、ペーパーテストで数学の問題を解く様子に似ています。問題文から丁寧に式を起こして、脳内メモリーに頼らずに丁寧に計算や式変形の過程を書きます。そのため読み返したとき書いた内容がちゃんと分かります。私はそんなペーパーテストの解き方をコードで実践しているように感じます。私にとって関数型プログラミングを実践することはバグのないプログラムを丁寧に組むために必要不可欠な存在です。

組み合わせて安心して使えるパーツになる

関数型プログラミングのスタイルによって書かれたコードは、その振る舞いを理解するのが非常に簡単です。そのため組み合わせやすいこともメリットです。

チームで関数型プログラミングを実践することでコードを共有するハードルが下がることが期待できます。また、関数型プログラミングのスタイルで書かれたライブラリーが生まれることで関数型プログラミングをより簡単に実践できるようになると思っています。

シンプルな3つの原則

今日から関数型プログラミングを実践するための3つの原則です。

これらの制約を受け入れるメリットとして次のことが言えます。

  • 変数宣言時に値が決定されるので読みやすい
  • 関数は引数のみで戻り値が決まるためテストしやすい
  • 関数は戻り値を返す以外の処理をしないので予測不能なバグが発生しにくい

よくある関数型プログラミングの記事では、これらのルールやメリットを専門用語で説明します。
参照透過性、純関数、副作用といった用語で説明されますが、なんだか難しそうですよね?

しかし関数型プログラミングのはじめの一歩で、これらの用語を理解しておく必要はありません。
まず関数型プログラミングのメリットを実感してもらい、これらの用語はもっと知りたくなってから学んでも遅くはありません。

もう一回、3つの原則を確認しましょう。

  • 変数の値を変更しない
  • 関数の戻り値は引数のみに依存する
  • 関数は戻り値を返す以外の処理をしない

是非この記事を最後まで読んでもらえればと思います。

変数の値を変更しない

変数の値を変更しません。計算結果は新しい変数に格納します。

数学では計算によって求められた変数の値が変更されることはありません。=は数学において等しいことを意味しているので、値が変わってしまうと=の前提が崩れます。例えば、ある問題においてx = 2と記述したなら、そのxが変わることはありません。もし問題を解き進めてx = 4のように変ってしまうことがあれば、きっと解き方を間違えています。

JavaScriptのコードでは=は代入演算子ですが、数学と同じように等しいと解釈して問題にならないようなコードを目指します。

ここで変数の値が変更されるデメリットについて考えてみます。if文やfor文で変数の値が変更されるコードの場合、変数が宣言されてから使用されるまですべてのコードを読まないと変数に格納されている値を理解できません。つまり変数の値が変更されるコードは、読むためのコストが高いと言えます。

一方で、変数の宣言以降は値が変わらない関数型プログラミングは可読性が高いです。

JavaScriptで「変数の値を変更しない」という制約を実践するには、まず変数の宣言にconstを使用してプログラムを組み立てます。そしてconst宣言以外の場面で=を使わないことも重要です。constで宣言していても配列の要素やオブジェクトのプロパティーは=によって変更できてしまいます。また配列を操作する際にはpush()sort()といった配列のデータを変更するメソッドには注意を払いましょう。

例外的に変数の値を変更するなら

constを使用してプログラムを書くようになると、どんどん上達してほとんどのケースでconstで問題ないコードが書けるようになります。

しかし、変数の値を変更しないので計算のために値をコピーするケースが多くなります。コピーのためのオブジェクトの生成が原因で処理速度が低下したり、消費メモリーが増加することがネックになるかもしれません。また、複雑なロジックを記述する場合、letを使用する方が全体的に可読性が高いケースがあるかもしれません。そのような課題がletを使用することで改善されるならletを使用してください。

letを使用すると決めたら次に考えてほしいのはletで宣言した変数をできるだけ小さな関数に閉じ込めることです。処理における関心ごとのレイヤーによって、下位レイヤーの処理は関数やメソッドに切り出します。そのように処理を分けると、可読性の低下を最小限にできるでしょう。

関数の戻り値は引数のみに依存する

引数のみをreturnする値の計算に使用します。

数学では、引数で与えた変数以外が関数の計算に使用されることはありません。このことをプログラミングに適用することで、コードの振る舞いは理解しやすいものとなります。

戻り値の計算に使用される可能性のある、引数以外の値の例を挙げます。

  • グローバル変数
  • 親スコープの変数
  • 関数内部でnew Date()した現在時刻
  • 関数内部でDBや状態管理ライブラリーから取得した値

これらの値を使用するときは引数に渡します。

また、関数の内部で「戻り値は引数のみに依存する」に違反した関数を直接使用することも避けます。そういった関数は直接使用する代わりに、やはり引数に渡して使用します。そうすると関数のテストを書くときにモックを渡すことでテストができます。

関数は戻り値を返す以外の処理をしない

関数の本来的な作用は計算結果を返すことであり、それ以外は副作用です。

関数の副作用となる処理の例を挙げます。

  • グローバル変数や親スコープの変数の変更
  • APIを呼ぶ
  • DBを操作する

これらの処理を含む関数は、影響範囲が広いため純関数よりも特別な注意を向ける必要があります。またグローバル変数や親スコープの変数の変更は、「変数の値を変更しない」という原則も破っていますね。ちなみに厳密にはconsole.log()の実行も副作用だと言えますが、その影響を把握することは簡単なのであまり問題にはなりません。

どこまで実践するか

DBのデータを取得する/書き換える、といった処理はアプリケーションでは日常茶飯事です。そういった副作用が必要な関数は、できるだけ小さく作ることを意識してください。letと同じアドバイスです。私の見解としては、それで十分バグの少ないコードになります。

関数型プログラミングとしてきれいに副作用を扱いたい場合は、「関数型プログラミングのシンプルな3つの原則」を実践できるようになったら「関数型プログラミング モナド」で検索してみてください。

JavaScriptによる関数型プログラミング

3つの原則を紹介しました。この3つの原則を実際のコードに適用して関数型プログラミングの第一歩を踏み出しましょう。

しかしconst宣言時にしか計算できないなど、これらの原則を厳しいと感じるかもしれません。そこで3つの原則を守りながら不自由なくロジックを表現するための、強力な武器を紹介します。

関数型プログラミングを支える配列メソッドたち

関数型プログラミングのツールとして使える配列メソッドの一部を紹介します。関数型プログラミングで配列メソッドを使用する場合、元の配列を変更しないことが重要です。データを変更しないメソッドは非破壊的メソッド、データを変更してしまうメソッドは破壊的メソッドと呼びます。

以下は、よく使用する配列の非破壊メソッドです。これらのメソッドの共通点は関数を受け取ることです。その関数には各要素に対して3つの引数(element, index, array)が渡され、実行されます。

  • map: 各要素を変換する。
  • filter: 要素をフィルターする。
  • reduce: データの次元を1つ下げる。配列 => 値、二次元配列 => 配列
  • some: 関数の戻り値が1つでもTruthyならtrue
  • every: 関数の戻り値がすべてTruthyならtrue

JavaScriptの進化は関数型プログラミングをよりサポートする方向に向かっており、新しく4つの配列メソッドが追加される見込みです。 新しく4つの配列メソッドが追加されました。

https://github.com/tc39/proposal-change-array-by-copy

  • Array.prototype.toReversed() -> Array
  • Array.prototype.toSorted(compareFn) -> Array
  • Array.prototype.toSpliced(start, deleteCount, ...items) -> Array
  • Array.prototype.with(index, value) -> Array

これらはすべて非破壊メソッドです。個人的には.toSorted().toSpliced()が嬉しいです。
.slice()を記述しておくことで配列がシャローコピーされることを利用して、今までは.slice().sort().slice().splice()のように記述して関数型プログラミングの原則を守っていました。 今後はもっとスッキリ書けるようになりそうですね。 このプロポーザルは既にJavaScriptの仕様としてマージされています。MDNを見ても一覧のすべてのブラウザーが✅されていますね。もうくどい書き方とはおさらばです。機会があれば使っていきましょう。

https://github.com/tc39/proposal-change-array-by-copy/commit/71ebbf844cf74e05317ad0803f4b827b706b6738

mapの使い方

関数型プログラミングにおける主役とも言えるメソッドの使い方を紹介します。

次のコード(TypeScript)はペーパーテストの結果から個人評価を決める処理です。

/** 評価 */
type AlphabetScore = 'A' | 'B' | 'C' | 'D';

/** 評価の基準 */
type ScoreBorder = {
  /** 評価: A, B, C, D */
  alphabet: AlphabetScore;
  /** 基準: ペーパーテストの点数が基準以上かどうか */
  border: number;
};

/** 基準表: 評価が高い順番で定義 */
const scoreBorders: ScoreBorder[] = [
  { alphabet: 'A', border: 90 },
  { alphabet: 'B', border: 80 },
  { alphabet: 'C', border: 70 },
  { alphabet: 'D', border: 0 },
];

/** 基準表からペーパーテストの点数を評価に変換する */
const toAlphabetScore =
  (borders: ScoreBorder[]) =>
    (testScore: number): AlphabetScore => {
      const found = borders.find(({ border }) => border <= testScore);
      if (!found) throw new Error('Test score must be between 0 and 100.');
      return found.alphabet;
    };

/** ペーパーテストの結果 */
const testScores = [100, 80, 90, 70, 40];

/** 評価 */
const alphabetScores = testScores.map(toAlphabetScore(scoreBorders));
console.log(alphabetScores); // ['A', 'B', 'A', 'C', 'D']

こちらのプレイグラウンドでも確認出来ます。

このサンプルコードは、まず変数の値を変更しないという原則を守っています。変数宣言はすべてconstを使用しています。そして再代入はありませんね。

またtoAlphabetScoreは純関数です。引数であるborders: ScoreBorder[]testScore: numberに依存しており、副作用はありません。

変数の値を変更しない原則を守りながら、新しい値を作ることでデータ変換を実現しました。

letforEachには要注意

繰り返しになりますがletは変数の再代入を許容します。つまりletは変数の値を変更しない原則を破っているサインです。letは禁止ということではなく、letがあれば注意してコードを読む必要があるということです。またコードを読むコストを下げられるように、letは小さな関数で使うことを意識してください。

forEachmapと同様に配列の各要素ごとの処理を書けますが、決定的な違いはforEachには戻り値がありません。つまりforEachを使用していれば必ず副作用を持つ関数が実行されます。その点を注意してコードを読む必要があります。

関数型プログラミングと親和性の高いライブラリー

昨今、ライブラリーに頼らない開発は考えられません。ということで関数型プログラミングと親和性の高いライブラリーを2つ紹介します。

React

https://react.dev/

メジャーなUIライブラリーですね。ここでは少しReactを知っていることを前提に説明している点をご容赦ください。

関数型プログラミングと親和性が高いことを示す式を紹介します。

// v: view
// f: function(Reactコンポーネント)
// m: model
v = f(m)

// Reactらしい表現
ReactElement = ReactComponent(props)

この式の構成は数学の関数のようですね。

実際にはReactのコンポーネントは副作用を扱えないという制約はありません。コンポーネントは状態を持つことが可能ですし、コンポーネントからAPIをリクエストすることも可能です。

しかしReactのコンポーネントを純関数にすることでバグの少ない、テストしやすいコンポーネントが作れることはReactの業界では広く認知されています。また、上手く副作用を扱うプラクティスは日々進化しています。

Reactと同じぐらいメジャーなUIライブラリーであるVueと比べた場合、Reactはデータの流れを片方向に扱うので、その点が関数型プログラミングと親和性が高いと言えます。データの流れが片方向ということは、コンポーネント内部でpropsで与えられた値を書き換えることはありません。そのため関数型プログラミングを実践したいならReactをお勧めします。

Vueはデータを双方向に扱うことが出来るため、Vueでもデータの流れを片方向に統一することで関数型プログラミングを実践できますが、それはVueの文化ではないためお勧めしません。Vueコンポーネントを扱う点においてはVueの文化を受け入れましょう。しかし、Vueプロジェクトで関数型プログラミングを実践できないわけではありません。Vueの状態管理の新しいスタンダードとなったPiniaやその前身であるVuexでは関数型プログラミングを実践できます。

https://pinia.vuejs.org/

https://vuex.vuejs.org/ja/

Redux

メジャーな、そして歴史のある状態管理ライブラリーです。最近では少しずつ新しい状態管理ライブラリーがシェアを奪いつつあります。しかし、Reduxのインターフェースを整理したRedux Toolkitは十分現役だと思います。

https://redux-toolkit.js.org/

Reduxの仕組みを説明するためにはstoreactionなどたくさんの登場人物が存在するのですが、ここではreducerのみ紹介させてください。

reducerは状態を変更する純関数です。どのような役割かは次の式で理解できます。

store' = reducer(store, action)

storeは変更前の状態、store'が変更後の状態です。変更前の状態とアクションから、次の状態を計算します。純関数によって状態を変更することで、テストしやすくバグのないプログラムが組めます。

まとめ

関数型プログラミングのシンプルな3つの原則と、サンプルコード、ライブラリーを紹介しました。
是非、関数型プログラミングを実践してコードの品質を高めてください。関数型プログラミングを広め、平穏に開発したいと切に願っています。

Discussion