🔧

OOPはプログラマが用いる道具の一つに過ぎない

2024/04/02に公開

はじめに

OOPは批判にさらされることが多く、これまでに問題点が多く指摘されてきています。その一方で、OOPは大規模開発でもよく用いられており、仕事で使える程度には有用であることが経験的に証明されているとも言えます。そのため、OOPに関する議論は「OOPのアイデアは悪い vs OOPは使えるから良い」というような構図に陥りがちで、あまり建設的ではないと感じることが多いです。

しかし、OOPは所詮は一つのプログラミングのスタイル、つまりは問題を解決する道具のひとつでしかありません。OOPの批判はWeb開発、特にサーバサイドの文脈上であることが多いですが、例えばGUIやゲーム開発においては便利に活用されているため、オブジェクト指向のアイデアが完全に悪いというわけではありません。オブジェクト指向の構成要素はオブジェクト、継承、カプセル化、多態性、動的束縛と言われていますが、これらをただの言語機能として見れば「あれば便利な機能」に過ぎません。プログラマはプログラミング言語という道具を適切に使ってソフトウェアを開発するのが仕事であり、その道具の選択と道具をどう使うかはプログラマの責任です。ゆえにOOPの良し悪しを議論するのではなく、どう使い分けるか、どう使うかを議論した方が建設的です。

とはいえ、詳細は本文にて後述しますが、オブジェクト指向のコンセプトについては実用上は確かに不完全なものだと考えられます。そのためにオブジェクト指向は不完全だから間違いであるという論調になりがちで、FPに誘導する結論になることが多いように思います。しかしながらオブジェクト指向のアイデア自体は有用であることや、FPにはFPの苦労があること、OOPとFPは相互に補完する関係にあることなどが見落とされがちであるとも思います。本記事はそういった思考の落とし穴に気を配りつつ、現代におけるOOPの立ち位置を分析するものとして書かれています。本記事で本当に伝えたいことは、OOPもFPも所詮はプログラマが手にするひとつの道具でしかなく、それぞれを適切に使いこなせばよいのであり、どちらか気に食わない方を捨てればよいというものでは決してないということです。

※ 本記事ではWeb開発の文脈を前提としており典型的なOOPLとしてPHPを想定しています

結論

  • OOPはGUIやゲームなど「本質的に状態を持つ軽量な部品が多く存在するソフトウェア」の開発には向いているが、そうでないものの扱いは苦手である
  • オブジェクト指向の基本コンセプトだけではカバーしきれない問題がある
    • Webアプリの本質はデータの変換だが、OOPはドメインを跨ぐデータの変換の記述が苦手
    • 「オブジェクト」として扱うと不都合があるものが存在する
    • オブジェクトで解決できない部分にはデータと手続きが現れる
  • OOPは適正の低い題材に対しても手続き型プログラミングと組み合わせて対応する余地があり、柔軟である
    • その上でOOPによる設計の知見も十分にたまっており、少なくとも大規模開発で採用できる程度には実用性がある
    • 一方で不便があることもまた事実である
  • データを直接扱う機会を無くすことが出来ず、オブジェクト指向の基本機能だけでは力不足なので、それを補完する機能の需要が高い
  • OOPLに足りない機能は関数型言語が持っていることが多く、OOPとFPは互いに補完的であり、言語を選べる状況であればOOPとFPの両方をサポートするマルチパラダイムの言語を使うのが良い
  • OOPLを使用して開発する際にFPのエッセンスを取り入れるときは、無理のない範囲でなら良いが、程度によっては言語機能が足りているかどうかを慎重に検討する必要がある

オブジェクト指向には向き不向きがある

オブジェクト指向の基本的なコンセプトはざっくり言うと「オブジェクトに対するメッセージングによってプログラム全体を再帰的に構成する」というようなものです。このアイデアは元々は状態を持っていて何かしらのインターフェイスを通して対話が可能な構成要素、つまりはプロセスのようなものが意図されていました。そのため、独自の軽量プロセスを扱うことが特徴である Erlang がオブジェクト指向を最もよく体現していると言われることがあります。

オブジェクト指向のコンセプトから考えると、本来的に状態と振る舞いを持つ部品が多いソフトウェアの開発についてはOOPが適していると考えられます。実際、GUIやゲームの開発についてはOOPは良く機能し、便利に使われています。

