💠
【C#】インターフェース既定実装による菱形継承ライクな状況
追記
- 「具体例」を修正しました (詳細は「具体例」セクションにて)
一言
- C#8 以降の default interface methods で起こり得る菱形継承っぽい設計のリスクと、
その回避策を考えていきまーす - 関連記事:インターフェースの使い方と落とし穴 よかったら見てね
菱形継承とは
- C++ など、多重継承を許す言語で発生する
- 基底クラス A のメンバーを2経路で継承したとき、どちらを呼び出すか, フィールドが二重に存在するかが曖昧になる
- D で A の関数, フィールド参照が二重化し、意図せぬ多重実体を招く
C# ではどのように似た状況が発生するのか
- インターフェースの複数実装と既定実装で、同一関数の実装が複数ルートから提供される
- 実行時に呼ばれる関数は キャスト先の型で一意に決まる
- ビルドが通らないわけではない(CS0108 警告が出たりはする)
- しかし、責務の所在が不明確な設計になる
- 全て自分が実装していれば、状況の予想ができるが、
外部ライブラリ更新で既定実装が追加され、意図せず挙動が変わるケースもあるカモ
具体例 (修正)
トップレベルステートメントで記述
var d = new Down();
//それぞれIRoot, ILeft, IRight の Foo() が呼ばれる
((IRoot)d).Foo();
((ILeft)d).Foo();
((IRight)d).Foo();
interface IRoot { void Foo() => Console.WriteLine("IRoot"); }
interface ILeft : IRoot { new void Foo() => Console.WriteLine("ILeft"); }
interface IRight : IRoot { new void Foo() => Console.WriteLine("IRight"); }
class Down : ILeft, IRight { }
-
new
による隠蔽では、利用者はキャストを強いられるため面倒 - エラーは起きない
修正箇所と修正前
修正箇所
-
((IRoot)d).Foo(); //IRoot の振る舞いが呼ばれる…?
のところ -
IRoot
にキャストしてるんで当然IRoot
が呼ばれますね😋
修正前
具体例
トップレベルステートメントで記述
var d = new Down();
((IRoot)d).Foo(); //IRoot の振る舞いが呼ばれる…?
interface IRoot { void Foo() => Console.WriteLine("IRoot"); }
interface ILeft : IRoot { void Foo() => Console.WriteLine("ILeft"); }
interface IRight: IRoot { void Foo() => Console.WriteLine("IRight"); }
class Down : ILeft, IRight { }
- どの実装が呼ばれるかは分からないため、可読性と信頼性を損なう
対応策, 回避策
インターフェースの責務を分割
- ISP (Interface Segregation Principle)
-
IFooable
,IBarable
など、振る舞いごとに分ける
既定実装を使う範囲を決めておく
- 最小限の機能 (例:
throw new NotSupportedException
)に留める - 新機能や振る舞い追加目的では使わない など
振る舞いの共有は別の手段にする
- 拡張メソッド, 抽象クラス, コンポジションを活用
- 共通処理をヘルパー関数, クラスに切り出して適宜注入する
- 静的抽象メンバーとジェネリックの合わせ技もアリ
static abstract interface method
× ジェネリック
- 記事を書く途中で静的抽象メンバーをインターフェースが持てることを知った
- せっかく学習したので本記事にも解説を載せる
- なお、静的抽象メンバーをインターフェースが持てるのは C#11 以降
解説
- 型パラメータ越しに静的メンバーを多態的に呼び出せる
- 既定実装 × インターフェース複数実装での曖昧さ回避に有効
実装例
トップレベルステートメントで記述
/*************************
実行例
*************************/
ExecuteProcessing<AddProcessor>(3, 5); //Result: 8
ExecuteProcessing<MultiplyProcessor>(3, 5); //Result: 15
/*************************
定義部分
*************************/
//ジェネリック関数でインターフェースの利用
void ExecuteProcessing<T>(int a, int b) where T : IProcessor<T>
{
int result = T.Process(a, b);
Console.WriteLine($"Result: {result}");
}
//static abstract インターフェース定義
interface IProcessor<T> where T : IProcessor<T>
{
static abstract int Process(int x, int y);
}
//static abstract インターフェース実装
struct AddProcessor : IProcessor<AddProcessor>
{
public static int Process(int x, int y) => x + y;
}
struct MultiplyProcessor : IProcessor<MultiplyProcessor>
{
public static int Process(int x, int y) => x * y;
}
これが曖昧さを低減させる理由
-
再帰的な制約 (
where T : IProcessor<T>
) がコンパイル時に保証-
Execute<T>()
でT.Process()
を呼ぶ時点で、
「T
は自身を型引数に持つIProcessor<T>
を実装している」ことが制約で保証される
-
-
インターフェースの静的メンバーは継承で引き継がれることはない (型に束縛)
-
AddProcessor.Process
とMultiplyProcessor.Process
は別シンボル - インターフェースを経由して混ざり合うことがない
-
-
呼び出し側が経路を明示
int result = T.Process(a, b); //ここで T を自分で選ぶ
Discussion
それぞれ の Foo() は 上書きではなく独自で new void Foo() 相当なので IRoot の実装が使われるのは自明なのでは……?(その例だと
ご指摘ありがとうございます🙏
「具体例」にあった、
new
での隠蔽 &IRoot
へのキャストの例でその実装が使われるのは自明でしたので修正しました(ただ、どの実装が呼ばれるかわからない例が思いつかなかったので(そもそもあるのか)「new
による隠蔽では、利用者はキャストを強いられるため面倒」という、説明内容自体を変更する修正になってしまいましたが…)また、これは完全に私事なのですが
コメントいただくのが今回初めて、自分の記事を読んでくださる方がいるとわかり感激してます~
改めてご指摘ありがとうございます🙏
例えばこの様にすると ILeft / IRight それぞれ IRoot.Foo を継承されたデフォルト実装を持てますが Down に継承した時点で コンパイルが通らないので安全です。