配列とポリモーフィズム

C++はちゃんと理解すれば面白いけど、
ちゃんと理解するのが大変だから、
ちゃんと使うのが難しい言語だと思います。

標題についてちょっと気になったことがあったのでメモ書き。
私がちゃんと理解できていなかっただけで、ごくごく当たり前のことかもしれません。

いつものごとく、ごちゃごちゃなりそうなので、最初に、自分の中で得た結論から。

【結論】
ポリモーフィズムを使うオブジェクトの配列(具体的にはクラス変数へのポインタの配列、あるいはvector)は、適切な型にコピーしてから受け渡す。


例えば、こんな場合。

【問題】
ある2つのクラス(Parent, Child)が継承関係にあります。
ここでは省略しますが、これら2つのクラスには、なんらかの属性やメソッドがあり、ChildクラスはParentクラスの仮想関数をオーバーライドしているとします。
(※つまりポリモーフィズムを使うことが前提)

class Parent {};
class Child : public Parent {};

あるクラスが多数のChildオブジェクトを管理(属性に保持)します。

class Manager
{
public:
    void method();
	
private:
    std::vector<Child*> carray;
};

また、Parentオブジェクトをまとめて引数で受け取って処理する関数があるとします。
(引数のvectorの中身を変更することはない)

void Function(const std::vector<Parent*>& parray)
{
    // ...
}

さて、Managerクラスのmethod()内から、Function関数を使おうとした場合、どのように呼び出すべきでしょうか。


私はここで一瞬、

「同じvectorだし中身が継承オブジェクトへのポインタなんだからそのまま渡せてもいいだろう」

と思ってしまったのですが、

void Manager::method()
{
    Function(carray);    // コンパイルエラー
}

つうことです。

まあ、これは考えてみれば当然のことですね。
templateはコンパイル時にパラメータに応じたクラスを静的に生成するのですから、vectorvectorは全く別のクラスであり、継承関係にすらありません。

では、これはどうでしょう。

void Manager::method()
{
    // 危なくね?
    Function(*reinterpret_cast<std::vector<Parent*>*>(&carray));
}

一度ポインタとして、vector*をvector*に再翻訳キャストしています。
これ、良くないやり方だとは思うんですけど、多分動くと思います。
なぜなら、vector*型とvector*型は両方ともポインタ型で、その指し示す先は、vector型とvector型で、それらは「おそらく」同等のメモリサイズを持っており、「おそらく」同じように動く同じ名前のメソッドを持っているからです。

しかしこれは、vector変数としてのアドレスを指しているポインタに対して、vectorという異なるクラスとしてのメンバ関数の呼び出しを指示することになります。クラス変数へのポインタに対するメンバ関数呼び出しの内部的な仕組みを理解していないので、正確なことは言えませんが、「たぶん動きはするだろうけど、危険なやり方だ」という認識でいます。

少し蛇足。
ここまでは主にC++の言語仕様に関する話でしたが、ここで少し視野を広げて、「オブジェクト指向における配列のポリモーフィズム」について考えてみます。
Javaでは、Parent配列に対してChild配列を代入することができます。(後で例を示します)
ParentとChildが継承関係にあるから、配列としての代入もOKということです。
しかし、これは一概に良いこととは言えません。本題の例では、Function関数の引数のvectorにconstがついています。
仮に、このconstがなくて、Function内でvectorに何かを入れるとします。
そして例えば、フルーツクラスの派生クラスとして、りんごとぶどうがあったとしましょう。

// フルーツかごの中にぶどうとりんごを入れる(これだけ見れば正しい行為)
void Function(std::vector<Fruit*>& f_array)
{
    f_array.push_back(new Grape());
    f_array.push_back(new Apple());
}

// 私はりんごが嫌いなのでぶどうだけを収集するかごを作ります
std::vector<Grape*> g_array;

// もしも以下の命令のコンパイルが通るとしたら、
Function(g_array);

// 私のぶどうかごの中にりんごが混ざってしまうことになります。

このように、派生クラスの配列を暗黙に基底クラスの配列へと変換できてしまうことには、問題もあるということです。特にC++では実行時の型チェックは行われないので、危険を伴います。


ということで、結論に至るわけですが、こういう場合は面倒くさがらずに、ちゃんと正しい型の変数にコピーして渡してやるべきです。

【解答】

// これが正解だと思う
void Manager::method()
{
    std::vector<Parent*> v;
    v.insert(v.end(), carray.begin(), carray.end());

    Function(v);
}

Parent* に対してChild*を代入するのは、この2つが継承関係にあるので、正しい行為です。
ちょっと無駄なコピー処理にも思えてしまうけれど、これはもう仕方ないかな〜

ここでは例を単純にするためにFunctionをただの関数としてきましたが、本当にちょっとした関数であるなら、

template <class T>
void Function(const std::vector<T*>& array) { /* ... */ }

としても良いかもしれませんが、これはもはやポリモーフィズムとは全く異なるアプローチです。
TにParentと無関係のクラスが指定された場合に、どこかで辻褄が合わなくなるでしょうし、あと、Functionが関数ではなく、クラス間のメソッド呼び出しだとこのような指定はできなくなると思います。


Childの数が物凄く多くて、どうしても無駄なコピーを避けたい場合は、ポインタとして受け渡しをしてやれば良いと思います。

void Function(Parent** p, int count)
{
    // ...
}

void Manager::method()
{
    Child** c = &carray[0];
    Parent** p = reinterpret_cast<Parent**>(c);
    Function(p, carray.size());
}

この場合のreinterpret_castは問題ないと思います。
なぜなら、

  • sizeof(Parent*)とsizeof(Child*)は同じなので、イテレーション間隔に問題はありません。(Child**変数の++とParent**変数の++が同じ移動距離になる)
  • vector内のメモリは連続して取られる仕様になっているので、イテレーション距離に問題はありません。(10個の要素があるならば、sizeof(Child*) * 10のメモリに連続してChild*が格納されている)
  • ChildはParentを継承しているクラスなので、Child*変数をParent*変数として使うことに問題はありません。


よく、「reinterpret_castは環境依存だから使うな」という人が居ますが、本当でしょうか?
私の認識では、「値を保持したまま、型解釈だけを変える」ということでしたが。
例えば、どこかのbyte配列へのポインタをなんらかの構造体へのポインタとして使うときに、アラインメントをどう解釈するかは処理系依存ですよ、とか、そういうことじゃないのかな。





Javaとかいう言語があるらしい

public class Test {
	public static void main(String[] args) {
		Parent pa[] = new Parent[10];
		Child1 ca[] = new Child1[10];
		pa = ca;	// OK!
		
		List<Parent> pl = new ArrayList<Parent>();
		List<Child1> cl = new ArrayList<Child1>();
		pl.addAll(cl);	// OK

		pl = cl;	// NG
		
		notlegal(ca);
	}

	static void notlegal(Parent[] array) {
		array[0] = new Child2();	// りんごの果物かごにぶどうをぶち込んでやる!
		// ArrayStoreException
	}
}

やはりJavaジェネリクスでも異なる型パラメータを持つクラス間の代入はできませんでした。
Child配列をParent配列のサブクラスと認めてくれるところがC++と違うところですね。