継承


内容

継承とは

プログラムを作成していると、これから実装したい機能と似たようなコードを別の場所で書いていたな・・・ということが起こります。

こういった場合の対応としてビギナープログラマにありがちなのは、その似たようなコードをコピーしてきて、必要な部分を修正して使うという方法です。このような方法を繰り返していると、やがてプログラムが似たようなコードで埋め尽くされて、コード量が増加してきます。 それだけならばまだ良いのですが、ここでもしもコピー元のコードにバグが含まれていたとしましょう。これを修正するのに、プログラム中にちりばめられたコピーコードのすべてを修正する必要が出てきます。

こういった状況に懲りたビギナーを卒業したプログラマは、もとのコードをコピーして使用するのはよくないことに気がつきます。決まった操作は一元化して記述しておけば、修正も容易です。元のコードをコピーして修正するのではなく、一元化した関数にパラメータを与えて挙動を変化させればいいのです。なかなかうまくいきそうな方法です。しかし、気がつくと元のコードには if 文やswitch文でちりばめられて、結局読みづらく保守性の悪いコードとなってしまうことが多々あります。

このような状況をスマートに解決する方法として、オブジェクト指向言語には継承と呼ばれる機能があります。継承とは、すでにあるなにかを引き継ぐことです。

継承の機能を使うことにより、すでに定義済みのオブジェクトに 
機能を追加 
変数を追加 
機能の一部を変更 
などをエレガントに記述することができるようになり、オブジェクト(コード)の再利用性が向上します。すでに実装済みの機能に、自分が実装したい機能として足りない部分だけを追加してプログラムを作成できるようになりますので、これを差分プログラミングと呼んだりします。

Visual C++には MFC と呼ばれるクラスライブラリが付属しています。これは、Windowsアプリケーションの基本となるウインドウなどの部品を記述したクラスの集合体です。これらのクラスの中から必要なものを継承して、自分のアプリケーション特有の部分だけをコーディングしてプログラムを完成させます。クラスライブラリを使うということは、まさに「差分」プログラミングであり、これから学ぶ「継承」の機能を正しく理解する必要があります。

C++における継承

C++の場合には、クラスを基盤としてプログラムを作成していきます。すでにあるクラスに継承の機能を使って新しいクラスを派生させます。もとになるクラスを基本クラス基底クラスと呼び、派生して作成したクラスを派生クラスと呼びます。

base という名前のクラス定義がすでにあるとします。base には ひとつのメンバ変数とひとつのメンバ関数があります。

この、baseクラスの定義をもとにして、新しいクラスを書きたいときに「継承」という機能を使うことができます。

新しく定義する derived という名前のクラス定義を上図のように書くと、base クラスのメンバに加えて、さらにひとつづつのメンバ変数とメンバ関数を加えたクラスを定義することができます。 新しく作成したクラスは、二つのメンバ変数(var1, var2) と、二つのメンバ関数 (func1とfunc2)を持つことになります。

練習問題1-1 メンバ変数・メンバ関数の追加

class Student {
public:
    char name[20];  //氏名
    int kadai1;  //課題1の点数
    int kadai2;  //課題2の点数
    int ave;   //平均点  

    void SetName(const char *n) { strcpy(name, n); }
    void SetKadai1(int k1) { kadai1 = k1;  } 
    void SetKadai2(int k2) { kadai2 = k2; } 
    void CalcAve(); //平均点を求める
    char *Name() { return(name); } 
    int GetKadai1() { return(kadai1); } 
    int GetKadai2() { return(kadai2); } 
    int Average(){ return(ave); }
        
    int GetSum();
};
                        

初級編で使用した Studentクラスを派生して、以下を追加した派生クラス StudentEx を作成しなさい。(Studentクラスはこの課題の為に合計点を返す関数が追加してあります。また、この課題では継承機能の基礎に集中して学習したいので、すべてのメンバは public にしてあります。)

※それぞれのメンバは public メンバとしてください。

それぞれ 課題3の得点を保持する変数、課題3の得点を設定する関数、課題3の得点を返す関数、となります。

p3k3_1 という名前でプロジェクトを作成し、以下のソースを p3k3_1.cpp という名前でプロジェクトに加えて使用すること。

ヒント:main関数内では、Student型のオブジェクト taro と、StudentEx型のオブジェクト jiro を作成して、それぞれの課題の得点の設定と表示を行っています。

実行結果

