クラス

【C#】継承とoverrideで学ぶポリモーフィズムの基本と実践テクニック

C#の継承は基底クラスの機能を派生クラスへ共有し、共通ロジックを再利用できます。

virtualを付けたメンバーは派生側でoverrideして振る舞いを上書きでき、これがオーバーライドです。

ポリモーフィズムにより基底型変数から呼んでも派生型の実装が走るため、拡張と保守が柔軟になります。

継承の基本

C#における継承は、オブジェクト指向プログラミングの中核をなす機能の一つです。

継承を使うことで、既存のクラスの機能を引き継ぎつつ、新しいクラスを効率的に作成できます。

ここでは、継承の基本的な考え方や特徴について詳しく解説します。

is-a 関係とクラス階層

継承の根底にあるのは「is-a(〜は〜である)」関係です。

これは、派生クラスが基底クラスの一種であることを意味します。

たとえば、「犬は動物である」という関係は、DogクラスがAnimalクラスを継承することで表現できます。

この関係を利用して、クラス階層を構築します。

クラス階層は、基底クラスから派生クラスへと機能を拡張・特化していく構造です。

以下の例でイメージを掴んでみましょう。

// 基底クラス
public class Animal
{
    public void Eat()
    {
        Console.WriteLine("食べる");
    }
}
// 派生クラス
public class Dog : Animal
{
    public void Bark()
    {
        Console.WriteLine("吠える");
    }
}
class Program
{
    static void Main()
    {
        Dog myDog = new Dog();
        myDog.Eat();  // 基底クラスのメソッドを呼び出せる
        myDog.Bark(); // 派生クラス固有のメソッド
    }
}
食べる
吠える

この例では、DogクラスはAnimalクラスを継承しているため、Eatメソッドをそのまま使えます。

DogAnimalの一種であるため、DogAnimalの機能を持ちつつ、独自の機能も追加できます。

このように、is-a関係を意識してクラス設計を行うことで、自然でわかりやすい階層構造を作れます。

継承は単なるコードのコピーではなく、意味的な関係性を表現する手段です。

コード再利用と保守性

継承の大きなメリットは、コードの再利用性が高まることです。

基底クラスに共通の処理をまとめておけば、派生クラスはその処理を再実装する必要がなくなります。

これにより、重複コードを減らし、保守性を向上させられます。

たとえば、複数の動物クラスが共通して持つ「食べる」動作をAnimalクラスにまとめておけば、DogCatなどの派生クラスは個別にEatメソッドを実装しなくて済みます。

public class Animal
{
    public void Eat()
    {
        Console.WriteLine("食べる");
    }
}
public class Cat : Animal
{
    public void Meow()
    {
        Console.WriteLine("ニャー");
    }
}
class Program
{
    static void Main()
    {
        Cat myCat = new Cat();
        myCat.Eat();  // Animalクラスのメソッドを再利用
        myCat.Meow(); // Cat固有のメソッド
    }
}
食べる
ニャー

このように、共通処理を基底クラスに集約することで、コードの重複を防ぎ、修正が必要な場合も基底クラスだけを変更すれば済みます。

結果として、保守コストが下がり、バグの混入リスクも減らせます。

ただし、継承を使いすぎるとクラス階層が複雑になり、逆に理解や保守が難しくなることもあります。

適切な粒度で継承を設計することが重要です。

sealed キーワードで継承を抑制

C#では、sealedキーワードを使うことで、クラスの継承を禁止できます。

sealedを付けたクラスは、それ以上派生クラスを作れません。

これにより、設計上の意図を明確にしたり、予期しない継承による問題を防いだりできます。

// sealedクラス
public sealed class FinalClass
{
    public void ShowMessage()
    {
        Console.WriteLine("これ以上継承できません");
    }
}
// 以下はコンパイルエラーになる
// public class DerivedClass : FinalClass
// {
// }
class Program
{
    static void Main()
    {
        FinalClass obj = new FinalClass();
        obj.ShowMessage();
    }
}
これ以上継承できません

sealedは特に、以下のようなケースで役立ちます。

  • クラスの設計が完成していて、拡張を許可したくない場合
  • セキュリティや安定性の観点から、継承による振る舞いの変更を防ぎたい場合
  • パフォーマンス最適化のために、JITコンパイラに継承がないことを伝えたい場合

また、メソッド単位でもsealedを使えます。

これは、派生クラスでオーバーライドしたメソッドをさらに派生クラスでオーバーライドできないようにするものです。

以下の例をご覧ください。

public class BaseClass
{
    public virtual void Display()
    {
        Console.WriteLine("BaseClassのDisplay");
    }
}
public class DerivedClass : BaseClass
{
    public sealed override void Display()
    {
        Console.WriteLine("DerivedClassのDisplay(これ以上オーバーライド不可)");
    }
}
public class FurtherDerivedClass : DerivedClass
{
    // 以下はコンパイルエラーになる
    // public override void Display()
    // {
    //     Console.WriteLine("FurtherDerivedClassのDisplay");
    // }
}
class Program
{
    static void Main()
    {
        BaseClass obj = new DerivedClass();
        obj.Display();
    }
}
DerivedClassのDisplay(これ以上オーバーライド不可)

このように、sealedを使うことで継承やオーバーライドの範囲を制御し、設計の意図を明確にできます。

継承を使う際は、必要に応じてsealedを活用して安全でわかりやすいクラス設計を心がけましょう。

ポリモーフィズム入門

基底型参照と動的ディスパッチ

C#のポリモーフィズムは、基底クラスの参照を使って派生クラスのオブジェクトを操作し、実行時に適切なメソッドが呼び出される仕組みです。

