なぜ、継承の理解が重要なのか、その理由

この記事では、当社の新人エンジニア研修の参考にC#を解説します。

前回は「インスタンスの活用」を学びました。今回は、継承(inheritance)について解説します。

継承を使うことで異なるクラスを「一つのグループ」として扱えるようになり、コードの再利用性や拡張性を高めます。


1. すべてのクラスの親クラス object

  • すべてのクラスはデフォルトでobjectを継承するか、あるいは別のクラスを継承し、その先祖をたどれば最終的にobjectに行き着きます。

objectクラスが持っている代表的なメソッドには ToString(), Equals(), GetHashCode(), GetType() などがあります。すべてのクラスがこれらを使える(もしくはオーバーライドできる)のは、継承の仕組みのおかげです。


2. 継承(:)とは何か?

C#ではあるクラスが他のクラスを継承するには、「:」記号を使って

class 子クラス : 親クラス

と書きます。
親クラス(スーパークラス、基底クラス)のフィールドやメソッドを子クラス(サブクラス、派生クラス)が引き継ぐ仕組みです。

例: 社員クラス Employee とエンジニアクラス Engineer

namespace Chap10
{
    public class Employee
    {
        public int Id;
        public string Name;
    }
}

namespace Chap10
{
    public class Engineer : Employee
    {
        public string Skill;
    }
}

使ってみる

namespace Chap10
{
    public class Example01
    {
        public static void Main()
        {
            Engineer se = new Engineer();
            se.Id = 1;      // `Employee`で定義したフィールドを利用
            se.Name = "今井";
            se.Skill = "C#";

            Console.WriteLine($"私は{se.Name}。私のスキルは {se.Skill} です。");
        }
    }
}

<実行結果>

私は今井。私のスキルは C# です。
  • EngineerEmployee を継承しているため、EngineerIdName持っていないように見えても利用できます。

クラス図

継承関係を示すクラス図では、矢印を用いて示されることが多いです。

+-----------+         +-----------+
| Employee  |         | Engineer  |
|-----------|         |-----------|
| Id        | <------ | (inherits Employee)
| Name      |         | Skill     |
| (methods) |         | (methods) |
+-----------+         +-----------+
  • EngineerEmployee を継承

継承のメリット:

  1. 差分プログラミング: 子クラスは親クラスを再利用しつつ、新しい機能や変更のみを追加できる。
  2. 親クラスの変数で子クラスを扱える: 「エンジニアは一種の社員」(is-a関係) なら、Employee e = new Engineer(); のように書けます。

3. 親クラスの変数で子クラスを扱う

「サブクラスは一種のスーパークラス」(is-a関係)

C#では、例えば Car : Vehicle と定義した場合、車は一種の乗り物なので、Vehicle v = new Car(); が可能です。

namespace Chap10
{
    public class Vehicle
    {
        public virtual void Run()
        {
            Console.WriteLine("I'm running.");
        }
    }

    public class Car : Vehicle
    {
    }

    public class Example02
    {
        public static void Main()
        {
            Vehicle v1 = new Car();
            v1.Run();  // "I'm running."
        }
    }
}

ここで Car クラスに特別な動作を追加していなければ、Vehicle側の Run()メソッドが呼び出されます。C#ではメソッドをオーバーライドするには override と書き、親のメソッドに virtual(または abstract) としておく必要があります。


Vehicle v = new Airplane();

サブクラスをスーパークラスの参照で扱えるのは嬉しい点ですが、一方で、サブクラス固有のメソッドは呼び出せません。例えば、

namespace Chap10
{
    public class Airplane : Vehicle
    {
        public void Fly()
        {
            Console.WriteLine("I'm flying.");
        }
    }

    public class Example03
    {
        public static void Main()
        {
            Vehicle v = new Airplane();
            // v.Fly(); // コンパイルエラー:VehicleにFlyがない
        }
    }
}


キャストしてサブクラスのメソッドを呼ぶ

実体は Airplane でも、型が Vehicle だと Fly() は呼べません。キャストを使って「(Airplane)v」に変換すれば使えます。

Vehicle v = new Airplane();
Airplane a = (Airplane)v;
a.Fly();  // "I'm flying."

ただし、実体が本当に Airplane でないなら**InvalidCastException** となります。


4. メソッドのオーバーライド (Override)

継承で最も重要な機能がオーバーライドです。オーバーライドとは、親クラスのメソッドを子クラスで“上書き”して、処理内容を変えること。C#では下記のように書きます。

namespace Chap10
{
    public class Vehicle
    {
        public virtual void Run()
        {
            Console.WriteLine("I'm running.");
        }
    }

    public class RacingCar : Vehicle
    {
        public override void Run()
        {
            Console.WriteLine("I'm running fast.");
        }
    }

    public class Example05
    {
        public static void Main()
        {
            Vehicle v = new RacingCar();
            v.Run();  // "I'm running fast."
        }
    }
}

<実行結果>

I'm running fast.

このように同じRun()メソッドでも、子クラスでは別の動作をさせられます。
ここで親型変数 Vehicle v で子のRacingCarを扱いつつ、実行時には子クラスのRun()を呼ぶ、これがオブジェクト指向のポリモーフィズム(多態性)です。


ポリモーフィズムの利点

たとえば乗り物というスーパークラスを定義し、各種サブクラス(車、飛行機、船、サーフィン…)をRun()で走る・飛ぶ・泳ぐ… とオーバーライドさせれば、Vehicle[] 配列に全種類の乗り物を混在させても Run() の呼び出しが可能です。

namespace Chap10
{
    public class PatrolCar : Vehicle
    {
        public override void Run() => Console.WriteLine("I'm running.");
    }

    public class Rocket : Vehicle
    {
        public override void Run() => Console.WriteLine("I'm flying.");
    }

