JavaやCなんとかの「オブジェクト指向」はここが嫌い
「オブジェクト指向と10年戦ってわかったこと」
https://qiita.com/tutinoco/items/6952b01e5fc38914ec4e
というエントリにコメントを書いていたら長くなったので、自分なりにまとめてみました。JavaやC++、C#が好きな人はイラっとすると思うので、読まないでください。完全に書きたらしですので、JavaやC++、C#が嫌いな人も、読んで得られるものはないと思います。「ああ、そうだよね、そうだけど、それQiitaに書く必要あるの?」というようなことです。元がコメントなので、全部、ただの感想意見の類です。が、なんとなく、途中まで書いてしまったので書くことにしました(いやー、こんな風にネットワークリソースを無駄にできるなんて、豊かな時代ですね)
そんなわけで、自分の知ってることを整理しつつ、JavaやCのようなオブジェクト指向言語における「継承」、「ポリモーフィズム」、「カプセル化」について考えてみました。
オブジェクト指向の根幹は「ポリモーフィズム」なのかも
オブジェクト指向の源流をたどるとSimula→Smalltalk→C++のような感じになると思うのですが、C++以前のオブジェクト指向言語では、状態を持ったオブジェクトとその相互作用を分離する目的で、オブジェクトやクラスといった概念を用いていたようです。Smalltalkなど初期のオブジェクト指向言語は徹底的で、対象がなんであるか意識することなくメッセージを投げれるようなものでした(もちろん、実装されていないメッセージには応答できないため、そういった場合はdoesNotUnderstandのような実行時エラーとなります)。
Smalltalkの例になりますが、この言語では条件分岐さえ、メッセージとして実装されています。i<4 :ifTrue ['do something']のような書き方になるのですが、これはi<4によって返されるBooleanオブジェクトに:ifTrueというメッセージを[]で囲まれたコードブロック(クロージャ)を引数として送信する、というような形で実現されます。Booleanは自分がtrueなら:ifTrueのパラメータとして渡されたブロックを実行するというような動作を行うよう、:ifTrueメッセージを実装します。ここで、「ポリモーフィズム」が使われています――要するに、メッセージ:ifTrueのディスパッチは動的です。この時点ですでに「ポリモーフィズム」は不可欠な要素として現れてきます。しかしながら、CやJavaで「ポリモーフィズム」とセットで語られがちな「インターフェイス」は言語機能として明示的に表れてくるわけではありません。このように汎用的な構文ですらメッセージとされる状況では、これらに対していちいち「インターフェイス」を定義するのは現実的ではないでしょう。メッセージを投げる側は、オブジェクトに対して何の仮定も置く必要がないからこそ、柔軟性を確保できているのです(インターフェイス?そんなもん知らん!あるいは、それはアドホックにいつでも修正可能で、プログラムとともに変わりゆくプロトコルである)。
「継承」がクローズアップされるのはC++のせい
かくして、このモデルの理想的な状態ではオブジェクトの内部状態は「完全に」隠蔽され、メッセージだけが飛び交う状態になります。ディスパッチは基本的に動的なので、「インターフェイス」なんていりません。どのオブジェクトも互いに依存してはいないのです。メッセージをもらったら、応答する、それだけの世界です。しかし、型が入り込むとそれだけでは済まなくなります。コンパイラが、どのようなメッセージが、どのような形式で呼ばれるのか、コンパイル時に判別できるよう言語仕様を構成する必要があるからです。これは、オブジェクト指向の本質的要素と言えるでしょうか?個人的な意見ですが、「インターフェイス」の存在は、むしろ効率や(副次的にもたらされる)コンパイル時の型チェックによって、本来オブジェクト指向ではなかった言語によって導入されたものです。
オブジェクトの型によって「インターフェイス」を宣言すること自体、オブジェクトがどのようなものであるのか?を外部に露出させることになるので、そこにある種の依存関係を作り出していることにほかなりません(インターフェイスを定義する、ことだけなら無害ですしこれは必要ですが、それに対する参照を持つということが、依存性を作り出す元凶となり得る――が、コンパイル時にメソッド呼び出しの型チェックをしたりインライン展開をしようとするとそれは避けられない)。これにより、メッセージの受け渡しだけで済んでいた状態では発生しなかった、型によって規定されるオブジェクトとある型のオブジェクトが受信可能なメッセージ(メソッド)という依存性が加わります。
C系やJavaなどのオブジェクト指向型言語の問題点はその辺にある気がします。動的なオブジェクト指向の世界では、「継承」という機能はオブジェクト指向というパラダイムの本質的な要素ではありませんでした。CやJavaスタイルの同じ実装を繰り返さないための補助的な機能として導入すれば、利便性は向上するでしょうが本質的ではないのです(そして、実際そういった機能は、Baseのようなオブジェクトに対して、メッセージをフォールバックするような、特殊な委譲として表現されるでしょう。そうすれば実行時に基底クラスを入れ替えれるので、template<class T> class Foo : Tとかともおさらばです)。「ポリモーフィズム」のみが本質的な言語機能で、他はどうでもよい気がします。カプセル化も理想状態では不要です(なぜなら、それはメッセージを介した相互作用のみを用いるのなら、自動的に実現されるから)。単に、「オブジェクト指向」でプログラムを書きたいだけなら、もう、ダックタイピングでいいんじゃないかと。というわけで、私は本当にオブジェクト指向的なら、言語は動的型付けのほうがいい派なのです。
では、なぜ「継承」というのがオブジェクト指向とセットで語られがちなのかというと、それはビャーネ・ストラウストラップというおじさんが、C++という言語を発明してしまったせいでしょう。Cとの互換性を維持しようと思った場合、ダックタイピングなんて言語道断です。そんな機能があったんじゃ、C互換のマシン語へのコンパイラなんてかけたもんじゃありません。もちろんC++はパワフルな言語です。でもそれは、僕が想像するに、ビャーネおじさんの構想とは別のところで、何かが起きたせいでしょう。ポールグレアム風に言うと、C++を強力にしているのは、主に組み込みのマクロです(テンプレートと言う、おとなしい名前で呼ばれていますが、あれはどう考えてもマクロですよね?そこのテンプレート使いさん)
静的型付けの手続き型言語を拡張したオブジェクト指向言語は、コンパイラによる最適化の恩恵を最大限受けることができ、必要があれば機械語レベルまでコンパイルできるという利点があります。ようするに、実行効率ですね。C++なんぞを好んで使いたくなる時は、システムレベルのプログラミングが必要で、がちがちに最適化が必要な場合(グラフィックライブラリの中身とか)がほとんどな気がします。なので、動的型付け型とポリモーフィズムがあれば他には何もいらないとも言えません。そういった需要を満たすため、実行時サブタイピングをthisポインタ経由で解決できる「継承」という言語機能がクローズアップされるようになったのではないかと、そんな気がします(事実はどうなのでしょうか…)。そして、それはオリジナルのオブジェクト指向言語の発想とはかなりかけ離れたものなのではないでしょうか?
C++について
これは、完全に余談ですが、C++は最初に知ったオブジェクト指向型言語だったので、個人的には愛憎半ばです。C#のジェネリックを使い始めてから、え?これってコンパイル時にコード生成するんじゃないの?ジェネリックなTに対してメソッド呼び出しとかできないの?と思って以来、C++のテンプレートをいじくっていた日々が懐かしく思い出されました…あれも、ライブラリのインターフェイスとかにして、汎用性を高くかつ型が曖昧にならないようにとかやろうとすると大変ですが、「あー、ここはこうなってほしいんだよなぁ」という不満が日に日に鬱積する設計になっている気がしてなりません。もちろん、理由があってそうなっているのであり、悪いのは自分の頭というのは承知の上です。が、最初の職場で、例のビャーネ・ストラウストラップへのインタビューのネタを冗談半分で先輩に見せたとき、「え?マジ?」となったのはいい思い出。誰しも巨大で複雑なC++の仕様には辟易しているのではないでしょうか?近年、ますます巨大化&難解化しているようで、何よりです。
カプセル化?なにそれおいしいの?
なんか、言いたいことがごちゃごちゃしているので、一旦まとめます(どうせ、誰も読んでないから無駄か?)
1. オブジェクト指向は原理的には、ポリモーフィズムと動的型付けで実現できるし、それがもともとのアイディア
2. ところが、C++のような実行効率の高い言語にオブジェクト指向を導入する際、技術的な問題を解決するため「継承」を特別扱いする必要が発生した。現在、CやJavaのような言語でサポートされている「継承」は静的型付け型言語にオブジェクト指向を無理やり組み合わせた結果うまれたただの厄介者。
そして、ここからが本当に言いたいことです。これは元の記事で三大要素の一つとされている「カプセル化」及び、それに付随する言語機能に対する不満です。「カプセル化」自体は(個人的な主張ですが)言語機能でどうにかなるものではありません。それは設計の問題ですから。オブジェクト指向かどうかも関係ありません。何型言語とかそういう問題ではないのです。「あれ」が「それ」の参照を持っていて、あるタイミングで不定期に更新するような設計になっている場合(十分に大きなプログラムなら、絶対に一二か所はそういうところがありますよね?ここで、十分に大きいとは、そういうコードがついつい発生してしまう規模のことを示す、主観的な基準です)、そこで都合の悪いことが起こります。それを減らすような設計にしましょうということです。元の記事でもこのことには言及されていますので、これは元の記事に対する批判ではありません。むしろ、使用者である元記事の執筆者のような人々(われらがプログラマー!)ですら、普通にわかっていることを、言語設計者はなぜ理解できないのでしょうか?
ほとんどの言語がpublicだのprivateだのとあまりにうるさすぎる気がするのは私だけでしょうか…インターフェイスの宣言は最低限必要悪として認めるならば、本来オブジェクト同士の相互作用は、宣言されたインターフェイスを介して行うべきなので、メンバ関数やメンバ変数に対していちいちアノテーションを付けることを強制すること自体、「悪い設計」の可能性をあえて表にだしているような気さえします(もちろん、たいていの場合「インターフェイス」を介したアクセスはポインタの間接参照なので、せっかく効率的な言語を用いている以上、直接関数呼び出しにしたいというケースはありますが、その辺もコンパイラで何とかできないのかとか)。まるで、やるなと言っていることを、出来ますと言っているような仕様ですよね?そして、それを何かを宣言する度、毎回やらせようとするのです(暗黙的にprivateになるからとか、そういう問題ではなく)。
カプセル化の本質は、あくまでおかしな依存性をなくすことです。publicだのprivateだのpublic abstractうんたらだのという余計な機能を実装する暇があったら、もうちょっとまともな工夫をして、オブジェクトの相互依存性が下がるような設計を自然と促すような言語機能を作ったらどうでしょうか?それができないから、バカみたいなキーワードでお茶を濁し、自ら言語仕様を複雑化させているんじゃないかと邪推したくもなります(もちろん、深遠な意味があるのでしょう。悪いのは(たぶん)私の頭です)。privateにしておけば安心、カプセル化完了みたいな思考法がはびこると、悪化の一途をたどるだけです。静的型付け型言語において、ある程度の不自由と引き換えに、効率と安全性をとるということは理解できます。が、「カプセル化」という名目のもとで、publicだのprotectedだのとやるのは無駄です…それこそ、大元のオブジェクト指向的な考えに真っ向から反対しているような機能だと私は思うのですが。(ええ?それだけメンドクサイキーワードがあるのにconstはない?そっちのほうが、余計な参照が余計な状態を変更していないか見張るのに、よっぽど役に立つのでは?)
で、結局何が言いたいのか?
何なんでしょう?自分でもよくわからなくなってきました…日曜日、おいしいケーキを食べに行きました、みたいな感じでインスタに写真を挙げている人々と同じ心理なのかもしれませんが、なんとなく書きたらしたくなったまでのことです。ここまで読んでくれた人がいたら、時間を無駄にさせたことをお詫びいたします。
その他の言語について
これも個人的な感想ですが、JavaやC++のような言語は、そのうち新し言語にどんどん入れ替わっていくのではないかという気がします。RustやScalaでおなじみのtraitとか、ML系言語の十八番だった型推論を持ったJava、C++ライクな言語などがどんどん出て生きている今、これまでJavaやC++の強みだった
1.実行時の効率
2.コンパイル時の型チェック
の両方が、もはや強みではなくなってきている印象を受けます。やはり、型推論の影響力は大きいのではないでしょうか。結局、関数の引数くらいにしか型を書かなくてよくて、それ以外は全部コンパイラがやってくれる、そして効率はC++並みでネイティブコンパイルも可、となれば目端の利くプログラマならそちらにどんどん移行してゆくのではという気もします。もちろん、JavaやC++には巨大なコードベースがあるので、すぐにはなくならないでしょうが、遅かれ早かれ、一昔前のCOBOLのような末路が待っている気がしてなりません…(多分、そんな中でもLisp、ML、Cは元気に過ごしていることでしょう)。
いやー、それにしても、ポストC++はいつ来るのでしょうか…RustはBoxedとかUnboxedとか厳しいので、なんかせっきょくてきにはお勉強する気になれない…。