arrow一覧に戻る

2021.06.15

JavaScriptで覗き見る関数型プログラミングの世界

はじめに

はじめまして。Fixel で開発に携わっているフリーランス Web エンジニアの佐藤(@thesugar)と申します。

現在私は、デザインシステムの作成と共有を簡単に行えるプラットフォーム UXHub の開発を担当しています。数ヶ月前に UXHub 開発に参画したのですが、そのときまずはじめに、フロントエンドコードの全面 TypeScript 化や、React のクラスコンポーネントから React Hooks + 関数コンポーネントへの書き換えといったリファクタリングを提案しました。新機能の追加ではなくすでに動いているコードの書き換えですから、その提案が受け入れられるかどうかは未知数だったのですが、将来の開発効率は確実に向上するだろうから是非やろうということになり、エンジニアチームで一致団結し一ヶ月ちょっと掛けて取り組みました。

このように、Fixel にはコードの品質や技術的負債の返済といった部分を重視する文化があります。その一環で、私が毎週社内で「週刊コードレビュー」と題した資料を配布し、それを読み合わせるミニ勉強会のようなものを開催しているのですが、今回はそこで使った資料をもとに記事をお届けします。

今回は「関数型プログラミング」がテーマです。関数型プログラミングというと取っ付きづらい印象があるかもしれませんが、基本的な考え方はわかりやすく、面白いものです。Web 開発者にとっては馴染みのある JavaScript を使って、関数型プログラミングの考え方に少しだけ触れてみましょう。

関数型プログラミングと命令的/手続き的プログラミングの違い

関数型プログラミングとは、プログラミングにおけるパラダイム(考え方)のひとつです。関数型プログラミングの特徴を考えるにあたって、まずはそれと対になる命令的/手続き的プログラミングとの違いをざっと見てみましょう。