    public class Ship : Vehicle
    {
        public override void Run() => Console.WriteLine("I'm sailing.");
    }

    public class Surfing : Vehicle
    {
        public override void Run() => Console.WriteLine("I'm surfing.");
    }

    public class Example06
    {
        public static void Main()
        {
            Vehicle[] vs = { new PatrolCar(), new Rocket(), new Ship(), new Surfing() };
            foreach (Vehicle v in vs)
            {
                v.Run();
            }
        }
    }
}

<実行結果>

I'm running.
I'm flying.
I'm sailing.
I'm surfing.

このように同一のインターフェース (Run())を呼んでいるにも関わらず、実際の動作がインスタンスによって異なる。これが多態性 (polymorphism) の大きな強みです。


5. ToString() オーバーライド

前に学んだ System.Object.ToString() は、C#では「クラス名」や「名前空間.クラス名」を返すデフォルト実装になっています。
stringDateTime などは自前で ToString() をオーバーライドして、分かりやすい文字列を返しています。

自作クラスでも、ToString() をオーバーライドすれば Console.WriteLine(myObj) の表示内容を制御できます。

namespace Chap10
{
    public class Bicycle : Vehicle
    {
        public int RegistryNumber;

        public Bicycle(int registryNumber)
        {
            this.RegistryNumber = registryNumber;
        }

        public override string ToString()
        {
            return $"私の防犯登録番号は {RegistryNumber} です。";
        }
    }

    public class Example07
    {
        public static void Main()
        {
            Bicycle bi = new Bicycle(1234);
            Console.WriteLine(bi);  // => "私の防犯登録番号は 1234 です。"
        }
    }
}

多くの場合、クラスを作成したらToString()をオーバーライドしておくのが定番です。デバッグやログ出力で役立つからです。


6. コンストラクタ呼び出し順序 (初期化の流れ)

親クラスから順番に初期化

C#でも Java同様に、子クラスのコンストラクタが呼ばれるとき、先に親クラスのコンストラクタが呼ばれるというルールがあります(base(...)で明示または暗黙的に呼び出し)。

namespace Chap10
{
    public class Parent
    {
        public Parent()
        {
            Console.WriteLine("Hello from SuperClass");
        }
    }

    public class Child : Parent
    {
        public Child()
        {
            Console.WriteLine("Hello from SubClass");
        }
    }

    public class Example08
    {
        public static void Main()
        {
            Child c = new Child();
        }
    }
}

<実行結果>

Hello from SuperClass
Hello from SubClass

親 → 子 の順にコンストラクタが実行されました。
この仕組みをコンストラクタチェーンと呼ぶこともあります。


7. 継承は「最後の手段」

Java同様、C#も単一継承です。1つのクラスは1つの基底クラスしか持てません。
C++のような多重継承(複数クラスから継承)はできません。「diamond problem」などの複雑性を避けるためです。

is-a vs. has-a

  • Car is a Vehicle → 継承(:
  • Car has an Engine → フィールドやプロパティで持つ関係

オブジェクト指向設計で継承を使う場合、「子クラスは親クラスの一種か? (is-a)」をよく考え、単に他クラスのメソッドを使いたいから継承するのは避けるべきです。

もしメソッドを使うだけなら「委譲 (delegation)」や「has-a 関係」を使うほうが無理がありません。

public class SomeClass
{
    private Engine engine = new Engine(); // has-a

    public void Drive()
    {
        engine.Start();
        // ...
    }
}

継承を用いると、親クラスの変更が子クラスに波及したり、意図しないメンバーが子クラスに引き継がれるリスクがあります。


8. InvalidCastException

子クラスインスタンスを親クラスの変数に代入できるのが継承の特長ですが、に親クラスインスタンスを子クラス型にキャストするときは要注意です。
Vehicle v = new Vehicle(); Car c = (Car)v; のように「乗り物は一種の車?」は成立しないため、InvalidCastException が発生します。


<まとめ:隣の人に正しく説明できたらチェックを付けましょう>

□ すでにあるクラスのフィールドやメソッドを新しいクラスが引き継ぐ仕組みを「継承」と呼ぶ。このとき、サブクラス(派生クラス)はスーパークラス(基底クラス)の一種として扱うことが重要である。これにより、型の互換性やコードの再利用が可能になる。

□オーバーライドとは、派生クラスで基底クラスのメソッドを上書きすることである。これによりポリモーフィズム(多態性)が実現される。ポリモーフィズムとは、同じ名前のメソッドに異なる処理をさせることを指す。C#では、基底クラスでvirtualを指定し、派生クラスでoverrideを用いる。

□オリジナルのクラスを作成したら、ToString()メソッドをオーバーライドするのが推奨される。ToString()はオブジェクトを文字列化する際に利用され、デバッグやログ出力で役立つ。

□メソッドの引数を基底クラス型に設計することで、派生クラス型のオブジェクトを引数として受け取れるようになる。これをポリモーフィズムを活用した設計と言い、柔軟で拡張性の高いコードを実現できる。

□「is-a」か「has-a」で迷ったら「has-a」を使う方が良い。「is-a」は継承の関係を、「has-a」はコンポジション(別クラスをフィールドとして保持する)を指す。無理に継承を使わず、コンポジションで対応することでクラス間の関係をシンプルに保つことができる。

□クラス設計では、クラスの責務を明確にすることが重要。一つのクラスが多くの責務を持つと、保守が難しくなるため、単一責任の原則を意識して設計する。

まとめができたら、アウトプットとして演習問題にチャレンジしましょう。

以上、今回は「継承を使ってクラスをグループ化する」方法について見てきました。

継承を使ってクラスをグループ化する 最後までお読みいただきありがとうございます。