これを実現するのが「動的ディスパッチ」と呼ばれる機能です。

たとえば、基底クラスAnimalに仮想メソッドMakeSoundを定義し、派生クラスDogCatでそれをオーバーライドします。

基底クラス型の変数に派生クラスのインスタンスを代入しても、実際に呼ばれるのは派生クラスのメソッドです。

public class Animal
{
    public virtual void MakeSound()
    {
        Console.WriteLine("動物の鳴き声");
    }
}
public class Dog : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("ワンワン");
    }
}
public class Cat : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("ニャー");
    }
}
class Program
{
    static void Main()
    {
        Animal animal1 = new Dog();
        Animal animal2 = new Cat();
        animal1.MakeSound(); // DogのMakeSoundが呼ばれる
        animal2.MakeSound(); // CatのMakeSoundが呼ばれる
    }
}
ワンワン
ニャー

この例では、animal1animal2はどちらもAnimal型ですが、実際に呼ばれるメソッドはそれぞれの派生クラスのMakeSoundです。

これが動的ディスパッチの効果で、実行時にオブジェクトの実際の型に基づいてメソッドが選択されます。

この仕組みにより、同じ基底クラス型の変数を使っても、異なる派生クラスの振る舞いを柔軟に扱えます。

これがポリモーフィズムの基本的な動作です。

アップキャストとダウンキャストの安全性

ポリモーフィズムを活用する際、基底クラス型への変換(アップキャスト)と派生クラス型への変換(ダウンキャスト)が頻繁に行われます。

アップキャスト

アップキャストは、派生クラスのオブジェクトを基底クラス型の変数に代入する操作です。

これは暗黙的に行われ、安全で問題ありません。

Dog dog = new Dog();
Animal animal = dog; // アップキャスト(暗黙的)
animal.MakeSound();  // DogのMakeSoundが呼ばれる

アップキャストにより、派生クラスのオブジェクトを基底クラスのインターフェースとして扱えます。

ダウンキャスト

ダウンキャストは、基底クラス型の変数を派生クラス型に変換する操作です。

これは明示的なキャストが必要で、実行時に型の安全性がチェックされます。

Animal animal = new Dog();
Dog dog = (Dog)animal; // ダウンキャスト(明示的)
dog.Bark();

もしanimalが実際にはDog以外の型だった場合、InvalidCastExceptionが発生します。

安全にダウンキャストを行うには、is演算子やas演算子を使う方法があります。

Animal animal = new Cat();
if (animal is Dog dog)
{
    dog.Bark();
}
else
{
    Console.WriteLine("Dog型ではありません");
}
Dog型ではありません

または、

Dog dog = animal as Dog;
if (dog != null)
{
    dog.Bark();
}
else
{
    Console.WriteLine("Dog型ではありません");
}

このように、ダウンキャストは型の安全性を意識して行う必要があります。

誤ったキャストは例外を引き起こすため、事前に型チェックを行うことが推奨されます。

実行時バインディングの流れ

C#の仮想メソッド呼び出しは、コンパイル時ではなく実行時にどのメソッドを呼ぶか決定されます。

これを「実行時バインディング」と呼びます。

仮想メソッドには、基底クラスのメソッドテーブル(vtable)に派生クラスのオーバーライド情報が登録されます。

実行時にオブジェクトの型を参照して、適切なメソッドのアドレスがvtableから取得され、呼び出されます。

以下の流れで動作します。

  1. 変数の型は基底クラスAnimalであるが、実際のオブジェクトはDog
  2. MakeSoundメソッド呼び出し時に、実行時にオブジェクトの型を調べます。
  3. DogのvtableからMakeSoundのアドレスを取得。
  4. DogMakeSoundが呼び出されます。

この仕組みのおかげで、基底クラス型の変数を使っても、派生クラスのメソッドが正しく呼ばれます。

仮想メソッドでない場合は、コンパイル時に呼び出すメソッドが決定されるため、基底クラスのメソッドが呼ばれます。

これが静的バインディングです。

public class Animal
{
    public void Eat()
    {
        Console.WriteLine("Animalが食べる");
    }
    public virtual void MakeSound()
    {
        Console.WriteLine("Animalの鳴き声");
    }
}
public class Dog : Animal
{
    public new void Eat()
    {
        Console.WriteLine("Dogが食べる");
    }
    public override void MakeSound()
    {
        Console.WriteLine("ワンワン");
    }
}
class Program
{
    static void Main()
    {
        Animal animal = new Dog();
        animal.Eat();        // AnimalのEatが呼ばれる(静的バインディング)
        animal.MakeSound();  // DogのMakeSoundが呼ばれる(動的バインディング)
    }
}
Animalが食べる
ワンワン

この例では、Eatは仮想メソッドではないため、基底クラスのEatが呼ばれます。

一方、MakeSoundは仮想メソッドなので、実行時にDogのオーバーライドが呼ばれます。

このように、実行時バインディングはポリモーフィズムの根幹であり、柔軟なオブジェクト指向設計を支えています。

virtual と override のルール

キーワード構文と制約

C#でメソッドのオーバーライドを行うには、基底クラスのメソッドにvirtualキーワードを付け、派生クラスでoverrideキーワードを使って再定義します。

これにより、基底クラスのメソッドを派生クラスで動的に置き換えられます。

public class BaseClass
{
    public virtual void Show()
    {
        Console.WriteLine("BaseClassのShow");
    }
}
public class DerivedClass : BaseClass
{
    public override void Show()
    {
        Console.WriteLine("DerivedClassのShow");
    }
}

