C#でクラスを作ろう(10)/仮想メソッド

3月 25, 2020

このシリーズについて

C言語やC++言語などを学んではいるけどクラスをあまり作ったことが無い、という方を対象にしています。
このシリーズでは、C#でクラスを作るための基本的な構文を解説しています。C++やJavaなどと共通している概念も多いですが、サンプルコードは基本的にC#で解説します。ところどころ、C++特有の概念を解説することもあります。

クラスを継承して派生クラス(子クラス)を作り、クラスの機能を拡張したり共通部分をまとめたりする方法を、C#でクラスを作ろう(8)/クラスの継承C#でクラスを作ろう(9)/抽象基本クラスで見てきました。

しかし、クラスの継承にはもう1つ、ポリモーフィズム(多態性)という側面があります。今回はそのポリモーフィズムを解説する前準備として、仮想メソッドというものを解説していきます。

スポンサーリンク

アップキャスト

//親クラス
class Person
{
    //人物を表わすクラス
    public string Name { get; set; }
    public string Address { get; set; }
    public DateTime Birthday { get; set; }
}
  
//子クラス
class Student : Person
{
    //小学生を表わすクラス。人物を表わす親クラスPersonを継承している
    public string SchoolName { get; set; }
    public int Grade { get; set; }
}
 
//アップキャストの例
Student st = new Student();
st.SchoolName = "☆☆小学校";
Person ps = st;  //親クラスへアップキャスト
ps.Grade = 3;  //コンパイルエラー。PersonクラスにはGradeプロパティは無い

このようなPersonクラスとStudentクラスがあったとします。そしてまず、Studentクラスのインスタンスを作成し、Student型の変数stに格納します。stはStudent型なので、Student型のプロパティSchoolNameにアクセスできます。

ここで、stをPerson型の変数psに代入することができます。これがアップキャストです。なぜこのようなことができるかというと、親クラスと子クラスは「is a」の関係であるからです。

is aの関係

StudentはStudentであると同時に、Personでもあります。だから、Student型のインスタンスstはPersonでもあるということです。stをPerson型だとみなしたいときは、このようにPerson型変数psに代入することができます。

アップキャストは、プリミティブ型のキャストとは少し異なります。

float f = 1.0f;  //単精度浮動小数点型の"1.0"
double d = f;    //倍精度浮動小数点型にキャスト

プリミティブ型のキャストの場合、double型の変数dは元のfloat型の値ではなく、double型に変換されてから変数dに格納されます。

一方、クラス間のアップキャストは、元のインスタンスの状態を保ちます。

元の状態を保つ

元の状態は保っているのでStudent型特有の部分も存在はしていますが、アップキャストされた後の変数psはもうPerson型なので、元のStudent型のプロパティGradeにはアクセスできません。

ダウンキャスト

//ダウンキャストの例
Person ps = new Student();  //Student型のインスタンスを生成し、Person型へアップキャスト
Student st = (Student)ps;   //子クラスへダウンキャスト
 
//これはできない
Person ps2 = new Person();   //Person型のインスタンスを生成
Student st2 = (Student)ps2;  //実行時エラー。ps2はStudent型のインスタンスではないため。

逆に、親クラスから子クラスへキャストすることをダウンキャストといいます。アップキャストの場合は「Person ps = st」のようにキャスト先の型名を書かずにダイレクトにキャストできましたが、ダウンキャストの場合は「Student st = (Student)ps」のようにキャスト先の型名を書く必要があります。

さてこのダウンキャストですが、できる場合とできない場合があります。

ダウンキャストできる場合とできない場合

元々Student型として作られたインスタンスはStudent型特有の機能を持っているので、一旦Person型にアップキャストされたあと、再度Student型にダウンキャストすることができます。

一方、最初からPerson型として作られたインスタンスはStudent型特有の機能を持っていないので、Student型にダウンキャストすることができません。

