なぜ、多態性の理解が重要なのか、その理由

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

前回は「オブジェクト指向基礎② 継承」について学びました。今回は多態性(たたいせい)について解説します。この用語を初めて聞く方も多いでしょう。多態性は、英語でポリモーフィズム(polymorphism)です。poly(色々な)morph(変化をする)ism(性質)という意味です。いかにも難しそうですが、オブジェクト指向の理解には必須の知識ですし、仕組みがわかれば大変便利で有用な技術です。しっかりと理解しましょう。

多態性を一言でざっくり説明すると、同じ名前のメソッドに色々な動作をさせるための仕組みです。

例えば、親クラスと全く同じメソッドを子クラスにも持たせて違う動作をさせたり、一つのクラスに同じ名前で引数が違うメソッドをいくつも作ったりすることができるのです。まずは、概要を見てみましょう。

1. 多態性の概要と分類

多態性を大きく分類すると、次の二種類になります。

  1. 実行時の多態性 - 子クラス毎の同名メソッドが実行時に動的に選ばれて呼び出される- メソッドのオーバーライド -
  2. コンパイル時の多態性 - 一つのクラスが違う引数を取る複数の同名メソッドを持てる- メソッドのオーバーロード

以下、それぞれを詳しく解説していきます。

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

オーバーライドは、実行時の多態性、又は動的多態性と呼ばれます。親クラスと同じメソッドを子クラスにも持たせ、実行時に呼び出すメソッドをその場で選んで子クラス毎に違う動作をさせることを指します。実行する時に動作が決まるので、実行時多態性と呼ばれています。こう書くと、子クラスのメソッドが親クラスのメソッドを上書きして消してしまうようにも思えますが、そうではありません。親クラスのメソッドも消滅せずにちゃんと機能しますし、当然呼び出すことも可能です。なので、共通の処理を親クラスのメソッドに持たせておき、独自の処理だけを子クラスのメソッドに記述することもできます。

オーバーライドが成立する条件

C#でオーバーライドを使うルールは、以下の通りです。オーバーライドは大変強力で有用な技術ですが、クラス構成や、親クラスが持つ共通処理と子クラスで持つ独自処理が整合するようにしっかりと設計、実装を行わないと、設計ミスや実装漏れによる深刻な不具合が発生する危険があります(これについては後述します)。なので、C#では、オーバーライドを成立させるための条件を、以下のように定めています。

  1. 親クラスのメソッドが、virtual 又は abstract 宣言されていること-
  2. 子クラスのメソッドが、override宣言されていること
  3. メソッドのシグネチャ(メソッド名、引数の型・数・順序)が親子クラスで完全一致していること
  4. 子クラス側メソッドのアクセス修飾子が親クラス側と同じか、又は親クラス側より広いこと

1.のvirtualは、子クラスでオーバーライドされるメソッドの実装に使います。abstractは抽象メソッドといって、親クラスでは実際の処理を書かずに、子クラスで同じシグネチャのメソッド実装を強制するための宣言です。これは実装漏れを防ぐための仕組みで、親クラスでabstract宣言されたメソッドを子クラスが実装しないと、エラーになってコンパイルできなくなります。

2.は子クラス側のメソッドに記述する宣言です。override宣言が無いメソッドは、たとえ親クラスと同じシグネチャのメソッドであってもオーバーライドとは見なされません。注意しましょう。

3.も厳格にシグネチャを親クラスと全く同じにしなければ、オーバーライドとは認められません。

4.は、子クラスのメソッドの有効範囲を親クラスのメソッドと同じか又は広いスコープに設定する必要がある、ということです。例えば親クラスのメソッドがprotectedだったとすると、子クラスのメソッドはprotected又はpublicにする必要があるわけですね。子クラスのメソッドをprivateにはできないわけです。

それでは以上を踏まえて、簡単な実例から見ていきましょう。以下のサンプルは、単純に子クラスのメソッドが親クラスに代わって動作することを体験するためのものです。

using System;

namespace Chap13 {

    using System;
    public class Example01 {
        public static void Run() {
            // RacingCarのインスタンスを生成し、親クラス型の変数に代入する
            Vehicle v = new RacingCar();
            // VehicleではなくRacingCarのMove()メソッドが実行される
            v.Move();  // "I'm running fast."
        }
    }