virtualメソッドは、基底クラスでオーバーライド可能なメソッドとして宣言されます。

overrideは、基底クラスのvirtualメソッドを派生クラスで上書きすることを明示します。

アクセス修飾子との組み合わせ

オーバーライドするメソッドのアクセス修飾子は、基底クラスのメソッドと同じか、よりアクセス範囲が広い修飾子でなければなりません。

たとえば、基底クラスのメソッドがprotectedなら、派生クラスのオーバーライドメソッドはprotectedpublicでなければコンパイルエラーになります。

public class BaseClass
{
    protected virtual void Display()
    {
        Console.WriteLine("BaseClassのDisplay");
    }
}
public class DerivedClass : BaseClass
{
    // OK: protected -> public は可
    public override void Display()
    {
        Console.WriteLine("DerivedClassのDisplay");
    }
}

逆に、アクセス範囲を狭めることはできません。

public class BaseClass
{
    public virtual void Display()
    {
        Console.WriteLine("BaseClassのDisplay");
    }
}
public class DerivedClass : BaseClass
{
    // コンパイルエラー: public -> protected は不可
    // protected override void Display()
    // {
    //     Console.WriteLine("DerivedClassのDisplay");
    // }
}

このルールは、オブジェクトの利用者が基底クラスのメソッドを呼び出せる範囲を保証するために重要です。

非同期メソッドをオーバーライドする場合

非同期メソッド(asyncメソッド)もvirtualoverrideを使ってオーバーライドできます。

非同期メソッドの戻り値は通常TaskTask<T>であり、基底クラスと派生クラスで戻り値の型を一致させる必要があります。

public class BaseClass
{
    public virtual async Task<string> GetDataAsync()
    {
        await Task.Delay(100);
        return "BaseClassのデータ";
    }
}
public class DerivedClass : BaseClass
{
    public override async Task<string> GetDataAsync()
    {
        await Task.Delay(50);
        return "DerivedClassのデータ";
    }
}
class Program
{
    static async Task Main()
    {
        BaseClass obj = new DerivedClass();
        string result = await obj.GetDataAsync();
        Console.WriteLine(result);
    }
}
DerivedClassのデータ

非同期メソッドのオーバーライドでは、asyncキーワードは任意ですが、通常は付けて非同期処理を記述します。

戻り値の型やシグネチャは基底クラスと一致させる必要があります。

base キーワードの呼び出し手順

派生クラスのオーバーライドメソッド内で、基底クラスの同名メソッドを呼び出したい場合は、baseキーワードを使います。

これにより、基底クラスの実装を再利用しつつ、追加の処理を加えられます。

public class BaseClass
{
    public virtual void Show()
    {
        Console.WriteLine("BaseClassのShow");
    }
}
public class DerivedClass : BaseClass
{
    public override void Show()
    {
        Console.WriteLine("DerivedClassのShow開始");
        base.Show(); // 基底クラスのShowを呼び出す
        Console.WriteLine("DerivedClassのShow終了");
    }
}
class Program
{
    static void Main()
    {
        DerivedClass obj = new DerivedClass();
        obj.Show();
    }
}
DerivedClassのShow開始
BaseClassのShow
DerivedClassのShow終了

baseを使うことで、基底クラスの処理を明示的に呼び出せるため、オーバーライド時に処理の拡張や前後処理の追加が簡単にできます。

new キーワードによる隠蔽との違い

overrideとは異なり、newキーワードを使うと基底クラスのメソッドを隠蔽(シャドウイング)します。

これはオーバーライドではなく、基底クラスのメソッドとは別のメソッドとして扱われます。

public class BaseClass
{
    public void Show()
    {
        Console.WriteLine("BaseClassのShow");
    }
}
public class DerivedClass : BaseClass
{
    public new void Show()
    {
        Console.WriteLine("DerivedClassのShow");
    }
}
class Program
{
    static void Main()
    {
        BaseClass baseObj = new DerivedClass();
        baseObj.Show(); // BaseClassのShowが呼ばれる
        DerivedClass derivedObj = new DerivedClass();
        derivedObj.Show(); // DerivedClassのShowが呼ばれる
    }
}
BaseClassのShow
DerivedClassのShow

この例では、baseObjBaseClass型なので、Show呼び出しは基底クラスのメソッドになります。

DerivedClassShowは隠蔽されているだけで、動的ディスパッチの対象ではありません。

newは、基底クラスのメソッドを意図的に隠したい場合に使いますが、ポリモーフィズムを活かしたい場合はvirtual/overrideを使うべきです。

キーワード動作内容動的ディスパッチ基底クラス参照での呼び出し時の挙動
virtual仮想メソッドとして宣言あり派生クラスのオーバーライドメソッドが呼ばれる
override仮想メソッドのオーバーライドあり派生クラスのメソッドが呼ばれる
newメソッドの隠蔽(シャドウイング)なし基底クラスのメソッドが呼ばれる

この違いを理解して使い分けることで、意図した動作を正確に実装できます。

抽象クラスとインターフェースの使い分け

抽象メソッドで実装を強制

抽象クラスは、基底クラスとしての役割を持ちつつ、派生クラスに特定のメソッドの実装を強制したい場合に使います。

抽象クラス内でabstract修飾子を付けたメソッドは、実装を持たず、派生クラスで必ずオーバーライドしなければなりません。