しかし、OOPのアイデアにはいくつかの欠点があるため、OOP批判が多いWeb開発のみならず比較的適しているゲーム開発でも注意して使用する必要があります。

  1. Webアプリの本質はデータの変換だが、OOPはドメインを跨ぐデータの変換の記述が苦手
  2. オブジェクトを含む全ての抽象化には「漏れ」がある
  3. オブジェクトで解決できない部分にはデータと手続きが現れる

以下、これらの詳細について説明していきます。

OOPはドメインを跨ぐデータの変換の記述が苦手

Webアプリの動作を簡単に説明するなら「リクエストに応じてデータの参照および変更を行い、必要なら変更を永続化し、処理結果をレスポンスとして返却する」といったものになります。この一連のアプリ上の処理は、本質的には全てデータの変換であると説明できます。

リクエストを受け取る
↓
リクエストに付属しているデータをドメインロジックが解釈する形式に変換して引き渡す
↓
リクエストされた処理を実行する(データの参照と変更) → DBへのリクエストにデータを変換する
↓
処理結果をレスポンス可能な形式に変換する
↓
レスポンスを返す

上記の通り、少なくとも3回、データの変換が必要になります。

  1. リクエストをドメインロジックに引き渡すとき
  2. 永続化されたデータを参照するとき、または変更されたデータを永続化するとき
  3. ドメインロジックの処理結果を受け取とってレスポンスを構築するとき

しかし、オブジェクト指向はデータの変換を記述することが苦手であり、どこかで妥協が必要になります。

// ドメインAに属するデータAを、ドメインBに属するデータBに変換する場合を考える

// 単純にデータを変換する関数を作ると、
// 単純な配列である $dataA と $dataB の状態が正常であることを保証する良い手段がない。
$dataB = dataAToDataB($dataA);

// データAとデータBをそれぞれオブジェクトに格納した状態で変換しようとすると、
// BのことをAが知っているという状況になっておかしい。
$objB = $objA->toB();

// ドメインBにファクトリメソッドを用意すると、上の問題に加えて
// 今度はその処理中で $objA のカプセル化を破壊しなければ
// データを取り出せないのでドメインAの知識が漏れる。
// 例えば $objA->toArray() のようなメソッドが必要になるので、
// 設計上は誤った使い方のできる「抜け道」が残る形になる。
$objB = DomainB::fromA($objA);

// 補足として、この問題に対してDTOが使われることもあるものの、
// DTOはクラス数が増えて煩雑になったり内部状態の安全性が担保されなかったりするため、
// 使用感が微妙。DB周りでは使った方が楽なことが多いので使いはする。

これで何が困るかと言うと、一番困るのはドメインモデルを永続化するときのデータの変換が煩雑になりやすいことです。具体的にはカプセル化のせいでドメインモデルからデータを取り出してクエリに変換する流れが非常に面倒くさくなります。わざわざ取り出さない方法もあるものの概ね ActiveRecord になり、単純で書きやすい代わりにモデルが太りますし、最適化も難しくなります。また、CQRSという設計手法においては参照と更新で完全に処理を分離しており、参照処理はレスポンスに使うデータを取得してまとめるだけの手続き的な処理になり、ドメインモデルを使用しません。このことからもOOPとデータ変換の相性の悪さが垣間見えます。

対して、例えば関数型言語は以下のような特徴を持つものが多いですし、上記の問題は設計上のコンセプトが根本から異なるためあまり問題になりません。

  • 型システムが柔軟で、データの取りまわしが良い
  • immutable な変数やアクセス制御の基本単位がモジュールであることなど、安全にデータを取り扱うためのサポートが豊富である

少なくとも非オブジェクトであるデータの取り扱いに関しては関数型言語の方が機能が充実していることが多いですし、データの変換も自然に記述することができます。

オブジェクトを含む全ての抽象化には「漏れ」がある

抽象化は便利です。抽象化された中身を知らないでもそれを使うことができるためコードリーディングの効率が向上します。プログラムを構造化することの助けにもなります。

ところで、例えば以下のようなコードがあったとします。

// 細かい突っ込みはナシでお願いします
foreach($users as $user) {
  $user->save();
}

もし $users の要素数が100で $user->save() で毎回データベースにクエリが発行されていたとすると、合計の処理時間は相当なものになるでしょう。そうなってくると、最早 $user->save() の中身を知らずにはいられなくなります。