    // 親クラス
    internal class Vehicle {
        // 親クラスのMoveメソッド
        public virtual void Move() {
            Console.WriteLine("走行中です。");
        }
    }

    // 子クラス
    internal class RacingCar : Vehicle {
        // 子クラスのMoveメソッド
        public override void Move() {
            Console.WriteLine("ぶっ飛ばしてます!");
        }
    }
}

<実行結果>

ぶっ飛ばしてます!

親クラスであるVehicle型の変数にRacingCar型のインスタンスを代入しているのに、RacingCarが持つMoveメソッドが実行されていますね。これが最も単純なオーバーライドです。

実験

上記オーバーライドが成立する条件を確かめてみましょう。どうなりますか?

1. VehicleのMoveメソッドからvirtual宣言を外してみる
2. RacingCarのMoveメソッドからoverride宣言を外してみる
3. RacingCarのMoveメソッドの型をintに変え、0を返してみる
4. RacingCarのMoveメソッドのスコープをprotectedにしてみる

実験結果を簡単に解説しておきます。1はvirtual<->overrideという対になる関係(動的バインディングと言います)が崩れてしまいます。2.はoverrideしないのなら、newを書きなさい(つまり、親クラスと関係の無い新しいメソッドを定義する記述にしなさい)という警告が出ます。ちなみにnew宣言をすると、MoveメソッドはVihecle型で呼び出せば親クラス側のメソッドが、RacingCar型で呼び出せば子クラス側のメソッドが動作するようになります。余裕があれば試してみましょう。3.は、シグネチャが不一致となりエラーが出ます。4.は親メソッドと同様には使えないスコープのメソッドになってしまうため、コンパイルエラーとなっているのです。

※なお、このサンプルでは便宜上三つのクラスを一つのファイルに書いていますが、実務のコーディングでは、このような実装は避けるべきです。小さいクラスであってもnamespaceできちんと整理して、クラスはちゃんと別ファイルに記述するよう心がけましょう。そうしないと、プロジェクト全体でどのクラスがどこにあるのかがわかりにくくなってしまい、これも可読性を下げる原因になるからです。

子クラスのインスタンスを親クラス型の配列に入れてみる

オーバーライドの仕組みを理解するため、次はもう少しだけ複雑な例を見てみましょう。親クラス型の配列に様々な子クラスを入れてループ処理でオーバーライドされたメソッドを実行してみます。前の例で使ったVehicle親クラスを、スポーツカー、ロケット、船、サーフボードの子クラスを継承させ、Move()で走る・飛ぶ・サーフィンする… とオーバーライドさせれば、Vehicle[] 配列に全種類の乗り物を混在させてもそれぞれの子クラスが持つ Move() の呼び出しが可能です。

using System;

namespace Chap13 {

    using System;

    public class Example02 {
        public static void Run() {
            // 各子クラスののインスタンスを生成し、親クラス型の配列に代入する
            Vehicle[] vs = { new RacingCar(), new Rocket(), new Ship(), new Surfboard() };

            // それぞれのMoveメソッドを実行する
            foreach (Vehicle v in vs) {
                v.Move();
            }
        }
    }

    // 親クラス
    internal class Ship : Vehicle {
        // 船クラスのMoveメソッド
        public override void Move() {
            Console.WriteLine("航行中です。");
        }
    }

    // 子クラス
    internal class Surfboard : Vehicle {
        // サーフボードクラスのMoveメソッド
        public override void Move() {
            Console.WriteLine("波乗り絶好調!");
        }
    }

    internal class Rocket : Vehicle {
        // ロケットクラスのMoveメソッド
        public override void Move() {
                Console.WriteLine("打ち上げ順調です。");
        }
    }
}

<実行結果>

ぶっ飛ばしてます!
打ち上げ順調です。
航行中です。
波乗り絶好調!

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

新人エンジニア研修
ポリモーフィズムのイメージ

3. オーバーライドのメリットを体感する

それではいよいよ、ちょっと本格的なオーバーライドの例に触れて、そのメリットを体感していただこうと思います。簡易的なRPGを想定して、敵味方5種類のキャストにランダムな順番で独自のアクションをしてもらおう、というサンプルです。まずは下準備として、以下のファイルをChap13に作ってください。

using System;

namespace Chap13 {

    // 性質(正義、邪悪)の列挙
    public enum Natures {
        // 正義
        Justice,
        // 邪悪
        Evil
    }