public abstract class Animal
{
    // 抽象メソッド(実装なし)
    public abstract void MakeSound();
    // 通常のメソッド(共通実装)
    public void Eat()
    {
        Console.WriteLine("食べる");
    }
}
public class Dog : Animal
{
    // 抽象メソッドの実装を強制される
    public override void MakeSound()
    {
        Console.WriteLine("ワンワン");
    }
}
class Program
{
    static void Main()
    {
        Dog dog = new Dog();
        dog.Eat();        // 基底クラスの共通メソッド
        dog.MakeSound();  // 派生クラスで実装した抽象メソッド
    }
}
食べる
ワンワン

抽象クラスは、共通の処理を持ちながら、派生クラスに必須のメソッド実装を強制できるため、設計の柔軟性と安全性を両立できます。

抽象クラス自体はインスタンス化できません。

デフォルト実装付きインターフェース

C# 8.0以降、インターフェースでもメソッドのデフォルト実装が可能になりました。

これにより、インターフェースに共通の処理を持たせつつ、必要に応じて派生クラスでオーバーライドできます。

public interface IAnimal
{
    void MakeSound();
    // デフォルト実装
    void Eat()
    {
        Console.WriteLine("食べる");
    }
}
public class Cat : IAnimal
{
    public void MakeSound()
    {
        Console.WriteLine("ニャー");
    }
    // Eatはデフォルト実装を利用
}
class Program
{
    static void Main()
    {
        IAnimal cat = new Cat();
        cat.Eat();        // インターフェースのデフォルト実装
        cat.MakeSound();  // Catの実装
    }
}
食べる
ニャー

デフォルト実装付きインターフェースは、既存のインターフェースに機能を追加したい場合や、多重継承的に共通処理を共有したい場合に便利です。

ただし、抽象クラスのように状態(フィールド)を持つことはできません。

多段階継承と依存関係の整理

抽象クラスは多段階継承が可能で、基底抽象クラスから派生抽象クラスを経て具体クラスへと継承を深められます。

これにより、段階的に機能を拡張し、依存関係を整理できます。

public abstract class Animal
{
    public abstract void MakeSound();
}
public abstract class Mammal : Animal
{
    public void Walk()
    {
        Console.WriteLine("歩く");
    }
}
public class Dog : Mammal
{
    public override void MakeSound()
    {
        Console.WriteLine("ワンワン");
    }
}
class Program
{
    static void Main()
    {
        Dog dog = new Dog();
        dog.Walk();       // Mammalのメソッド
        dog.MakeSound();  // Dogの実装
    }
}
歩く
ワンワン

一方、インターフェースは多重継承が可能で、複数のインターフェースを実装することで機能を組み合わせられます。

これにより、依存関係を柔軟に設計できます。

public interface IWalker
{
    void Walk();
}
public interface ISoundMaker
{
    void MakeSound();
}
public class Dog : IWalker, ISoundMaker
{
    public void Walk()
    {
        Console.WriteLine("歩く");
    }
    public void MakeSound()
    {
        Console.WriteLine("ワンワン");
    }
}
class Program
{
    static void Main()
    {
        Dog dog = new Dog();
        dog.Walk();
        dog.MakeSound();
    }
}
歩く
ワンワン

抽象クラスは状態や共通実装を持ちつつ、継承階層を整理したい場合に適しています。

インターフェースは複数の役割を柔軟に組み合わせたい場合に有効です。

設計の目的や依存関係の複雑さに応じて使い分けることが重要です。

設計原則と継承

リスコフの置換原則 (LSP)

リスコフの置換原則(Liskov Substitution Principle、LSP)は、オブジェクト指向設計の基本原則の一つで、「派生クラスのオブジェクトは基底クラスのオブジェクトと置き換えてもプログラムの正しさが保たれるべき」という考え方です。

具体的には、基底クラスの型で期待される動作や契約を、派生クラスも必ず満たさなければなりません。

これを守らないと、ポリモーフィズムの恩恵が失われ、予期しないバグや動作不良が発生します。

public class Rectangle
{
    public virtual int Width { get; set; }
    public virtual int Height { get; set; }
    public int GetArea()
    {
        return Width * Height;
    }
}
public class Square : Rectangle
{
    public override int Width
    {
        get => base.Width;
        set
        {
            base.Width = value;
            base.Height = value; // 正方形なので幅と高さを同じにする
        }
    }
    public override int Height
    {
        get => base.Height;
        set
        {
            base.Width = value;
            base.Height = value;
        }
    }
}
class Program
{
    static void Main()
    {
        Rectangle rect = new Square();
        rect.Width = 5;
        rect.Height = 10;
        Console.WriteLine(rect.GetArea()); // 期待値は50だが、実際は100になる
    }
}
100

この例では、SquareRectangleを継承していますが、SquareWidthHeightの設定がRectangleの期待する動作と異なり、GetAreaの結果が予想と違う値になります。

つまり、SquareRectangleの置換として正しく振る舞っていません。

これがLSP違反の典型例です。

LSPを守るためには、派生クラスは基底クラスの契約(メソッドの振る舞いや副作用)を壊さず、一貫性を保つ必要があります。

設計段階でこの原則を意識し、継承関係を慎重に決めることが重要です。

開放閉鎖原則 (OCP) と拡張性

開放閉鎖原則(Open/Closed Principle、OCP)は、「ソフトウェアのエンティティ(クラス、モジュール、関数など)は拡張に対して開かれており、修正に対して閉じているべき」という原則です。

継承はOCPを実現する強力な手段です。

基底クラスを変更せずに、派生クラスで機能を拡張・変更できるため、既存コードの修正を最小限に抑えられます。