taro
kadai1:100 kadai2:80
ave:90 sum:180
jiro
kadai1:90 kadai2:70 kadai3:50
ave:80 sum:160
続行するには何かキーを押してください . . .

メンバ関数の再定義

練習問題1-1の実行結果をみると、StudentEx クラスには三つめの課題の得点が追加されていて、その設定や取得が出来ていることがわかります。また、Studentクラスで実装されていた他の関数(SetKadai1やCalcAve、Average関数)もそのまま使用できています。このように、継承を使うことによりすでに実装済みの機能はそのままに、新しい機能を追加することができます。

ところが、実行結果を見ると、平均点と合計点の計算結果が間違っています。課題の数が増えたのに対して、平均点を計算するCalcAve関数では課題1と課題2の平均しか計算していないからです。

そこで StudentEx クラスでは、StudentExクラス用に CalcAve関数を書きなおす必要があります。

派生クラスでは、基底クラスにあったメンバ関数を同名・同引数で再定義することができます。

練習問題1-2

StudentExクラスで CalcAve関数を再定義し、正しく3科目の平均を計算するようにしなさい。

また、GetSum関数を再定義して、合計点を正しく表示するようにしなさい。

実行結果

taro
kadai1:100 kadai2:80
ave:90 sum:180
jiro
kadai1:90 kadai2:70 kadai3:50
ave:70 sum:210
続行するには何かキーを押してください . . .

再定義した関数からの基底クラスメンバ関数の呼び出し

あるクラスのメンバ関数内から、同じクラス内のメンバ関数を呼び出すことができるのと同様に、派生クラスのメンバ関数内から基底クラスのメンバ関数を呼び出すことができます。

ところが、次の例を見てください。

class base {
    void function();
};

void base::function()
{
    ....
}

class derived : public base {
   void function();  // function を再定義
};

void derived::function()
{
    ....
    ここから、基底クラスのfunction関数を呼びたい。。
}
                     

derivedクラスで定義した derived::function関数の中から、基底クラスのfunction関数を呼び出したい場合があります。基底クラスの処理にちょっとだけ改変を加えたい場合などに必要になる処理です。ここで下のように記述してしまうと自分自身を再帰的に呼び出してしまいます。

void derived::function()
{
    ....
    function();
}

このような場合には、スコープ解決演算子(::)を使って、基底クラスのメンバを明示的に呼び出します。

void derived::function()
{
    ....
    base::function();
} 

練習問題1-3

GetSum関数の実装を、kadai1,kadai2,kadai3 を加算して求めるのではなく、StudentクラスのGetSum関数の戻り値(kadai1とkadai2の合計)にkadai3の値を加算して求めるように書き直しなさい。

練習問題1-4

Studentオブジェクト、StudentExオブジェクトそれぞれのサイズを求めなさい。(ヒント:デバッガやsizeof演算子を使用する。)そして、それぞれがなぜそのような大きさになったかを考察しなさい。

継承とコンストラクタ

C++ではオブジェクトの初期化にコンストラクタと呼ばれるメンバ関数を使うことができました。コンストラクタはオブジェクトがインスタンス化されたとき(メモリ上に配置された時)に自動的に呼び出されます。(もう少し別の言い方をすると、オブジェクトをメモリに割り当てた後に、コンストラクタの呼び出しコードがコンパイラによって自動で生成されます。)

継承関係にある複数のオブジェクトがコンストラクタを持つ場合、その呼び出し順序は基底クラスのコンストラクタから順に呼び出されます。

練習問題1-5

StudentクラスとStudentExクラスにそれぞれ引数をとらないコンストラクタを追加し、その呼び出し順序を確認しなさい。

ヒント:以下のようなコンストラクタを追加して、その表示順序を確認する。StudentExクラスにも同様に追加する。

class Student {
public:
    Student() { cout << "Studentクラスのコンストラクタ" << endl; }
    ...
}; 

 

基本クラスの引数付きコンストラクタの呼び出し

引数を持つコンストラクタをオーバーロードして作成し、オブジェクトの構築時にパラメータによって初期化することができました。基本クラスの Studentに、以下の名前、課題1と課題2の得点を受け取るコンストラクタを追加します。

class Student {
public:
    Student() { cout << "Studentクラスのコンストラクタ" << endl; }
    Student(const char* name, int k1, int k2); // 引数付きコンストラクタ
    ...
}; 

Student::Student(const char* name, int k1, int k2)
{
    SetName(name);
    kadai1 = k1;
    kadai2 = k2;
}