    // 列挙型Natureに、反対の性質を得る拡張メソッド
    // 「正義⇔邪悪」のように、
    // 敵味方の立場を反転させる処理が必要なため、
    // 拡張メソッドで反対の性質を取得できるようにしています。
    public static class OppositeNature {
        public static Natures GetOppositeNature(this Natures nature) {
            return nature == Natures.Justice ? Natures.Evil : Natures.Justice;
        }
    }

    // ジョブの列挙
    public enum Jobs {
        // 勇者
        Hero,
        // ヒーラー(味方を回復する)
        Healer,
        // 魔術師
        Magician,
        // 敵のボス
        Boss,
        // 敵の雑魚
        Minion
    }

    // アクション相手の性質の列挙
    public enum Objects {
        // 味方
        Ally,
        // 敵
        Enemy
    }
}

これは、enum(列挙型)で宣言された各種定数を記述したファイルです。ConstはConstants(定数)の略で、Naturesが正義か悪かの性質を、Jobsが勇者や魔導士といったジョブを、Objectsが敵か味方かという相手の種類を定数として宣言しています。GetOppositeNature()メソッドは、各キャストが自分のアクションの対象を選ぶ場合に、敵か味方一人を選べるように、逆の性質の定数(つまり正義なら邪悪を、邪悪なら正義)を得るための追加メソッドです。

using System;
using System.Collections.Generic;

namespace Chap13;
public class Cast {
    /*************************
     * インスタンスメンバー
    **************************/
    // 名前
    public string Name { get; set; }
    // 職業
    public Jobs Job { get; set; }
    // 性質
    public Natures Nature { get; set; }

    // コンストラクタ
    public Cast(string name) {
        this.Name = name;
    }

    // 親クラスのアクション
    public virtual void Action() {
        // 誰のターンかを表示
        Console.WriteLine("\n★" + this.Name + "のターン!");
        // 各子クラスのアクション表示をインデントする
        Console.Write("\t");
    }

    /*************************
      * スタティックメンバー
     **************************/

    // キャストのリスト
    public static List<Cast> Casts = new List<Cast>();

    // シャッフルしたキャストのリストを返す
    // 元のリストの順番を変えずに新たにランダムに並び順を変えたリストを生成する
    public static List<Cast> GetShuffledCasts() {
        return Casts.OrderBy(a => Guid.NewGuid()).ToList(); 
    }

    // 自分と同じ、又は反対の性質を持つ相手を選ぶ
    // 勇者は敵を、ヒーラーは味方を、というように行動の対象を選ぶ
    // 相手が見つからない場合は、nullにならぬよう自身のインスタンスを返す
    // うっかり敵のキャストを作らなかったら勇者が自分を攻撃してしまうので注意
    public static Cast ChooseWhom(Cast me, Objects obj) {

        // リストをシャッフル
        List<Cast> tmp = GetShuffledCasts();

        // 選ぶ相手の性質を決定(味方なら自分と同じ性質、敵なら反対の性質をのキャストを探す)
        Natures targetNature = obj == Objects.Ally ? me.Nature : me.Nature.GetOppositeNature();

        // 味方or敵の、最初のキャストを見つける
        foreach (Cast item in tmp) {
            if (item.Nature == targetNature) {
                return item;
            }
        }

        // 見つからなかった場合は自身のインスタンスを返す
        // 例外で処理が止まらないようにするための安全策です
        return me;
    }
}

次に、各キャストの親クラスです。名前、職業、性質をプロパティとして持ち、スタティックメンバーとしてCastのリストも保持します。コンストラクタは名前だけを引数に取り、残りのプロパティは子クラスで設定する設計です。そして、Actionメソッドがオーバーライドの対象になっていますね。GetShuffledCastsメソッドは元のリストを変えずにランダムな順番に並べ替えたリストを生成するメソッドです。また、ChooseWhomメソッドは、各キャストの行動の対象(敵か味方一人)をリストからランダムに選ぶ処理を行います。

using System;

namespace Chap13;
// 勇者クラス
// Castを継承し、指定した名前の、職業:Hero 性質:正義のキャストとなる
public class Hero : Cast {

    /*************************
     * インスタンスメンバー
    **************************/

    // 親クラスのコンストラクタで名前を設定
    public Hero(string name) : base(name) {
        // 職業:Hero
        this.Job = Jobs.Hero;
        // 性質:正義
        this.Nature = Natures.Justice;
    }