つまり、抽象化はパフォーマンスの問題によって簡単に破綻します。もっと言うと、パフォーマンスや利用上の制約事項、副作用、あるいは準正常系や異常系の挙動などの事情が、抽象化を突き破って漏れてくるのです。抽象化は万能ではないので、オブジェクトもまた万能ではないということになります。

抽象化の漏れについてより詳しい解説が必要であれば下記の記事を読むと良いでしょう。

https://www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/

All non-trivial abstractions, to some degree, are leaky.

オブジェクトで解決できない部分にはデータと手続きが現れる

前述のコードを最適化しようとすると、例えば以下のようになるかと思います。

// 細かいことは気にしないで雰囲気だけ感じてもらえれば……
$userArray = [];
foreach($users as $user) {
  $userArray[] = $user->toArray();
}
$db->bulkUpdate('tbl_user', $userArray);

オブジェクトのカプセル化を破って配列にすることで、まとめて更新するクエリを1回だけ発行すればよいようにしています。ここまでくるとだいぶ手続き的になってきており、データを直接触る処理が増えています。

Webアプリのパフォーマンス最適化の文脈においては、DB処理は非常にボトルネックになりやすく、これを最適化する需要が非常に高いことは一般的な事実として良いと思われます。基本的にはクエリの数を減らし、インデックスの貼り方を最適化し、クエリの投げ方を工夫することでチューニングを行うため、ロジックに与える影響も少なくありません。時には複数のデータをまとめたバルク処理も必要になってくるわけですが、そういった処理ではデータを直接扱うことも増えるため、その部分は最早OOPとは言えなくなってきます。上記の例はその典型的なものです。

つまり、OOPにおいてもデータを扱う手続きの存在を無視することは出来ません。

意外と柔軟なOOP

これまで述べてきたように、OOPには本質的な欠点があるため、実際に適している領域は限られていると考えられます。しかし、現実的にはOOPは手続き型プログラミングのパラダイムを内包しているため、抽象化が物事を隠蔽する性質と合わせることで工夫次第でほとんどのソフトウェア開発ができてしまいます。これまでに挙げてきたOOPの欠点はOOPをしていてもデータを扱う手続きが少なくない頻度で現れることを意味しますが、部分的に手続き型プログラミングを用いることで解決できる限りは致命的に困るわけでもないのです。勿論、設計的には妥協が残るものになるため、より良い手段は欲しいところです。

また、OOPを実施するにあたっての設計に関する知見は既に十分にたまっています。Web開発の文脈ではDDDをはじめとする開発手法およびデザインパターン、ICONIXなどの分析手法やモデリング手法、Clean Architecture やCQRSといったアーキテクチャ設計などなど、先人たちの知恵が手法としてしっかりとまとめられています。そのため、OOPを用いて大規模開発に立ち向かうには十分な知見が溜まっていると言っても良いでしょう。

ただし、これはOOPの複雑化を招いている部分にもなります。

例えばDDDのデザインパターンを単純に適用するとパフォーマンスに問題が発生しやすくなり、時には Unit of Work パターンを採用した分厚いORMフレームワークが必要になることがあります(それでも完全に解決するわけではない)。あるいは最適化のために大きく設計を変更する必要があるかもしれません。

例えばトランザクション境界によって結果整合性を受け入れることを検討することがあります。

例えばドメインモデルの軽量化のためにCQRSパターンを使用して参照と更新で処理を分ける必要性が出てくることがあります。

例えばIDなどに型を付けたいときに、独自型の定義が言語機能にないのでクラスを使って実現することを検討することがあります(言語による)。

確かにこれらによって設計的には良くなるかもしれませんが、この複雑さと冗長さをもう少し軽減できないかとも思うところでもあります。OOPの良いところとして初心者にとって取っつきやすくて間口が広いことが挙げられますが、かと言って良い設計をするための学習量を減らせるわけではないので、この辺のギャップが話をややこしくしている一因でもあるでしょう。

一方で他の話題として、よくよく考えると部分的には手続き型プログラミングに時代が逆行してしまっているわけです。OOPはどちらかというとコード上から「データ」を消すための手法であるため、オブジェクト指向のコンセプトの中にはデータに関する機能への言及はありません。そのため、典型的なOOPLにはデータを安全かつ便利に扱うための言語機能のサポートが比較的少なく、設計上不便を感じる機会が多くなります。一例としてPHPはOOPがサポートされてからも徐々に型システムが強化されて行っているあたり、その辺の機能に対する需要が非常に高いことが伺い知れます。つまるところ、オブジェクト指向の基本機能だけでは力不足なので補完する機能の需要が高いというわけです。ただ、データを重視するとその部分は最早「オブジェクト指向」ではなくなってしまいそうですが、問題を解決できることこそが重要なので全体をOOPで構成することにこだわる必要はないでしょう。

