現代のオブジェクト指向の class の割れ窓化と宣言的プログラミング

5 min read読了の目安(約4800字

オブジェクト指向には、カメラがやっとついたころのガラケーのイメージがある - きしだの Hatena

の件。基本的には同意。ただちょっと切り口が違うので自分の意見を言っておく。ただ、このテーマで何度か書こうとして失敗していて、今回も成功しているとはいえない。

宣言的プログラミングの時代

現代の主流は「宣言的プログラミング」であると思っている。これはリソースの宣言と、その状態遷移の手続きや振る舞いの付与が中心にある。

宣言型プログラミング - Wikipedia

その代表的な例がフロントエンドの React と、バックエンドの k8s で、どちらも時系列に基づいた状態の宣言と、フレームワーク側による状態遷移処理、 Reconcillation(調停) が基礎にある。 フロントエンドとバックエンドという両極端な世界で、この変化が起きたのがこの時代を反映したものであると思う。

例えば、jQuery で手続き的に DOM を変更すると、どの状態からどの状態へ遷移したか、変更後からはわからない。今何を行っていて、どのような状態にあるか?を意識する必要をなくし、差分更新はフレームワークで行うので、ゼロから状態を作っていいことにしたのが React の意義。

例えば、サーバーにログインしてちょっとずつ設定を変更して運用すると、その過程で何をどう変更したかの情報が失われる。コンテナを常に作り直すので、 Docker によってコンテナレベルの冪等性を担保して、 k8s の宣言的なリソースの差分からローリングデプロイさせるのが、k8s の意義。AutoScaler のようなリソース自体の変化も宣言的なものとしてリソース定義に含んでいる。

Reconcillation の対象が DOM か インフラかで違うが、やってることは「宣言的なリソース宣言」「フレームワークによる差分検出と実環境への適用」と共通している。そもそもインフラがコードで管理される時代になっている。コードの抽象がそのままインフラの抽象になる。

そして、宣言的なリソース宣言に不可欠なのが、処理結果の「予測可能性」であり、プログラミング言語の不変性サポートであると思っている。

宣言的プログラミングと予測可能性

ここで大事なのは、遷移過程は隠蔽されたとしても、リソースの宣言を行うのはプログラマであるということで、宣言的なリソースを生成する過程でプログラミング言語レベルの状態管理というものは依然として存在する。

静的なデータを生成するためのプログラミング処理においての、宣言されるデータの予測可能性というのが重要になってくる。何に依存してリソースが変化するかがアプリケーションの振る舞いの本質になり、デバッグとは副作用の発生する経路を分析することになる。そしてその複雑さが低いほど良いコードと言える。

ある程度経験を積んだプログラマなら、「状態は少なければ少ないほどいい」ということに同意してくれると思う。状態を管理するには、純粋関数と外界に対して副作用を起こす関数を明示的に分離することが大事になる。つまり同じ入力に対して、同じ結果が得られるか、つまり、関数型の言葉を借りると、「参照透過性の担保」がプログラミング言語レベル、そして個々人がコードを書く単位の水準で重要になっている。

予測可能性はデバッガビリティだけではなく、パフォーマンスの文脈でも重要になる。とくにキャッシュ構築は参照透過性がある処理に対して行うことが大事で、参照透過性を頼って入力に対して何らかの一意なキーを作って、そのキーについてのキャッシュがあれば計算過程をすっ飛ばす、というのが可能になる。

現代の class は不必要な副作用と継承の割れ窓

プログラミング言語レベルの処理の予測可能性、出力の透過性、つまり同じデータであること、を担保するのために邪魔になるのが、「シリアライズできないメモリ上のデータ」で、これは処理過程においてしか存在せず、処理をくりかえしたときに等価かどうかの判定が困難になる。その例として class のインスタンス参照や関数参照があり、データの予測性の担保のために、データと処理(関数)を分離する要請が、ここで強く発生している。

関数の入出力の予測可能性を決定するのは、関数スコープの外の変数をどれだけ参照するかと、そして引数として受け取った参照自体が等価な振る舞いをするかで、クラスメソッドというのは、メンバー変数にアクセスする以上、この純粋性を持たない。もちろん、これに対してシリアライザ、デシリアライザを実装したり、等価判定のオーバーライドができる言語もある。ただ、その処理自体を人間が書く場合は、ここでバグが発生する余地が生まれる。

基本的に現代の主流なプログラミング言語のセマンティクスでは、変数や class の仕組みは getter/setter を明示的に書かなければ mutable になってしまう。これによって、プログラミング初学者が踏む経路は、「将来の変更の予測ができないのですべてを mutable で扱う」から、経験を積むと「複雑性を廃するために限定的な mutable」 という推移を辿る(と勝手に思っている)。

一応、現代では class のデータと振る舞いを同時に記述するというやり方から離れて、明確にデータと振る舞いを区別する手法が整理されつつある。プログラミング言語の世界でも、 2010 年代に新規に流行った Rust や Go はもはや class を持たない。Scala や PHP は trait がある。 Swift には Protocol があるし、C# にも拡張メソッドがある。

プログラミング言語の水準でもサーバーと同じようなプロセス管理を行うのが Erlang OTP の supervisor で、プロセスの数を宣言し、またプロセスが破棄されたときの再起動戦略を指定する。

すごい Erlang ゆかいに学んだメモ 17 章 スーパーバイザー - Qiita

いいたいのは、 状態コンテナとしての class とメンバーが不必要な副作用を生み出す割れ窓になっていて、副作用を持つメンバや関数というのは例外的な事項として扱うべきである、ということ。class をまったく書くべきではないという話ではなく、class は例外的な状態コンテナであるということ。

また、継承が悪と叫ばれて久しい。実際に自分は継承でいい目に合ったことがない。protected は開放閉鎖原則で考えても存在が破綻している。人間は継承ツリーでコードを管理できるほど賢くなかったと思う。

Game Programming Patterns という本がよくて、Game と付いているが一部の章を除いて一般的なプログラミングパターンの本で、継承と GoF のデザパタについて結構ボロクソに言っている。現代でデザパタを学ぶのにおすすめ。

Game Programming Patterns ソフトウェア開発の問題解決メニュー impress top gear シリーズ | Robert Nystrom, 阿部和也, 上西昌弘, 武舎広幸 | 工学 | Kindle ストア | Amazon

そういえば microsoft/vscode の TypeScript を読んだときに思ったのが、 継承が使われるのはほぼ Disposable クラスの継承で、これはリソース開放処理に制約をかけているだけだった。ほぼ interface の implements で Adapter パターンによって書かれている。

副作用は本質

…と、 ここまで副作用を目の敵にしてるようなことを言ってきたが、本当にいいたいのは、副作用が悪ということではなく、副作用こそが本質であって、そして副作用の数だけ複雑さが掛け合わせで激増する。その管理コストを消すために、不要なノイズを消すべきだ、ということ。

Haskell を使うと型のセマンティクスのレベルで副作用を起こす関数と起こせない関数が自然と分離できる(ことを学ぶのにセマンティクスを学ぶのがしんどい)。Haskell を使わない環境でも、この指針を採用して、計算するだけの関数、副作用を起こす関数、という風に分離するプログラミング指針は常に有用。

言いたかったこと

なぜこのように推移したかというと、これはきしださんの先の記事で述べられたように、時代的な背景が大きい。昔はメモリが希少で、確保したリソース(メモリ)に対して何度も読み書きしていたが、現代では仮想化技術による投機的なコンテナ生成、汎用プログラミング言語に置いては GC による投機的なインスタンス破棄に依存して、宣言的なデータを繰り返し生成しては破棄する、というプログラミングモデルになっている。ただし、そのような「富豪的な」プログラミングスタイルだからこそ、パフォーマンス計測もセットになっていると思う。経験的に、実際に発生するボトルネックは全体の小さな積み重ねではなく、局所的に発生する。

で、何が言いたかったかというと、状態を class で表現する OOP はもう古くなっていて、宣言的リソースの時系列管理、そしてリソースの更新手順の分離という風にトレンドが移っており、コーディングのミクロのレベル、インフラのようなマクロのレベルでも状態というものが可能な限り廃するのがよい。

とされていることをプログラミング初学者に伝える資料がないので、気軽に副作用起こしまくりなコードを再生産することで現場で問題が起き続けている…。

この記事に贈られたバッジ