関数ハイジャック
ソフトウェアが複雑化するにつれ、我々は、より一層モジュールのインターフェイスに 依存するようになっています。アプリケーションは、複数のソースからなる複数のモジュール、 社外のソースを含むモジュールなども組み合わせて使っています。 各モジュールの開発者は、一緒に使われる他のモジュールについて踏み込んだりする必要無しに、 自分のモジュールの開発を続けることが可能でなければなりません。 そしてアプリケーションの開発者は、モジュール側の変更がアプリケーションに破壊的な影響を与えるならば、 その変更に気づけるようになっていることが重要です。 このドキュメントでは、モジュールにおける無害な問題のない宣言の追加が、 C++やJavaのアプリケーションプログラムにありとあらゆる大惨事を引き起こす 「関数ハイジャック」についてお話しします。 そして、D言語がどんな言語設計の変更で この問題を大幅に軽減しているのかについても 見ていこうと思います。
グローバル関数ハイジャック
二つのモジュール ‐ XXX社のXモジュールとYYY社のYモジュール ‐ を import するアプリケーションの開発中としましょう。 モジュール X と Y はお互い無関係で、 完全に違った目的に使用されます。 こんな感じの関数を提供しています:
module X;
void foo();
void foo(long);
module Y;
void bar();
アプリケーション側のプログラムはこうなります:
import X;
import Y;
void abc()
{
foo(1); // X.foo(long) を呼ぶ
}
void def()
{
bar(); // Y.bar(); を呼ぶ
}
ここまでのところは問題ありません。アプリケーションはテストされ無事動作し、出荷されました。 時は流れ、プログラマも入れ替わり、このアプリケーションはメンテナンスモードに移ります。 一方その頃、YYY社は顧客からの要望に応え、 モジュールに 型 A と関数 foo(A) を追加しました:
module Y;
void bar();
class A;
void foo(A);
メンテナンスプログラマはYの最新版を手に入れ、 再コンパイルします。まだ問題はありません。 しかしここで、YYY社が foo(A) の機能を拡張し、 関数 foo(int) を追加しました:
module Y;
void bar();
class A;
void foo(A);
void foo(int);
さて、我らがメンテナンスプログラマがいつものようにYを最新版に入れ替え再コンパイルすると、 突然アプリケーションの挙動がおかしくなりました:
import X;
import Y;
void abc()
{
foo(1); // X.foo(long) ではなく Y.foo(int) を呼ぶ
}
void def()
{
bar(); // Y.bar(); を呼ぶ
}
Y.foo(int) の方が X.foo(long)よりもオーバーロードのマッチとして適合度が高いためです。 しかし、X.foo の行うはずだった処理は Y.foo とは完全に違うものなので、このアプリケーションは重大なバグを潜在的に抱え込むことになってしまいました。 最悪なのは、コンパイラからはこのような事態になっていることを示唆することがない/できない、ということです。 少なくともC++では、この動作が言語仕様の通りなのですから。
C++ では、モジュール X や Y の中で名前空間や ユニーク(と期待される) 接頭辞 を使った回避策が知られています。これはしかし、X や Y に対して手を加えることのできない アプリケーションプログラマには役に立ちません。
プログラミング言語 D でこの問題の解決のためにとった最初の方法は、 以下のルールを追加することでした:
- デフォルトでは、同じモジュールの関数どうしのみが オーバーロードされる
- 同名の関数が複数のモジュールに存在する場合は、呼び出しの際には 完全修飾名を使わなければならない
- 複数のモジュールの関数をオーバーロードするには、 alias文を使ったオーバーロードのマージが必要
この規則があるため、YYY社が foo(int) の宣言を追加したときには アプリケーションメンテナは コンパイルエラーを目にすることになり(foo がモジュール X と Y の 両方で定義されているため)、問題に対処する機会が与えられます。
これは一応の解決策にはなっていますが、制限が少しきつすぎます。例えば、 foo(A) が foo() や foo(long) と 混同されることはありえないのに、何故コンパイラがこれに文句を言うのでしょう? 結局のところ、 新たな解決策として「オーバーロード集合」の概念を持ち込むことになりました。
オーバーロード集合
同じスコープで宣言された同名の関数グループが、 オーバーロード集合 を形成します。モジュール X の例では、関数 X.foo() と X.foo(long) が1個のオーバーロード集合を形成します。 そして、関数 Y.foo(A) と Y.foo(int) もまた別のオーバーロード集合になります。fooの呼び出しを解決するステップは、以下のようになります:
- それぞれのオーバーロード集合ごとに別々に、オーバーロードの解決を行う
- どのオーバーロード集合でもマッチがなければ、エラー
- 1つのオーバーロード集合だけでマッチがあれば、それを選択
- 2つ以上のオーバーロード集合でマッチしていれば、エラー
この規則でもっとも重要な点は、あるオーバーロード集合でのマッチが他のオーバーロード集合のマッチより より"適合度が高い"場合でも、依然としてエラーになるということです。 1つの関数が複数のオーバーロード集合に同時に含まれることはありません。
先ほどの例を使うと:
void abc()
{
foo(1); // Y.foo(int) には完全マッチ、X.foo(long) は暗黙変換を通したマッチ
}
はエラーになりますが:
void abc()
{
A a;
foo(a); // Y.foo(A) に完全マッチ。Xからはマッチなし
foo(); // X.foo() に完全マッチ。Y からはマッチなし
}
これは直感的に期待するとおりに、エラー無しでコンパイルされます。
X と Y の foo をオーバーロードしたい場合は、次のようなコードになります:
import X;
import Y;
alias X.foo foo;
alias Y.foo foo;
void abc()
{
foo(1); // X.foo(long) ではなく Y.foo(int) を呼ぶ
}
これはエラーにはなりません。先ほどのエラー例との違いは、 この場合はアプリケーションプログラマがXとYのオーバーロード集合を意図的に合成しているので、 何が起こるかをきちんと把握しており、XとYの更新時に foo に変更がないかチェックする意志があると仮定できるということです。
派生クラスメンバ関数ハイジャック
関数ハイジャックは他にもパターンがあります。AAA社のクラス A があったとしましょう:
module M;
class A { }
アプリケーションコードでは A から派生し、 仮想メンバ関数 foo を追加しています:
import M;
class B : A
{
void foo(long);
}
void abc(B b)
{
b.foo(1); // B.foo(long) を呼ぶ
}
万事OKです。しかしまた月日は流れ、AAA社 (もちろん B のことなど知りません) が A の機能をちょっと拡張するために foo(int) 関数を追加しました:
module M;
class A
{
void foo(int);
}
Java風のオーバーロード規則が採用されていたとすると、 基底クラスの関数と派生クラスの関数はお互いをオーバーロードします。 その結果、アプリケーション側の呼び出し:
import M;
class B : A
{
void foo(long);
}
void abc(B b)
{
b.foo(1); // A.foo(int) を呼ぶ!!!!!!!!!!
}
で B.foo(long) を呼んでいたはずの部分が基底クラス A の A.foo(int) にハイジャックされます。 そしてこの関数はおそらく B.foo(long) とは違った処理をする関数でしょう。 これが、私がJavaのオーバーロード規則を良しとしない理由です。 C++ はこの点では正しく考えられていて、派生クラスでは、同名の基底クラスの関数を (例え基底クラスの関数の方がよりよいマッチであっても) マッチ候補としないようになっています。 D もこの規則に従います。 グローバル関数の場合と同様、両方の関数を混ぜてオーバーロードしたい場合は、 C++ではusingを使うのと同じように D では alias 宣言で実現できます。
基底クラスメンバ関数ハイジャック
まだこれだけじゃないだろう、と思っておられる読者の方、正解です。 逆方向のハイジャックもありるのです。 派生クラスが基底クラスのメンバ関数をハイジャックするパターンです!
こんなクラスを考えます:
module M;
class A
{
void def() { }
}
アプリケーションコードでは、A から派生し 仮想メンバ関数 foo を追加しています:
import M;
class B : A
{
void foo(long);
}
void abc(B b)
{
b.def(); // A.def() を呼ぶ
}
AAA社は、またしても B について何も知らず、 関数 foo(long) を追加して何か A の新しい機能を実装するのに使用しました:
module M;
class A
{
void foo(long);
void def()
{
foo(1L); // A.foo(long) を呼ぶと期待されている
}
}
しかし、なんと困った、A.def() は B.foo(long) を呼び出してしまうのです。 つまり B.foo(long) が A.foo(long) をハイジャックしたわけです。 Aの設計者はこれを予見して foo(long) を非仮想関数にしておくべきだった、と考える方もいらっしゃるでしょう。 しかし問題なのは、A の設計者が A.foo(long) を仮想関数として使うことを意図して機能追加することも大いにあり得るということです。 彼にはすでに B.foo(long) が別の用途で存在することを知る術はありません。 論理的な帰結として、 このオーバーライドのシステムの中で安全に A に機能追加する方法はない、ということになります。
D での解決策は簡単です。派生クラスの関数が基底クラスの関数をオーバーライドする際には、 必ず override 宣言を行わなければなりません。 override と宣言せずにオーバーライドを行おうとすると、 エラーになります。また逆に、何もオーバーライドしていない関数を overrideと宣言するのもエラーです。
class C
{
void foo();
void bar();
}
class D : C
{
override void foo(); // ok
void bar(); // エラー。C.bar() をオーバーライドしている
override void abc(); // エラー。C.abc() は存在しない
}
これによって、派生クラスのメンバ関数が 基底クラスのメンバ関数をハイジャックする潜在的な危険は取り除かれます。
派生クラスメンバ関数ハイジャック #2
最後にもう一つ、基底クラスのメンバ関数が派生クラスのメンバ関数をハイジャックする パターンがあります。
module A;
class A
{
void def()
{
foo(1);
}
void foo(long);
}
foo(long) は何か特定の機能を持った仮想関数とします。 派生クラスの設計者は foo(long) をオーバーライドして、 派生クラスの目的に合わせてカスタマイズしようとします:
import A;
class B : A
{
override void foo(long);
}
void abc(B b)
{
b.def(); // 中で B.foo(long) が呼ばれる
}
ここまでは問題ありません。A の内部の foo(1) は正しく B.foo(long) を呼び出します。さて、A の設計者が最適化のために foo: のオーバーロードを追加しました:
module A;
class A
{
void def()
{
foo(1);
}
void foo(long);
void foo(int);
}
この結果、
import A;
class B : A
{
override void foo(long);
}
void abc(B b)
{
b.def(); // 中で A.foo(int) が呼ばれる
}
おっと! B は A の foo の機能を置き換えたつもりでいますが、そうなっていません。 B のプログラマは、動作を正しく直すために別の関数を B に追加する必要があります:
class B : A
{
override void foo(long);
override void foo(int);
}
しかし、この変更が必要だとは誰も教えてくれません。 A のコンパイル時には、 B が何をオーバーライドするかなどの情報はまったくありませんから。
さて、A でどのように仮想関数が呼ばれるかを考えてみましょう。 仮想関数呼び出しは vtbl[] 経由で行われます。A の vtbl[] はこうなっています:
A.vtbl[0] = &A.foo(long);
A.vtbl[1] = &A.foo(int);
B の vtbl[] はこうです:
B.vtbl[0] = &B.foo(long);
B.vtbl[1] = &A.foo(int);
A.def() 内での foo(int) の呼び出しは、 実際には vtbl[1] の呼び出しです。 本当は、B のオブジェクトから A.foo(int) へのアクセスは不可能としたいのです。 解決策としては B の vtbl[] を:
B.vtbl[0] = &B.foo(long);
B.vtbl[1] = &error;
こう書き換えます。実行時にはエラー関数が呼び出され、例外を投げます。 これはコンパイル時にエラーが検出されないという意味で、完璧ではありません。 しかし少なくとも、アプリケーションプログラムが間違った関数を呼んで そのままそしらぬ顔で実行を続けてしまうということはなくなります。
追記: 現在では、 vtbl[] にエラーエントリが入る場合は、コンパイル時に警告が出るようになっています。
まとめ
関数ハイジャックは、アプリケーションプログラマからはこれを防止する手段がないため、 複雑なC++やJavaのプログラムでは特にやっかいな問題となっています。 言語のセマンティクスにちょっとした修正を加えることで、 表現力やパフォーマンスを失うことなく、これを防止することが可能になりました。
参考文献
- digitalmars.D - Hijacking
- digitalmars.D - Re: Hijacking
- digitalmars.D - aliasing base methods
- Eiffel, Scala, C# には override やそれに似た機能があります
Credits:
- Kris Bell
- Frank Benoit
- Andrei Alexandrescu