Build Insiderオピニオン:岩永信之(14)
デフォルト実装の導入がもたらす影響 ― C#への「インターフェースのデフォルト実装」の導入(中編)
前回は一般論としてのインターフェースとその課題を見た。今回はC#にインターフェースのデフォルト実装を導入すると、どのようなコードが書けるようになるのか、導入するために必要な修正点などについて見ていく。
前編では、一般的にインターフェースがどのように実装されているかと、インターフェースが抱える問題を説明し、その問題はインターフェースが実装を持てれば解決するという話をした。前置きが長くなったが、今回と次回ではC#における事情について見ていこう。C#でも、インターフェースに実装を持てるようにしたいという動きが出始めている。
要するに、これはインターフェースに対して過剰に掛かっていた制限を緩めるというものであり、技術的な課題はそれほど大きくない。ただし、C#コンパイラーだけでなく、.NETランタイムの修正が必要となる。これは.NET Framework 2.0(2005年)以来のことであり、これまでになかった配慮が必要になる。
また、デフォルト実装と一部同じ目的に使えるものとして、C#にはこれまでも拡張メソッドという機能があった。この機能との差については次回説明する。
C#への「インターフェースのデフォルト実装」の導入
インターフェースが持つ実装は、互換性を壊さないように取りあえず既定動作として実装しておくものである。多くの場合、実装するクラス側でoverride
して使う想定である。そのため、この機能の名前は、Javaではデフォルトメソッド(default method: 既定動作のメソッド)と呼ばれる。C#での呼び名はまだ決定はしていないが、提案段階ではデフォルト実装(default implementation)と呼ばれている。
.NETランタイムの修正
C#では、前編で説明したような型情報テーブルの読み込み・仮想関数テーブルの構築は、C#コンパイラーではなく、.NETランタイムの仕事である。
すなわち、C#にインターフェースのデフォルト実装を導入するに当たっては、C#だけではなく、.NETランタイムに手を加える必要がある。現在、インターフェースが実装を持てないという制限を掛けているのは.NETランタイムであり、この制限を取り払う修正が必要になる。C#コンパイラーだけの修正では済まないため、需要がある割にはこれまで優先的に取り組まれてこなかった。
一方で、前回に話した通り、インターフェースに実装を持てないのはどちらかというと思想的な判断であって、技術的にはそれほど難しい問題ではない。クラスでやっていることをインターフェースでも認めるだけである。一応、.NETの型システムの根幹に関わる部分の修正なので関連する作業量はそれなりに多いが、特に革新的なアイデアを必要とするわけではなく、淡々と作業できる類いのものだ。
要するに、一度「.NETランタイムの修正をやろう」と決まりさえすればすぐにでも実装に取り掛かれるものである。むしろ、ランタイム修正による機能追加を試みる最初の試金石として最適な機能といえるだろう。
その他の制限緩和
インターフェースに実装を持てるようにするついでに、以下のようなインターフェースの制限を緩和することも検討されている。
- 静的なメンバーを持てるようにする
private
やprotected
、internal
などのアクセシビリティも認める
要するに、インスタンスフィールドを持てない以外はほとんど抽象クラスと同じである。もともと、「状態を持っていると多重継承が難しい」という問題に対する解決策がインターフェースだったのだから、必要十分なライン(フィールドさえなければいい)にまで制限を緩めようという話である。
C#の文法
C#的にも、基本的にやることは「制限を緩めるだけ」である。恐らくはリスト3に示す通り、インスタンスフィールドとコンストラクターを除いた全てが使えるようになるだろう。
interface I
{
// 今までも書けたもの(publicな非静的関数メンバーのみ)
void PublicMethod();
int PublicProperty { get; }
// 今まで書けなかったけども書けるようになるもの
protected void ProtectedMethod();
public void ImplementedMethod() { }
public int ImplementedProperty => 0;
public const int Constant = 2;
public static readonly int StaticField = Math.Exp(Constant);
public static int StaticMethod(int x) => StaticField * x;
// 今後も書けないもの(インスタンスフィールドとコンストラクター)
int InstanceField;
public I(int value) => InstanceField = value;
}
|
少し留意すべき点もある。現状のクラスやインターフェースでも起きていることだが、継承に伴って同名の別メンバーができることがあって、それに伴う問題回避のためのルールがいくつか必要となるのだ。
基底型に同名のメンバーを追加
リスト4に示すように、派生クラスに同名のメンバーがすでにあると気付かず、基底クラスにメンバーを足してしまうことがあり得る。
class Derived : Base
{
// Derivedクラスが元からこのMを持っていたとして
public void M() => WriteLine("Derived M");
}
class Base
{
// 後から基底クラスにMを足した場合にどうすべきか
public virtual void M() => WriteLine("Base M");
}
|
こういう状況に対してC#では、Derived
クラス側の既存動作を壊さないようにするため、override
修飾子が付いていない限り、基底クラスの同名メソッドとは無関係の別メソッド扱いをするようにしている。具体的にはリスト5に示すような挙動になる。
using static System.Console;
class Base
{
// 後から基底クラスにMを足した場合どうすべきか
public virtual void M() => WriteLine("Base M");
}
class A : Base
{
// overrideが付いていない場合、Base.Mとは別のメソッドになる(警告あり)
public void M() => WriteLine("A M");
}
class B : Base
{
// こちらはoverrideが付いているので、Base.Mを上書き
public override void M() => WriteLine("B M");
}
class Program
{
static void Main()
{
new A().M(); // A M
((Base)new A()).M(); // Base M
new B().M(); // B M
((Base)new B()).M(); // B M
}
}
|
インターフェースのメンバーがデフォルト実装を持つ場合、これと同様の配慮が必要である。この辺りは検討中ではあるが、恐らくリスト6のような仕様になるだろう。インターフェースのメンバーは、デフォルト実装を持つかどうかによって実装の仕方が変わってくる。デフォルト実装持ちのメンバーは、クラスのメンバーと同様の扱いになる。
interface I
{
// これまで通りの、実装を持たないメソッド
void M();
// デフォルト実装持ちのメソッド
void X() => WriteLine("I X");
}
class A : I
{
// 実装を持たないメソッドであれば、暗黙的にI.Mを上書き
public void M() => WriteLine("A M");
// デフォルト実装を持つ場合、overrideが付いていないとI.Xとは別メソッド
public void X() => WriteLine("A X");
}
class B : I
{
// 明示的にI.Mを上書き
void I.M() => WriteLine("B M");
// overrideを付ければ、I.Xを上書き
public override void X() => WriteLine("B X");
}
|
別インターフェースの同名メソッド
インターフェースは多重継承できるということは、同名のメンバーを持つ別のインターフェースを2つ以上継承してしまう場合があり得るということだ。これに対して、C#では、リスト7に示すように、暗黙的実装という書き方で、個別実装・呼び分けができる。
using static System.Console;
interface I
{
void M();
}
interface J
{
// Iと同名のメソッドを持つ
void M();
}
class A : I, J
{
// 暗黙的実装。この場合、このMはI.MとJ.Mを兼ねる
public void M() => WriteLine("A M");
}
class B : I, J
{
// 明示的に個別実装
void I.M() => WriteLine("B I.M");
void J.M() => WriteLine("B J.M");
}
class Program
{
static void Main()
{
// この3行はいずれも同じ結果
new A().M(); // A M
((I)new A()).M(); // A M
((J)new A()).M(); // A M
// それぞれのインターフェースのMを呼び分け可能
// 一方、クラス自身にはメソッドMがない状態
new B().M(); // コンパイルエラー
((I)new B()).M(); // B I.M
((J)new B()).M(); // B J.M
}
}
|
これに、デフォルト実装を追加すると、恐らくリスト8のようなルールが必要になる。
using static System.Console;
interface I { void M() => WriteLine("I M"); }
interface J { void M() => WriteLine("J M"); }
class A : I, J
{
// I.MもJ.Mも明示的実装扱い
}
class B : I, J
{
// overrideがないし、I.MともJ.Mとも別物扱い
public void M() => WriteLine("B M");
}
class C : I, J
{
// overrideを付けて、I.MもJ.Mも上書き(これを認めるかは議論の余地あり)
public override void M() => WriteLine("C M");
}
class D : I, J
{
// それぞれを明示的実装(明示的実装でもoverride修飾子必須すべき?)
void I.M() => WriteLine("D I.M");
void J.M() => WriteLine("D J.M");
}
class Program
{
static void Main()
{
new A().M(); // コンパイルエラー
((I)new A()).M(); // I M
((J)new A()).M(); // J M
new B().M(); // B M
((I)new B()).M(); // I M
((J)new B()).M(); // J M
new C().M(); // C M
((I)new C()).M(); // C M
((J)new C()).M(); // C M
new D().M(); // コンパイルエラー
((I)new D()).M(); // D I.M
((J)new D()).M(); // D J.M
}
}
|
この例のA
クラスのように、クラス内で特に何もしない場合は、明示的実装をしたときと同様の挙動になる。すなわち、A
自身にはM
メソッドは作られず、I
またはJ
にキャストした場合にだけ、それぞれのM
を呼び出せる。
B
クラスは、前節での説明の通りである。デフォルト実装があるメンバーに関しては、override
修飾子を付けない限り、無関係な別のメソッドと見なされる。
C
クラスのような書き方を認めるかどうかには議論の余地がある。もともとI
とJ
のどちらか片方だけがM
メソッドのデフォルト実装を持っていて、もう片方には後からデフォルト実装を足すような場合に問題を起こすかもしれない。
D
クラスは明示的実装をする例である。ここでも1点議題がある。前節の通り、デフォルト実装があるメンバーにはoverride
修飾子が必須になるわけだが、明示的実装の場合でもoverride
を必須にするかどうかは悩ましい。
菱形継承
菱形継承によって、同じインターフェースのメソッドに対する別実装が起こり得る。クラスも交えたリスト9のような例や、インターフェースで菱形を作るリスト10のような例があり得るだろう。
interface I
{
void M() => WriteLine("I M");
}
interface J : I
{
override void M() => WriteLine("J M");
}
class Base : I
{
public virtual void M() => WriteLine("Base M");
}
class Derived : Base, J
{
// 認めるべきか。認めるならBase.MとJ.Mのどちらを選ぶべきか
}
|
interface I
{
void M() => WriteLine("I M");
}
interface J : I
{
override void M() => WriteLine("J M");
}
interface K : I
{
override void M() => WriteLine("K M");
}
class A : J, K
{
// 認めるべきか
}
class B : J, K
{
// どのインターフェースのM実装を採用するか、呼び分けの仕組みが必要
void I.M() => J.base.M();
}
|
リスト9の例であれば恐らく、基底クラスのメンバーを優先すべきと思われる。new Derived().M()
の呼び出しは認めてよく、Base.M
メソッドが呼ばれるべきだろう。
リスト10のクラスA
は、どちらを選ぶべきか決められず、コンパイルエラーになる見通しだ。一方、クラスB
のように、どの実装を採用するかの呼び分けの仕組みが提供される予定である。
ランタイムの修正の影響
デフォルト実装の追加には.NETランタイムの修正が必要という話をしたが、これは、.NET Framework 2.0以来のことになる。.NET Framework 2.0というと2005年リリースで、延長サポート期限が残っている最古のWindowsであるWindows Vista(この記事が公開されるころにちょうどサポート終了)ですら標準でインストールされている。
つまり、最新のC# 7.0を含め、現在のC#の機能は全て、やろうと思えばサポートされている全てのWindowsの、クリーンインストール状態で動く。「やろうと思えば」というのは、ランタイムだけでなくライブラリの対応が必要な機能が一部あるからだが、それも、ライブラリの移植をすれば古いバージョンの.NETランタイム上で動かせる。
例えば、いくつか標準ライブラリ中の型を自作する必要はあるが、リスト11のコードは.NET Framework 2.0上でも動く(ちなみに、必要とする型の自作も含めたコード全体をGist上に置いてある)。
using System.Linq;
using System.Runtime.CompilerServices;
using static System.Console;
class Program
{
static void Main(string[] args)
{
// 型推論(var、new[]での配列生成)
var inputs = new[] { A(), B(), C(), D() };
// LINQ(クエリ式、拡張メソッド)
var outputs =
from x in inputs
where x.line % 2 == 1
// 文字列補完
select $"line: {x.line}, name: {x.name}";
foreach (var x in outputs)
{
WriteLine(x);
}
}
// タプル
// Caller Info属性
static (int line, string name) CallerInfo(
[CallerLineNumber] int line = 0,
[CallerMemberName] string name = null)
=> (line, name);
static (int line, string name) A() => CallerInfo();
static (int line, string name) B() => CallerInfo();
static (int line, string name) C() => CallerInfo();
static (int line, string name) D() => CallerInfo();
}
|
ランタイムの更新
かつては、.NETランタイムを更新しようと思うと、エンドユーザーに.NET Frameworkの所定のバージョンをインストールしてもらう必要があった。しかし、現在の.NET Coreであれば、ランタイムの導入方法に以下のような選択肢がある。
- これまで通り、OSに対してインストールして、全てのアプリで共有して使う
- アプリ単位で最新のランタイムをダウンロードして使う
- ビルド時にランタイム機能もアプリ自体に組み込んだ実行可能形式を作る
これらの仕組みによって、ランタイム更新のハードルはだいぶ下がっている。そろそろ、ランタイム更新が必要な言語機能の追加を考えてもいい頃合いである。
機能フラグ
ただ問題は、使いたい機能を使えるランタイムと使えないランタイムができることである。「このライブラリはインターフェースのデフォルト実装機能を使っている」というようなフラグを持つ必要があるだろう。
現在でも、.NET向けのライブラリには依存しているフレームワークに関する情報が入っている。例えば「.NET Framework 4.6向け」となっているライブラリであれば、.NET Framework 4.6の標準ライブラリ中のクラスを全て使える代わりに、.NET Framework 4.6以上をターゲットとしたアプリからしか参照できなくなる。
同様に、ライブラリを作るときにランタイム機能も指定して作ることになるだろう。「デフォルト実装を使う」という設定をしているライブラリでだけこの機能が使える代わりに、参照できるアプリが限られることになる。
まとめ
現在のC#で、インターフェースが実装を持てないというのは、.NETランタイムであえて制約を掛けているためである。この制約を緩めるため、.NETランタイム自体の仕様変更を行う予定である。
インターフェースに掛かっていた制限がだいぶ緩和され、インスタンスフィールドを持てない以外はほとんど抽象クラスと同じ状態になる。また、現在のインターフェースやクラスでも同様だが、同名別実装のメンバーをどう扱うかに少々工夫が必要で、現在その検討も行われている。
.NETランタイムの修正が必要となるのは.NET Framework 2.0以来のことであり、これまでなかった配慮が求められる。「どのライブラリがどのランタイム機能依存しているか」などの機能フラグ管理が必要になるだろう。
次回は、インターフェースのデフォルト実装と拡張メソッドを比較しながら、それぞれができること、得手・不得手なこと、なぜ既存のインターフェースの機能を拡張する方法が複数必要なのかについて考えてみよう。
岩永 信之(いわなが のぶゆき)
※以下では、本稿の前後を合わせて5回分(第12回~第16回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
12. C#でのnull参照問題への取り組み ― null参照問題(後編)
最近のC#ではnullの存在が大きな問題となっている。前回(前編)で説明したnullの事情を踏まえ、今回(後編)は、将来のC#がnullをどう取り扱っていくのかを見ていく。
13. インターフェースを「契約」として見たときの問題点 ― C#への「インターフェースのデフォルト実装」の導入(前編)
C#におけるインターフェースとは、ある型が持つべきメソッドを示す「契約」であり、実装は持てない。だが、このことが大きな問題となりつつある。今回から全3回に分けて、C#がこの問題にどう対処しようとしているかを見ていく。
14. 【現在、表示中】≫ デフォルト実装の導入がもたらす影響 ― C#への「インターフェースのデフォルト実装」の導入(中編)
前回は一般論としてのインターフェースとその課題を見た。今回はC#にインターフェースのデフォルト実装を導入すると、どのようなコードが書けるようになるのか、導入するために必要な修正点などについて見ていく。
15. インターフェースを拡張する2つの手段 ― C#への「インターフェースのデフォルト実装」の導入(後編)
破壊的な影響を他に及ぼすことなくインターフェースの機能を拡張するには、デフォルト実装に加えて拡張メソッドも使用できる。今回はこれら2つの方法がなぜ必要なのか、それぞれが得意としている分野について詳しく見る。
16. C# 7のタプルが一般的なガイドラインに沿わずに書き換え可能な構造体である背景
C# 7.0で登場した新しいタプル(ValueTuple構造体)は、複数の値をひとまとめにして扱うのに便利なデータ構造だが、その実装は一般的な構造体のガイドラインに従っていない。なぜそうなっているのか、技術的背景を追う。