クラス管理の責任

この章でまなぶこと
  • コンポジション
  • コンストラクタ・デストラクタ
  • コピーコンストラクタ
  • 演算子のオーバーロード

  • コンポジション
    前回は単一のクラスの責任について見た。 次は複数のクラスについてみていこう。 これを考察することによって「オブジェクト同士の関係」の概念が見えてくるだろう。 どうやって責任を集中させればいいのだろうか? 原則に従えば個々のクラスに責任を集中すべきだろう。 たとえば前回の敵の例をまた出すとこれにショットを追加する場合はたとえば
    #include <iostream>
    
    class NormalShot{
    public:
    	void shot(){
    		std::cout << "普通のショット" << std::endl;
    	}
    
    };
    
    
    class Enemy{
    public:
    	
    	Enemy():
    	    PositionX(0),
    		PositionY(0)
    	{}
        
     
        //取得系
        int getPosX() const{return PositionX;}
        int getPosY() const{return PositionY;}
     
        void move(){
            PositionX += 5;
            return;
        }
    
    	void shot(){
    		//shotの実行(実装)の責任はNormalShotに移譲
    		Shot.shot();
    	}
     
    private:
        int PositionX;
        int PositionY;
    
    	//内部にNormalShotを持つ
    	NormalShot Shot;
    
    };
     
     
    int main(){
     
        Enemy enemy;
     
    	enemy.shot();
    
        return 0;
     
    }
    
    のようにすることができるだろう。 注目する点はEnemyではshotを直接実装することなくNormalShotに実装の責任を任せている。 これのメリットはなんだろうか?例えばshotで「普通のショット」を「Normal Shot」と表示するように変更することを考えよう。 この場合NormalShotの部分を
    
    class NormalShot{
    public:
    	void shot(){
    		std::cout << "Normal Shot" << std::endl;
    	}
    
    };
    
    のように置き換えるだけでいい。shotがインターフェースとなっているからだ。 今回は簡単な表示だけだったが、実際のshotのコードは非常に複雑になることが 予想される。そのときEnemyをいじると他のコードまで破壊する可能性があり 「バグがバグを生む」ということが起こる可能性がある。 今回のようにメンバ変数として他のクラスのインスタンスを持ち それに実装の責任を移譲することで安全性・柔軟性が高まる。 このパターンはよく使うのでコンポジション(合成。あるいは包含とも)呼ばれる。 また、EnemyのshotからNormalShotのshotを呼び出しているように あるメンバ関数が、関連するクラスのメンバ関数を呼び出すことは単に移譲と呼ばれる。 これは今までいってきた責任の移譲と基本的に同一だ。 今回の場合はEnemyのshotの責任をNormalShotに移譲している。 このように責任をより専門化させた小さなクラスに分け、それをコンポジションによって まとめることによってクラスを作っていけばより柔軟で修正しやすいコードが出来上がる。
    生成・解体の責任
    さて、C++では以下の機能が手動で管理できる。
    クラスの機能
    管理できる責任 管理するもの
    初期化 コンストラクタ
    コピーによる初期化 コピーコンストラクタ
    代入 代入演算子
    解体 デストラクタ
    これらを定義しなければデフォルトのものが自動生成される。 ただ、これだと色々と問題があったり、あるいは自分でしたほうがいい場合がある。 次にそれを見ていこう。 初期化と代入の何が違うんだ?ということだがこれも話す。
    [文法]引数が一つのコンストラクタ
    初期化と代入に説明する前にいくつか概念を準備しておこう。 引数が一つのコンストラクタだけ変換コンストラクタということでC++では特別扱いされている。 自作のクラスが組み込み型とおなじように扱えるようになる ということで便利といえば便利なのだがわかりにくくなっている。 そのためあまり入門講座で解説しているところは少ない印象を受けるが、 ここではせっかくなので解説しておこう。 また、これをしっているとのちのコピーコンストラクタや初期化と代入の違いについても理解しやすいだろう。 まず、次のような単純なクラスを考える。
    #include<iostream>
    
    
    class Number{
    public:
    	Number(int num):
    		number(num)
    	{}
    	
    private:
    	int number;
    
    };
    
    int main(){
    	
    	Number hoge(2);
    
    	return 0;	
    }
    
    さて、これのmain関数は初期化部分を次のように書くことができる。
    	//Number hoge(2);←これと同一
    	Number hoge = 2;
    
    これはあたかも2をhogeに変換しているように見えるので変換コンストラクタと呼ばれる。 これはstd::stringなどをみてわかるように組み込み型のラッパークラスを作った際組み込み型と あたかも同じようにつかえるようにしたいという時に便利だ。 ただし、これだとクラスが複雑になってくると予期せぬことがおこる(問題参照)ので この機能を明示的に禁止することができる。方法は簡単でコンストラクタに explicitをつけるだけだ
    
    #include<iostream>
    class Number{
    public:
    	explicit Number(int num):
    		number(num)
    	{}
    	
    private:
    	int number;
    
    };
    
    使い分けだが 極力explicitなコンストラクタにしたほうがいいだろう。 ただし組み込み型のラッパーとしてを意図したクラス(例:std::string等)なら禁止しなくてもいい。 それ以外のクラスでたまたま引数が1つのコンストラクタができてしまったということが多いはずだ。 この時=で初期化できるということをおそらくクライアントは考えていないだろう。
    [文法]C++の代入と初期化
    以上の変換コンストラクタがわかると初期化と代入の違いがよりわかりやすく説明できる。 また初期化リストのほうが効率がいいのだろうか?ということもわかる。 僕の前期の講座では代入と初期化の違いについてあまり明確に区別して来なかった。 また、他の講座でも扱われてなかったのではないか? しかし、C++をより深く理解していく上ではこの2つの区別をしっかりすることはさけて通れない。 以下のコードを見て欲しい。
    
    int main(){
    
    	//初期化
    	int a = 2;
    
    	int b;
    	//代入
    	b=2;
    
    	return 0;
    }
    
    int型について3つの初期化と代入のパターンを示してみた。どれも結果は同じである。 これだと初期化と代入がどう違うんだ?と思うかもしれない。 そこで=から()に書き直してみよう。
    
    int main(){
    
    	//初期化
    	int a(2);
    
    	int b;
    	//代入
    	b(2);
    
    	return 0;
    }
    
    二番目の代入の式を書き直したものは意味をなしていない。コンパイルすればエラーが出るのでより明確だ。 以下の例を見ればもっとわかりやすいかもしれない。
    #include<iostream>
    
    class Hoge{
    public:
    	Hoge():data(9){std::cout <<"デフォルトが呼ばれた"<<std::endl;}
    	Hoge(int a):data(a){std::cout <<"intの方が呼ばれた"<<std::endl;}
    	
    private:
    	const int data;
    };
    
    
    int main(){
    
    	//初期化
    	Hoge a(2);
    
    	//代入
    	Hoge b;
    	b = a;
    
    	//初期化
    	Hoge c = 2;
    
    
    	return 0;
    }
    
    代入のところに注目してほしい。そこでデフォルトコンストラクタが呼ばれていることがわかる。 そのあとint型の引数を持つコンストラクタが呼ばれる。 そして最後に(結果には表示されていないが)そのあと代入がなされる。(問題参照) なんか無駄な気がしないだろうか? じつは初期化リストを使わないとこの無駄なことが行われる。 以下のプログラムを実行してほしい。
    #include<iostream>
    
    class Foo{
    public:
    	Foo(){std::cout <<"デフォルトが呼ばれた"<<std::endl;}
        Foo(int a){std::cout <<"intの方が呼ばれた"<<std::endl;}
         
    };
    
    class Hoge{
    public:
        Hoge(){foo = 2;}
    
    private:
    	Foo foo;
    };
     
    class Bar{
    public:
        Bar():foo(2){}
    
    private:
    	Foo foo;
    };
    
     
     
    int main(){
     
    	std::cout <<"Hoge"<<std::endl;
    	Hoge hoge;
    	std::cout <<"Bar"<<std::endl;
    	Bar bar;
    
     
        return 0;
    }
    
    たしかに初期化していないHogeのコンストラクタではFooのデフォルトコンストラクタと 引数ありのコンストラクタ両方が呼ばれていることがわかる。 いや、実はそれ以上に効率が悪い。それを次のセクションで見ていく。 それより重要なのはここで初期化と代入の違いがより鮮明になってきているということだ。 実はC++では代入も制御できる。 また、ほかのオブジェクトをコピーして初期化することもできその時は コピーコンストラクタというものが呼ばれる。
    [文法]コピーコンストラクタとデストラクタ
    あるオブジェクトを他のオブジェクトと中身が同じ状態で初期化したい、 つまりコピーしたいことがあると思う。通常はそれで問題がないのだが ポインタが絡むと厄介である。以下のプログラムを実行してみて欲しい。
    #include<iostream>
    
    class Object{
    public:
    	Object(){
    		std::cout << "生成された" << std::endl;
    	}
    
    	~Object(){
    		std::cout << "解体された" << std::endl;
    	}
    
    
    };
    
    
    class Hoge{
    public:
    	Hoge():
    	  Pointer(new Object)
    	{}
    
    	//デストラクタ(解体時に呼ばれる)
    	~Hoge(){
    		delete Pointer;
    	}
    
    
    private:
    	Object* Pointer;
    
    
    };
     
     
    int main(){
    
    	Hoge hoge;
    
    	Hoge bar = hoge;
    
    
        return 0;
    }
    
    おそらくコンパイルは通ったがエラーで落ちたと思う。これは動作を見てみればわかるし、 言われてみれば明らかだがポインタの中身ではなくポインタ自体がコピーされてしまうからだ。 そこで自分と同じクラス(のconst参照)を受け取るコンストラクタを定義してみよう。 こうすれば前の話を考えればこれで初期化がされるはずだ。
    #include<iostream>
     
    class Object{
    public:
        explicit Object(int a):
    		data(a)
    	{}
    
    	int getData() const {return data;}
     
    private:
    	const int data;
    };
     
     
    class Hoge{
    public:
    	explicit Hoge(int a):
    		Pointer(new Object(a))
        {}
    
    	//コピーコンストラクタ
    	//objは同じHogeのインスタンスなのでprivateメンバにアクセスできる点に注目
    	Hoge(const Hoge& obj):
    		Pointer(new Object(obj.Pointer->getData()))
    	{ 
    	}
     
        ~Hoge(){
            delete Pointer;
        }
     
     
    private:
        Object* Pointer;
     
     
    };
      
      
    int main(){
     
        Hoge hoge(4);
     
        Hoge bar = hoge;
     
     
        return 0;
    }
    
    ここでみたようにコピーコンストラクタはユーザ定義のコピーコンストラクタがない場合自動的に定義されるという点をのぞいて 普通のコンストラクタと基本的に一緒だ。(ちなみにデフォルトコンストラクタも自動生成される) explicitなコピーコンストラクタを定義してみればわかるだろう。 (もちろんできるからと言ってexplicitなコンストラクタを定義するのはあまり意味が無いが...)
    [文法](代入)演算子のオーバーライド
    C++で演算子は特別な形で扱えるメソッド/関数だ。今回はメソッドとして定義する方法について学ぶ。 代入演算子とは何かを見るためには以下のサンプルを見るのが一番だろう。
    #include<iostream>
      
    class Hoge{
    public:
    	explicit Hoge(int a):
    		data(a)
    	{}
    
    
    	//代入演算子=のオーバーロード
    	Hoge& operator=(const Hoge& obj){
    		//コンストラクタでないので初期化子リストが使えない
    		data = obj.data;
    		
    		//*thisを返すのは定形
    		return *this;
    	}
    
    
    	int getNum()const{return data;}
    
    private:
        int data;
     
     
    };
      
      
    int main(){
     
        Hoge hoge(4);
     
        Hoge bar(3);
    	std::cout << bar.getNum() <<std::endl;
    
    	bar = hoge;
    	std::cout << bar.getNum() <<std::endl;
    
        return 0;
    }
    
    operator=という部分で=を関数として自前で定義しますよとしている。 また返り値のHoge%amp;はそう書くものだと思っておけば良い。*thisもそうだ。 ほかの演算子も同じようにoperator(演算子)という形で定義できる。(std::coutでよく使っている<<もそう) その際に従うべき決まりがあるので、ここでは述べないが各自実装する際は調べてそれに従って欲しい。
    まとめ
    以上で長々と書いた。きっと非常にめんどくさいと思う。 実は多くの場合必要なのはそれを実装することではなく、禁止することだ。(問題参照) 初期化と代入は違うということ、またどういうものが存在するかだけ理解して守られば良い。 必要になったら調べればいいだろう。

    また、コンポジションに関しては初心者向けの本ではあまりしっかり触れられることがないが、 非常に重要な概念であり、テクニックなのでしっかり使えるようにして欲しい。 ただ、言っていることは非常に単純である。使っていくうちにわかっていくだろう。 また、今回解説したのは補足すると「is implemented in terms of関係」つまり今回の例でいうと「EnemyはShotによって実装される」だ。 当然もっと内部にオブジェクトを持っていることを連想させる単純な「has a」関係を表現するときにもコンポジションは使える。

    問題
  • ★privateでコピーコンストラクタや代入演算子を定義すると初期化や代入の際 どういう風になるか考え実際にコードを書いて確かめよ。(これはコピーや代入を禁止するテクニックとしてよく使われる。 そして多くの場合こうしておいたほうが無難)
  • ★以下の例はよく「継承(次々回解説予定)」という文法の説明をするために使われる(最悪な)例だ。 しかし、これはコンポジションを使って書くことができる(しそちらのほうがよい)。 「以下のAnimalクラスを拡張してeatの機能はそのままでflyメソッド(飛んだと表示する)を備えたBirdクラスを実装せよ。」
    class Animal{
    public:
    	void eat(){std::cout<<"もぐもぐ" <<std::endl;} 
    };
    
  • 以下のEnemyクラスの引数はint型が複数並んでおり、 順番を間違えて渡したとしてもそれに気づくことができない。 そこで間違えた時型が違うという コンパイルエラーが出せるようにEnemyクラスを Sizeクラス、Positionクラスを使って書き換えよ。 また、それが正しく動くかmain関数を書いて確かめよ。
  • 
    class Size{
    public:
    	Size(int width, int height):
    		Width=width,
    		Height=height
    	{}
    
    	int getWidth() const {return Width;}
    	int getHeight() const {return Height;}
    
    private:
    	const int Width;
    	const int Height;
    	
    	
    };
    
    class Position{
    public:
    	Position(int x, int y):
    		PosX=x,
    		PosY=y
    	{}
    
    	int getPosX() const{return PosX;}
    	int getPosY() const{return PosY;}
    
    private:
    	const int PosX;
    	const int PosY;
    	
    };
    
    
    class Enemy{
    public:
    	Enemy(int width, int height, int startX, int startY);
    
    private:
    	int Width;
    	int Height;
    	int StartX;
    	int StartY;
    
    };
    
  • ★explicitを付けないと以下のようなバグを生み出す。explicitなコンストラクタに書きなおした時、 そうでない時で動作(コンパイルエラー)を見比べよ。
  • #include<iostream>
     
    //音符クラス
    class Note{
    public:
    	Note(int i):
    	  data(i)
    	{}
    
    	//==演算子のオーバーロード
    	bool operator==(int i) const { return (i == data);}
    
    	//intへのキャスト(あまりきにしなくていいです)
    	operator int() const{return data;}
    
    private:
    	int data;
    };
    
    
    int main(){
    
    	Note myNote(0);
    
    	//myNote==1とするつもりが...
    	if (myNote = 1){
    		std::cout<< "true" <<std::endl;	
    	}
     
        return 0;
    }
    
  • 上の問題でconst Note&を引数に取る ==演算子を定義してif文を書きなおし、さらにその際に同じようなバグが起きないようにするにはどうしたら良いか 考えよ(ヒント:一番最初の問題)
  • ★以下のプログラムでデフォルトコンストラクタ、intを受け取るコンストラクタ、コピーコンストラクタ、代入演算子 をそれぞれ定義したHogeクラスを作成して、それぞれがどのタイミングで呼びさされているか確認し、 動作の理由を考察せよ。 また、intを受け取るコンストラクタをexplicitなコンストラクタにするとどうなるか確認せよ。
  • int main(){
    
    	std::cout<< "〜代入〜" <<std::endl;	
    	Hoge hoge;
    	hoge = 4;
    
    	std::cout<< "〜初期化〜" <<std::endl;	
    	Hoge foo = 2;
    
        return 0;
    }