ダウンキャストできるかどうかはコンパイル時にはわからないため、コンパイルエラーは出ません。実行時にダウンキャストができないとわかったときに初めて、実行時エラーが出ます。

仮想メソッド

//親クラス
class Person
{
    //人物を表わすクラス
    //中略...
    public virtual void SelfIntroduction()
    {
        //自己紹介をする
        Console.WriteLine($"私は{this.Name}です");
    }
}
  
//子クラス
class Student : Person
{
    //小学生を表わすクラス。人物を表わす親クラスPersonを継承している
    //中略...
    public override void SelfIntroduction()
    {
        //自己紹介をする
        Console.WriteLine($"私は{this.Name}です");
        Console.WriteLine($"小学{this.Grade}年生です");
    }
}

親クラスでキーワードvirtualを付けて定義されたSelfIntroductionメソッドが、仮想メソッドです。子クラスで、同名のSelfIntroductionメソッドをキーワードoverrideによってオーバーライドしています。

これはまず、動きを見たほうが理解が早いでしょう。

Person ps = new Person();
ps.Name = "△川□子";
ps.SelfIntroduction(); //出力1:私は△川□子です
 
Student st = new Student();
st.Name = "○山×郎";
st.Grade = 3;
st.SelfIntroduction();  //出力2:私は○山×郎です 小学3年生です
Person ps = st;
ps.SelfIntroduction();  //出力3:私は○山×郎です 小学3年生です

出力1のところはそのままです。Person型のSelfIntroductionメソッドが呼ばれています。

出力2のところも見た目通りです。Student型のほうのSelfIntroductionメソッドが呼ばれ、「小学3年生です」という出力が追加されています。

出力3のところが仮想メソッドの特徴をあらわしています。psはPerson型なので、ps.SelfIntroductionメソッドはPerson型バージョンのほう(小学何年生と言わないほう)が呼ばれそうなものです。しかし実際は、Student型バージョンのほう(小学何年生と言うほう)が呼ばれています。

キーワードvirtualを付けて定義された仮想メソッドはこのように、変数の型ではなく、実体が何であるかによって、親クラスのバージョンのメソッドを呼ぶか子クラスのバージョンのメソッドを呼ぶかが決定されます。

仮想メソッド

仮想メソッドを子クラスで上書き(オーバーライド)するためのキーワードがoverrideです。子クラス側で、「メソッドの動作を実体に即したものにしなさい」という指示をキーワードoverrideによって与えています。

overrideできるのは仮想メソッドだけです。つまり、virtualとoverrideは対になります。

overrideされたメソッドは、さらにその派生クラスでoverrideすることができます。つまり、キーワードoverrideは実際には「override兼、virtual」という指示になっています。

class Parent
{
    public virtual void Foo()
    {
        Console.WriteLine("ワシは親");
    }
}
class Child : Parent
{
    public override void Foo()
    {
        Console.WriteLine("わたしは子供");
    }
}
class GrandChild : Child
{
    public override void Foo()  //Parent.Fooも実際にはvirtualなので、overrideできる
    {
        Console.WriteLine("ぼくは孫だよ");
    }
}

抽象メソッド(純粋仮想関数)

C#でクラスを作ろう(9)/抽象基本クラスで、キーワードabstractを付けることによって、インスタンス化されることのない抽象的なクラスが定義できることを解説しました。

//何らかの共通部分
abstract class FileCopyItem  //抽象クラスとして定義
{
    public string SourceFilePath { get; set; }  //コピー元のファイルパス
    public abstract void DoFileCopy();   //実装を持たない抽象メソッド
}
 
//ローカルコピーを担うクラス
class LocalFileCopyItem : FileCopyItem
{
    public string DestinationFilePath { get; set; }  //コピー先のファイルパス
    public override void DoFileCopy()
    {
        //ファイルコピーの実行
        File.Copy(this.SourceFilePath, this.DestinationFilePath);
    }
}

この抽象的な基本クラス(親クラス)FileCopyItemに、仮想メソッドDoFileCopyを定義したいとします。