public abstract class Shape
{
    public abstract double GetArea();
}
public class Circle : Shape
{
    public double Radius { get; set; }
    public Circle(double radius)
    {
        Radius = radius;
    }
    public override double GetArea()
    {
        return Math.PI * Radius * Radius;
    }
}
public class Rectangle : Shape
{
    public double Width { get; set; }
    public double Height { get; set; }
    public Rectangle(double width, double height)
    {
        Width = width;
        Height = height;
    }
    public override double GetArea()
    {
        return Width * Height;
    }
}
class Program
{
    static void Main()
    {
        Shape[] shapes = new Shape[]
        {
            new Circle(5),
            new Rectangle(4, 6)
        };
        foreach (var shape in shapes)
        {
            Console.WriteLine($"面積: {shape.GetArea()}");
        }
    }
}
面積: 78.53981633974483
面積: 24

この例では、Shape抽象クラスを基底にして、CircleRectangleがそれぞれの面積計算を実装しています。

新しい図形を追加したい場合は、Shapeを修正せずに新しい派生クラスを作ればよく、既存コードに影響を与えません。

このように、継承とポリモーフィズムを活用することで、拡張に強い設計が可能になります。

過剰継承のアンチパターン

継承は便利ですが、過剰に使うと設計が複雑化し、保守性や理解性が低下します。

過剰継承のアンチパターンには以下のような問題があります。

  • 深すぎる継承階層

継承階層が深くなると、どのクラスでどの機能が実装されているか追いにくくなります。

変更の影響範囲が広がり、バグの原因になりやすいです。

  • 不適切なis-a関係

継承は「is-a」関係を表現するべきですが、無理に継承を使うと意味的に不自然な関係が生まれます。

これにより、LSP違反や予期しない動作が発生します。

  • 多重継承の代替としての乱用

C#は多重継承をサポートしませんが、インターフェースで代替します。

クラス継承で多くの機能を詰め込みすぎると、設計が硬直化します。

public class Vehicle
{
    public virtual void Drive()
    {
        Console.WriteLine("車両が走る");
    }
}
public class FlyingCar : Vehicle
{
    public override void Drive()
    {
        Console.WriteLine("空飛ぶ車が走る");
    }
    public void Fly()
    {
        Console.WriteLine("空を飛ぶ");
    }
}
public class AmphibiousFlyingCar : FlyingCar
{
    public override void Drive()
    {
        Console.WriteLine("水陸両用の空飛ぶ車が走る");
    }
    public void Swim()
    {
        Console.WriteLine("水上を泳ぐ");
    }
}

このように、VehicleFlyingCarAmphibiousFlyingCarと継承階層が深くなり、機能が複雑に混在しています。

設計が複雑化し、将来的な拡張や修正が難しくなる恐れがあります。

過剰継承を避けるためには、継承よりもコンポジション(部品の組み合わせ)を優先したり、インターフェースで役割を分割したりすることが推奨されます。

設計のシンプルさと柔軟性を保つことが重要です。

実践シナリオ集

テンプレートメソッドパターンで処理フロー共通化

テンプレートメソッドパターンは、処理の骨組み(アルゴリズムの流れ)を基底クラスで定義し、具体的な処理の詳細を派生クラスで実装するデザインパターンです。

これにより、共通の処理フローを保ちつつ、部分的な振る舞いを柔軟に変えられます。

抽象基底クラスの構造

抽象基底クラスでは、テンプレートメソッドとして処理の流れを定義し、部分的に抽象メソッドや仮想メソッドを用いて派生クラスでの実装を促します。

public abstract class DataProcessor
{
    // テンプレートメソッド(処理の流れを定義)
    public void Process()
    {
        ReadData();
        ProcessData();
        SaveData();
    }
    // 共通処理(基底クラスで実装)
    protected void ReadData()
    {
        Console.WriteLine("データを読み込む");
    }
    // 派生クラスで実装を強制
    protected abstract void ProcessData();
    // 共通処理(基底クラスで実装)
    protected void SaveData()
    {
        Console.WriteLine("データを保存する");
    }
}

このDataProcessorクラスは、Processメソッドで処理の流れを固定し、ProcessDataだけを派生クラスに任せています。

派生クラスのロジック分岐

派生クラスはProcessDataを具体的に実装し、処理内容を変えられます。

public class CsvDataProcessor : DataProcessor
{
    protected override void ProcessData()
    {
        Console.WriteLine("CSVデータを処理する");
    }
}
public class JsonDataProcessor : DataProcessor
{
    protected override void ProcessData()
    {
        Console.WriteLine("JSONデータを処理する");
    }
}
class Program
{
    static void Main()
    {
        DataProcessor csvProcessor = new CsvDataProcessor();
        csvProcessor.Process();
        Console.WriteLine();
        DataProcessor jsonProcessor = new JsonDataProcessor();
        jsonProcessor.Process();
    }
}
データを読み込む
CSVデータを処理する
データを保存する
データを読み込む
JSONデータを処理する
データを保存する

このように、共通の処理フローは基底クラスで管理し、処理の詳細だけを派生クラスで切り替えられます。

コードの重複を減らし、拡張性を高める効果があります。

UI コンポーネント階層のサンプル設計

UIコンポーネントは多くのアプリケーションで共通の要素であり、継承を使って階層的に設計することが多いです。

以下は、基本的なUIコンポーネントの継承例です。

// 基底クラス
public abstract class UIComponent
{
    public int X { get; set; }
    public int Y { get; set; }
    public abstract void Render();
}
// ボタンコンポーネント
public class Button : UIComponent
{
    public string Text { get; set; }
    public override void Render()
    {
        Console.WriteLine($"ボタンを描画: '{Text}' at ({X},{Y})");
    }
}
// テキストボックスコンポーネント
public class TextBox : UIComponent
{
    public string Content { get; set; }
    public override void Render()
    {
        Console.WriteLine($"テキストボックスを描画: '{Content}' at ({X},{Y})");
    }
}
class Program
{
    static void Main()
    {
        UIComponent button = new Button { X = 10, Y = 20, Text = "送信" };
        UIComponent textBox = new TextBox { X = 15, Y = 25, Content = "入力してください" };
        button.Render();
        textBox.Render();
    }
}
ボタンを描画: '送信' at (10,20)
テキストボックスを描画: '入力してください' at (15,25)