命令的/手続き的プログラミングとは

  • 何をするか」(how) を記述する
  • 文 (statement) と式 (expression) が存在する。
    • 文 (statement)
      • コンピュータが処理する 1 ステップ
      • if 文(条件分岐)や for 文(繰り返し)、switch 文など
      • 文は式ではないため、変数に代入できない(🛑 const foo = if (true) { ... }
    • 式(expression)
      • 数字や文字などのリテラル、変数、関数呼び出し。式と演算子の組み合わせも式。
      • 式を計算(評価)して得られた結果(評価値)を変数に代入することができる。
  • 命令的プログラミングで重要なのは。必要な計算は式を使って行いつつ、文でプログラムを構成する。

関数型プログラミングとは

  • 何であるか」(what) を記述する
  • 基本的にはすべてがであり、式を組み合わせて関数を構成する。

命令的/手続き的プログラミングと関数型プログラミングの基本的な違いを押さえたところで、さっそく具体的な例を見てみましょう。

実例で見る関数型プログラミングの特徴

sum 関数:配列の和を求める

最初の例として、配列の和(合計)を求めるプログラムを考えてみます。

命令的/手続き的な書き方

命令的 / 手続き的な書き方では以下のように書くと思います:

const sum = (arr) => {
  let sum = 0
  for (let i = 0; i < arr.length; i++) {
    sum += arr[i]
  }
  return sum
}

console.log(sum([1, 2, 3])) // 6

関数型的な書き方

さて、関数型では以下のように書きます:

const sum = (arr) => (arr.length === 0 ? 0 : arr[0] + sum(arr.slice(1)))

arr[0].slice を使っているのが少し不格好な気がするので以下のように書き換えます:

const sum = ([x, ...rest]) => (x === undefined ? 0 : x + sum(rest))

arr[x, ...rest] に展開しているだけで、やっていることは同じです。 arr[0]x で、 arr.slice(1)rest になります。

ただ、もう少し順を追って説明するためにいったん上記の式に改行を挟んで以下のようにします。

const sum = ([x, ...rest]) =>
  x === undefined ? 0
  :
  x + sum(rest)

console.log(sum([1,2,3])) // 6

実はこの式にはすでに、関数型プログラミングにおいて重要なことが現れています。

それを説明する前に、まずは式の意味を読み解いてみましょう。

関数 sum は引数として配列を受け取っています。先ほども少し説明しましたが、 [x, ...rest] は単純に配列を先頭の要素 x と残りの要素 rest に分けているだけです。

右辺を見ると三項演算子が使われています。

改めて説明すると、三項演算子は (条件式) ? (式 a) : (式 b) という形で表され、条件式が true の場合は 式 a が評価され、条件式が false の場合は 式 b が評価されます。

「式と演算子の組み合わせは式」ですから、三項演算子を用いた記法 (条件式) ? (式 a) : (式 b) も式です(変数に代入できることからも明らかですが)。

三項演算子を用いた記法は一見わかりづらさもありますが、すべてを式の組み合わせで表す関数型プログラミングとは相性がいいのです (*1)。

ちなみに、純粋関数型言語の Haskell では if 文のかわりに if 式が存在します。これは JavaScript の三項演算子による式のようなもので、 parity x = if x `mod` 2 == 0 then "even" else "odd" のように書きます(これは引数 x の偶奇 (parity) を求める parity 関数を定義しています。JavaScript で書くと const parity = x => x % 2 === 0 ? "even" : "odd" となります)。

if 式は if (条件式) then (式 a) else (式 b) という形で、else 節が必須です。関数は「入力に対して必ずなんらかの出力を返すもの」である必要があります。そのため、関数を構成する if 式も必ず値を返す必要があり、命令的/手続き的プログラミングにおいて else 節のない if 文を書いたときのように、条件次第で何かをしたりしなかったりということは許されていません。

さて、この sum 関数は先頭要素 x が undefined かどうかで評価値が異なりますが、メインとなるパターンは x が undefined でない場合です。

すなわち、 const sum = x + sum(rest) がメインのパターンということです。これは、数からなる配列の和とは先頭の数と残りのリストの和を足し合わせたものだということです。

たとえば、[1,2,3] の和は、1 + 「[2,3]の和」に等しく、[2,3] の和は 2 + 「[3] の和」に等しく、[3] の和は 3 + 「[]の和」に等しいということです。これを合わせると、[1,2,3] の和は 1+ 2 + 3 + [] の和に等しいということになります。

ただし、最後に出てきた [] の和というのを定義してあげないと答えが定まりませんから、 x === undefined ? 0 の部分で、 sum 関数に渡される引数が空配列の場合は答えは 0、として定めているのです。

上記の const sum = x + sum(rest) のように、関数型プログラミングでは再帰 (recursion) が重要です。どうやって計算すればよいか指定する命令的/手続き的プログラミングとは異なり、関数型プログラミングでは求めるものが何であるかを宣言しなくてはならないからです。それはつまり、計算の手順を示すのではなく、欲しい結果が何であるかを直接定義するということであり、そのために再帰的な定義をよく用います。

そして、 x === undefined ? 0 のような部分は基底部 (base condition あるいは edge condition) と呼ばれます。基底部とは、これ以上問題を分解できなくて解を明示的に(再帰を使わずに)定義しなければならないケースのことです。

また、基底部が存在しなければ無限に再帰が繰り返されることになりますが、基底部を定義することで無限ループを防ぐことになります (*2)。

ちなみに、このように基底部と再帰部分に分けて立式するにあたって、JavaScript では三項演算子を使いましたが Haskell のような言語ではパターンマッチが有力です。パターンマッチとは、関数の定義式をパターンごとに記述できる記法で、具体的に今回の sum 関数を Haskell を使って書き表すと以下のようになります:
sum :: Num a => [a] -> a -- 型定義
sum [] = 0
sum (x:xs) = x + sum xs
パターンマッチとは上記で 2 行に分けて式を定義しているこの記法のことです。パターンが複雑化したような場合に if then else の連鎖に陥らずに済みます。

改めて整理すると、再帰で関数を定義する際は、解こうとする問題をより小さな問題に分解し、その小さな問題を解くことを考えます。実際に再帰を書く際には、まず再帰によらない自明な解を持つ基底部から考えるのがよいでしょう。そのあとで、最終的に解きたい問題をより小さな問題に分割していくのです。

💡 再帰は関数型プログラミングで立式する際の単なるテクニックやパターンのひとつのようなものではなく、本質的なものです。 たとえばフィボナッチ数列のような数列でも、Fib(0) = 0, Fib(1) = 1 というふうに基底部の定義から始まり、残りは Fib(n) = Fib(n-2) + Fib(n-1) と再帰的に定義されます。 自然数全体を公理化したものであるペアノの公理においても、最初の公理としては「自然数 0 が存在する」というふうに基底部を定め、それ以降の自然数は「任意の自然数 n には後者が存在する」として再帰的に定義します。 数学的帰納法も基底部と再帰部からなります(実はペアノの公理の 5 つめの公理が数学的帰納法です)。 上記のペアノの公理とも関連しますが、計算論の領域では、ゼロ関数定数関数後者 (succ) 関数射影関数、それらの合成および原始再帰法によって組み合わせた関数たちを原始再帰的関数と呼びます。これは計算可能性を証明する基礎となります(じっさい、この原始再帰的関数はコンピューター誕生以前のゲーデルの不完全定理の証明においても重要な役割を果たします)。また、オートマトン理論などのコンピュータサイエンスに関する理論を支えるものにもなっています。 ほかにも、プログラミング言語の文法定義などに用いられる BNF 記法でも再帰的な定義を行います。

関数型の考え方に親しむために、他の関数の例も見てみましょう。

reverse 関数:配列を引数に取り、逆順の配列を返す関数

命令的/手続き的な書き方

const reverse = (arr) => {
    const result = []
    for (let i=0; i < arr.length; i++) {
      result.push(arr[(arr.length - 1) - i])
    }
    return result
 }
 
console.log(reverse([1,2,3,4,5])) // [5,4,3,2,1]

関数型的な書き方

const reverse = ([x, ...rest]) => x === undefined ? [] : [...reverse(rest), x]

面白いのは、基底部と部分問題さえ正しく選んだら、詳細を考えなくても答えが導き出せるということです。

この場合は、 reverse 関数は正しく配列を逆順にしてくれていると信じて、 メインパターンとしては[...reverse(rest), x] と書くだけで、全体的な処理を考えずとも最終的な答えが得られるのです。 すなわち、 x が先頭にある配列を逆順にするには、先頭要素 x と残りの要素に分けて、「逆順になっているはずの配列の末尾に先頭要素 x を置けば、全体としても逆順になっているはず」と考えてそれを式にするだけでよいということです。

max 関数:配列内の最大値を返す関数

命令的/手続き的な書き方

const max = (arr) => {
   let max_ = arr[0]
   for (let i = 1; i < arr.length; i++) {
     if (arr[i] > max_) {
       max_ = arr[i]
     }
   }
   return max_
}

関数型的な書き方

const max = ([x, ...rest]) => x === undefined ? [] : (x > max(rest) ? x : max(rest))

map 関数

JavaScript では map は配列のメソッドとして定義されており、たとえば [1,2,3,4].map(x => x ** 2)[1,4,9,16] を返します。今回は、メソッドではなく、関数と配列を受け取る関数 map(関数, 配列) として map 関数を定義してみましょう。

たとえば map((x => x**2), [1,2,3,4]) は第一引数の関数 (x => x**2) を第二引数の配列 [1,2,3,4] の各要素に適用して [1,4,9,16] という結果を返します。

命令的/手続き的な書き方

const map = (f, arr) => {
    const result = []
    for (let i=0; i < arr.length; i++) {
      result.push(f(arr[i]))
    }
    return result
 }
  
console.log(map((x => x**2), [1,2,3,4])) // [1,4,9,16]

関数型的な書き方

const map = (f, [x, ...rest]) => x === undefined ? [] : [f(x), ...map(f, rest)]

map 関数は配列と配列の写像を定義するものです(配列 —写像 (map)—> 配列)。したがって、map は単なる for 文の代用品ではありません(結果的に for 文の代用として使えるケースはもちろんあるにせよ)。

クイックソート

命令的/手続き的な書き方は少し長くなるため省略します。こちらなどをご参照ください。

関数型的な書き方

const quickSort = ([x, ...rest]) => x === undefined ? []
                : [...quickSort(rest.filter(n => n <= x)), x, ...quickSort(rest.filter(n => n > x))]
 
console.log(quickSort([15,2,5,3,1,6,7,4,2,3,4,9,12])) // [1, 2, 2, 3, 3, 4, 4, 5, 6, 7, 9, 12, 15]
console.log(quickSort("i am a web developer")) // [" ", " ", " ", " ", "a", "a", "b", "d", "e", "e", "e", "e", "i", "l", "m", "o", "p", "r", "v", "w"]

(*1) 三項演算子についてもう少し付け加えます。

三項演算子を if 文の別の書き方というふうに捉えていらっしゃる方もいるかもしれませんが、文は変数に代入できません。たとえば変数 bar の正負によって foo の値を分けたいとき、if 文を使った命令的・手続き的な書き方だと以下のようになります:

let foo
if (bar > 0) {
  foo = 'positive'
} else {
  foo = 'negative'
}

if 文を使うと、どうしても再代入を許す let で変数を宣言せざるを得ません。 foobar の正負で値が定まる変数で、再代入を許す必要などまったくないのにも関わらずです。さらに言うと、変数 foo の宣言時にはそれが bar の正負で値が変わる変数だということすらわからず、その直後に登場する if 文でプログラマが文字通り手続き的に条件分岐を行って foo に値を代入しているだけです。

一方、三項演算子を使うと以下のようになります。

const foo = bar > 0 ? 'positive' : 'negative'

これはシンプルで美しい以前に、変数の宣言と同時に値が定まり、再代入は許されません。

(*2) 基底部のない関数を定義することもできます。たとえば、ある数が与えられた時、その数以下の数を順番に列挙するような関数です(10 が与えられたら [10, 9, 8, …, 0, -1, …] を返す)。これは無限に続きます。

Haskell には遅延評価という特徴があり、こういった関数を扱うことができます。

実際のコードは以下のようになります:

equalOrSmallers :: Num a => a -> [a]
equalOrSmallers n = n : equalOrSmallers (n-1)

ここで take という(Haskell では元々組み込まれている)関数を紹介します。これは、 take (数) (リスト) (Haskell において JavaScript でいう配列はリストと呼ばれます(配列と呼ばれるものは別に存在します))という形で、リストから指定された数ぶんだけ要素を取り出すという関数です。

これと組み合わせると、 take 5 (equalOrSmallers 10) のようにすれば [10, 9, 8, 7, 6] という評価値が得られます。つまり、無限に続くはずの評価値は、必要なぶん(先頭から 5 つめの要素まで)しか実際には評価されないのです。このようにして、無限に続くリスト自体や、基底部のない関数を計算に活用することができます。

一方、JavaScript は正格評価(遅延評価ではない)です。

take 関数も含めて、以下で試してみます:

const equalOrSmallers = n => [n, ...equalOrSmallers(n-1)]

const take = (n, [x, ...rest]) => n === 0 ? [] : [x, ...take(n-1, rest)]

console.log(take(5, equalOrSmallers(10))
// Uncaught RangeError: Maximum call stack size exceeded

上記のとおり、これを実行すると Maximum call stack size exceeded エラーが出てしまいます。

(脱線の脱線)JavaScript で遅延評価っぽいことをやろうとすると generator 関数を使うことになります(遅延評価っぽくなったはいいものの、少なくとも以下のコードはぜんぜん関数型な書き方ではないですが)。

function* equalOrSmaller (n) {
 while (true) {
   yield n
   n = n-1
 }
}

const take = (howManyToTake, generator, n) => {
  const g = generator(n)
  const result = []
  for (i=0; i < howManyToTake; i++) {
    result.push(g.next().value)
  }
  return result
}

console.log(take(5, equalOrSmaller, 10)) // [10, 9, 8, 7, 6]

関数型プログラミングと宣言的プログラミング、宣言的 UI

今まで見た通り、関数型プログラミングは「何をするか」ではなく「何であるか」を示すパラダイムです。これは UI の表示と親和性が高い考え方です。どのように表示するかを手続き的に記述するのではなく、表示するものが何であればよいかを宣言するということです。

このような考え方を宣言的 (declarative) UI と言います。宣言的 UI の考え方に則って記述することで、UI の宣言部分と状態を分離することができます。

React も宣言的 UI の考え方に基づいており(むしろ先駆的な存在)、宣言的 UI、ひいては関数型プログラミングを理解することで React やそれ以外のフレームワーク、ライブラリをさらに使いこなすことができるようになるでしょう。

モダンな UI フレームワーク(ライブラリ)は宣言的 UI の考え方に基づいているものが多い

おわりに

最後までお読みいただきありがとうございました。関数型プログラミングの世界は奥が深く、この記事では触れられていないことがまだまだありますが、その世界のほんの一端でも感じ取っていただけましたら幸いです。

この記事を書いた人

佐藤 遼平

工学部卒業後、金融機関に入社し、機械学習モデルの開発やフロントエンド開発、デジタル化推進などを経験。その後フリーランスの Web エンジニアとして独立し、2021 年 3 月より Fixel で UXHub の開発に携わる。UXHub 開発では新機能追加のほか、フロントエンドの全面 TypeScript 化や React Hooks の導入といったリファクタリングも主導した。好きな言語は Haskell。

Contact

どのようなお悩みでも
まずはお気軽に
ご相談ください

arrow

Careers

柔軟で先進的な思考を持った
デザイナーやエンジニア、
コンサルタントを募集しています

arrow

Copyright© Fixel Inc. All rights reserved.