Build Insiderオピニオン:岩永信之(13)
インターフェースを「契約」として見たときの問題点 ― C#への「インターフェースのデフォルト実装」の導入(前編)
C#におけるインターフェースとは、ある型が持つべきメソッドを示す「契約」であり、実装は持てない。だが、このことが大きな問題となりつつある。今回から全3回に分けて、C#がこの問題にどう対処しようとしているかを見ていく。
現在、「C#にインターフェースのデフォルト実装(Javaでいうデフォルトメソッドに相当する機能)を追加しよう」という話がある。C#にこの機能を導入するに当たっては、C#コンパイラーだけではなく、.NETランタイムの修正が必要になる。
この機能の説明に入る前に、前編では、そもそもインターフェースというものが必要とされる理由や、その内部的な仕組みについて説明したい。
インターフェース
多くのプログラミング言語で、クラスとは別にインターフェース(interface: 境界面、接点)*1というものが用意されている。この2つの違いはおおむね、以下のようなものである。
- クラス: 状態を持てる代わりに単一継承しかできない
- インターフェース: 状態を持てない代わりに多重継承できる
「状態を持つ」というのは、C#でいうとメンバーとしてフィールドを持てるかどうかである。フィールドを持つということは、メモリ上のどこかにデータを記憶しておく領域が必要になる。後述するように、多重継承が絡むとデータの記憶領域のレイアウトが複雑になるため、クラスとインターフェースに分けることで複雑さを回避している。
- *1 言語によってはインターフェースの代わりにプロトコル(protocol: 協定、手順)やトレイト(trait: 特性、特色)などと呼ぶこともあるが、大体は似たような概念である。中には、インターフェース相当のものに状態を持てるものもあるが、クラスと比べるとやはり制限が大きく、特殊なことをしている。
多重継承のレイアウト問題
まず、クラスのフィールドが、メモリ上にどういうレイアウトで領域割り当てされるかについて説明しよう。多重継承問題に先立って、単一継承の場合から見てみよう。例えば、図1のような継承階層を見てみよう。
これらA
、B
、C
の3つのクラスは、図2のようなレイアウトで領域割り当てされる。単一継承の場合、派生クラスのレイアウトに関して、領域の前の方だけを見れば、基底クラスと全く同じになっている。その結果、A a = new B();
というように、派生クラスのインスタンスを基底クラスとして扱ってもほとんどコストがかからない。
ちなみに、header
の部分には、後述する型情報テーブルへのポインターなどが入っている。
ここで、多重継承を認めた場合どうなるかを考えよう。例えば、図3のような継承が認められたとしよう(C#では認めていない。C++などでは認められる)。
この場合は図4のようなレイアウトが行われる。この場合、B b = new C();
というように、C
をB
として扱う際にとびとびのレイアウトになるといった問題がある。
多重継承がらみの問題で特に困るのは、菱形継承(diamond class inheritance)問題と呼ばれるものである。よく、図5に示すような構造の多重継承が例に挙がる。派生の仕方が菱形状なため、菱形継承と呼ばれている。
この場合のレイアウトにはいくつか案があるが、おおむね、図6に示す2案のどちらかが採用される。案1では、データは連続して配置されるが、代わりにa
のデータが2カ所にダブっていて、「書き換えたつもりが書き換わっていない」などの問題を引き起こす。案2では、ダブりはなくなったものの、データの配置がとびとびで、D
をC
として扱う際に一工夫必要になる。
単一継承(図2)と比べて、多重継承(図4、図6)ではフィールドのレイアウトが不規則になり、仮想化(後述する仮想関数テーブルと同種の間接参照)が必要になる。そして、単なるフィールドの読み書きに対して使うには、仮想化は少々高コストである。得られるメリットに対してコストが高すぎるということで、クラス(=状態を持てる)の多重継承を認めるプログラミング言語は少なくなった。
仮想関数
逆にいうと、状態さえ持たなければ多重継承の問題の大半が解決する。ある型のメンバーとなっている関数*2の呼び出しに対しては、先ほど言葉だけ出てきた仮想化という手法が非常に有効である。「仮想関数」や「仮想メソッド」という言葉をよく耳にするだろう。これはリスト1に示すように、型に応じて呼び出される関数を切り替える手法である。
- *2 C#でいうと、メソッド、プロパティ、インデクサーなど、手続きを伴うメンバーのこと。
using static System.Console;
interface I { string GetName(); }
interface J { string GetName(); }
class A : I, J
{
// インターフェースの暗黙的実装
public string GetName() => "AのGetName";
}
class B : I, J
{
// インターフェースの明示的実装
string I.GetName() => "BのI向けGetName";
string J.GetName() => "BのJ向けGetName";
}
class Program
{
static void Main()
{
var a = new A();
var b = new B();
WriteI(a); // AのGetName
WriteJ(a); // AのGetName
WriteI(b); // BのI向けGetName
WriteJ(b); // BのJ向けGetName
}
// xに渡された型(AかBか)に応じて、異なるメソッドが呼び出される
static void WriteI(I x) => WriteLine(x.GetName());
static void WriteJ(J x) => WriteLine(x.GetName());
}
|
フィールドの読み書きと比べれば、関数呼び出しは元から少々のオーバーヘッドを伴うものであり、そこに仮想化のコストが加わっても相対的には影響が少ない。実要件としても、状態を多重継承したいことは実はそれほど多くなく、仮想関数だけを多重継承できれば十分なことが多い。そこで生まれたのがインターフェース、すなわち、状態を持てない代わりに多重継承ができる型である。
仮想関数テーブル
仮想関数の実現方法についても説明しておこう。この手の機能を持っているプログラミング言語では、型ごとに仮想関数テーブルというものを作って、このテーブルを引くことで型に応じた関数の呼び分けを行っている。2つの例を挙げて行こう。
1つ目は、図7のような型を考える。クラスの単一継承階層である。
この型を含むプログラムを実行するとき、図8のようなテーブルが作られる。
各番号の説明は本文中に記載している。
図の下半分は各関数のコンパイル結果が置かれている場所である。通常の(非仮想な)関数(この例でいうとM
)であれば、呼び出したときに直接この場所が参照される。
一方、図の上半分は、型ごとに作られるもの(型情報テーブルの一部)で、それぞれの仮想関数に対して実際にはどこを参照すべきか、その実体を指し示すためのテーブルである。仮想関数は、このように1段階テーブル参照を挟むことで、型ごとの呼び分けを行っている。a.X()
というようなコードを書いたときに、実際には以下のような手順で関数呼び出しが行われている。
- インスタンス
a
のheader
(前節の図2などの中にあったもの)から、型情報テーブルを引く - (型情報テーブル内の)仮想関数テーブルから、関数
X
の実体の場所を引く - 引いた実体にジャンプする
そして、図中の1のようにoverride
しなかった関数については基底クラスと同じ実体を参照して、2のようにoverride
したものについては異なる実体を参照する。
次に、2つ目の例として、インターフェースも加えた図9のような型を考えよう。
この場合は図10のようなテーブルが作られる。
各番号の説明は本文中に記載している。
図中の1のように、各型のテーブルの中に、インターフェースごとの仮想関数テーブルが別途並んでいる。要するに、先ほどの手順の1(型情報テーブルを引く)と2(関数の実体の場所を引く)の間に、インターフェースごとのテーブルを引く作業が加わる。
インターフェースの暗黙的実装をしている場合は、2のように、クラス側とインターフェース側で同じ実体を指すことになる。一方で、明示的実装の場合は3のように、異なる実体を指している。
インターフェースへの実装の追加
前節で説明したような実装上の都合でいうと、状態さえ持たなければ多重継承を認めることができる。状態を持てない代わりに多重継承を認めたものがインターフェースである。
では、関数の実体はどうだろう。実装都合上は、別にインターフェースが実体を持っていても構わない。リスト2のようなコードを認めることは、技術的には容易である。
using static System.Console;
interface I
{
void X() => WriteLine("I X");
}
class A : I
{
// I.XはA側で実装しなくても、I側の実装が引き継がれる
}
|
しかし、現在(C# 7.0時点)のC#ではこれを認めていない(.NETランタイムの制約でできない)。Javaでも、当初は認めていなかったものの、Java 8でインターフェースが関数の実体を持てるようになった(この機能をJavaではデフォルトメソッドと呼んでいる)。
契約と実装の分離
当初、インターフェースに関数実体の定義を認めなかったのは、契約と実装を明確に分離すべきという思想的な判断である。ここでいう契約や実装というのは、図11に示すような意味合いである。
- 契約(contract): ある型がどういう関数を持っているべきかという対外的な約束事
- 実装(implementation): その関数が具体的にどう実装されるか
確かに、「インターフェースとは、契約のみを与える型である」とした方が、役割が明瞭ではある。その一方で、インターフェースが実装を持てないことで、明瞭さというメリット以上にデメリットを受けているのではないかという疑念もある。
メンバー追加による互換性問題
インターフェースが実装を持てないことの最大のデメリットは、インターフェースに後からメンバーを追加できないことである。一度公開してしまったインターフェースにメンバーを追加すると、そのインターフェースを継承しているクラスに破壊的な影響が生じる。
例えば図12のような場面を考えてみよう。インターフェースI
と、それを実装するクラスA
が別々のライブラリで定義されているものとする。
異なるライブラリは、異なる作者が異なる時期に更新することを想定しなければならない。もしも、「ライブラリαの変更に伴って、利用側のコードも修正が必要」ということになっても、ライブラリβの作者がその修正時間を取れるかどうかは分からない。最悪の場合、ライブラリβはあまり保守されていなくて、全く対応されないということもあり得る。そうなると、ライブラリα、βの両方を使っているコードは、ライブラリαも更新できなくなってしまう。
こういう問題を起こさないよう、利用側に破壊的な影響を生じるような変更は避けるべきである。それなりに広く、長く使われているライブラリでは、ユーザーに破壊的変更を受け入れてもらえることはまれで、作者としても破壊的変更を行う政治的判断は下せず、実質的には不可能といってもよい*2。
- *2 多くの場合、機能追加した新しいバージョンは普及せず、新旧両方のバージョンを保守することになるか、旧バージョンに戻す決断を迫られる。
その前提を踏まえたうえで、インターフェースI
にメンバーを追加することを考えてみよう。図12に示すように、インターフェースI
にメソッドY
を追加する。このとき、ライブラリβは元のままなので、クラスA
にはY
に対する実装が含まれていない。このプログラムをロードすると、クラスA
の仮想関数テーブル中のY
が指すべき実体がどこにもない状態になる。
この状態のライブラリα、βはエラーを起こす。この2つを参照したコードを書こうとするとコンパイルエラーになるし、配布先でライブラリαだけ差し替えてプログラムを実行すると実行時エラー(MissingMethod例外)になる。すなわち、ライブラリαのインターフェースI
へのメンバーの追加は、ライブラリβやそれを利用する全てのプログラムに対して破壊的な変更になっている。これは先ほどいった「規模によってはユーザーに受け入れられる見込みがなく、実質的に不可能」といえる変更である。
これに対して、図14に示すように、インターフェースに対してメンバーの実装を持つことを認めれば、破壊的変更を避けることができる。クラスA
側にY
がないのなら、I
自身が定義したY
を使えば済む話である。
破壊的変更の問題が認知されてきたためか、最近では、インターフェースにメンバーの実装を持つことを認めるプログラミング言語が多い。当初は認めていなかったJavaも、Java 8でデフォルトメソッドを追加した。そして、同様の話がC#でも検討され始めた。
まとめ
インターフェースは、クラスの多重継承に伴うメモリのレイアウトに関する問題を解決するために導入された。技術的には、状態を持たないということが重要である。
一方、いくつかのプログラミング言語では、インターフェースは契約のみを持っていて、実装を一切持たないものとして定義されている。これはどちらかというと思想的な判断であって、技術上は、状態さえ持たなければ、メソッドなどの実装は持てるものである。
インターフェースが実装を持てないことによって、後からそこにメンバーを追加できないという大きな足かせが生まれている。そして、「契約のみを持つ」という明瞭さがもたらすメリットよりも、今述べた足かせというデメリットの方が開発者に与える影響が大きいと見なされるようになりつつある。そこで、近年では「状態を持てない」という最低限の制約だけを残して、インターフェースにも実装を持てるようにすることが増えている。
次回と次々回は、C#でインターフェースにデフォルトの実装を持たせるとどのようなコードが書けるようになるのか、そのためにはどんな変更が(ランタイムのレベルで)必要になるか、検討中の課題、デフォルト実装と拡張メソッドの比較などの話題を取り上げる。
岩永 信之(いわなが のぶゆき)
※以下では、本稿の前後を合わせて5回分(第11回~第15回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
11. nullが生まれた背景と現在のnullの問題点 ― null参照問題(前編)
Cの系譜を継ぐC#ではnullが長らく使い続けられてきたが、最近ではその存在が大きな問題だと認識されている。前後編でこの問題を取り上げ、今回(前編)はnullを取り巻く事情について考察する。
12. C#でのnull参照問題への取り組み ― null参照問題(後編)
最近のC#ではnullの存在が大きな問題となっている。前回(前編)で説明したnullの事情を踏まえ、今回(後編)は、将来のC#がnullをどう取り扱っていくのかを見ていく。
13. 【現在、表示中】≫ インターフェースを「契約」として見たときの問題点 ― C#への「インターフェースのデフォルト実装」の導入(前編)
C#におけるインターフェースとは、ある型が持つべきメソッドを示す「契約」であり、実装は持てない。だが、このことが大きな問題となりつつある。今回から全3回に分けて、C#がこの問題にどう対処しようとしているかを見ていく。
14. デフォルト実装の導入がもたらす影響 ― C#への「インターフェースのデフォルト実装」の導入(中編)
前回は一般論としてのインターフェースとその課題を見た。今回はC#にインターフェースのデフォルト実装を導入すると、どのようなコードが書けるようになるのか、導入するために必要な修正点などについて見ていく。
15. インターフェースを拡張する2つの手段 ― C#への「インターフェースのデフォルト実装」の導入(後編)
破壊的な影響を他に及ぼすことなくインターフェースの機能を拡張するには、デフォルト実装に加えて拡張メソッドも使用できる。今回はこれら2つの方法がなぜ必要なのか、それぞれが得意としている分野について詳しく見る。