    // 各ターン毎の行動
    // 敵の一人をランダムに選び、攻撃する
    // Cast型でHeroを扱っても、オーバーライドによってこのメソッドが呼び出される
    public override void Action() {
        // 親クラスのActionを呼び出し、ターン表示
        base.Action();
        // 敵の一人をランダムに選ぶ
        Cast whom = Cast.ChooseWhom(this, Objects.Enemy);

        // 行動実行
        DoAction(this, whom);
    }

    /*************************
     * スタティックメンバー
    **************************/

    // 行動処理本体
    public static void DoAction(Hero me, Cast whom) {
        // 行動を表示
        Console.WriteLine(me.Name + "は、" + whom.Name + "を攻撃した!");

        // ここにダメージ計算などの処理を追加できる
    }
}

勇者クラスです。親クラスと子クラスのメソッドの振り分け方を意識してご覧下さい。コンストラクタが親クラスのコンストラクタを利用していて、Actionメソッドもオーバーライドした親クラスのメソッドを呼び出していますね。

また、インスタンスメソッドとスタティックメソッドの振り分けにも注目です。インスタンスメソッドであるActionメソッドはスタティックメソッドのアクション処理本体であるDoActionを自身のインスタンスを引数にして呼び出しています。これが、オブジェクトとメモリ領域で解説した、インスタンスメンバーとスタティックメンバーの振り分け方の実例です。

DoActionメソッドの引数がCast型ではなくHero型になっているのは、型安全なメソッドにするためです。このような設計にすることで、今後、ゲーム性を向上させるための追加機能を記述する余地を盛り込んでいます。CastクラスのChooseWhomメソッドがpublicなstaticメソッドになっているのも、外から呼び出せるユーティリティメソッドとしての役割を持たせるためです。以下、Heroと同様の構成を持つ魔導士、ヒーラー、敵のボス、敵雑魚のクラスが続きます。

using System;

namespace Chap13;

// 魔道士クラス
// Castを継承し、指定した名前の、職業:Magician 性質:正義のキャストとなる
public class Magician : Cast {

    /*************************
     * インスタンスメンバー
    **************************/

    // 親クラスのコンストラクタで名前を設定
    public Magician(string name) : base(name) {
        // 職業:Magician
        this.Job = Jobs.Magician;
        // 性質:正義
        this.Nature = Natures.Justice;
    }

    // 各ターン毎の行動
    // 敵の一人をランダムに選び、魔法をかける
    // Cast型でMagicianを扱っても、オーバーライドによってこのメソッドが呼び出される
    public override void Action() {
        // 親クラスのActionを呼び出し、ターン表示
        base.Action();
        // 敵の一人をランダムに選ぶ
        Cast whom = Cast.ChooseWhom(this, Objects.Enemy);

        // 行動実行
        DoAction(this, whom);

    }

    /*************************
      * スタティックメンバー
     **************************/

    // 行動処理本体
    public static void DoAction(Magician me, Cast whom) {
        // 行動を表示
        Console.WriteLine(me.Name + "は、" + whom.Name + "にファイアを唱えた!");

        // ここにダメージ計算やMP消費などの処理を追加できる
    }
}

using System;

namespace Chap13;

// ヒーラー(回復役)クラス
// Castを継承し、指定した名前の、職業:Healer 性質:正義のキャストとなる
internal class Healer : Cast {

    /*************************
     * インスタンスメンバー
    **************************/

    // 親クラスのコンストラクタで名前を設定
    public Healer(string name) : base(name) {
        // 職業:Healer
        this.Job = Jobs.Healer;
        // 性質:正義
        this.Nature = Natures.Justice;
    }

    // 各ターン毎の行動
    // 味方一人をランダムに選び、回復する
    // Cast型でHealerを扱っても、オーバーライドによってこのメソッドが呼び出される
    public override void Action() {
        // 親クラスのActionを呼び出し、ターン表示
        base.Action();
        // 味方一人をランダムに選ぶ
        Cast whom = Cast.ChooseWhom(this, Objects.Ally);

        // 行動実行
        DoAction(this, whom);
    }

    /*************************
      * スタティックメンバー
     **************************/