この設計では、UIComponentが共通の位置情報や描画メソッドを持ち、ButtonTextBoxが具体的な描画処理を実装しています。

新しいUI要素を追加する際も、UIComponentを継承してRenderをオーバーライドすれば簡単に拡張できます。

マイクロサービス向けベースクラスの活用例

マイクロサービス開発では、共通の処理や設定をベースクラスにまとめておくと効率的です。

たとえば、APIの共通レスポンス処理やログ記録、例外処理などをベースクラスに実装し、各サービスで継承して使います。

public abstract class BaseService
{
    public void Execute()
    {
        try
        {
            Log("処理開始");
            Run();
            Log("処理成功");
        }
        catch (Exception ex)
        {
            Log($"エラー発生: {ex.Message}");
            HandleError(ex);
        }
    }
    protected abstract void Run();
    protected void Log(string message)
    {
        Console.WriteLine($"[ログ] {DateTime.Now}: {message}");
    }
    protected virtual void HandleError(Exception ex)
    {
        Console.WriteLine("エラー処理を実行");
    }
}
public class OrderService : BaseService
{
    protected override void Run()
    {
        Console.WriteLine("注文処理を実行");
        // ここに注文処理のロジックを実装
    }
}
class Program
{
    static void Main()
    {
        BaseService service = new OrderService();
        service.Execute();
    }
}
[ログ] 2024/06/01 12:00:00: 処理開始
注文処理を実行
[ログ] 2024/06/01 12:00:00: 処理成功

この例では、BaseServiceが共通の実行フローやログ記録、例外処理を持ち、OrderServiceが具体的なビジネスロジックを実装しています。

これにより、サービスごとに共通処理を重複させずに済み、保守性が向上します。

高度な応用テクニック

ジェネリックと共変性・反変性

C#のジェネリックは型安全で柔軟なコードを実現しますが、継承やインターフェースと組み合わせる際に「共変性(Covariance)」と「反変性(Contravariance)」の概念が重要になります。

これらは、ジェネリック型パラメータの型変換のルールを定め、より柔軟な型の互換性を提供します。

共変性(Covariance)

共変性は、派生型から基底型への変換を許可する性質です。

たとえば、IEnumerable<Derived>IEnumerable<Base>に代入可能です。

共変性は出力(戻り値)に使われる型パラメータに適用されます。

public class Animal { }
public class Dog : Animal { }
class Program
{
    static void Main()
    {
        IEnumerable<Dog> dogs = new List<Dog>();
        IEnumerable<Animal> animals = dogs; // 共変性により代入可能
        foreach (var animal in animals)
        {
            Console.WriteLine(animal.GetType().Name);
        }
    }
}
Dog

IEnumerable<out T>のように、outキーワードを使って共変性を宣言します。

共変性は読み取り専用のシナリオで有効です。

反変性(Contravariance)

反変性は、基底型から派生型への変換を許可する性質です。

主に入力(引数)に使われる型パラメータに適用されます。

たとえば、IComparer<Base>IComparer<Derived>に代入可能です。

public class Animal { }
public class Dog : Animal { }
class Program
{
    static void Main()
    {
        IComparer<Animal> animalComparer = Comparer<Animal>.Default;
        IComparer<Dog> dogComparer = animalComparer; // 反変性により代入可能
        int result = dogComparer.Compare(new Dog(), new Dog());
        Console.WriteLine(result);
    }
}
0

IComparer<in T>のように、inキーワードを使って反変性を宣言します。

反変性は書き込みや引数として使う場合に有効です。

ジェネリックインターフェースでの共変性・反変性の宣言例

public interface IProducer<out T>
{
    T Produce();
}
public interface IConsumer<in T>
{
    void Consume(T item);
}

共変性・反変性を正しく使うことで、柔軟で安全な型の互換性を実現し、API設計やライブラリ開発での利便性が向上します。

sealed override による最適化

sealed overrideは、派生クラスでオーバーライドしたメソッドをさらに派生クラスでオーバーライドできないように制限するための修飾子です。

これにより、継承階層の末端でメソッドの振る舞いを固定し、パフォーマンスの最適化や設計の明確化が可能になります。

public class BaseClass
{
    public virtual void Display()
    {
        Console.WriteLine("BaseClassのDisplay");
    }
}
public class DerivedClass : BaseClass
{
    public sealed override void Display()
    {
        Console.WriteLine("DerivedClassのDisplay(これ以上オーバーライド不可)");
    }
}
public class FurtherDerivedClass : DerivedClass
{
    // 以下はコンパイルエラーになる
    // public override void Display()
    // {
    //     Console.WriteLine("FurtherDerivedClassのDisplay");
    // }
}
class Program
{
    static void Main()
    {
        BaseClass obj = new DerivedClass();
        obj.Display();
    }
}
DerivedClassのDisplay(これ以上オーバーライド不可)

sealed overrideを使うことで、JITコンパイラはメソッドの呼び出しを静的に解決しやすくなり、仮想呼び出しのオーバーヘッドを減らせる場合があります。

また、設計上の意図を明確にし、誤ったオーバーライドを防止できます。

プラグインアーキテクチャへの組み込み

