継承の濫用による設計の矛盾
コード共通化のためだけの継承の濫用が設計の矛盾を生むというのを分かっている方は、下の問題設定だけ見て、次の項まで読み飛ばしてください。
問題設定
- 人間・犬・猫には「歩く」という共通の振る舞いがあり、現在の「位置」を持っている。
- 全ての生き物はデフォルトでは速度1で歩くが、犬と猫は速度2で歩く。
- 猫は昼寝をし、昼寝を始めると歩かない。
C#er 的には自然な考えとして、まずは継承で実装してみましょう。
public abstract class Walkable { public int X { get; protected set; } public virtual void Walk() => X += 1; } public class Human : Walkable{ } public class Dog : Walkable { public override void Walk() => X += 2; } public class Cat : Walkable { public bool IsSleeping { get; private set; } public override void Walk() { if(IsSleeping == false) { X += 2; } } public void Sleep() => IsSleeping = true; }
普通ですね。ここで犬と猫でX += 2
の部分が同じです。現実的なコードではもっと複雑な実装だとすると、二か所に同じことを書くのはメンテナンス性に欠けるため、共通化したくなります。この時、何も考えていないと継承を使ってしまいがちで、これは継承の濫用です。以下、継承を濫用したよくない実装です。
// Walkable と Human は先ほどと同じ public class Dog : Walkable { public override void Walk() => X += 2; } public class Cat : Dog { public bool IsSleeping { get; private set; } public override void Walk() { if(IsSleeping == false) { base.Walk(); // Dog の Walk() メソッドを呼ぶ } } public void Sleep() => IsSleeping = true; }
X += 2
という犬と猫に共通な処理は犬の中に実装されて共通化されています。そして「犬を継承した猫」という自然界の冒涜、超生命体が爆誕しています。ありえません。もし逆に猫を犬が継承したとしても、今度は犬も昼寝をできることになってしまいます。ついでに猫を継承したキメラ犬が降臨するのも解消されません。
もし継承でコードを共通化したいのであれば、速度2で歩く抽象クラスを間に挟んで実装すると正しく実装することができます。しかし、ここでは別の方法として interface を使い、かつコードを共通化する方法を示したいと思います。
interface と実装の分離
継承ではなく interface を使って、かつコードを共通化する方法を示します。
// 共通の振る舞いを表す interface public interface IWalkable { int X { get; } void Walk(); } // 生き物のデフォルトの IWalkable 実装 public struct DefaultWalkableCore { public int X { get; set; } public void Walk() => X += 1; } // 犬と猫用の IWalkable 実装 public struct DogCatWalkableCore { private DefaultWalkableCore _baseCore; public int X { get => _baseCore.X; set => _baseCore.X = value; } public void Walk() => X += 2; } // 人間 public class Human : IWalkable { private DefaultWalkableCore _core; public int X => _core.X; public void Walk() => _core.Walk(); } // 犬 public class Dog : IWalkable { private DogCatWalkableCore _core; public int X => _core.X; public void Walk() => _core.Walk(); } // 猫 public class Cat : IWalkable { private DogCatWalkableCore _core; public bool IsSleeping { get; private set; } public int X => _core.X; public void Walk() { if(IsSleeping == false) { _core.Walk(); } } public void Sleep() => IsSleeping = true; }
振る舞いは interface で定義し、その具体的な処理 (Walk()
) は各クラスには書かず、実装用の別の構造体に書いておきます。そして、各クラスからはその実装を呼びます。犬と猫が速度2で歩くというコードも共通化されており、猫は昼寝をするという要件もきちんと満たしています。
例ではDogCatWalkableCore
にDefaultWalkableCore
を持たせていますが、メモリレイアウト的に同じなので
public struct DogCatWalkableCore { public int X { get; set; } public void Walk() => X += 2; }
のようにして、ベースになる実装を持たせずに書いてもよいです。
このパターンによる実装は継承よりも柔軟で、継承の場合 Human
, Dog
, Cat
は class である必要がありますが、構造体にすることもできます。また、別の interface と core を追加で持たせることもできるため、多重継承のようなことも実現できます。大規模なプログラムの場合、そもそもクラス間に継承関係を持たせること自体かなり慎重に設計しないと負債になりがちですが、それを避けられます。
この interface/core パターンは私が勝手に考えたわけではありません。現在の C# の非同期処理の標準実装であるValueTask
を動かすためのIValueTaskSource
という interface があるのですが、そこに inteface/core パターンが使われています。(逆に言うとそれ以外で典型的にこのパターンが使われているのを見たことがありません、知っていたら教えてください。)
このIValueTaskSource
の実装オブジェクトとしてManualResetValueTaskSourceCore
という構造体があり、core という名前もこれにならってつけています。
継承をすることなく別の型から機能だけを取り入れているという意味では mixin と言ったほうが近いかもしれません。
多段に呼び出しを経由しているのでオーバーヘッドが発生して遅いのではないかと一見思いますが、core のメソッドの呼び出しはメソッドのサイズが小さいのでほぼ間違いなくインライン展開されて、実行時には消えます。また、core を構造体実装しているため、メモリレイアウトも継承で書いた場合と同一になり無駄がありません。(メモリレイアウトについては普通は気にする必要はありません)
他言語でのポリモーフィズム
C# 以外の言語に目を向けると、Rust にもポリモーフィズムの概念はあります。ところがオブジェクト指向言語ではないのでオブジェクト指向言語的な継承はありません。つまり、「ポリモーフィズム ≠ 継承」です。これは重要なことなので明確にしておきます。実装を提供するxxxCore
という構造体と、振る舞いを表す interface を組み合わせた前述の実装は、実は Rust の trait/impl と全く同じです。Rust の trait/impl にちなんで、私が interface/core パターンと勝手に呼んでいますが、名前はないので特に何でもいいです。
非OOP 言語などメインで使っている人には「継承なんてバグの温床、オブジェクト指向の負の遺産」という過激派な人もいますが、この意見には私は否定的です。目的はポリモーフィズムの実現であって、継承やオブジェクト指向はその道具にすぎません。正しく使えば継承は強力な機能であり、正しく使えていないから壊れるのです。
とはいえ Rust の trait/impl は合理的で、良いものは C# にだって取り入れられます。しかし、これが C# において継承を駆逐するとは思いません。継承には継承の価値があり、どちらを使うかは設計と実装者の腕次第だと思います。
おまけ
前述の例でHuman
, Dog
, Cat
を構造体にすることもできると書きましたが、構造体に interface 経由でアクセスすると boxing によって不要なコストが発生します。これは、interface で受け取らずに型制約付きジェネリクスで受け取ることで回避できます。
// interface で構造体を受け取ると boxing が起こる public void Foo(IWalkable walkable) { // 何か処理をする } // 型制約付きジェネリクスで受け取ると boxing が起こらない public void Foo<TWalkable>(TWalkable walkable) where TWalkable : IWalkable { // 何か処理をする }
使っていきましょう。