    // 行動処理本体
    public static void DoAction(Healer me, Cast whom) {
        // 行動を表示
        Console.WriteLine(me.Name + "は、" + whom.Name + "にキュアを唱えた!");

        // ここに回復量計算やMP消費などの処理を追加できる
    }
}

using System;

namespace Chap13;

// 敵ボスクラス
// Castを継承し、指定した名前の、職業:Boss 性質:邪悪のキャストとなる
public class Boss : Cast {

    /*************************
     * インスタンスメンバー
    **************************/

    // 親クラスのコンストラクタで名前を設定
    public Boss(string name) : base(name) {
        // 職業:Boss
        this.Job = Jobs.Boss;
        // 性質:邪悪
        this.Nature = Natures.Evil;
    }

    // 各ターン毎の行動
    // 味方一人をランダムに選び、命令する
    // Cast型でBossを扱っても、オーバーライドによってこのメソッドが呼び出される
    public override void Action() {
        // 親クラスのActionを呼び出し、ターン表示
        base.Action();
        // 味方一人をランダムに選ぶ
        Cast whom = Cast.ChooseWhom(this, Objects.Ally);

        // 行動実行
        DoAction(this, whom);
    }

    /*************************
      * スタティックメンバー
     **************************/

    // 行動処理本体
    public static void DoAction(Boss me, Cast whom) {
        // 行動を表示
        Console.WriteLine(me.Name + "は、" + whom.Name + "にやっちまえ!と叫んだ!");

        // ここに攻撃対象の指定やデバフなどの処理を追加できる
    }
}

using System;

namespace Chap13;

// 敵雑魚クラス
// Castを継承し、指定した名前の、職業:Minion 性質:邪悪のキャストとなる
public class Minion : Cast {

    /*************************
     * インスタンスメンバー
    **************************/

    // 親クラスのコンストラクタで名前を設定
    public Minion(string name) : base(name) {
        // 職業:Minion
        this.Job = Jobs.Minion;
        // 性質:邪悪
        this.Nature = Natures.Evil;
    }

    // 各ターン毎の行動
    // 敵一人をランダムに選び、攻撃する
    // Cast型でMinionを扱っても、オーバーライドによってこのメソッドが呼び出される
    public override void Action() {
        // 親クラスのActionを呼び出し、ターン表示
        base.Action();
        // 敵一人をランダムに選ぶ
        Cast whom = Cast.ChooseWhom(this, Objects.Enemy);

        // 行動実行
        DoAction(this, whom);
    }

    /*************************
      * スタティックメンバー
     **************************/

    // 行動処理本体
    public static void DoAction(Minion me, Cast whom) {
        // 行動を表示
        Console.WriteLine(me.Name + "は、" + whom.Name + "に襲いかかった!");

        // ここにダメージ計算や逃げ出すなどの処理を追加できる
    }
}

これで、準備が整いました。最初にオーバーライドを使わずに各子クラスに独自のアクションを実行させる例を、次にオーバーライドを使って同じ機能を実装する例をご覧ください。

オーバーライドを使わない例

using System;

namespace Chap13;

// オーバーライドを使わない実装例
// オーバーライドを使わないと、サブクラス(子クラス)の型を判断し、
// それぞれの型に応じて条件分岐を作り、メソッドを呼び出さねばならない
public class Example03 {

