はじめに
例えばあなたが通りすがりJuliaプログラマに、Julia言語の何が素晴らしいのか聞いてみたと仮定しよう。おそらくその人は目を爛々と輝かせながら、いくつかの機能をまくしたてるだろうが、そのうちの一つに多重ディスパッチが挙げられることにほとんど疑いの余地はない。多重ディスパッチ・・・どっしりとした重厚さを感じる響きだ。
多重ディスパッチとは何かというと、同じ名前だが引数の違う関数をいくつも定義することができて、実際に呼び出される関数が、実行時の引数の型によって決められる機能のことを指す。「それって、関数のオーバーロードの事だよね!?」と思ったあなた。素晴らしい。わたしはあなたのような人に会いたかったのだ。多重ディスパッチとオーバーロードは少し違う。ほんの些細な違いに思えるが、その差が無視できないほどになることもあるのだ。あなたに多重ディスパッチのめくるめく世界を紹介しよう。
モジュール化と糊
いきなり多重ディスパッチの詳細に入る前に、モジュール化というトピックについて話しておこう。なぜ関数プログラミングは重要かという有名な文書がある。古い文書だが、その価値は未だに色褪せない。この文書の中で、次のような一節がある。
今やモジュール化設計がプログラミングを成功させる鍵であることは一般に受 け入れられている。そして Modula-II [Wir82]、 Ada [oD80]、 Standard ML [MTH90]のような言語はとくにモジュー ル性を向上させるように設計された機能を含んでいる。しかしながら、多くの 場合、見過ごされる非常に重要なポイントがある。問題を解くための部品プロ グラムを書くとき、その問題を部分問題に分割し、部分問題を解き、その解を 合成する。元の問題を分割する方法は、部分解を貼り合せる方法に直接依存す る。それゆえに、概念的には問題をモジュール化する能力を高めるためにはそ のプログラミング言語のなかで新たな糊の類を用意しなければならない。複雑 なスコープ規則や分割コンパイルの規約は事務的な詳細でしかなく、問題を分 解する新しい概念的道具をあたえるものではない。
糊の重要性は、大工仕事との類比によって、正しく評価できる。椅子は、部分 (座部、脚、背もたれなど)を作り、それらを正しくくっつけ合せることで容易 に作ることができる。しかし、これはジョイントと木を張り合せるという能力 に依存する。その能力がなければ、椅子を作る方法はひとつ木の塊からそれを 彫り出す以外なく、非常に難しい作業になる。この例は、モジュール化の強大 な力と正しい糊を持つことの重要性の両方を例示するものである。
https://www.sampou.org/haskell/article/whyfp.html
Juliaの多重ディスパッチは非常に良い「糊」なのだ。これを今から見ていきたい。
プログラムとは何か
いまから多重ディスパッチとはプログラムをモジュール化して貼り付けるための良い糊であることを語っていくのだが、その前にプログラムとは一体なんなのかの認識合わせをしておきたい。
ここで、プログラムとは「入力のデータに対して、いくつかの手続きが作用していき、最終的になんらかの出力データを得るものである」というであるとしておこう。もしかすると、そうでないプログラムというものもあるのかもしれないが、まあ99.9%のプログラムはそのようなものである。
この「プログラム全体を通しての手続きの並び」というのは、そのプログラムそのものの価値を提供する非常に重要な手続きであり、この流れをまずは見失わないようにする必要がある。ごくシンプルなプログラムであれば、単に手続きを並べるだけでも良いだろうが、複雑なプログラムになるにつれ、我々の脳の性能の限界に驚くべき速さで到達し、簡単に全体像を見失ってしまう。
そうならないためには、全体の大きな手続きを小さな手続きの組み合わせとして認識する必要がある。つまり、プログラムは全体として入力と出力があるが、それぞれを小さな手続きに分解し、それぞれの手続きでも同様に入力と出力があると想像しよう。
そうなると、プログラムというのは、次のように表現することができるだろう。
入力データ | 手続き | 出力データ |
---|---|---|
データ0(初期入力) | 手続き1 | データ1 |
データ1 | 手続き2 | データ2 |
データ2 | 手続き3 | データ3 |
… | … | … |
データN-1 | 手続きN | データN(最終結果) |
ちょっと曖昧な表現になってしまっているが、ここで言う入力、手続き、出力、というのは、関数の引数、関数、返り値とは必ずしも一致しない。そのような具体的な実装より手前の、もっと抽象的な表現だ。これは具体的な関数というよりも、全体の手続きを見たときに我々がぼんやりと理解する、手続きのまとまりである。
例えば、何人かの生徒のテストの点数のリストをもとに、その平均点を求める、というようなプログラムを考えよう。次のようになるだろう。
function main()
#点数のリスト
score_list = [100, 78, 81, 65]
#総和を求める
sum = 0
for s in score_list
sum += s
end
#要素数を求める
cnt = 0
for s in score_list
cnt += 1
end
#商を求める
ave = sum/cnt
println(ave)
end
これを先ほどの表形式で表すと次のようになる。
入力データ | 手続き | 出力データ |
---|---|---|
点数のリスト(初期入力) | 総和を求める | 点数のリスト 点数の総和 |
点数のリスト 点数の総和 | 要素数を求める | 点数のリスト 点数の総和 点数リストの要素数 |
点数のリスト 点数の総和 点数リストの要素数 | 商を求める | 点数のリスト 点数の総和 点数リストの要素数 平均点(最終結果) |
例えば、「要素数を求める」手続きをプログラムの関数として表現したら、生徒の点数の総和は入力の引数にはならないはずだ。しかし一度開始された変数のスコープは、それ以降の処理に使われる可能性があり、保持している必要があるのでこのような表記にしている。
注:少し脱線すると、このように、プログラムの実行中のある断面を切り取り、その後の計算に必要な全ての情報(その断面のデータとその後の手続き)をまとめて、「継続」という名前で呼ぶことがある。「継続」はあまりに当たり前に存在するので意識するとこは少ないが、Schemeという言語では「継続」というデータ型が明確にサポートされている。
ここで何を言いたいかというと、それぞれの小さな手続きは大きな手続の文脈の中で強力にハードコードされているということだ。あるブロックを抜き出してみると、それは関数のように見えるが、決して関数ではない。なぜならば、そのブロックで使っている変数はどこかよその箇所でも使われているかもしれず、そのブロックはその前後のコードと無関係ではいられないからだ。ハードコードされているということは、簡単には独立できないということであり、モジュール化という意味では甚だ不満だ。
そこで、そのような小さな手続きをモジュール化する方法が関数である。
関数
関数の素晴らしさについては今さら語るまでもないだろう。素晴らしき関数!
関数は手続きをモジュール化する。手続きをくるんで外部からのアクセスを遮断し、手続きを文脈から独立させる。
切り出された関数は、物理的には文脈から独立しているが、意味的に文脈から独立しているかはケースバイケースだ。例えばscore_list_init()という関数は「点数のリストを初期化する」という文脈で使うことになるだろう。それ以外の状況で使うことは、ほとんど想像さえできない。この関数は文脈に極めて強く依存している。一方、score_list_length()という関数の文脈への依存はもっと緩やかだ。点数のリストにまつわるさまざまな処理で、リストの長さを取得したくなることはあるだろう。モジュール化という意味では、score_list_length()の方が、score_list_init()よりも上等だ。しかし、score_list_length()にしても、点数のリストに対して長さを取得する以外の文脈で使っては不適切だ。モジュール化という意味では、一般的なリストに対して、list_length()と言う関数があって、score_listに対してそれを適用するようにした方がいいだろう。list_length()というのは極めて文脈依存性と低い関数だ。もちろん、list_length()だって、リストの長さが必要でない文脈で呼ぶわけにはいかないので、そう言った意味で完全に文脈にから独立しているわけではない。
注:いかなる文脈であっても、呼び出すだけであなたが必要とする処理を提供してくれる、究極に文脈から独立したモジュールというのはあるのだろうか?私には心当たりが一つあって、そのモジュールにはニンゲンという名前がついている。
どんな関数であれ、関数を使うときには、適切な文脈で呼び出してあげる必要がある。これはコントロールするのはプログラマである。
プログラマが文脈に沿った適切な関数を呼び出すためには、関数名を頼りに推測するか、関数に付属しているドキュメントを読むか、関数の実装を確認するか、方法はいろいろあるが、とにかく人間が頑張ってコンピュータに教えてあげる必要がある。
変数が文脈を担う
さて、首尾よく関数を組み合わせてプログラムを書いたとしよう。出来上がったプログラムを見ると、関数は文脈から独立しているので、文脈を担っているのはデータと、関数の適用順序であることに気づくだろう。そして、関数の適用順序というのは、結局のところデータの変換順序を決めているに過ぎない。我々はデータがどう移り変わっていくかを見て、文脈を推測することになる。
データは変数に保持される。そして変数は、データの値以外に、データ型と変数名という情報を持つ。これらのうち、どの情報が文脈を担うかというと、普通は変数名だ。int average_score = 5;
というような構文があったとして、処理の流れを読むのに一番役に立つのはaverage_score
であろうということだ。
これが、変数名に良い名前を付けることは大事であると言われる理由である。変数の名前がaとかbのような意味のない名前であれば、文脈の推測は困難を極めるだろう。一方で、変数名が分かりやすければ、文脈の推測は容易い。良い変数名を付けることは、プログラマの基本であるとともに、永遠に追い求めるべき課題でもある。
文脈に沿った手続きを取得したい
ところで、データ変換の連鎖としてのプログラム、という観点で見た時に、関数の適用というものは別の意味を持ってくる。まず、あるデータから別のデータに対する変換というものがまず定義され、それを実現するための処理が何か決まるというものである。例えば、「点数のリスト」というデータからは、「要素の個数」「平均点」「最高点」「偏差値」などのデータを変換先として想像することができる。
逆に、汎用的なリスト対しては想像可能な手続きも、「点数のリスト」という文脈を考えると途端に意味を見失うことがある。「逆順にして2つ飛ばしにデータを抽出する」という手続きは、「点数のリスト」に対してどのような意味を持つだろうか?その手続きを適用した結果は、意味のあるデータになるだろうか?汎用的な「リスト」というものに対しては無数の操作が可能であるが、実際にプログラムで取り扱うデータは、無味乾燥な「リスト」ではなく、もう少し何か意味を持つデータのはずで、意味を持つデータに対して意味のある操作というのは限られてくる。
さらに考えを進めると、さまざまな種類のデータに対して、同じような意味を持つ操作を考えることもできるだろうし、それらは同じ名前で呼びたくなってくるだろう。
このように考えると、次のように縦軸にデータの種類を、横軸にデータの種類に付随した手続きの表を考えることができる。○のついているところが、意味のある操作として定義可能な部分だ。
データ種類 | 科目別最高点の取得 | 最大値の取得 | 対前年伸び率が最高の年の取得 |
---|---|---|---|
受験生全員の点数 | ○ | ○ | |
過去5年の売上高 | ○ | ○ |
そうして、「受験生全員の点数」のデータに対して「最大値の取得」を行いたいのだと宣言すれば、事前に定義されたメソッドの中から、適切な手続きを探し出してくれるような機構が欲しくなってくる。なぜならば変数が文脈を担ってくれているのだから、変数を手がかりにすれば、適切な手続きを探し出す労力が減るということが期待できるからだ。
そうすると我々はいくつかの機構が欲しくなってくる。
- 与えられた意味に応じたデータの種類を定義するための機構
- 指定されたデータの種類に対して、実行可能な操作の名前を定義するための機構
- 「データの種類」と「操作の名前」から、「具体的な手続き」を特定する機構
これらがユーザー定義型とか構造体とかクラスとか呼ばれるものであり、関数のオーバーロードとかオーバーライドとか多重ディスパッチとか呼ばれる機構である。
こういった、ユーザー定義のデータ型に対するサポートは言語により違う。そして、時代を経るにつてどんどん強力になってきていると言っていいだろう。いきなり自然言語だけでぼんやりとしたイメージの説明してしまったので、私が何を言っているのかわからないかもしれない。ここで、いくつかの言語の機能を確認しながら進んでいくことにしよう。
手続き型言語
多重ディスパッチより前に、オーバーロードより前に、まずは手続き型言語の時代から始めよう。
注:多重ディスパッチやオーバーロードと対比する言葉が手続き型と言っていいのか自信がないが、これ以上伝わりやすい言葉を思いつかないので、この文章ではそう呼ぶことにする。
代表的なのはC言語だ。C言語は関数の多重定義を認めていない。そのため、次のように引数の方が違う同名の関数を定義すると、コンパイルエラーとなる。
//これはコンパイルエラー
#include
int double(int a){
return 2*a;
}
float double(float a){
return 2*a;
}
int main(void){
printf("%d\n", double(1));
printf("%f\n", double(0.5));
}
この点においてユーザーの取れる手立てはほとんどなく、愚直に関数名を分けるしかない。(マクロを使ったトリッキーな技を駆使すれば何か手はあるかもしれないが、今回のテーマはそういう話ではない。)
#include
int double_int(int a){
return 2*a;
}
float double_float(float a){
return 2*a;
}
int main(void){
printf("%d\n", double_int(1));
printf("%f\n", double_float(0.5));
}
C言語には構造体というユーザー定義型の機能があるが、この制約のために有用さが幾分損なわれている。コードを書くときに、型に応じた適切な関数を呼び出すように、人間がコーディングする他はない。「ええっと、引数がintだからdouble_intを呼び出さないとダメだな。」
型と関数は密に結合している。これ自体はごく自然なことだ。問題はその結合を人間がコーディング時に行なっていることだ。だから、何か大きな文脈の中で、これまではintが渡されてきたのでintが引数の関数を呼び出していたときに(テストの点数は整数に決まっているよね!)、floatが渡されるようになると、呼び出す関数を書き換える必要があるため困ってしまうのだ。(ええっ、傾斜配点をするので科目別に0.2から0.8の係数をかけるだって!?)
オーバーロード
C++やJava、C#などの言語では、関数のオーバーロードという機能がある。関数fooがあったときに、引数の型に応じて複数の関数定義をすることができる機能をオーバーロードという。foo(int a)とfoo(int a, int b)とfoo(string s)とが別々に定義できるのだ。そして、関数を呼び出すときには、引数の型によって適切な関数が選択される。ここで型と呼んだものには、自分で定義したクラスや構造体も含まれる。
C#で書くとこんな感じだ。
注:ここ以降でもちょこちょこC#のコードを例に出すが、それは私がJavaとかTypeScriptよりはC#が得意だというだけの理由で、特段C#に固有の概念は使わない。
public class Hello{
public static int Double(int a){
return 2*a;
}
public static float Double(float a){
return 2.0f*a;
}
public static void Main(){
System.Console.WriteLine(Double(1).ToString());
System.Console.WriteLine(Double(0.5f).ToString());
}
}
オーバーロードはちょっとした飛躍だ。依然として型と関数は密に結合しているが、どの型のときにどの関数が選択されるかということを人間が意識する必要はない。コンピュータがコンパイル時に正しい関数を選択してくれるのだ。そのためコーディング時には、実際にどの関数が呼び出されるかということはあまり意識をする必要がなくなる。
オーバーロードの限界
オーバーロードはあくまでコンパイル時の決定だ。そのため、次のようなシチュエーションではうまく働かない。
クラスBaseがあり、それを継承するクラスDerivがあったとしよう。そして、関数hogeはクラスBaseに対する時とクラスDerivに対する時で、振る舞いを変えたいとしよう。オーバーロードの機能を使い、hoge(Base b)とhoge(Deriv d)を定義するとどうなるか?
このときは呼び出し時の変数の型をどう宣言しているかで決まる。もしも、引き渡す変数の型宣言が Base x =
new Deriv(); のように宣言しているのであれば、hoge(Base b)
の方が呼び出される。実際のインスタンスがDerivであるにもかかわらずだ。Deriv x = new Deriv()
;と宣言していれば、hoge(Deriv d)
の方が呼び出される。
これは実際には不合理に思える。宣言した型の違いはあれど、実際に生成されるデータの型はDerivだからだ。しかし、コンパイル時にどちらのコードを呼び出すかを決定しなければならないという制約がある以上、この決定は仕方のないところだ。代入の右辺値が、コンパイル時に型を決定できるものとは限らない。例えば次のような例では、ローカル変数xの中身がBaseなのかDerivなのか、実行時にしか決まらない。そのため、コンパイル時には、xの型は左辺の宣言であるBaseであるとみなしてコンパイルするしかない。結果として生成されるx自体の型は実行時にはBaseだったりDerivだったりするが、hoge(x)は常に引数の型がBaseのものが呼ばれるのだ。
C#のコードを例に出そう。
using System;
public class Sample{
public class Base{}
public class Deriv : Base{}
//返り値のインスタンスの型は実行時にランダムに決まる
public static Base createInstance(){
var rand = new Random();
if (rand.Next()%2 == 0){
System.Console.WriteLine("Baseが生成されました。");
return new Base();
} else {
System.Console.WriteLine("Derivが生成されました。");
return new Deriv();
}
}
public static void hoge(Base x){
System.Console.WriteLine("引数の型はBaseです。");
}
public static void hoge(Deriv x){
System.Console.WriteLine("引数の型はDerivです。");
}
public static void Main(){
Base x = createInstance();//結果はランダムに変わる
hoge(x);//常に同じ結果になる
}
}
Base x = ...
という形で書けないのは大変困った話だ。変数に基底クラスをとるようにして、具体的なインスタンスの型が実行時に決まるようにするというのが、オブジェクト指向のポイントなのだ。
オーバーライド
もちろんここで困ってばかりはいられない。普通この問題はメソッドのオーバーライドと呼ばれる方式で解消される。オーバーライドの前提として、関数がデータに「所属している」という状態を作り出す必要がある。すなわち、関数をクラスの所有物とするのだ。
そうしてから、基底クラスと派生クラスに同名の関数を定義し、派生クラスが基底クラスの関数を上書きできるようにする。
C#のコードを例に出そう。
using System;
public class Sample{
public class Base{
public virtual void hoge(){
System.Console.WriteLine("型はBaseです。");
}
}
public class Deriv : Base{
public override void hoge(){
System.Console.WriteLine("型はDerivです。");
}
}
//返り値のインスタンスの型は実行時にランダムに決まる
public static Base createInstance(){
var rand = new Random();
if (rand.Next()%2 == 0){
System.Console.WriteLine("Baseが生成されました。");
return new Base();
} else {
System.Console.WriteLine("Derivが生成されました。");
return new Deriv();
}
}
public static void Main(){
Base x = createInstance();//結果はランダムに変わる
x.hoge();//結果はランダムに変わる
}
}
こうすることで、先ほどとは動きが変わる。Base x = ...
と書いてあったとしても、実際のインスタンスの型に応じた関数が選択されるのだ。
今度はメソッドの選択はコンパイル時に行われているわけではない。「xに所属するhogeという関数を呼び出しなさい」という命令はコンパイル時に決定されるが、実際にxの所属を辿るのは実行時である。
ここまでの流れから、型に対して適切な手続きの呼び出しの決定が、コーディング時からコンパイル時、実行時へと徐々に後ろにずれていっているのがわかるだろう。
少し興味深いのが、オーバーロードよりもデータと関数の紐付きが明示的であるというところだ。通常の関数呼び出しでは、呼び出し時にデータと関数が密に結合していた。それがオーバーロードに進化したときには、少なくともコードの見かけ上ではデータと関数の直接の紐付きは緩くなった。しかし、オーバーライドでは、データと関数の結合がクラス定義の中で物理的にガッチリとハードコードされている。この点がオーバーライドの弱点の一つとなっている。
多重ディスパッチ
Juliaの多重ディスパッチはどんなものだろうか?まず見かけはオーバーロードによく似ている。型をstruct
というキーワードで定義し、その型を引数に取る関数を定義する。Juliaには変数自体に型の情報はなく、変数に束縛されているデータが型を持つ。
struct Base1 end
struct Deriv end
hoge(x::Base1) = println("Base1です")
hoge(x::Deriv) = println("Derivです")
function create_instance()
if rand(Int)%2==0
return Base1()
else
return Deriv()
end
end
x = create_instance()
hoge(x) //「Base1です」となったり、「Derivです」となったりする。
Juliaの場合にはメソッドがデータに属するという考え方ではない。そのためメソッドの定義場所は原則として構造体定義の外側だ。データ構造と関数は、論理的には結合しているが、物理的には結合していない。これはオーバーロードの仕組みとよく似ている。
オーバーロードと違うのは、コンパイル時ではなく実行時に、言語処理系によって、データに応じて最も特定的な型を引数に持つ関数が選択されるという点だ。これによりオーバーロードの時の欠点が克服されているのだ。
ポケモンの例
ここで、より具体的な例を出すことで、これらの違いについての理解を深めよう。例として扱うのは、みんな大好きポケットモンスター、通称ポケモンだ。
注:ポケモンという名前をそのまま出してしまうか、似た別の名前にするか迷ったが、明らかにポケモンの仕様にフリーライドした例を出すのに名前だけ変えるのは不誠実な気もしたので、ポケモンという名前をそのまま使っている。調べた範囲では、権利関係の問題が発生するような物ではないと思っている。
ポケモンのダメージ計算は非常に複雑だが、それをぐっと簡略化した例を実装したい。今から実装するダメージ計算の仕様を次のように定義しよう。
- ポケモンは炎ポケモン、草ポケモン、水ポケモンに別れる。
- ポケモンが使う技にも、炎タイプの技、草タイプの技、水タイプの技がある。
- 防御側のポケモンと攻撃技の間には相性があり、組み合わせに応じた補正がかかる。(相性係数)
- ポケモンと受ける技のタイプには相性がある。自分のタイプの弱点タイプの技を受けるとダメージが2倍になる。
- 炎ポケモンは水タイプの技に弱い。
- 草ポケモンは炎タイプの技に弱い。
- 水ポケモンは草タイプの技に弱い。
- ポケモンは自分と同じタイプの技を受けるとダメージが半分になる。
- ポケモンと受ける技のタイプには相性がある。自分のタイプの弱点タイプの技を受けるとダメージが2倍になる。
- ポケモンは自分のタイプと同じタイプの技を使うと1.5倍のダメージを与えることができる。(タイプ一致係数)
- 詳細なダメージ計算式は次のようになる。
- ダメージ=攻撃ポケモンのレベル*技の威力*相性係数*タイプ一致係数
ではこれらの仕様を満たす関数を考えてみたい。
C#の例
まず、ポケモンを表すPokemonクラスがあり、それを継承するFirePokemonクラス、GrassPokemonクラス、WaterPokemonクラスを作る。本当は_level
はprivateにした方がいいのだろうが、今はカプセル化について語りたいわけではないので、コードが簡単になる方を選ぶ。
public class Pokemon{
public int _level;
public Pokemon(int level){
_level = level;
}
}
public class FirePokemon : Pokemon{
public FirePokemon(int level) : base(level){}
}
public class GrassPokemon : Pokemon{
public GrassPokemon(int level) : base(level){}
}
public class WaterPokemon : Pokemon{
public WaterPokemon(int level) : base(level){}
}
次に、スキルを表すSkillクラスがあり、それを継承するFireSkillクラス、GrassSkillクラス、WaterSkillクラスを作る。こちらも本当は_power
はprivateにした方がいいのだろうが、コードが簡単になる方を選ぶ。
public class Skill{
public int _power;
public Skill(int power){
_power = power;
}
}
public class FireSkill : Skill{
public FireSkill(int power) : base(power){}
}
public class GrassSkill : Skill{
public GrassSkill(int power) : base(power){}
}
public class WaterSkill : Skill{
public WaterSkill(int power) : base(power){}
}
これらは次のように使う。
public static void Main(){
Pokemon defencePoke = new FirePokemon(10);
Pokemon attackPoke = new WaterPokemon(10);
Skill attackSkill = new WaterSkill(6);
decimal damage = CalcDamage(defencePoke, attackPoke, attackSkill);
Console.WriteLine(damage.ToString() + "のダメージ");
}
そして、CalcDamageを書きたい。すると次のように書けるだろう。
public static int CalcDamage(Pokemon defencePoke, Pokemon attackPoke, Skill attackSkill){
double damage = attackPoke._level * attackSkill._power
* CalcEffectivityRatio(defencePoke, attackSkill)
* CalcSameTypeRatio(attackPoke, attackSkill);
return (int)Math.Truncate(damage);
}
ここまではいいのだが、次に具体的に相性係数の関数を書こうとすると、はたと困ってしまう。できれば次のように書きたい。
public static double CalcEffectivityRatio(FirePokemon defencePoke, WaterSkill attackSkill){
return 2.0;
}
public static double CalcEffectivityRatio(FirePokemon defencePoke, FireSkill attackSkill){
return 0.5;
}
炎ポケモンは水攻撃で2倍の係数がかかる。炎ポケモンは炎攻撃を半減する。まさに事実をそのまま表現した記述だ。しかし、これがうまくいかないのはご存知の通りだ。
Visitorパターン
このような時に使うのがVisitorパターンというやつだ。なお、教科書的なVisitとかAcceptという関数名がわかりづらい気がするので、少し我流の書き方になっている。
まず、どちらか片方のインスタンスに対して、もう一方のインスタンスを渡して計算をするように命じる。(教科書的にはこの時にAcceptというメソッド名にすることが多いようだ。)
Pokemonクラスに問い合わせてもSkillクラスに問い合わせても、どちらでもいいのだが、Pokemon側に問い合わせることにする。すると、Pokemonクラスではなく、FirePokemonクラスやWaterPokemonクラスのメソッドを、実行時のインスタンスに応じて呼び出すことができるようになる。
public static decimal CalcEffectivityRatio(Pokemon defencePoke, Skill attackSkill){
return defencePoke.CalcEffectivityRatio(attackSkill);
}
こうすると、PokemonクラスにはCalcEffectivityRatioを実装する必要が出てくる。PokemonクラスのCalcEffectivityRatioでは、今度は引数のattackSkillに計算をするよう命じている。(教科書的にはこの時にVisitというメソッド名にすることが多いようだ。)
public class Pokemon{
...
//この仮想関数は呼ばれない想定
public virtual decimal CalcEffectivityRatio(Skill attackSkill){
throw new NotImplementedException();
}
}
//炎ポケモン
public class FirePokemon : Pokemon{
public FirePokemon(int level) : base(level){}
public override double CalcEffectivityRatio(Skill attackSkill){
return attackSkill.CalcEffectivityRatio(this);
}
public override double CalcSameTypeRatio(Skill attackSkill){
return attackSkill.CalcSameTypeRatio(this);
}
}
//草ポケモン
public class GrassPokemon : Pokemon{
public GrassPokemon(int level) : base(level){}
public override double CalcEffectivityRatio(Skill attackSkill){
return attackSkill.CalcEffectivityRatio(this);
}
public override double CalcSameTypeRatio(Skill attackSkill){
return attackSkill.CalcSameTypeRatio(this);
}
}
//水ポケモン
public class WaterPokemon : Pokemon{
public WaterPokemon(int level) : base(level){}
public override double CalcEffectivityRatio(Skill attackSkill){
return attackSkill.CalcEffectivityRatio(this);
}
public override double CalcSameTypeRatio(Skill attackSkill){
return attackSkill.CalcSameTypeRatio(this);
}
}
attackSkillに問い合わせることで、FireSkillクラスやWaterSkillクラスのメソッドを、実行時のインスタンスに応じて呼び出すことができるようになる。また、この時にthisを引数に渡していることで、実行時のインスタンスの型に応じたメソッドが(オーバーロードの機構により)選択されるようにすることができる。
Skill側の実装はこのようになる。
public class Skill{
...
//これらの仮想関数は呼ばれない想定
public virtual double CalcEffectivityRatio(FirePokemon defencePoke){
throw new NotImplementedException();
}
public virtual double CalcEffectivityRatio(GrassPokemon defencePoke){
throw new NotImplementedException();
}
public virtual double CalcEffectivityRatio(WaterPokemon defencePoke){
throw new NotImplementedException();
}
}
public class FireSkill : Skill{
...
public override double CalcEffectivityRatio(FirePokemon defencePoke){
return 0.5;
}
public override double CalcEffectivityRatio(GrassPokemon defencePoke){
return 2.0;
}
public override double CalcEffectivityRatio(WaterPokemon defencePoke){
return 1.0;
}
}
public class GrassSkill : Skill{
...
public override double CalcEffectivityRatio(FirePokemon defencePoke){
return 1.0;
}
public override double CalcEffectivityRatio(GrassPokemon defencePoke){
return 0.5;
}
public override double CalcEffectivityRatio(WaterPokemon defencePoke){
return 2.0;
}
}
public class WaterSkill : Skill{
...
public override double CalcEffectivityRatio(FirePokemon defencePoke){
return 2.0;
}
public override double CalcEffectivityRatio(GrassPokemon defencePoke){
return 1.0;
}
public override double CalcEffectivityRatio(WaterPokemon defencePoke){
return 0.5;
}
}
変数の持つ(オーバーライドされた)メソッドの呼び出しと、thisで自分自身を渡すことを組み合わせることで、段々と実行時のクラスの組み合わせを特定している様子を見てとることができる。
タイプ一致補正の方も似たような内容となる。そのためそこは省略して、Juliaの方に入ろう。
多重ディスパッチ
Juliaの多重ディスパッチを見てみよう。まず型の定義は似たようなものだ。Juliaでは型の階層構造の末端にしか値を保持できないので、level
やpower
の持ち方はC#とは違っている。
abstract type Pokemon end
struct FirePokemon <: Pokemon
level
end
struct GrassPokemon <: Pokemon
level
end
struct WaterPokemon <: Pokemon
level
end
abstract type Skill end
struct FireSkill <: Skill
power
end
struct GrassSkill <: Skill
power
end
struct WaterSkill <: Skill
power
end
次にメインの計算ロジックの書き方も似たようなものだ。
function main()
defence_poke = FirePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = WaterSkill(6)
damage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(damage)のダメージ")
end
function calc_damage(defence_poke, attack_poke, attack_skill)
damage = attackPoke.level * attackSkill.power *
calc_effectivity_ratio(defencePoke, attackSkill) *
calc_sametype_ratio(attackPoke, attackSkill)
return Int(floor(damage))
end
しかし、ここからが違う。calc_effectivity_ratio
は次のように書ける。ただ単に欲しい型の組み合わせを列挙するだけで良いのだ。
#炎ポケモン
function calc_effectivity_ratio(defencePoke::FirePokemon, attackSkill::FireSkill)
return 0.5
end
function calc_effectivity_ratio(defencePoke::FirePokemon, attackSkill::GrassSkill)
return 1.0
end
function calc_effectivity_ratio(defencePoke::FirePokemon, attackSkill::WaterSkill)
return 2.0
end
#草ポケモン
function calc_effectivity_ratio(defencePoke::GrassPokemon, attackSkill::FireSkill)
return 2.0
end
function calc_effectivity_ratio(defencePoke::GrassPokemon, attackSkill::GrassSkill)
return 0.5
end
function calc_effectivity_ratio(defencePoke::GrassPokemon, attackSkill::WaterSkill)
return 1.0
end
#水ポケモン
function calc_effectivity_ratio(defencePoke::WaterPokemon, attackSkill::FireSkill)
return 1.0
end
function calc_effectivity_ratio(defencePoke::WaterPokemon, attackSkill::GrassSkill)
return 2.0
end
function calc_effectivity_ratio(defencePoke::WaterPokemon, attackSkill::WaterSkill)
return 0.5
end
ちなみに、倍率が1.0
のケースをまとめて次のように書くこともできる。特別に定義されている場合を除いて、引数がPokemon
、Skill
の配下にある時には、次の実装がデフォルトになるということである。
#デフォルトの倍率
function calc_effectivity_ratio(defencePoke::Pokemon, attackSkill::Skill)
return 1.0
end
#炎ポケモン
function calc_effectivity_ratio(defencePoke::FirePokemon, attackSkill::FireSkill)
return 0.5
end
function calc_effectivity_ratio(defencePoke::FirePokemon, attackSkill::WaterSkill)
return 2.0
end
#草ポケモン
function calc_effectivity_ratio(defencePoke::GrassPokemon, attackSkill::FireSkill)
return 2.0
end
function calc_effectivity_ratio(defencePoke::GrassPokemon, attackSkill::GrassSkill)
return 0.5
end
#水ポケモン
function calc_effectivity_ratio(defencePoke::WaterPokemon, attackSkill::GrassSkill)
return 2.0
end
function calc_effectivity_ratio(defencePoke::WaterPokemon, attackSkill::WaterSkill)
return 0.5
end
タイプ一致係数の計算も似たようなものである。
ここで、「もっと良い設計をすればC#側だってVisitorパターンなど使わずとも、すっきりした実装になるだろう」とツッコミたくなる人もいるかもしれない。例えば、ポケモンとスキルから「タイプ」という要素を抽出して独立したクラスとするのはどうだろうか。こうすると、「タイプ」という型に閉じた設計になるので、ダブルディスパッチなどは必要なくなるだろう。あるいは、単にenumにしてもいいかもしれない。しかし、私が言いたいのは、Juliaではそのような工夫をしなくてもすっきりした実装にできるという話である。Juliaでも同様の工夫ですっきりさせることができるだろうし、そのような工夫をしなくてもやはりすっきりとした実装になるのである。
固定ダメージ技
次に、もう少し複雑にしてみよう。ポケモンには固定ダメージ技というものがある。双方の能力値や相性によらず、20とか40とかの固定値でのダメージを与える技だ。これを実装してみよう。
Juliaの例
先ほどとは逆にJuliaから見てみよう。まず、固定ダメージ技のための型を定義しよう。
abstract type FixedDamageSkill <: Skill end
struct FixedDamageFireSkill <: FixedDamageSkill
power
end
struct FixedDamageGrassSkill <: FixedDamageSkill
power
end
struct FixedDamageWaterSkill <: FixedDamageSkill
power
end
その上で、固定ダメージ技の時の専用の式を次のように定義しよう。
function calc_damage(defencePoke, attackPoke, attackSkill::FixedDamageSkill)
return attackSkill.power
end
これだけである。あとは、同じように呼び出せば良い。
function main()
...
defence_poke = FirePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = FixedDamageWaterSkill(20)
fixeddamage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(fixeddamage)のダメージ")
end
相性などは無視して20の固定ダメージとなる。今回の変更に際して、既存の型や関数は何も変更しておらず、新しい型や関数をバラバラといくつか追加しただけであるということに注目して欲しい。Juliaがそれらをひっつけてくれたのだ。これこそが理想的なモジュール化だ。Juliaは確かにモジュール化のための良い「糊」を提供してくれているようだ。
ちなみに型の階層が
- Skill
- FireSkill
- GrassSkill
- WaterSkill
- FixedDamageSkill
- FixedDamageFireSkill
- FixedDamageGrassSkill
- FixedDamageWaterSkill
となっているが、これが気持ち悪ければ、次のようにすればいい。
- Skill
- OrdinarySkill
- FireSkill
- GrassSkill
- WaterSkill
- FixedDamageSkill
- FixedDamageFireSkill
- FixedDamageGrassSkill
- FixedDamageWaterSkill
- OrdinarySkill
まあこれは好みの問題だが、このような型階層にするのであれば、次のように引数に型の情報を追加した方が読み手からすると混乱が少ないだろう。
function calc_damage(defencePoke, attackPoke, attackSkill::OrdinarySkill) #型情報を追加
damage = attackPoke.level * attackSkill.power *
calc_effectivity_ratio(defencePoke, attackSkill) *
calc_sametype_ratio(attackPoke, attackSkill)
return Int(floor(damage))
end
C#の例
C#ではどのように変更すればいいだろうか?まあとりあえず、固定ダメージ用のスキルを定義しておこう。
public class FixedDamageSkill : Skill{
public FixedDamageSkill(int power) : base(power){}
}
public class FixedDamageFireSkill : FixedDamageSkill{
public FixedDamageFireSkill(int power) : base(power){}
}
public class FixedDamageGrassSkill : FixedDamageSkill{
public FixedDamageGrassSkill(int power) : base(power){}
}
public class FixedDamageWaterSkill : FixedDamageSkill{
public FixedDamageWaterSkill(int power) : base(power){}
}
しかしここまで見てきたように、Juliaとは違い、次のようにすることはできない。定義することはできるが、呼び出し側の変数の型宣言がSkill
な以上、FixedDamageSkill
が引数の関数が呼び出されることはないからだ。
//既存のCalcDamage関数と並列に定義しても、この関数が呼び出されることはない。
public static int CalcDamage(Pokemon defencePoke, Pokemon attackPoke, FixedDamageSkill attackSkill){
return attackSkill.power;
}
//なぜなら呼び出し側で渡す変数の型がSkillのため。
public static void Main(){
Pokemon defence_poke = new FirePokemon(10);
Pokemon attack_poke = new WaterPokemon(10);
Skill attack_skill = new FixedDamageWaterSkill(20); //引数の型宣言はSkill
double damage = CalcDamage(defence_poke, attack_poke, attack_skill);
Console.WriteLine(damage.ToString() + "のダメージ");
}
似たようなやり方というと、やはりオーバーライドになる。スキルの種類に応じて計算パターンがあるので、詳細はスキルのオブジェクトに問い合わせてしまえということだ。attackSkill
にダメージを計算させるのだ。このような方針をストラテジーパターンと呼んだりする。まあ名前はどうでもいい。
public static int CalcDamage(Pokemon defencePoke, Pokemon attackPoke, FixedDamageSkill attackSkill){
//attackSkillの実行時の型に応じて適切なComputeDamageが呼び出される。
return attackSkill.ComputeDamage(defencePoke, attackPoke, attackSkill);
}
Skillの基本的なダメージ計算は次のようになる。
public class Skill{
...
public virtual int ComputeDamage(Pokemon defencePoke, Pokemon attackPoke, Skill attackSkill){
double damage = attackPoke._level * this._power
* defencePoke.CalcEffectivityRatio(attackSkill)
* attackPoke.CalcSameTypeRatio(attackSkill);
return (int)Math.Truncate(damage);
}
...
}
そして、固定ダメージ技のダメージ計算は次のようになる。
public class FixedDamageSkill : Skill{
...
public override int ComputeDamage(Pokemon defencePoke, Pokemon attackPoke, Skill attackSkill){
return this._power;
}
}
クラス階層が気に入らなければ、OrginarySkill
というものを作っても良いというのも同じだ。その場合は、通常スキルのダメージ計算は、OrdinarySkill
クラスが受け持つことになるだろう。
public class Skill{
...
public virtual int ComputeDamage(Pokemon defencePoke, Pokemon attackPoke, Skill attackSkill){
throw new NotImplementedException();
}
...
}
public class OrdinarySkill : Skill{
public OrdinarySkill(int power) : base(power){}
public override int ComputeDamage(Pokemon defencePoke, Pokemon attackPoke, Skill attackSkill){
double damage = attackPoke._level * this._power
* defencePoke.CalcEffectivityRatio(attackSkill)
* attackPoke.CalcSameTypeRatio(attackSkill);
return (int)Math.Truncate(damage);
}
}
//以下FireSkillなどはOrdinarySkillから継承するように変更
...
最終的に出来上がったものを見ると、C#もJuliaとよく似ているように見えるかもしれない。しかし、Juliaと違って、attackSkill
を経由するように修正する必要があったことを思いだそう。Juliaではそういったことは必要がなかった。
ある関数を変更しなくても振る舞いを変えることのできる箇所を、関数の接合部と呼ぶことがある。
C#の例では、CalcDamage
関数の動作を攻撃技に応じて変更させるために、attackSkill
に対して問い合わせるという形に変更した。attackSkill
はCalcDamage
関数の接合部だ。CalcDamage関数の動作を変えたければ、attackSkillを差し替えれば良い。逆に、CalcDamage関数の動作を変えるには、attackSkillを差し替える必要がある。これは重大な事実を意味している。それはCalcDamage
関数の作者が「CalcDamage
関数の動作を変更するポイントはattackSkill
ですよ!」と事前に決めているということだ。これは当たり前のことに思えるかもしれないが、そうではない。
Juliaの例では、calc_damage
関数の作者は、この関数の動作を変更させるための仕掛けは何も入れ込んでいない。にもかかわらず、後付けで動作を変更することができる。多重ディスパッチのおかげで、Juliaにおいては、単なる関数呼び出しが接合部となることができるのだ。
無敵ポケモン
もういっちょ複雑にしよう。あなたが「無敵ポケモン」というものを作りたくなったとしよう。無敵ポケモンは無敵なので、どんな攻撃を受けてもダメージがゼロになる。固定ダメージ技も例外ではない。どうやって実装すれば良いだろうか?
C#の例
C#では大変困ったことになる。現在下記のように、CalcDamage
は、attackSkill
により決定される。
public static int CalcDamage(Pokemon defencePoke, Pokemon attackPoke, FixedDamageSkill attackSkill){
//attackSkillが具体的な処理を決定している。
return attackSkill.CalcDamage(defencePoke, attackPoke, attackSkill);
}
ところが、今回はdefencePoke
が具体的な処理を決定したいいう話である。(attackSkill, defencePoke).CalcDamage(...)
のような表式で記述できればいいが、そういうわけにもいかない。
一応、それに近いやり方はなくはない。まず基底クラスと派生クラスの一群を新たに定義して、与えられたSkillとPokemonの型に応じて具体的なクラスが生成されるような処理を作って(多分Visitorパターンが必要になるだろう)、そのクラスに応じてCalcDamage
を定義すれば良い。うひゃー、面倒だね!
そのため、現実的には、Pokemon
クラスに無敵フラグのようなものを持たせて、CalcDamage
関数の中で条件分岐でも書くことになるだろう。もし無敵フラグがtrue
ならば、ダメージは0だ。シンプルだが、モジュール化には反している。
Juliaの例
Juliaではどうだろうか?ここでもJuliaの強力なモジュール化機構の恩恵に預かることができる。
まず、無敵ポケモンの型を定義する。
abstract type InvinciblePokemon <: Pokemon end
struct FireInvinciblePokemon <: InvinciblePokemon
level
end
struct GrassInvinciblePokemon <: InvinciblePokemon
level
end
struct WaterInvinciblePokemon <: InvinciblePokemon
level
end
そしてcalc_damage
を次のように定義する。
function calc_damage(defencePoke::InvinciblePokemon, attackPoke, attackSkill)
return 0
end
function calc_damage(defencePoke::InvinciblePokemon, attackPoke, attackSkill::FixedDamageSkill)
return 0
end
こうするだけで、型に応じた適切なcalc_damage
が選択されるのだ。
defence_poke = FireInvinciblePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = WaterSkill(6)
damage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(damage)のダメージ") #0のダメージ
defence_poke = FireInvinciblePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = FixedDamageWaterSkill(6)
damage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(damage)のダメージ") #0のダメージ
ここでもJuliaの多重ディスパッチがモジュール化の良い糊を提供していることがわかるだろう。あなたは新しい型と関数を定義するだけでよく、既存の関数との繋ぎ込みはJuliaが上手くやってくれるのだ。
境界を踏み越えろ
さてあなたの革新的なアイデアである「無敵ポケモン」は、実のところ製品に組み込まれることはない。なぜならば、あなたは開発元とは全く無関係な部外者で、「無敵ポケモン」のアイデアを実装したいだけの、いちポケモンマニアに過ぎないからだ。
製品版のポケモンゲームが、次のように完全にモジュール化されていたとしよう。
#pokemon_game.jl
module PokemonGame
export calc_damage
export Skill, FireSkill, GrassSkill, WaterSkill
export FixedDamageSkill, FixedDamageFireSkill, FixedDamageGrassSkill, FixedDamageWaterSkill
export Pokemon, FirePokemon, GrassPokemon, WaterPokemon
include("skill.jl") #Skill, OrdinarySkill, FixedDamageSkill 等が定義されている
include("pokemon.jl") #Pokemon, FirePokemon 等が定義されている
function calc_damage(defencePoke, attackPoke, attackSkill)
damage = attackPoke.level * attackSkill.power *
calc_effectivity_ratio(defencePoke, attackSkill) *
calc_sametype_ratio(attackPoke, attackSkill)
return Int(floor(damage))
end
function calc_damage(defencePoke, attackPoke, attackSkill::FixedDamageSkill)
return attackSkill.power
end
#calc_effectivity_ratio の定義
...
#calc_sametype_ratio の定義
...
end
このモジュールは開発元によって配布されているが、オープンソースプロジェクトではないので、あなたはこのモジュールの中身を書き換えることができない。しかし、そんな時でもあなたは、「無敵ポケモン」を実装することができるのだ。
注:実際に配布される際には単なるモジュールではなくてパッケージの形で配布されるはずだ。その場合、構文がここで挙げた例とはほんの少し異なる(includeする必要がないなど)。しかし、些細な点なので無視してほしい。
#my_pokemon_game.jl
include("./pokemon_game.jl")
module MyPokemonGame
export InvinciblePokemon, FireInvinciblePokemon, GrassInvinciblePokemon, WaterInvinciblePokemon
using ..PokemonGame
abstract type InvinciblePokemon <: Pokemon end
struct FireInvinciblePokemon <: InvinciblePokemon
level
end
struct GrassInvinciblePokemon <: InvinciblePokemon
level
end
struct WaterInvinciblePokemon <: InvinciblePokemon
level
end
function PokemonGame.calc_damage(defencePoke::InvinciblePokemon, attackPoke, attackSkill)
return 0
end
function PokemonGame.calc_damage(defencePoke::InvinciblePokemon, attackPoke, attackSkill::FixedDamageSkill)
return 0
end
end
「無敵ポケモン」の型の定義に続いて、PokemonGame.calc_damage
の定義を行なっている。MyPokemonGame
モジュールはPokemonGame
モジュールとは無関係にあなたが作ったモジュールだが、その中で、PokemonGame
モジュールのcalc_damage
の定義を勝手に拡張しているのだ。Juliaではモジュールが作る境界を、やすやすと踏み越えることができる。
いわゆるオブジェクト指向言語ではこのようなことはできない。オブジェクト指向言語では、境界をとても大事にする。PokemonGameに属するメソッドはPokemonGameクラスの内部に定義するものと相場が決まっている。私がこの文章の上の方で書いた、
しかし、オーバーライドでは、データと関数の結合がクラス定義の中で物理的にガッチリとハードコードされている。この点がオーバーライドの弱点の一つとなっている。
というのが、まさにこの点を表しているのだ。
結局のところ、モジュール化するということは、後から差し替え可能な形にするということであり、物理的にハードコードする部分は少なければ少ないほど良いのだ。
Juliaも型とメソッドの関係をソースコードにハードコードしているという意味ではまだ改善の余地があるように思うが、その先どうなるのかは私には想像できない。
最終的に「無敵ポケモン」機能を搭載したバージョンを使いたいユーザーは次のようにすれば良い。
#main.jl
include("./pokemon_game.jl")
include("./my_pokemon_game.jl")
using .PokemonGame
using .MyPokemonGame
function main()
defence_poke = FirePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = WaterSkill(6)
damage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(damage)のダメージ")
defence_poke = FirePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = FixedDamageWaterSkill(6)
damage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(damage)のダメージ")
defence_poke = FireInvinciblePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = WaterSkill(6)
damage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(damage)のダメージ")
defence_poke = FireInvinciblePokemon(10)
attack_poke = WaterPokemon(10)
attack_skill = FixedDamageWaterSkill(6)
damage = calc_damage(defence_poke, attack_poke, attack_skill)
println("$(damage)のダメージ")
end
main()
PokemonGame
モジュールとMyPokemonGame
モジュールは非常に滑らかに繋がっている。一見して、FirePokemon
やWaterPokemon
とFireInvinciblePokemon
が全く別々に定義されているようには見えないだろう。calc_damage
という関数呼び出しの裏では、Juliaが頑張ってそれぞれのモジュールのファイルから適切なメソッドを探して呼び出してくれているのだが、全く同種の関数呼び出しに見えるだろう。Juliaはとても良い糊を提供し、とても上手く貼り合わせてくれているため、表面上は全くそれを感じさせないほどだ。
このようにして、あなたは自身の「無敵ポケモン」のアイデアを世に問うことができるようになる。あなたが法的な問題をどうするつもりなのかは少し心配だが、少なくとも技術的な問題は何もないのだ。
オブジェクト指向は劣っているのか?
今回はC#を引き合いに出し、Juliaの方がモジュール化としては良い性質を持っていることを示した。ではオブジェクト指向的アプローチは劣っているのだろうか?Juliaの多重ディスパッチのアプローチの前には、もはや顧みる必要のない旧時代の遺物なのだろうか?
私はそうは思わない。大きなシステムを、相互作用するオブジェクトに分解するというアプローチは、我々人間にとって、とても自然なパラダイムなのだ。これはおそらく、我々人間が地球で進化してきたためだろう。地球は固体型惑星なので、地表で活動する生命体にとっては、固体同士の相互作用を理解することが生存にとって決定的に重要になる。長い年月による淘汰圧の結果、我々人間の脳では、世界を相互作用する固体の集合として認識する能力が発達することになった。
地球型惑星の生命体であるところの我々にとっては、複雑な問題に対処するとき、オブジェクト指向的アプローチで問題を分解すると、状況が整理されることはよくあるのだ。
逆に木星型惑星で進化してきたガス生命体がいたとしたら、オブジェクト指向的な問題分解は全く意味不明なものになるだろう。きっと流体力学を模倣したモデルを組み上げるに違いない。
そのようなわけで、オブジェクト指向的な問題解決手法を一通り身につけておくことは悪いことではない。僕はJuliaをメインウェポンにするつもりだから無駄なことはしたくないのさ、って?無駄にはならない。オブジェクト指向的解決方法を理解した後で、Julia的な設計手法に翻訳することは常に可能だ。多重ディスパッチを使ってもいいし、クロージャを使ってもいいだろう。Juliaはオブジェクト指向的パラダイムも関数型的パラダイムも包摂している、マルチパラダイム言語なのだ。