宣言的プログラミングについて考える
まず宣言的プログラミングが何なのかではなく、なぜここまで注目されたかを考える必要があるように思う。
契機となったのは明らかにReactだ。
Reactが宣言的UIフレームワークとして話題となった。
その時期から、宣言的プログラミング、そして同時に関数型や論理型のプログラミングパラダイムや言語も話題に上るようになったように思う。
まず、Webドキュメントの宣言的記述自体はReactよりずっと前から行われている。
いわゆる静的ホスティングが主流の時代から、HTML(あるいはHTMLテンプレート)とCSSで宣言的なUI構築はできたわけだ。
更に、それをJavaScriptで拡張することもできた。
例えば、特定の属性がついたタグをdocument.querySelectorAllで対象にしたコードをJavaScriptで実装すれば、すなわちHTML仕様の拡張である。
この実装には課題がある。
それは、その「特定の属性」が付与されたり、削除されたり、属性を持ったタグが新たに追加されるといったような、属性にまつわる変化をすべて監視して、初期化・更新・クリーンアップといったライフサイクル処理を自動的に適用できなければ、静的な=サーバーから受信したままのHTMLに記述された内容にしか正しい動作を保証できない。
だが、これで十分だった時代がある。
それは、初期のサーバーサイドレンダリング主流の時代である。
静的なHTMLをサーバーから受け取って、フォーム処理などで更新する際はページを丸ごと再読み込みしてしまうため、常に静的なHTMLの内容がUIと1対1に対応していた。
ただその方針には当然問題があった。
というのも、データの最新化が必要な度にいちいち再読み込みが起こってしまうのではユーザー体験は悪い。
その観点と、クライアント端末の性能向上も相まって、いわゆるリッチクライアント化していくことになる。
そうすると現れてくるのが「状態=ステート」である。
極端なことを言えば、それまでプログラマが管理しなければいけなかった状態は、いわゆる永続化層であるDBだけだった。
サーバーサイドはHTMLを組み立てるだけならステートレスだし、それを受け取って表示するだけならクライアントサイドもステートレスでいい。
厳密なことを言えば、暗黙的に状態は持っているが、ページを再読み込みしてしまう以上は明示的に状態を管理する必要は無く、難しいことは考えなくて済んでいた。
しかし、ページを再読み込みせずにコンテンツを最新の状態にするには、永続化層との同期手段と、同期されたデータのUIへの反映手段の2つを考慮する必要に迫られる。
永続化層との同期手段は主にREST APIやAjaxが担った。
UIへの反映に関してはDOM APIを使っていたわけだが、先ほど書いたような要素の作成・更新・削除のようなライフサイクル処理を明示的に書く必要があった。
明示的に書く上で、なるべくコンパクトに書くためのユーティリティとしてjQueryが流行っていたわけだ。
ここにKnockoutJSのようなMVVMフレームワークが登場したことで、UIにバインドするデータ(View Model)の操作さえ行えば、ライフサイクルなど考えなくても自動的にUIに反映されるようになった。
言ってしまえば、リッチクライアントで宣言的UI構築が成立したのはこのタイミングである。
そうなってしまえば、もうReactが入り込む余地など無さそうに思える。
Reactが携えてきた仮想DOMは、それこそDOM全体を仮想化する分ViewModelよりもサイズが大きくなるわけで、実際、毎回新しく仮想DOMを作って差分を計算して反映するなんて高カロリーなことをして速くなるわけがないと思われていた。
そして実際、最適なMVVMパターンより速くなるわけでは無かった。
ただ、"安定"した。
大きくパフォーマンスを崩してしまう処理が書けなくなったのだ。
というのも、MVVMが流行ったころのフレームワークであるKnockoutJSは、ViewModelの変更検知=UI反映タイミングの確定のためにネストされたプロキシオブジェクトを使っていた。
問題が無いように思えるが、深くネストした大きなオブジェクトを反映している場合に、子プロパティにある配列やオブジェクトを何気なく操作しただけでいちいちUIに反映され、リフロー(再描画フロー)が大量に発生し、著しくパフォーマンスが落ちたのだ。
for文の中で配列を操作していたら、それらすべてのタイミングでリフローが起きるなど悪夢である。
逆に言えば、それを意識したViewModelの操作ができていれば速度は落ちないが、実装者次第であるが故にミスのリスクを抱えていた。
ディープコピーをすればいいのだが、それはそれで高コストである。
一方でReactの仮想DOMでは、明示的にsetStateによって状態を変更し、そこから作られる仮想DOMの差分を元にパッチを計算する。
setStateでタイミングが分かりやすくなることと、子プロパティまでディープコピーしなくてもsetStateする変数の参照さえ更新されていれば良いことも相まって、多少のオーバーヘッドがあってもミスは起きづらくなり、パフォーマンスが安定したというわけだ。
さらに、現在のファイバーの仕組みではいくらsetStateしようが、必要なタイミングまで反映が遅延されてバッチ化されるので、更に効率的になっている。
もちろん、仮想DOMのオーバーヘッドすら問題視する人もいる。
僕自身もそこを解決したいと思ってライブラリを作ったことがある。
そうした流れで登場したのがFine-grained reactivity(以下、FGR)であり、状態更新のスタイルを眺めてみればMVVMの正統進化と言えそうなのはこっちだ。
しかし、MVVM→FGRの進化は明らかにReactあってこそであり、MVVMにおける開発者体験の低さをReact+仮想DOMというケース学習によって乗り越えた結果であると考えてよいだろう。
こうして思い出してみると、MVVM以前も宣言的UI構築は可能であったにもかかわらず、Reactの特有の、あるいはReactによって開発者体験が構築した所以は、単に宣言的であることよりも、DOM操作のバッチ化によるリフロー回避や、状態とUI表現がコンポーネントによって近い位置に凝集されることによるローカリティの向上、UI表現をJavaScript世界に持ち込むことで従来のテンプレートよりも柔軟なコントロールが可能になった点(さらにJSXでHTMLフレンドリー)が強く影響していると言える。
こうして歴史を振り返ると、単に手続き型→宣言型のパラダイムシフトというより、地味な改善によって認知コストを下げる努力が積み重ねられてきたように思える。
よくよく考えてみれば、一般に手続き型言語で使われる変数宣言にしたって、メモリを確保して、それを書き換えたり、明示的に開放するという一連の手順を、変数の宣言と変数の参照や再代入といった構文によって抽象化し、GCあるいはスコープ脱出の際に自動開放することで認知コストを大幅に下げている。
さらに注目すべきと思われることは、変数がイミュータブルである場合は再代入操作は無く、宣言と参照のみが許される点だ。
あるいはRustのムーブセマンティクスのように所有権が移る場合も、同一変数への再代入は実現されない。(ただ、Rustはあくまで細やかなシステムプログラミングをサポートするためか、&mutなどによる明示的な書き換えも許されている)
実はReactでも同じような構造が見られる。
仮想DOMの構築時にcreateElementを使うのだが、editElementやdeleteElementは用意されていない。
当たり前だが、そうした差分適用はReactが内部的に仮想DOMの差分を基に自動で行うためだ。
「宣言=最初の作成操作」が中心にあり、その時点で与えられた情報があれば更新や削除、再作成は自動化されることで、安全性が確保され、失敗に対する認知コストが減ることで開発者体験と相対的な品質が向上する。
また、飛び飛びになってしまうはずの更新・削除の情報が作成処理に集約されるため、凝集度が上がり、結果的に理解しやすいコードにもなる。
操作を限定することで同じ目的を達成するための実装パターン自体を減らすことで迷いやミスを減らす効果もあるだろう。(パターンのカプセル化)
作成処理に着目したところで、具体的にどのような作成処理がなされているかを観察するために、またReactを引き合いに出す。
先ほどcreateElementに言及したが、通常はこの作成関数の呼び出しをラップして、より大きな作成関数を作る。
いわゆるコンポーネントである。
何を作成しているかと言うとツリーデータであり、最終的に大きなツリーを構成する。要はこれが仮想DOMである。
例:
createElement(
"div",
{ }
[
createElement("span", {}, []),
createElement("button", {}, [])
]
)
これは、
- div
- span
- button
という構造のデータを作り、これを返す関数Counterを定義したとして、以下のように組み合わせることができる。
function Counter() {
return createElement(
"div",
{ },
[
createElement("span", {}, []),
createElement("button", {}, [])
]
)
}
const root = createRoot(domElement)
root.render(createElement(Counter))
ただ、これでは表示するカウントとインタラクションが実装されていないので、
function Counter() {
const [count, setCount] = useState(0);
return createElement(
"div",
{ } // props or attributes,
[
createElement("span", {}, [count]),
createElement("button", {
onClick() { setCount(count + 1) }
}, [])
] // children
)
}
これでカウンターアプリのUIが表現できた。
createElementに渡しているのはCounter()ではなくCounterなので、好きなタイミングで再実行できる。
setCountを呼び出したタイミングで(ファイバーアーキテクチャ)においては変更がスケジュールされ、新しい状態で新しいツリーが計算しなおされる。
新旧のツリーを比較し、差分を実DOMに反映するプロセスをリコンサイルと呼ぶ。
先ほどのCounterコンポーネントはsetCountの度に関数として再実行される。
これはつまり、onClickにバインドする関数も新しくなることを意味する。
具体的な値で説明するなら、
onClick() { setCount(0 + 1) }
から
onClick() { setCount(1 + 1) }
になる。
Reactはカウントが上がる度にイベントハンドラとして新しい関数を再アタッチするので、カウントが正常に増加していく。
カウントが上がる度にイベントハンドラを再アタッチするのは非効率なので、setStateに関数を渡す方法とメモ化を組み合わせて再アタッチを防ぐ方法もあるが、今は説明しない。
毎回関数を実行し直して仮想DOMを構築し直して差分比較まで行うわけなので、リフローを最小限に抑えると言ってもやはり高カロリーなことをしている印象がある。
ただ、馬鹿正直とも言えるようなシンプルなインターフェースがオーバーヘッドを抱える一方で、Reactはリフロー回避やファイバーアーキテクチャのような、実際的な状況でパフォーマンスが問題にならないような工夫をしている。
React Compiler(Reactコードを認識して最適化してしまうコンパイラ)の進化によっては、そのオーバーヘッドすら最終的に無くなるのかもしれないが、今はそうはなっていない。
Reactの場合、新旧の仮想DOMを比較しつつ差分を適用するため、必ず仮想DOMの保持と比較のためのコストがかかる。
よりコストの少ないFGRの場合は、状態の値を直接指定するよりも、オブザーバブルなオブジェクトや値を返す関数を使うことで、ある意味可動部をより小さく限定する。
また、実はReactはUI専用のライブラリではなく、ツリーで表現できるならなんでも宣言的に扱える。
この点をもっと言えば、単なる配列や、より複雑なグラフで何かを表現して、宣言的に扱うような場合も想定可能だということだ。
こういうデータで何か外部リソースなどを表現することを一般にモデリングと言う。
モデリングができていれば、あとはそれを作成し、可変部を埋め込めるようにすれば、極論何であっても宣言的に扱えると言っても過言ではないだろう。
次に、「操作」と「関係」の対比について考える。
同じ表現でも「操作」として捉えるか「関係」として捉えるかで違いが出る。
例えば、以下の表現は「代入」と言う操作か「等価」という関係として捉えるかの違いを秘めている。
label.textContent = count.toString()
通常、JavaScriptではこれは代入操作だ。
しかし、countの値が変わればラベルの値に反映されるリアクティビティを実現するには、等価関係が求められる。
Reactは何度も関数が実行することで代入操作を疑似的な等価関係として成立させる。
これがあくまでライブラリによるものであり、言語的変換ではないことでコストが発生する。
宣言的プログラミングにおいて、表現は一時的な操作ではなく、永続的な関係であることが求められるように思う。
今だけそうして欲しいのではなく、常にそうであって欲しいということだ。
ここに論理プログラミングが宣言的であると言われる所以があるように思う。
先に書いた「今だけそうして欲しいのではなく、常にそうであって欲しい」という性質。
これはつまるところ「型」である。
ここでいう型とは「物事の不変な性質」を指す。
例えば、プログラムの進行中に変わっていく変数の値は明らかに型ではない。
しかし、その変数がプログラムの進行中に変化しながらも、常に「32bit整数値である」という性質を満たすなら、それは変数の型である。
あるいは、設定値のように変化しない定数であったり、同じ引数に対して常に同じ動作を保証する関数も、この定義上は型と言えるだろう。
しかし、その関数が自動的に呼び出されるわけでは無く、能動的に呼び出さなければいけない時、それは常に成立する関係であるという保証を失い、型として成立しなくなる。
型だけでプログラムを記述し、実際の状況あるいは環境の中でそれを満たす様に挙動が決定されれば、プログラムは真に望ましい結果の宣言となり、プログラムの進行中に動的に意味が変わることが無い。
型とは、設定・構造・関係等であり、それらを現実的な状況に応じて"適切な処理"に解釈するインタプリタやフレームワークのようなエンジンがあれば、宣言的プログラミングが可能になると言えるだろう。
概念的な話をした上で思うことは、宣言的プログラミングはプログラミングスタイルではないということだ。
例えば、パイプ演算子を使って関数をコンポジションするフロー表現も、ブロックの中で手続き的に書き下したフロー表現も、意味的には変わらないことがよくあるし、個人的には後者の方が直観的に読み書きしやすいようにすら思う。
宣言的プログラミングか否かは、どう書くかというスタイルよりも、そのプログラムがどう言語的に解釈されるか、あるいは、関数のようなプログラミング部品がどのようにプログラムに組み込まれるかによって決まるのではないだろうか。
そうなってくると、昨今の高級プログラミング言語において宣言的でないプログラムを探そうと思う方が難しい。
なぜなら、main関数であってもそれが自動的に処理系によって呼び出されるエントリーポイントの宣言として捉えることができるからだ。
このスクラップの最初で書いたように、変数宣言ですら、それが無かった時代に相対すれば間違いなく宣言的プログラミングの機能として捉えられる。
ただし、これも同様に最初の方に書いた通り、それだけでは不十分であるからこそReactのようなツールが生まれ、それはパラダイムシフトや理論をベースとした議論の終結というより、開発者体験の地道な改善の積み重ねであり、極めて実践的なレイヤーの価値であるということだ。
次に、副作用を考える。
考える上で一つの問いを立ててみる。
それは例えば「メモリ領域を確保するために変数を宣言しているのか?」という問いだ。
少なくとも言えることは、「メモリ領域の確保」とは変数宣言の副作用であるということだ。
副作用である以上は、実際にメモリの確保など一切していなくても、何も問題が無い。
むしろ、プログラムがもたらす効用が同じなら、そのために用いられるリソースは少ない方が良い。
メモリであれば省メモリであることに越したことは無いが、画面に描写したはずが表示されなければ困ってしまう。
要するに、最終的に必要とされる作用は主作用であり、それら理想と現実とのギャップを埋めるために発生する作用は全て副作用というわけだ。
そして、コードが主作用を表現する式である時、副作用が隠蔽されていると考えて良い。
ここで純粋関数の要件を思い出すと「副作用の排除」があるわけだが、より正確に言えば「副作用がコードから排除されていること」だろう。
例えば、純粋関数内でも変数宣言くらいするわけだし、その場合はメモリの確保や解放という副作用が伴うわけだ。
しかし、確保・解放などの操作がコード上に明言されていなければ、純粋関数としての要件を満たすことができる。
明言されていないということは、副作用の発生の有無やタイミングはインタプリタやフレームワーク、エンジンに委ねられる。
これはReactが作成関数のみを受け取って、実DOMの生成や除去を独自にスケジューリングしたりバッチ化していることと符合する。
適当な指示さえしておけば、いつどうやって達成するかや、リソースの確保や解放などはすべて自動で解決されることが重要で、バックグラウンドで発生する副作用についてはプログラマがあずかり知らぬということが宣言的プログラミングのもたらす重要な利得の一つであるのではないだろうか。
次に、副作用の文脈でRSC(React Server Components)を語りたい。
なぜなら、RSCはある副作用を隠蔽したからだ。
それは、クライアント・サーバー間の通信である。
正確に言えば、RSCとSA(Server Actions)によって、通信レイヤーに関する考え事をかなり省略できるようになった。
考え事を省略できるようになるということは、どうでもいい規約やスタイルの好みに関する悩みや議論をスキップできるということでもある。
また、境界をより意識しなくてよくなることは、複数のシステムを結合する処理を明示的にコード化する手間を省けるという意味でもある。
実際のシステム構成が複雑だとしても、コード上はなるべくモノリシックである方がシンプルでよい。
それはさておき、RSC(SA含む)の大きな特徴は専用のコンパイル処理が必要ということだ。
今までReactはフレームワークでありつつ、あくまでJavaScriptのAPIだった。
つまり、今まではJavaScriptの標準的機能の範疇に収まっていたが、RSC以降は構文的にはJavaScriptであることを維持しつつも、ディレクティブ(ファイル先頭にある単なる文字列リテラル)にReact特有の意味が伴うようになった。
ブラウザなどのランタイムがサポートしない構文や意味が発生するとビルドの設定が煩雑にはなるが、もはやTypeScriptを使っているなら事前コンパイルは不可欠なので、今更気にするようなことではない。
それよりも、そういった言語的拡張とAPIを利用した拡張は、実装難易度の差こそあれ、本質的な違いはほとんどないことに注目したい。
実際、プログラミング言語がサポートする言語的拡張において最もポピュラーであるマクロは、アドバンスドな機能として扱われることが多いように思われるが、マクロを使わないとエレガントに重複を排除できないことがあったりする。
ただ、ASTやら構文解析やらというのは多くの人にとって苦手領域のようで、そこに手を加えるよりAPIや基本的な言語を組み合わせてどうにかする方が優先されることが多いように思う。