    public static void Run() {

        // 各キャストを生成する
        Hero hero = new Hero("勇者");
        Magician magician = new Magician("魔導士");
        Healer healer = new Healer("聖者");
        Boss boss = new Boss("ゴロツキの親玉");
        Minion minion1 = new Minion("ゴロツキ①");
        Minion minion2 = new Minion("ゴロツキ②");

        // キャストリストのクリア
        Cast.Casts.Clear();

        // キャストのリストを生成する
        // 各キャストをnewしてListに追加する
        Cast.Casts.Add(hero);
        Cast.Casts.Add(magician);
        Cast.Casts.Add(healer);
        Cast.Casts.Add(boss);
        Cast.Casts.Add(minion1);
        Cast.Casts.Add(minion2);

        // ターン順をランダムにするため、キャストのリストをシャッフル
        List <Cast> shuffled = Cast.GetShuffledCasts();

        Console.WriteLine("=== オーバーライドを使わない例:キャストの型毎に条件分岐 ===");

        // Castのサブクラス型に対応するインスタンスのActionメソッドを条件分岐で実行
        foreach (Cast me in shuffled) {

            // ターン表示
            Console.WriteLine("\n★" + me.Name + "のターン!");
            Console.Write("\t");

            if (me is Hero) {
                // 相手を選ぶ:勇者は敵を攻撃する
                Cast whom = Cast.ChooseWhom(me, Objects.Enemy);
                Hero.DoAction((Hero)me, whom);

            } else if (me is Magician) {
                // 相手を選ぶ:魔道士は敵に魔法をかける
                Cast whom = Cast.ChooseWhom(me, Objects.Enemy);
                Magician.DoAction((Magician)me, whom);

            } else if (me is Healer) {
                // 相手を選ぶ:聖者は味方を回復する
                Cast whom = Cast.ChooseWhom(me, Objects.Ally);
                Healer.DoAction((Healer)me, whom);

            } else if (me is Boss) {
                // 相手を選ぶ:親玉は手下に命令する
                Cast whom = Cast.ChooseWhom(me, Objects.Ally);
                Boss.DoAction((Boss)me, whom);

            // ゴロツキのインスタンスは2つあるため、更に名前での判別を行う
            } else if (me is Minion && me.Name == "ゴロツキ①") {
                // 相手を選ぶ:ゴロツキは敵を攻撃する
                Cast whom = Cast.ChooseWhom(me, Objects.Enemy);
                Minion.DoAction((Minion)me, whom);

            } else if (me is Minion && me.Name == "ゴロツキ②") {
                // 相手を選ぶ:ゴロツキは敵を攻撃する
                Cast whom = Cast.ChooseWhom(me, Objects.Enemy);
                Minion.DoAction((Minion)me, whom);
            }
        }
    }
}

オーバーライドを使わないと、こうなります。各キャストのインスタンスをリストに追加してランダムな順番に並べ替えるところまではまだ良いのですが、その後の処理がいかにも冗長です。

各キャスト毎のアクションを実行させるために、インスタンスの型チェックをいちいち行わなければなりません。また、HeroのDoActionを呼び出すために、リストから取り出したCast型のインスタンスを、わざわざHero型にキャストする手間も必要です(DoActionメソッドの引数がHero型のため)。

さらに、敵雑魚のインスタンスが二つあるので、全キャストのアクションを実行するためには名前の比較を行ってインスタンスを特定するしかありません。これはオブジェクト指向的な設計からはズレたロジックです。このような「緩い」識別方法は推奨されません。本来なら、このような場合は敵雑魚のデータ型そのものを変えるべきです。

しかし、オーバーライドを使えばこれらの問題が全て一気に解消します。

オーバーライドを使った例

using System;

namespace Chap13;

// オーバーライドのメリット例
public class Example04 {
    public static void Run() {
        Console.WriteLine("=== オーバーライドのメリット例:キャストの独自アクション ===");

        // キャストリストのクリア
        Cast.Casts.Clear();

        // 各キャストをnewしてListに追加する
        Cast.Casts.Add(new Hero("勇者"));
        Cast.Casts.Add(new Magician("魔導士"));
        Cast.Casts.Add(new Healer("聖者"));
        Cast.Casts.Add(new Boss("ゴロツキの親玉"));
        Cast.Casts.Add(new Minion("ゴロツキ①"));
        Cast.Casts.Add(new Minion("ゴロツキ②"));

        // シャッフルしたキャストのリストを得る
        List <Cast> shuffled = Cast.GetShuffledCasts();

        // 順番にターンのアクション
        foreach (Cast cast in shuffled) {
            // Actionメソッドのオーバーライドにより、各キャストが独自の行動を取る
            cast.Action();
        }
    }
}

いかがですか?ずいぶんスッキリして可読性も上がりましたね。型チェックもキャストも名前による判別も不要です。これがオーバーライドの威力です。これを使わない手は無いな、と納得していただけたでしょうか。ただし、前述のようにオーバーライドには気を付けなければならない落とし穴も存在します。

4. オーバーライドの落とし穴

Heroクラスの25行目、親クラスのAction呼び出しコードをコメントアウトしてExample04を実行してみて下さい。勇者のターンだけ、誰のターンかの表示が出なくなりましたね。子クラスのメソッドから親クラスのメソッドを明示的に呼び出さねば、親クラスのメソッドは実行されないのです。とはいえ、もし実際のゲームでこんなことが起こったとしても、バグと言えばバグですが、RPGの文言表示部分だけの問題ですから、このサンプルでは重大な問題にはならないでしょう。

