【C++】多重継承とダイヤモンド問題とは?|仮想継承で重複を解消する仕組み

diamond inheritance thumbnail C++

多重継承は、結局何が危ないのか。ダイヤモンド問題という名前は聞いたことがあるけれど、実際には、どんな不具合になるのか。「危ない」で終わらせずに、メモリ上で実際に何が起きているのかまで、見ていきます。

この記事では、C++の多重継承で必ず話題になるダイヤモンド問題について、実際にコンパイル・実行した結果を交えながら解説します。virtual継承が、普通のvirtual関数と何が違うのかも、整理します。

ダイヤモンド問題とは?

ダイヤモンド問題とは、

・共通の基底クラスを持つ2つのクラスを、さらに1つのクラスが多重継承すると、基底クラスの実体が重複して持たれてしまう問題

です。Animalを継承したBirdとFishがあり、その両方を継承したFlyingFishを作ると、継承関係を図にしたときに菱形(ダイヤモンド)の形になることからこう呼ばれます。

実際に何が起きるか、実験してみる

百聞は一見にしかずなので、実際にダイヤモンド継承のクラスを書いて、内部で何が起きているかを見てみます。

struct Animal {
    int hp = 100;
    void Print() { printf("    Animal::hp = %d (this=%p)\n", hp, (void*)this); }
};
struct Bird : Animal {};
struct Fish : Animal {};
struct FlyingFish : Bird, Fish {}; // BirdとFishの両方からAnimalを継承

このFlyingFishのサイズと、Bird経由・Fish経由それぞれで見たAnimalのアドレスを出力してみます。

[1] 仮想継承なし(ダイヤモンド継承)
    sizeof(FlyingFish) = 8 バイト
    Bird::Animal::hp を経由:
    Animal::hp = 100 (this=000000B17D0FFB30)
    Fish::Animal::hp を経由:
    Animal::hp = 100 (this=000000B17D0FFB34)
    -> 2つのAnimalが別アドレスに存在している(hpも別々)

sizeof(FlyingFish)は8バイト。int1つ(4バイト)のAnimalが、2つ分ちょうどのサイズになっています。さらにアドレスを見ると、Bird経由で見たAnimalとFish経由で見たAnimalは、4バイトだけズレた別々のアドレスを指しています。つまりFlyingFishの中には、Animalが本当に2つ存在しているのです。

この状態でff.Print()やff.hpのようにBird/Fishを指定せずアクセスしようとすると、「どちらのAnimalのことか分からない」というあいまいさエラーでコンパイルが通りません。実用上も、片方のAnimal::hpだけを回復させても、もう片方は回復していない、というバグの温床になります。

【重要】仮想継承で実際に直してみる

この重複を解消するのが仮想継承(virtual継承)です。BirdとFishがAnimalを継承するときにvirtualを付けるだけです。

struct AnimalV {
    int hp = 100;
    void Print() { printf("    AnimalV::hp = %d (this=%p)\n", hp, (void*)this); }
};
struct BirdV : virtual AnimalV {};
struct FishV : virtual AnimalV {};
struct FlyingFishV : BirdV, FishV {};

同じように実行してみます。

[2] 仮想継承あり
    sizeof(FlyingFishV) = 24 バイト
    BirdV::AnimalV::hp を経由:
    AnimalV::hp = 100 (this=000000B17D0FFB48)
    FishV::AnimalV::hp を経由:
    AnimalV::hp = 100 (this=000000B17D0FFB48)
    -> 同じアドレスの1つのAnimalVを共有している
    ffv.hp = 50; 実行後 -> BirdV経由=50 FishV経由=50

今度はBirdV経由とFishV経由で、まったく同じアドレスを指しています。AnimalVは1つだけ共有され、ffv.hp = 50;と書き換えると、どちら経由で見ても50に変わっているのが確認できます。あいまいさエラーも出ず、ffv.hpと直接書けるようになります。

ただしsizeofが8バイトから24バイトに増えています。仮想継承は、共有された基底クラスの実体を実行時に正しく見つけるための管理情報(仮想基底クラスポインタなど)を余分に持つ必要があるためです。タダで重複が消えるわけではない、というのも実際に数値を見て初めて実感できるポイントでした。

多重継承のよくある失敗例と対処法

①菱形継承にvirtualを付け忘れる

上で実演した通りです。共通の基底クラスを2箇所以上から継承する設計になったら、まず菱形になっていないか疑い、なっているなら仮想継承を検討しましょう。

②あいまいさエラーを場当たり的に潰す

ff.Bird::Print()のように経由するクラスを毎回明示して回避することもできますが、それは「重複している」という根本原因を放置したまま症状だけ消しているにすぎません。本当に1つであるべきデータなら、仮想継承で実体を統一するのが筋です。

③そもそも多重継承しなくていい設計を検討しない

ダイヤモンド問題が起きるほど複雑な継承関係になっている場合、継承ではなくコンポジション(part-of関係)で組み替えられないか検討する価値があります。継承は「is-a」が成立するときだけに留めるのが安全です。

注意点

  • 菱形継承になるときは基底クラス側にvirtualを付ける
  • 仮想継承はサイズが増える(管理コストがタダではない)
  • あいまいさエラーは「重複している」ことのサインと捉える

ここまでのおさらい

  • ダイヤモンド問題は共通の基底クラスが多重継承で重複する問題
  • 実際にsizeofとアドレスを見ると、本当に2つ存在していることが確認できた
  • 仮想継承で1つに統一できるが、代わりにサイズが増える
  • そもそも多重継承しなくていい設計にできないかも検討する

sizeofとアドレスを出力して確かめると、基底クラスが、本当に2つ存在していました。仮想継承で1つに統一できますが、代わりにサイズが増えます。そもそも、多重継承しなくてよい設計にできないかも、検討してください。

関連記事