しかしFileCopyItemは抽象的なクラスなので、メソッドの動作を具体的に確定することができない場合があります。特にこの例の場合、「どこかにとにかくファイルコピーをする抽象的なFileCopyItemクラス」は、実際にファイルコピーの動作をDoFileCopyメソッドの中に書こうとしても「どこかにとにかくコピー」なんていう抽象的な動作を具体的に書くことが出来ません。

そういう場合は、メソッドの定義を「public abstract void DoFileCopy();」のようにし、メソッドの中身を書かずにセミコロンで終わらせます。これが抽象メソッドです。

キーワードabstractの意味は「中身を持たないvirtual」です。つまり、抽象メソッドとは、中身を持たない仮想メソッドのことです。抽象メソッドも仮想メソッドの一種なので、継承した子クラスでoverrideできます。

ちなみにC++の場合はabstractというキーワードはありませんが、

class FileCopyItem
{
    public:
        virtual void DoFileCopy() = 0;  //これが純粋仮想関数(抽象メソッド)
}

のように「= 0;」という構文で純粋仮想関数(抽象メソッドのこと)を定義します。この構文を知らないと「なんだこれ、0を代入してるのかな?」と思ってしまいがちですが、数値の0とは何の関係も無いので注意が必要です。

仮想デストラクタ

C#でクラスを作ろう(6)/ファイナライザ(デストラクタ)で、クラスの終了処理を記述するファイナライザというものを紹介しました。C#の場合はファイナライザに関して特に気をつける点はありませんが、C++の場合はデストラクタの定義に注意が必要です。

//親クラス
class Parent
{
    private:
        int* parentMember;
    public:
        Parent() { parentMember = new int[5]; }
        ~Parent() { delete [] parentMember; }
};
 
//子クラス
class Child : public Parent
{
    private:
        int* childMember;
    public:
        Child() { childMember = new int[10]; }
        ~Child() { delete [] childMember; }
}
 
//使う側
Parent* p = new Child();
delete p;  //※メモリリーク!

親のParentクラスは内部でparentMember配列をnewで確保し、デストラクタで解放します。子のChildクラスも内部でchildMember配列をnewで確保し、デストラクタで解放します。

さて、これらのクラスを使う側が、まずnew Child()で子クラスのインスタンスを作成し、アップキャストしてParent型のポインタpに格納したとしましょう。そしてなんやかんや使った後、delete pでインスタンスを解放しようとします。

しかし、ポインタpはParent型なので、Parent型のデストラクタが実行されてしまい、子クラスで確保したchildMember配列は解放されないままメモリに残ってしまいます。

このような事態を避けるために、親クラスのデストラクタを仮想デストラクタにします。

class Parent
{
    //中略
    virtual ~Parent() { delete [] parentMember; }
};

こうしておけば、デストラクタが仮想関数となり、delete pのときに正しく子クラスのデストラクタが実行されるようになります。

じゃぁその場合は親クラスのデストラクタは実行されないの? と思うかもしれませんが、そこは大丈夫です。(仮想デストラクタでなくてもそうですが)継承関係にある子クラスのデストラクタが実行されるときは必ず、その後に親クラスのデストラクタも自動的に実行されます。従って、childMember解放→parentMember解放の順に、正しく解放処理が行われるようになります。

C++のクラスでデストラクタを定義する際は、将来そのクラスが継承されるかもしれないことを想定して、常にvirutalキーワードを付け、仮想デストラクタにするように習慣付けましょう。

C#のファイナライザにはvirtualやabstractのようなキーワードを付けることはできません。常にC++の仮想デストラクタのように、実際の型に応じたファイナライザが呼び出されるので安心です。

仮想メソッドの有用性

仮想メソッドの構文と動作の説明は以上です。が、動きはわかったとしても、「これの何が面白いの?」と思われる方も多いかもしれません。

どんなときに仮想メソッドが有効に働くのかについては、また次回に解説します。

スポンサーリンク