しかしこれが、親クラスのメソッドで重要な処理を行っている場合は話が全く変わってきます。例えばデータベースの接続や、開いたファイルを閉じる処理を呼び忘れていたら?重要なログ(記録)を残す処理が抜けていたら?

詳しい説明はここでは省きますが、上記のようなバグはアプリの動作時すぐに発覚するとは限らず、後日に致命的な障害となって大問題になる可能性があるタイプの大変やっかいな不具合です。お客様に多額の賠償金を支払い、多大な時間を掛けてバグの原因を探ったら、単にいくつかある子クラスのオーバーライドしたメソッドから親クラスのメソッドを呼び忘れていただけだった、などという泣くに泣けない事態にもなりかねないわけです。

オーバーライドは確かに自由で便利、且つ強力な技術です。親クラスと子クラスに処理を分散して実装することにも、DRYに沿ったメリットがあります。しかし、一歩間違えば、設計ミスや実装漏れのポカミスによる重大なバグが発生する危険があることも知っておきましょう。

この危険性を指して、オーバーライドがオブジェクト指向のメリットとは言えないとする議論もありますが、それは的外れな主張だと筆者は思います。どんな技術でも使い方を間違えれば危険が伴います。しっかりした設計と実装、そして漏れの無いテストで、オーバーライドの力をうまく生かした堅牢な開発を目指しましょう。

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

オーバーライド学習の最後に、皆さんが手軽に使えてしかも便利なオーバーライドの例をご紹介します。それは、 ToString() のオーバーライドです。

前に学んだ System.Object.ToString() は、C#では「クラス名」や「名前空間.クラス名」を返すメソッドでしたね。
DateTime などは自前で ToString() をオーバーライドして、分かりやすい文字列を返してくれます。

自作クラスでも、ToString() をオーバーライドすれば Console.WriteLine(myObj) と書くだけで自身のプロパティをわかりやすく表示することができるようになります。以下のサンプルを実行してみてください。

using System;

namespace Chap13 {

    public class Example05 {
        public static void Run() {
            Bicycle bi = new Bicycle(1234);

            // ToStringのオーバーライドによるプロパティ表示
            Console.WriteLine(bi);
        }
    }

     // 自転車クラス
    internal class Bicycle {

        // 防犯番号
        public int RegistryNumber { get; set; }

        // コンストラクタ
        public Bicycle(int registryNumber) {
            this.RegistryNumber = registryNumber;
        }

        // ToStringをオーバーライドして防犯番号を表示できるようにする
        public override string ToString() {
            return $"私の防犯登録番号は {RegistryNumber} です。";
        }
    }
}

ToString()をオーバーライドしておけば、いつでもどこでもコンソールにインスタンスが持つ情報を表示できます。デバッグやログ出力にも使えますね。少し複雑なクラスのデバッグには、これも前に学んだデバッガで処理を追った方が効率が良い場合も多いでしょう。両方をうまく使い分けて、スキルアップに役立ててください。次はもう一つの多態性である、オーバーロードの解説です。

6. メソッドのオーバーロード (Overload)

オーバーロードとは、「名前が同じで、引数のが異なる複数のメソッドを定義できる仕組み」 です。メソッド名と戻り値の型以外のシグネチャが違うメソッドを、いくつでも一つのクラスに作れるのです。

オーバーライドでは実行時にどのメソッドが呼ばれるのかが決まるのに対し、こちらはコンパイルの時点で(シグネチャが違うので)どのメソッドが呼ばれるかが決まりますから、コンパイル時の多態性、又は静的多態性と呼ばれます。

これだけを聞いても何のメリットがあるのか伝わりにくいかもしれませんね。以下のサンプルを実行してみてください。

例: Console.WriteLine

namespace Chap13;
using System;

public class Example06 {
    public static void Run() {
        Console.WriteLine("Hello");  // string型
        Console.WriteLine('A');      // char型
        Console.WriteLine(256);      // int型
        Console.WriteLine(3.14);     // double型
    }
}

この、Console.WriteLine がオーバーロードされていることを確かめてみましょう。6行目から順に、WriteLineのメソッド名をctrlキーを押しながら左クリックしてください。各メソッドの実装部分のコードが見れます。string, char, int, double など様々な型の引数を持つConsole.WriteLineメソッドがたくさん定義されています。