継承とオーバーライドはプラグインアーキテクチャの構築においても重要な役割を果たします。

プラグインは共通の基底クラスやインターフェースを実装し、動的にロードして機能を拡張する仕組みです。

基底クラスを使ったプラグイン設計例

public abstract class PluginBase
{
    public abstract string Name { get; }
    public abstract void Execute();
}
public class HelloPlugin : PluginBase
{
    public override string Name => "HelloPlugin";
    public override void Execute()
    {
        Console.WriteLine("Hello, プラグインの世界!");
    }
}
public class GoodbyePlugin : PluginBase
{
    public override string Name => "GoodbyePlugin";
    public override void Execute()
    {
        Console.WriteLine("さようなら、プラグインの世界!");
    }
}
class Program
{
    static void Main()
    {
        List<PluginBase> plugins = new List<PluginBase>
        {
            new HelloPlugin(),
            new GoodbyePlugin()
        };
        foreach (var plugin in plugins)
        {
            Console.WriteLine($"プラグイン名: {plugin.Name}");
            plugin.Execute();
            Console.WriteLine();
        }
    }
}
プラグイン名: HelloPlugin
Hello, プラグインの世界!
プラグイン名: GoodbyePlugin
さようなら、プラグインの世界!

この例では、PluginBaseが共通のインターフェースと処理の枠組みを提供し、各プラグインはExecuteメソッドをオーバーライドして独自の処理を実装しています。

実行時にプラグインのリストをループし、動的に機能を呼び出せます。

動的ロードと拡張

実際のプラグインシステムでは、アセンブリの動的読み込みや依存性注入と組み合わせて、外部からプラグインを追加・削除できるようにします。

継承とオーバーライドは、こうした拡張性の高い設計の基盤となります。

このように、継承とオーバーライドを活用することで、柔軟で拡張性の高いプラグインアーキテクチャを構築できます。

テストとデバッグ

モック化しやすいクラス設計

ユニットテストを効率的に行うためには、テスト対象のクラスがモック化しやすい設計であることが重要です。

モック化とは、外部依存や複雑な処理を持つ部分をテスト用に置き換え、テストの独立性と再現性を高める手法です。

C#でモック化しやすいクラス設計のポイントは以下の通りです。

  • メソッドをvirtualにする

モックフレームワークは、virtualメソッドをオーバーライドして振る舞いを差し替えます。

virtualメソッドはモック化が難しいため、テスト対象のメソッドはvirtualにしておくとよいです。

  • インターフェースを活用する

クラスの依存先をインターフェースで抽象化し、テスト時にモック実装を差し替えられるようにします。

これにより、依存関係の切り離しが進み、テストが容易になります。

  • コンストラクタインジェクションを使う

依存オブジェクトをコンストラクタで受け取る設計にすると、テスト時にモックオブジェクトを注入しやすくなります。

以下は、virtualメソッドを使ったモック化しやすいクラスの例です。

public class DataService
{
    public virtual string GetData()
    {
        // 実際はDBや外部APIからデータ取得する想定
        return "本物のデータ";
    }
}
public class BusinessLogic
{
    private readonly DataService _dataService;
    public BusinessLogic(DataService dataService)
    {
        _dataService = dataService;
    }
    public string Process()
    {
        var data = _dataService.GetData();
        return $"処理結果: {data}";
    }
}

テストコードでは、DataServiceGetDataをモック化して、外部依存を排除できます。

using Moq;
class Program
{
    static void Main()
    {
        var mockDataService = new Mock<DataService>();
        mockDataService.Setup(ds => ds.GetData()).Returns("モックデータ");
        var logic = new BusinessLogic(mockDataService.Object);
        Console.WriteLine(logic.Process());
    }
}
処理結果: モックデータ

このように、virtualメソッドと依存注入を組み合わせることで、テストの独立性と柔軟性が向上します。

オーバーライド漏れの検出パターン

オーバーライド漏れは、基底クラスのvirtualメソッドを派生クラスで実装し忘れたり、意図せずoverrideを付けなかったりすることで発生します。

これにより、期待した動作が行われずバグの原因になります。

漏れを防ぐためのパターンや対策を紹介します。

  • 抽象メソッドを使う

基底クラスでabstractメソッドにすると、派生クラスで必ず実装を強制されるため、漏れを防げます。

public abstract class Animal
{
    public abstract void MakeSound();
}
public class Dog : Animal
{
    public override void MakeSound()
    {
        Console.WriteLine("ワンワン");
    }
}
  • コード解析ツールの活用

静的解析ツールやリントツールを使い、virtualメソッドのオーバーライド漏れを検出できます。

Visual Studioのコード分析機能やReSharperなどが有効です。

  • ユニットテストでのカバレッジ確認

派生クラスのメソッドが呼ばれているかをテストカバレッジで確認し、漏れを早期発見します。

  • overrideキーワードの必須化

C#ではoverrideを付け忘れるとコンパイルエラーになるため、意図しない隠蔽を防げます。

newキーワードを使った隠蔽は注意が必要です。

例外発生時のスタックトレース解析

例外が発生した際、スタックトレースは問題の原因箇所を特定する重要な手がかりです。

C#のスタックトレースは、例外が発生したメソッドの呼び出し履歴を示し、どのクラス・メソッドで例外が起きたかを把握できます。

