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

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

前回は「インスタンスでデータと処理を再利用可能部品にする」について学びました。いよいよオブジェクト指向らしくなってきました。

今回は、更にオブジェクト指向らしい概念である継承(inheritance)について解説します。

継承を使うことで異なるクラスを一定のグループとして扱えるようになります。乗り物を例に取れば、タクシーやバス、客船など色々な乗り物があります。これらはそれぞれ違ったクラスと考えることもできますが、大きく乗り物という一つのカテゴリに分類できますね。そうすると同じ性質や動作をもっているといえます。それらの乗り物を同じグループとして扱えるのが継承です。

継承はとても巧妙な仕組みです。ただし、新人エンジニアの皆さんには少し直感的に分かりにくいところがあります。手続き指向のプログラムではソースコードをざっと読めばどのような処理をしているかが一目瞭然なのですが、オブジェクト指向では一見、自分のクラスが持っていないはずのメンバーを使うことができたりするのです。そのあたりのところを今回はお話ししたいと思います。

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

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

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

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

継承とは、すでにあるクラスのフィールドやメソッドを新しいクラスが引き継ぐことをいいます。英語では“inheritance”で「継承や相続」といった意味があります。

継承を使うことで機能の拡張ができることから拡張と呼ばれることもあります。拡張は英語で“extends”です。皆さんは髪の毛のエクステを知っていますか?それがextendsのイメージです。

この継承の仕組みは皆さんも利用することが可能です。

ただし、何でもかんでもクラス間に継承関係をつくってフィールドやメソッドを取り込めばよいかというとそれは違います。そこには論理的必然性が必要になります。そうでないと体系がこんがらがってしまいます。

継承関係を作る際の基準としては、

サブクラスは一種のスーパークラスである

と言えるかどうかが重要です。

例えば、

車は一種の「乗り物」です。
飛行機は一種の「乗り物」です。
船は一種の「乗り物」です。
よって「乗り物」は車、飛行機、船のスーパークラスです、という具合に。

「サブクラスは一種のスーパークラスである」というのを簡潔に「is-a」関係といいますので覚えておいてください。

A car is a Vehicle.
An airplane is a Vehicle.
A ship is a Vehicle.

というわけですね。

スーパークラスには共通のメソッドを定義して、サブクラスにそれを拡張できるようにします。

乗り物クラスに共通のメソッドの例としては、例えば、

  • 進む
  • 止まる
  • 人を乗せる

等が考えられるでしょう。

皆さんも身の回りで継承関係が作れそうなオブジェクトの例を考えてみてください。

あなたが考えた継承関係の例:

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# です。

Employeeクラスのフィールドはidとname。Engineerクラスで宣言したフィールドはskill。EngineerクラスはEmployeeクラスをextendsしているため3つのフィールドを持っているのです。しかし、ぱっと見はクラスの持っているフィールドやメソッドが分かりにくいですね。

そこで登場するのが下図のようなクラス図というものです。

新人エンジニア研修で継承のクラス図を説明
継承のクラス図

クラス図ではクラスを四角形で表します。四角形を3つに区切って、一番上がクラス名、真ん中がフィールド、一番下がメソッドです。今回の2つのクラスにはメソッドがありませんので空欄になっています。

そして、2つのクラスの関連である継承関係を白抜きの実線矢印で表現します。クラス図の矢印の向きは重要です。なぜなら、依存関係を表現するからです。上記の例で言えば、親クラスが無いと子クラスが成立しません。一方、子クラスがなくても親クラスは成立します。これが依存関係です。

なお、Objectクラスのみは「:」を書かなくても継承されます。

すべてのクラスが自動的にObjectクラスの子孫クラスになることが保証されていますので、わざわざクラス図で表現しません。(さらに詳細説明は研修の目的に応じて講師からいたします)

このクラス図により、Engineerクラスには3つのフィールドがあることが明らかになります。

このあともいろいろなパターンが出てきますのでその都度ご紹介しますね。

継承のメリットは以下の2点です。

1.サブクラスはスーパークラスの差分だけをプログラミングすればよい。
※これを差分プログラミングといいますが、現在ではこれをメリットと捉えない考え方も有力です。
2.サブクラスをスーパークラスの変数で扱える。
※これを応用してポリモーフィズムを実現します。これは明らかにメリットです。

1については上記の例で簡単に説明できましたので、2について説明します。

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

サブクラス達をスーパークラスの参照で扱うことで複数のクラスを仲間として扱うことができます。

C#では、すべてのクラスのスーパークラスにはObjectクラスがあります。つまり、すべてのクラスにはObjectクラスのメソッドがあるということになります。書いてないけれど使える。それが継承(拡張)の仕組みでした。

ただし、以下の点に留意が必要でした。

「サブクラスは一種のスーパークラスである」(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. コンストラクタ呼び出し順序 (初期化の流れ)

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

子クラスのコンストラクタが呼ばれるとき、先に親クラスのコンストラクタが呼ばれるというルールがあります(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. 継承は「最後の手段」

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」はコンポジション(別クラスをフィールドとして保持する)を指す。無理に継承を使わず、コンポジションで対応することでクラス間の関係をシンプルに保つことができる。

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

次回は、「11章. カプセル化と情報隠蔽で部品の完成度を高める」を学びます。