新人エンジニア研修
インプットの幅を広げるオーバーロード

Console.WriteLineにどんな型の引数を渡しても、適切にコンソールに文字が表示できるようになっているのですね。つまり、オーバーロードとは、同じ処理を違う材料で実行するメソッドを同じ名前でたくさん作れる仕組みなのです。

Console.WriteLineの役目は、もらった引数の内容をコンソールへ文字列にして出力することです。もし表示したい情報の型毎にメソッド名を変えなければならないとしたら、名前を考えるだけでも大変ですし、覚えるのはもっと大変です。

WriteLineStringWriteLineIntWriteLineDouble....

ですから、もらった引数をコンソールに出力するメソッド名は一つだけでよいことにしましょう。もらう引数の型や数が異なるのならシグネチャが違うからどのメソッドを呼ぶのか区別できますよね?というのがオーバーロードの思想です。

実際にオーバーロードをしてみましょう

using System;

namespace Chap13 {
    internal class Example07 {
        public static void Run() {
            // 人参、じゃがいも、玉ねぎ、鶏肉を渡す
            Cook.Do(new Carrot(), new Potato(), new Onion(), new Chicken());

            // 人参、じゃがいも、玉ねぎ、牛肉を渡す
            Cook.Do(new Carrot(), new Potato(), new Onion(), new Beef());

            // 牛肉、人参、じゃがいも、玉ねぎを渡すが、引数の順序が違うのでエラーになる
            //Cook.Do(new Beef(), new Carrot(), new Potato(), new Onion());

            // じゃがいも、玉ねぎ、牛肉を渡す
            Cook.Do(new Potato(), new Onion(), new Beef());

            // にんにく、しょうが、鶏肉を渡す
            Cook.Do(new Garlic(), new Ginger(), new Chicken());
        }
    }

    // 材料:人参
    internal class Carrot { }

    // 材料:ジャガイモ
    internal class Potato { }

    // 材料:玉ねぎ
    internal class Onion { }

    // 材料:にんにく
    internal class Garlic { }

    // 材料:しょうが
    internal class Ginger { }

    // 材料:牛肉
    internal class Beef { }

    // 材料:鶏肉
    internal class Chicken { }

    // 料理クラス
    internal class  Cook {
        // 材料によって料理を作るメソッド(オーバーロード)
        public static void Do(Carrot carrot, Potato potato, Onion onion, Chicken chicken) {
            Console.WriteLine("カレーができました!");
        }
        public static void Do(Carrot carrot, Potato potato, Onion onion, Beef beef) {
            Console.WriteLine("ビーフシチューができました!");
        }
        public static void Do(Potato potato, Onion onion, Beef beef) {
            Console.WriteLine("肉じゃがができました!");
        }
        public static void Do(Garlic garlic, Ginger ginger, Chicken chicken) {
            Console.WriteLine("カラアゲができました!");
        }
    }
}

皆さんも、材料クラスを足して、好きな料理を作ってみてください。

ちなみに、13行目のコメントを外すとエラーになります。引数を渡す順番が違うからです。このように、オーバーロードと言っても引数の型が違えばなんでもOKではなく、引数を渡す順番もシグネチャに含まれることを忘れないで下さいね。

オーバーロードの使いすぎに注意

このように便利なオーバーロードですが、あまりにたくさんのメソッドを作ってしまうと、かえって可読性が悪くなり、メンテナンス性が低下することもあります。そういう場合はparams共通インターフェースの導入も検討すべきです。興味がある方は,是非調べてみてください。

これで、多態性についての講義を終わります。オブジェクト指向の奥深さが少しでも伝わったら幸いです。次回は、例外について学びましょう。


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

□ 多態性(ポリモーフィズム)とは、同じ名前のメソッドに色々な動作をさせるための仕組みで、動的多態性(オーバーライド)と静的多態性(オーバーロード)の二種類がある

□ オーバーライドは親クラスと同じメソッドを子クラスにも持たせ、実行時に呼び出すメソッドをその場で選んで子クラス毎に違う動作をさせること

□ オーバーライドは自由で強力な反面、堅牢な設計・実装・テストの裏付けが無いと大問題が起こる危険がある

□ オーバーロードは名前が同じで、引数のが異なる複数のメソッドを定義できる仕組み

最後までお読みいただき、ありがとうございます。