public class Calculator
{
    public virtual int Divide(int a, int b)
    {
        return a / b; // 0除算の可能性あり
    }
}
public class SafeCalculator : Calculator
{
    public override int Divide(int a, int b)
    {
        if (b == 0)
            throw new ArgumentException("除数は0以外でなければなりません");
        return base.Divide(a, b);
    }
}
class Program
{
    static void Main()
    {
        Calculator calc = new SafeCalculator();
        try
        {
            int result = calc.Divide(10, 0);
            Console.WriteLine(result);
        }
        catch (Exception ex)
        {
            Console.WriteLine("例外発生: " + ex.Message);
            Console.WriteLine("スタックトレース:");
            Console.WriteLine(ex.StackTrace);
        }
    }
}
例外発生: 除数は0以外でなければなりません
スタックトレース:
   at SafeCalculator.Divide(Int32 a, Int32 b)
   at Program.Main()

スタックトレースのポイントは以下です。

  • メソッド名とクラス名

どのメソッドで例外が発生したかがわかります。

  • 呼び出し階層

例外が伝播した経路を追跡できます。

  • 行番号情報

デバッグシンボル(PDBファイル)があれば、例外発生箇所のソースコードの行番号も表示されます。

スタックトレースを活用して、例外の原因を特定し、修正に役立てましょう。

ログにスタックトレースを記録することもトラブルシューティングの基本です。

言語機能の進化

C# 9.0 以降の record と継承

C# 9.0で導入されたrecordは、不変データを表現するための新しい型で、値の比較やコピーが簡単にできる特徴を持ちます。

recordはクラスと同様に継承が可能で、継承を活用したデータモデルの設計が柔軟に行えます。

public record Person(string FirstName, string LastName);
public record Employee(string FirstName, string LastName, string Position)
    : Person(FirstName, LastName);

この例では、EmployeePersonを継承し、追加のプロパティPositionを持っています。

recordはイミュータブル(不変)な性質を持ちつつ、継承によって共通のデータ構造を拡張できます。

class Program
{
    static void Main()
    {
        var emp = new Employee("太郎", "山田", "エンジニア");
        Console.WriteLine(emp);
    }
}
Employee { FirstName = 太郎, LastName = 山田, Position = エンジニア }

recordの継承では、基底recordのコンストラクタを呼び出す形で派生recordを定義し、with式によるコピーや値の比較も継承階層で自然に機能します。

var emp2 = emp with { Position = "マネージャー" };
Console.WriteLine(emp2);
Employee { FirstName = 太郎, LastName = 山田, Position = マネージャー }

このように、recordは継承と組み合わせることで、データの不変性を保ちつつ拡張性の高い設計が可能です。

default interface methods の可能性

C# 8.0で導入されたdefault interface methodsは、インターフェース内にメソッドのデフォルト実装を持てる機能です。

これにより、既存のインターフェースに新しいメソッドを追加しても、既存の実装を壊さずに拡張できます。

public interface ILogger
{
    void Log(string message);
    // デフォルト実装
    void LogWarning(string message)
    {
        Log($"警告: {message}");
    }
}
public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}
class Program
{
    static void Main()
    {
        ILogger logger = new ConsoleLogger();
        logger.Log("通常のログ");
        logger.LogWarning("これは警告");
    }
}
通常のログ
警告: これは警告

この機能により、インターフェースは単なる契約から、部分的に実装を持つ抽象的なクラスのような役割も果たせるようになりました。

多重継承ができないC#において、共通処理の共有手段として有効です。

ただし、デフォルト実装は状態を持てないため、状態管理が必要な場合は抽象クラスとの使い分けが必要です。

static abstract メンバーとジェネリック継承

C# 11で導入されたstatic abstractメンバーは、インターフェースに静的メソッドのシグネチャを定義できる機能です。

これにより、ジェネリック型パラメータに対して静的メソッドの実装を強制でき、より強力なジェネリック継承が可能になります。

public interface IAddable<T>
    where T : IAddable<T>
{
    static abstract T operator +(T left, T right);
}
public struct Vector2D : IAddable<Vector2D>
{
    public int X { get; }
    public int Y { get; }
    public Vector2D(int x, int y) => (X, Y) = (x, y);
    public static Vector2D operator +(Vector2D left, Vector2D right)
        => new Vector2D(left.X + right.X, left.Y + right.Y);
    public override string ToString() => $"({X}, {Y})";
}
class Program
{
    static T Add<T>(T a, T b) where T : IAddable<T>
    {
        return a + b;
    }
    static void Main()
    {
        var v1 = new Vector2D(1, 2);
        var v2 = new Vector2D(3, 4);
        var result = Add(v1, v2);
        Console.WriteLine(result);
    }
}
(4, 6)

この例では、IAddable<T>インターフェースが+演算子の実装を静的抽象メンバーとして定義し、Vector2D構造体がそれを実装しています。

Addメソッドはジェネリック型Tに対して+演算子を使えることを保証し、型安全に加算処理を行います。

static abstractメンバーは、ジェネリックプログラミングの表現力を大幅に向上させ、数学的演算やファクトリーメソッドなどのパターンを型安全に実装できるようにします。

これらの言語機能の進化により、C#の継承やポリモーフィズムはより強力かつ柔軟になり、モダンな設計やライブラリ開発において多様な表現が可能になっています。

まとめ

この記事では、C#の継承とoverrideを中心に、ポリモーフィズムの基本から高度な応用テクニック、設計原則、最新の言語機能まで幅広く解説しました。

継承の仕組みや動的ディスパッチ、virtualoverrideの使い方、抽象クラスやインターフェースの使い分け、テストやデバッグのポイント、さらにC# 9.0以降のrecorddefault interface methodsstatic abstractメンバーなどの進化も理解できます。

これにより、堅牢で拡張性の高いオブジェクト指向設計が実践できるようになります。

関連記事

Back to top button
目次へ