Student クラスを使用する main 関数からは、以下のように初期化時に名前と得点の設定をすることができます。

	Student taro("taro", 100, 80);		

そこで、派生クラスであるStudentEx にも引数つきのコンストラクタを作成して、名前、課題1〜3の得点をまとめて初期化するにはどうしたらよいでしょうか。まず、StudentExクラスにも、これらの4つの引数を受け取るコンストラクタを定義します。 このコンストラクタ内でそれぞれの変数に代入すると、次のようなコードになります。

class StudentEx : public Student {
public:
    StudentEx(const char* name, int k1, int k2, int k3) {	
        SetName(name);
        kadai1 = k1;
        kadai2 = k2;
        kadai3 = k3;
    }
    ...
    ...
};

この例の場合では、上記のようなコードでも期待した動作はしてくれます。しかし、Studentクラスのコンストラクタが自動的に呼び出された後に、StudentExクラスのコンストラクタ内で代入をやり直すのは多少の無駄があります。(この例では「多少の無駄」で済んでいますが、オブジェクトの種類によっては、初期化してから代入をやりなおすのにかなりのコストがかかる場合があります。)

そこで、せっかく定義した Studentクラスの引数付きのコンストラクタを使用したいですよね。StudentExクラスのコンストラクタに、イニシャライザを指定することにより、基本クラスの初期化の方法を指定して引数を与えることができます。以下にその例を示します。

class StudentEx : public Student {
public:
    StudentEx(const char* name, int k1, int k2, int k3) : Student(name, k1, k2) {  
        kadai3 = k3;
    }
    ...
    ...
};

下線の部分がイニシャライザと呼ばれ、コンストラクタが呼び出される前に初期化を済ませたいものを記述します。この例では、StudentExに渡された引数のうち name, k1, k2 をそのままつかって 基本クラスの Student を初期化しています。

練習問題1-6

Studentクラス と StudentExクラスを、上記の引数つきのコンストラクタを使って実装しなさい。main関数も、コンストラクタでオブジェクトを初期化するように書き換えなさい。

 

イニシャライザについて

練習問題1-8では、イニシャライザを基本クラスの初期化に使用しましたが、イニシャライザはメンバ変数の初期化にも使用できます。Studentクラスのコンストラクタで、メンバ変数の初期化にイニシャライザを使用した例を以下に示します。

class Student {
public:
    ...
    int kadai1;  //課題1の点数
    int kadai2;  //課題2の点数 
    ...
    Student() { cout << "Studentクラスのコンストラクタ" << endl; }
    Student(const char* name, int k1, int k2);
    ...
};

Student::Student(const char* name, int k1, int k2) : kadai1(k1), kadai2(k2)
{
    SetName(name);
}

上記の例のように、初期化したいメンバをカンマで並べて記述できます。C++では、int 型の kadai1やkadai2のような変数も、下線の部分のようにクラスの初期化と同様の書式で書くことができます。

またこの例のように、宣言部と実装部を分けて書いた場合、イニシャライザは実装部のほうに記述します。もし、どちらに書いて良いか悩んだら次の原則を思い出してください。

「宣言部はクラスを使う人に見せる場所、実装部は見せる必要が無い場所」

メンバや基本クラスの初期化にイニシャライザを使っていようがいまいが、クラスを使う人には関係ありません。ですからイニシャライザは実装部に書きます。

この原則は、関数のデフォルト引数の指定にも成り立ちます。クラスを使用する人は、デフォルト引数の値を知る必要があります。(デフォルト値を知らないと、引数を与えるかデフォルト値を使うかを決められませんからね。)ですから、デフォルト引数の指定は、宣言部に記述します。

練習問題1-7

これまで、Studentクラス、StudentExクラス、main関数をひとつのファイルに記述してきました。これらを以下の5つのファイルに分割してビルドしてください。

Student.h Studentクラスの定義
Student.cpp Studentクラスの実装
StudentEx.h StudentExクラスの定義
StudentEx.cpp StudentExクラスの実装
main.cpp main関数部

※ ヘッダファイルには、2重インクルード防止の措置を入れてください。

練習問題1-8 (オプション)

StudentExクラスからさらに派生クラス StudentEx2クラスを作成し、新たに課題4の得点を管理できるクラスとしなさい。StudentEx2クラスとヘッダは別ファイルで記述すること.また,main関数は、StudentEx2クラスの動作が確認できるように修正すること。


練習問題は、次週までに全て終わらせておくこと。