一旦ここまでの話を以下にまとめてみます。

  • OOPの適用対象には向き不向きがある
  • OOPは適正の低い題材に対しても手続き型プログラミングと組み合わせて対応する余地があり、柔軟である
  • OOPを実施するにあたっては十分な知見が溜まっていて、少なくともWeb開発においては実用には値する
  • データを直接扱う機会を無くすことが出来ず、オブジェクト指向の基本機能だけでは力不足なので、それを補完する機能の需要が高い

こうして見ると、OOPは十分な言語機能さえあればよりパワフルに使えるように思えてきます。

ところで、OOPとよく対比されるFPはデータと関数が主役であるため、関数型言語はデータの扱いに関しては非常にうまいと言えます。すると、近年になって続々と登場しているマルチパラダイムの言語が気になってきます。

OOPはFPとのマルチパラダイムで活きる

最近話題になる言語はマルチパラダイムのものが多いですが、これはマルチパラダイムが良いという結論に至った人が少なくないことの証左でもあるように思います。プログラミングパラダイムは結局のところプログラマにとっては問題解決の道具のひとつでしかないため、問題解決が出来るということを優先すれば複数の道具を使えるようにするという結論は自然であると言えます。実際、OOPとFPを組み合わせることで、OOPの視点からすればデータを扱う言語機能のサポートが格段に強力になる上にOOPが適さない箇所については別のアプローチを検討できるようになるので、確かに便利になっています。

OOPとFPの両方をサポートする言語としては、思いつきやすいところでは Scala、TypeScript、厳密にはやや違うものの Rust があります。この中で筆者が Web 開発に使うとすれば、無難な選択として TypeScript になるかなと思います。Scala はJVMの恩恵を受けつつJVMで苦しんでいるジレンマがあり、Rust は言語的には好きですが Web 開発の用途では比較的成熟していない印象があります。対して TypeScript はWeb開発の実績が多く、コンパイルは比較的速くはあって言語仕様もこの中だと扱いやすい方であり、フロントエンドとバックエンドの両方で使える汎用性の高さが魅力的です。TypeScript は例えば純粋関数型言語と比較するとFPに対するサポートはやや見劣りするのでガリガリFPをやろうとするとやりづらい部分が出てくることがありますが、無理しない範囲でFPをするくらいの気持ちだと悪くない使用感で使えます。

OOPLでFPをすることについて

少し別の話題になりますが、最近になってOOPLの使用者が言語を変えずにFPによる設計を検討する話をたまに聞くようになってきました。確かに、言語機能が許す範囲で無理なくFPのエッセンスを取り入れるのであれば良い試みです。特に参照透過の考え方などはOOPの設計にも良い洞察をもたらしてくれるでしょう。しかし、FPをサポートしていない言語でFPを徹頭徹尾適用しようとするのは全くお勧めしません。なぜなら、恐らく言語機能が足りません。もし出来そうであると感じてもどこかに落とし穴がある可能性が高く、かなり慎重に検討する必要があります。本当にFPをしたいのであれば、基本的にはFPをサポートしていると公式に謳っている言語を使うべきでしょう。言語のデザインや言語機能というのは非常に大事であり、それらに逆らうのは非常に危険です。そもそもFPも銀の弾丸ではないですし、経験的にはOOPでも生産性を高く保てるため、FPにしたからと言って生産性が劇的に向上するかは疑問です。OOPLもOOPも、突き詰めると実用上はそこまで悪いものではないので、あんまり嫌わないであげてください。

結局のところ、言語を適切に選択するのも適切に使用するのもプログラマの仕事であるということです。一度言語を選んだのであれば、その言語上で現実的に取れる手段で問題を解決しなければなりません

まとめ

  • OOPには向き不向きがある
  • OOPは不向きなものについても何とか対応できる柔軟さがある(多少の不満は残るが、使えはする)
  • プログラミングパラダイムは所詮はプログラマにとっての問題解決の道具でしかない
  • OOPとFPは補完関係にある
  • マルチパラダイムはいいぞ
  • 言語は適切に選んで適切に使おう

Discussion