最近いくつかの大規模なウェブ開発プロジェクトに取り組んでおり、以前に書いた solid.js のインスピレーションもあって、関数型リアクティブプログラミングに徐々に慣れてきました。関数型リアクティブプログラミングは、大規模なウェブプロジェクトにおいて非常に役立ちます。
多くの練習をすれば必ず成果が得られると実感しています。まさか、軽視されがちなウェブ開発の中で、軽視されることのない FP の知識を学べるとは思いませんでした。
Javascript は関数型プログラミングの正統#
最も典型的な関数型プログラミング言語は Haskell でしょう。しかし、Haskell にはservantフレームワークがあるものの、Haskell でウェブサービスを書く人はほとんどいないと思います。なぜなら、あまりにも苦痛だからです。
しかし、ウェブ開発に適した関数型プログラミング言語は確かに存在します。例えば、elixirという言語があります。この言語には非常に有名なプロジェクトがあり、policr-mini Telegram Botはこの言語で書かれています。それ以外にも、Java に似ているが関数型をサポートしているプログラミング言語として Scala があります。GitHub リポジトリによると、Twitter のアルゴリズムはこの言語で書かれています。
多くの人が考えもしないことですが、Javascript は入門のハードルが非常に低く、文法が非常に緩い、一般的には能力のないプログラマーがフロントエンドを書くための言語でありながら、大規模なウェブサービスを書くために関数型プログラミングを使用するのに非常に適しています。
V8 エンジンの性能は実際には低くありませんが、小規模なプロジェクトではより多くのメモリを消費します。また、技術の進歩により、現在の開発は性能よりも開発コストにより重点を置くことが多いので、JS の性能の損失はそれほど重要ではありません。
JS の関数型プログラミングサポート#
JS の型システムは非常に特異で、見た目には型がないように見えます。このような型のなさは、メンテナンスが難しいと感じさせます。そのため、Typescript が発明されました。しかし、別の視点から見ると、JS の型システムは関数型プログラミングの要件を満たしています:
- 一級関数:関数は他の型と同様に、他の関数の引数や戻り値として使用でき、変数に代入したりデータ構造に格納したりできます。明らかに、JS はこれを実現できます。
- クロージャ:クロージャは関数とその関数が捕捉したレキシカル環境です。JS プログラマーはしばしば考えずに簡単にクロージャを書き、意図しないメモリリークを引き起こすことがあります。
- 高階関数:高階関数は、関数を引数として受け取るか、関数を結果として返す関数です。JS では高階関数が非常に頻繁に使用されます。例えば、さまざまなコールバックや配列(または一般的なオブジェクト)のさまざまなメソッドはすべて高階関数です。
JS がこれほど簡単な理由#
Rust のような関数型プログラミングをサポートするウェブ開発の一般的な言語や、Haskell のようなウェブ開発をサポートする関数型プログラミング言語と比較して、JS ははるかに簡単に書けます。主な理由は、JS はガベージコレクションに依存し、typeof
キーワードがあり、型がない(TS には型がありますが、TS の型はすべてジェネリックです)からです。
具体的には、JS の簡単さは以下の点にあります:
- 型を書く必要がありません。Typescript の中でも、
typeof
キーワードを使って変数の型を抽出し、型計算や宣言に使用できます。 - クロージャ変数のライフサイクル問題を考慮する必要がありません。Rust のようなゼロコスト抽象や C++ の手動管理とは異なり、JS はよりシンプルなガベージコレクションメカニズムを使用しているため、クロージャ変数のライフサイクルを考慮する必要がなく、クロージャが再利用可能かどうかを考える必要もありません。
- ラムダ式を自由に書けます。関数型プログラミングに全く無知な人でも、
.map()
のようなメソッドを使って、ラムダ式を簡単に挿入できます。
関数型プログラミングの基本概念#
関数型プログラミングは、関数の使用をソフトウェアシステムを構築する主要な手段としています。これは、一般的な命令型プログラミング(オブジェクト指向プログラミングなど)とは顕著に異なります。
命令型プログラミングが「機械がどうやってやるか」を指定するのに対し、関数型プログラミングは「機械が何をするか」に焦点を当てています。
関数型プログラミングを始めるには、以下の基本概念を理解することが非常に重要です:
- 純関数(Pure Functions):これは関数型プログラミングの核心です。純関数は、出力が入力のみに依存し、隠れた入力(外部変数を捕捉するなど)がなく、実行中に副作用(グローバル状態を変更しない、IO を制御しない)がない関数です。純関数はテストが非常に容易です。
- 不変性(Immutability):関数型プログラミングでは、データは不変です。つまり、一度データが作成されると変更できません。すべてのデータ変更は、新しいデータ構造を作成することで実現され、既存のものを変更することはありません。これにより、プログラムの複雑さが低下し、プログラムの異なる部分でデータが意図せず変更されることを心配する必要がなくなります。
- 一級関数(First-Class Functions):関数型言語では、関数は「一等市民」と見なされ、他のデータ型と同様に渡したり操作したりできます。関数を他の関数に引数として渡したり、関数から関数を返したり、データ構造に格納したりできます。
- 高階関数(Higher-Order Functions):他の関数を引数として受け取るか、関数を戻り値として返す関数です。これは関数型プログラミングにおける組み合わせと抽象化の強力なツールです。
- クロージャ(Closures):これは、外部スコープの変数を捕捉できる関数を指します。クロージャにより、関数はその定義環境の外でも変数を使用できます。
- 再帰(Recursion):関数型プログラミングでは通常ループ構造(for や while ループなど)を使用しないため、再帰が繰り返しタスクを実行する主要な方法となります。
- 参照透明性(Referential Transparency):ある式がプログラムを変更せずにその計算結果で置き換えられる特性を指します。これは、関数の呼び出し(例えば
add(1,2)
)がその出力(例えば3
)に置き換えられ、プログラムの他の部分に影響を与えないことを意味します。 - 遅延評価(Lazy Evaluation):関数型プログラミングでは、式は変数にバインドされたときに即座に計算されるのではなく、その結果が必要なときに計算されます。これにより、特に大規模なデータセットに対して効率が向上します。
- パターンマッチング(Pattern Matching):これはデータを検査し、その構造に基づいて異なる操作を選択する技術です。一部の関数型言語では、パターンマッチングが複雑なデータ構造の操作を簡素化するために使用されます。
- 関数合成(Function Composition):関数型プログラミングでは、関数を合成することでより複雑な操作を構築できます。つまり、ある関数の出力が別の関数の入力になります。
- モナド(Monad):これは関数型プログラミングでよく見られる抽象概念で、副作用や状態変化などを処理するために使用されます。モナドは、一連の関数を順次適用できる構造を提供し、一般的な副作用を回避します。
ほとんどのフロントエンド開発者は jest を使ってテストを行うことを知っていますが、純関数を書かなければ、副作用のためにどうテストすればよいかわからず、頭を抱えることになります。純関数を書く習慣を身につけることは重要です。
モナド#
この概念は、理解が難しいため、別途取り上げる価値があります。また、よく使われます。
モナドという概念は、圏論から来ており、関数型プログラミング言語で遅延評価や副作用の遅延を実現するために使用されます。数学的な観点から見ると、モナドは自己函子圏の単位半群であり、その二項演算はbind
操作として定義され、単位元はreturn
操作(恒等変換)として実現されます。
しかし、プログラマーが数学を学ばないことは周知の事実であり、このような話を理解できる人はほとんどいません。したがって、この概念がどのように提案され、適用されているかを見てみましょう。
関数型プログラミングでは、純関数や副作用のないことを強調します。しかし、実際のアプリケーションでは、これはほぼ不可能です。例えば、ウェブ開発では、データベースクエリ操作、ランダム数生成操作、データベース書き込み操作などが必要になることがあり、これらはすべて副作用に依存しています。
私たちは、副作用の実行を遅らせ、遅延評価を利用して、評価時にこれらの副作用を実行したいと考えています。副作用を遅延させるのがモナドです。
Promise はモナド#
JS には、自然なモナドである Promise があります。
Promise は JavaScript のネイティブコンストラクタとして、非同期操作を処理するための優雅な方法を提供します。これはモナドの一例と見なすことができ、モナドの 2 つの基本操作であるbind
(Promise の.then()
メソッド)とreturn
(Promise のPromise.resolve()
)を満たしています。Promise を使用することで、非同期操作をチェーンして呼び出すことができ、コードの可読性と保守性を保つことができます。
例えば、設定ファイルを読み取る場合:
const reading = new Promise((resolve, reject) => {
const file = fs.readFileSync('./config.json');
resolve(file);
});
titlePromise = reading.then(file => JSON.parse(file)).then(jsonResult => jsonResult.appName);
title = await titlePromise;
ここで、fetch
関数は Promise オブジェクトを返し、それは将来完了するが現在はまだ完了していない非同期操作を表します。
.then()
メソッド(bind
)を使用することで、Promise が成功したときにデータに対して行う計算を定義できます。
上の例では、モナドを使用して副作用を含む関数(readFileSync
)をラップし、bind
が完了するまで(つまり、jsonResult.appName);
のセミコロンのところまで)、副作用のある操作は実行されません。最後に(title = await titlePromise;
)、副作用のある操作が実行されます。
上記のコードをデバッグする方法を想像してみてください。副作用のない部分については、純関数のデバッグ方法を使用できます。一方、副作用のある部分については、後続の処理ロジックに誤りがないか心配することなく、個別にデバッグできます。
モナドの定義#
上記の例を見れば、定義を理解しやすくなります。では、自己函子圏の単位半群という難解な言葉が何を意味するのかを説明しましょう。
圏論は説明するのが非常に難しく、もともと抽象的なものです。簡単に言えば、圏は点の集まりと点の間の矢印です。この点は抽象的な点であり、場合によっては圏そのものでもあります。
ある圏から別の圏への写像を函子と呼び、起点と終点が同じであれば自己函子と呼びます。抽象的な点が自己函子であるとき、構築された圏は自己圏になります。
数学において、単位半群は一組の要素、二項演算、および単位元を含む代数構造です。この二項演算は閉じている必要があり(演算後の結果が圏内にある)、結合的であり((a+b)+c = a+(b+c)
)、単位元はこの演算の下で中立的である必要があります(0+n=n
)。
抽象から実装へ#
モナドはデータ構造として実装され、2 つのメソッドを含み、時にはunwrap
メソッドも持ちます。
Promise は js と ts では unwrap を持っていません。
type Monad<A> = Promise<A>;
function pure<A>(a: A): Monad<A> {
// returnはキーワードなので、ここではpureを使用
return Promise.resolve(a);
}
function bind<A, B>(monad: Monad<A>, func: (a: A) => Monad<B>): Monad<B> {
return monad.then(func);
}
return
(pure
)は、返す内容が引数であり、型が Monad のデータ構造であることを要求します。bind
は値を計算する純関数です(bind
はMonad
があることを知る必要はありません)。
これはunwrap
を持つ Typescript の実装です。
export default interface Monad<T> {
unwrap(): T;
bind: <U = T> (func: (value: T) => Monad<U>) => Monad<U>;
}
export function pure<T>(initValue: T): Monad<T> {
const value: T = initValue;
return Object.freeze({
unwrap: () => value,
bind: <U = T>(func: (value: T) => Monad<U>) => func(value)
});
}
export const Monad = pure;
リアクティブプログラミングと関数型リアクティブプログラミング#
Vue や Svelte を使ったことがある読者は、この概念に馴染みがあるでしょう。
簡単に言えば、a
またはb
が変化すると、a+b
も自動的に変化します。
Vue で開発しているとき、バインドされたデータが変更されると、関連するデータや画面も変化し、開発者は「どのように変化を通知するか」というコードを書く必要がなく(例えばref.value = newValue
)、変化が起こったときに何をするかにだけ集中すればよいのです。これが典型的なリアクティブプログラミングです。
したがって、関数型リアクティブプログラミングは、関数型プログラミングとリアクティブプログラミングの組み合わせであることは容易に推測できます。
関数型リアクティブプログラミングは、モナド(オブザーバーシーケンス)を使用してデータフローを処理します。フロントエンドには、モナドの遅延評価を利用してリアクティブ性を実現し、状態をモナドに保存するフレームワークがあります。このフレームワークはSolid.jsと呼ばれています。
このフレームワークを使用しているプロジェクトを自分で探せば、自然に理解できるでしょう。
下手でも多く練習しよう
第一歩を踏み出す#
関数型プログラミングには、非常に重要な 3 つの関数があります。それらは:
-
Map(マップ):
- 役割:
map
関数は、集合内の各要素に同じ関数を適用し、新しい集合を返すために主に使用されます。この新しい集合には、関数を適用した後の要素が含まれます。 - 例:例えば、数字のリスト
[1, 2, 3]
があるとします。map
関数を使用すると、各数字を 2 倍にして、結果は[2, 4, 6]
になります。
- 役割:
-
Reduce(リデュース):
- 役割:
reduce
関数は、集合内のすべての要素をある方法で単一の結果に統合するために通常使用されます。これは、操作を集合の各要素と現在までの結果に連続的に適用します。 - 例:数字のリスト
[1, 2, 3, 4]
があるとします。reduce
関数を使用すると、それらを合計して 10(1+2+3+4)を得ることができます。
- 役割:
-
Filter(フィルター):
- 役割:
filter
関数は、集合から特定の条件を満たす要素を選択し、新しい集合を形成するために使用されます。 - 例:数字のリスト
[1, 2, 3, 4, 5]
があるとします。filter
関数を使用すると、2 より大きいすべての数字を選択し、結果は[3, 4, 5]
になります。
- 役割:
これらの 3 つの関数はすべて副作用がなく、元のデータ集合を変更することはなく、新しい集合を生成します。これは関数型プログラミングが「不変性」を強調する重要な特徴です。
この記事を書いている時点で、私は 2 つの大規模なウェブフルスタックプロジェクトを担当しており、そのうちの 1 つは私の組織のために書かれたクローズドソースのプログラムであり、詳細を明かすことはできません。もう 1 つはオープンソースです。
その関数型リアクティブプログラミングのプロジェクトの名前はNodeBoard-Coreで、v2boardのより高性能で、より拡張性があり、よりメンテナンスが容易で、よりデプロイが簡単で、より安全な上位代替を目指しています。このプロジェクトに興味がある方は、[email protected]までご連絡ください。
お願いです、私のブログ主サイトに遊びに来てください。流入を促さないと、本当に誰も見てくれません。