ハイジャック
ソフトウェアが複雑になるにつれ、モジュール・インターフェースへの依存度が高まる。 アプリケーションは、社外のソースを含む複数のソースからモジュールをインポートし、組み合わせることがある。 モジュール開発者は、 それらのモジュールをメンテナンスし、改善できる能力がなければならないが、 意図せずに、知識のないモジュールの動作に影響を及ぼすことがあってはならない。 アプリケーション開発者は、 モジュールの変更がアプリケーションを壊す可能性がある場合は通知を受ける必要がある。本講演では、 関数ハイジャックについて説明する。これは、無害で妥当な宣言をモジュールに追加することで、 C++やJavaのアプリケーションプログラムに任意の混乱を引き起こす可能性がある。次に、 プログラミング言語Dにおける問題を、言語設計のささやかな変更によって大幅に解消する方法について 説明する。
グローバル関数の乗っ取り
例えば、XXX社製のモジュールXとYYY社製のモジュールYの2つのモジュールをインポートするアプリケーションを開発しているとしよう。 モジュール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 Corporationは顧客の要望に応え、"型"A と"関数"foo(A) を追加した。
module Y; void bar(); class A; void foo(A);
アプリケーションの保守担当者は、Yの最新バージョンを取得し、 再コンパイルするが、問題はない。ここまでは順調だ。 しかしその後、YYY Corporationは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 におけるこの問題の解決策として最初に試みられたのは、 以下のルールを追加することだった。
- デフォルトでは、関数は同じモジュール内の他の関数に対してのみオーバーロードできる。
- 名前が複数のスコープで見つかった場合、それを使用するには、 完全修飾する必要がある
- 複数のモジュールから関数をオーバーロードするには、エイリアス 文を使用してオーバーロードをマージする
したがって、YYY Corporationがfoo(int) 宣言を追加した際には、 アプリケーションの メンテナンス担当者は、fooがモジュールXとモジュールYの両方で定義されているというコンパイルエラーを受け取り、それを修正する機会を得る。
この解決策はうまくいったが、少し制限がある。結局のところ、foo(A) がfoo() やfoo(long) と混同されることはありえないので、 なぜコンパイラが それを問題にするのか? 解決策は、オーバーロードセットの概念を導入することだった。
オーバーロードセット
オーバーロードセットは、同じ名前で 同じスコープで宣言された 関数のグループによって形成される。モジュールXの例では、関数X.foo() とX.foo(long) は単一のオーバーロードセットを形成する。関数Y.foo(A) とY.foo(int)は 別のオーバーロードセットを形成する。fooへの呼び出しを解決する方法は次のようになる。
- 各オーバーロードセットでオーバーロード解決を個別に実行する
- どのオーバーロードセットにも一致するものがない場合はエラーとする
- もし、1つのオーバーロードセットに一致するものがあれば、それを使う
- 複数のオーバーロードセットに一致するものがある場合はエラーとなる
この中で最も重要なことは、たとえ他のオーバーロードセットよりも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 foo = X.foo; alias foo = Y.foo; void abc() { foo(1); // X.foo(long)ではなく、Y.foo(int)を呼び出す }
エラーは発生しない。ここで異なるのは、ユーザーが XとYのオーバーロードセットを意図的に組み合わせたことである。そのため、おそらく 両者は自分が何をしているのかを理解しており、 XまたはYが更新された際にfooをチェックする意思がある
派生クラスのメンバ関数の乗っ取り
関数ハイジャックのケースは他にもある。A というクラスが AAA Corporationから提供されていると仮定しよう:
module M; class A { }
そして、私たちのアプリケーションコードでは、A を継承し、仮想 メンバ関数foo を追加する。
import M; class B : A { void foo(long); } void abc(B b) { b.foo(1); // B.foo(long)を呼び出す }
すべてうまくいく。これまで通り、AAA Corporationは (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では 同様の「エイリアス宣言」で
基底クラスのメンバ関数の乗っ取り
それ以上の何かがあるのではないかと疑っていることだろうが、その通りだ。 乗っ取りは逆方向にも起こり得る。派生クラスが基底クラスの メンバ関数を乗っ取ることも可能だ!
考えてみよう。
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 Corporationは再び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) を仮想関数にするつもりだった可能性が非常に高いということだ。なぜなら、それはA の新しい機能だからだ。 彼は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)を呼び出す }
ここまでは順調だ。foo(1) への呼び出しは、A内で 正しく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のように見える。
B.vtbl[0] = &B.foo(long); B.vtbl[1] = &A.foo(int);
また、A.def() からfoo(int)への呼び出しは、 実際には vtbl[1] への呼び出しである。A.foo(int) がB オブジェクトからアクセスできないようにしたい。 解決策は、B の vtbl[] を次のように書き換えることだ。
B.vtbl[0] = &B.foo(long);
B.vtbl[1] = &error;
ここで、実行時にエラー関数が呼び出され、例外がスローされる。 これはコンパイル時に捕捉されないため完璧ではないが、 少なくともアプリケーションプログラムが間違った関数を平然と呼び出し、処理を継続することはなくなる。
更新:vtbl[]がエラーエントリを取得するたびに、コンパイル時の警告が生成されるようになった。
結論
関数ハイジャックは、複雑なC++やJavaプログラムにおいて特に厄介な問題である。 なぜなら、アプリケーションプログラマーにとって、これに対する防御策がないからだ。 言語のセマンティクスを少し修正するだけで、 機能やパフォーマンスを犠牲にすることなく、この問題を防ぐことができる。
参考文献
- digitalmars/D/Hijacking_56458.html">digitalmars.D - Hijacking
- digitalmars/D/Re_Hijacking_56505.html">digitalmars.D - Re: Hijacking
- digitalmars/D/aliasing_base_methods_49572.html#N49577">digitalmars.D - aliasing base methods
- Eiffel、Scala、C#では、オーバーライドまたは類似の機能を使用している
クレジット:
- Kris Bell
- フランク・ブノワ
- アンドレイ・アレクサンダー
DEEPL APIにより翻訳、ところどころ修正。
このページの最新版(英語)
このページの原文(英語)
翻訳時のdmdのバージョン: 2.109.1
ドキュメントのdmdのバージョン: 2.109.1
翻訳日付 :
HTML生成日時:
編集者: dokutoku