敵クラスを継承して、Dragonを作り、攻撃力を100にしました。ところが、そのDragonを、Enemy型の引数に渡した関数の中で、攻撃力を読むと、1になります。virtualも、overrideも、正しく書いてあるのに、です。
これは、派生クラスを基底クラスの「値」で受け取ったときに起きる、オブジェクトスライシングという現象です。どんなときに起きて、何が失われるのかを、渡し方別・コンテナ別・代入で動かして確かめ、最後に、起きないようにする書き方も試しました。
検証は、Visual Studio 2022付属のMSVC(19.44、x64)を、Release(/O2 /MD、警告は/W4)で動かして行いました。
オブジェクトスライシングとは?
・派生クラスのオブジェクトを、基底クラスの値として受け取ると、基底クラスの部分だけがコピーされ、派生クラスの部分が切り捨てられる現象
ケーキを切り分けるように、派生クラスの「基底クラスの分」だけが取り出される、というイメージです。切り取られたあとのオブジェクトは、本当に基底クラスのオブジェクトです。派生クラスの追加メンバーも、overrideした関数も、もう、残っていません。
実験①:渡し方を変えて、攻撃力を読む
Enemyを継承したDragon(追加メンバーfireBreath = 100)を作り、値・参照・ポインタの3通りで、関数に渡して、攻撃力と、オブジェクトのサイズを表示しました。
▼e1_slice.cpp
// 派生クラスを、基底クラスの「値」で受け取ると、何が起きるか
#include <cstdio>
struct Enemy
{
int id = 0;
virtual ~Enemy() = default;
virtual const char* name() const { return "敵(基底クラス)"; }
virtual int attack() const { return 1; }
};
struct Dragon : Enemy
{
int fireBreath = 100;
const char* name() const override { return "ドラゴン"; }
int attack() const override { return fireBreath; }
};
void byValue(Enemy e) { std::printf("値渡し : %s / 攻撃力 %3d / sizeof = %zu\n", e.name(), e.attack(), sizeof(e)); }
void byRef(const Enemy& e) { std::printf("参照渡し : %s / 攻撃力 %3d / sizeof = %zu\n", e.name(), e.attack(), sizeof(e)); }
void byPointer(const Enemy* e) { std::printf("ポインタ渡し: %s / 攻撃力 %3d / sizeof = %zu\n", e->name(), e->attack(), sizeof(*e)); }
int main()
{
Dragon d;
std::printf("sizeof(Enemy) = %zu, sizeof(Dragon) = %zu\n\n", sizeof(Enemy), sizeof(Dragon));
byValue(d);
byRef(d);
byPointer(&d);
Enemy copy = d; // 値でコピー
std::printf("Enemy copy = d : %s / 攻撃力 %3d\n", copy.name(), copy.attack());
return 0;
}sizeof(Enemy) = 16, sizeof(Dragon) = 24
値渡し : 敵(基底クラス) / 攻撃力 1 / sizeof = 16
参照渡し : ドラゴン / 攻撃力 100 / sizeof = 16
ポインタ渡し: ドラゴン / 攻撃力 100 / sizeof = 16
Enemy copy = d : 敵(基底クラス) / 攻撃力 1値渡しだけ、名前が敵(基底クラス)、攻撃力が1になりました。参照渡しとポインタ渡しは、ドラゴンと100のままです。Enemy copy = d;のような、値でのコピーでも、同じように、基底クラスの動きに戻っています。
sizeof(Enemy)は16、sizeof(Dragon)は24でした。値で受け取ったeのsizeofが16なのは、Dragonの追加メンバー(fireBreathなど)を入れる場所が、もう無いからです。このEnemyの中身は、vtableのポインタも、Enemyのものに、付け替えられています。だから、overrideした関数が、呼ばれなくなります。
なお、/W4でビルドしても、このスライシングに対する警告は、1つも出ませんでした。コンパイラは、教えてくれません。
【重要】実験②:vectorに、敵を入れる
ゲームで起きやすいのは、敵をまとめてvectorに入れる場面です。スライム(5)・ゴブリン(12)・ドラゴン(100)を、vector<Enemy>とvector<unique_ptr<Enemy>>に入れて、攻撃力の合計を出しました。
▼e2_vector.cpp
// vector<Enemy> に派生クラスを入れて、攻撃力の合計を出す
#include <cstdio>
#include <memory>
#include <vector>
struct Enemy
{
virtual ~Enemy() = default;
virtual int attack() const { return 1; }
};
struct Slime : Enemy { int attack() const override { return 5; } };
struct Goblin : Enemy { int attack() const override { return 12; } };
struct Dragon : Enemy { int attack() const override { return 100; } };
int main()
{
// ① 値で入れる
std::vector<Enemy> byValue;
byValue.push_back(Slime());
byValue.push_back(Goblin());
byValue.push_back(Dragon());
int sum1 = 0;
for (const Enemy& e : byValue) sum1 += e.attack();
std::printf("vector<Enemy> : 攻撃力の合計 = %3d\n", sum1);
// ② ポインタ(unique_ptr)で入れる
std::vector<std::unique_ptr<Enemy>> byPtr;
byPtr.push_back(std::make_unique<Slime>());
byPtr.push_back(std::make_unique<Goblin>());
byPtr.push_back(std::make_unique<Dragon>());
int sum2 = 0;
for (const auto& e : byPtr) sum2 += e->attack();
std::printf("vector<unique_ptr<Enemy>> : 攻撃力の合計 = %3d (5 + 12 + 100 = 117 のはず)\n", sum2);
return 0;
}vector<Enemy> : 攻撃力の合計 = 3
vector<unique_ptr<Enemy>> : 攻撃力の合計 = 117 (5 + 12 + 100 = 117 のはず)
vector<Enemy>の合計は、正解の117ではなく、3でした。push_back(Dragon())の時点で、DragonはEnemyに切り取られて、3体とも、攻撃力1の、ただのEnemyになっています。vector<unique_ptr<Enemy>>なら、117で、正解です。
この間違いは、エラーも、警告も、クラッシュも、ありません。ゲームの画面で、「どの敵も、同じ弱さになる」という形で、初めて、気づきます。
実験③:基底クラスの参照どうしで、代入する
スライシングは、渡すときだけでなく、代入でも起きます。派生クラスのオブジェクトを、基底クラスの参照で受けて、代入してみました。
▼e3_assign.cpp
// 基底クラスの参照どおしで代入すると、基底クラスの部分だけがコピーされる
#include <cstdio>
struct Base
{
int id = 0;
virtual ~Base() = default;
virtual const char* who() const { return "Base"; }
};
struct Derived : Base
{
int hp = 0;
const char* who() const override { return "Derived"; }
};
int main()
{
Derived a; a.id = 1; a.hp = 10;
Derived b; b.id = 2; b.hp = 99;
std::printf("代入の前 : a = {id %d, hp %2d}, b = {id %d, hp %2d}\n", a.id, a.hp, b.id, b.hp);
Base& ra = a;
Base& rb = b;
ra = rb; // 基底クラス同士の代入(= Base の部分だけがコピーされる)
std::printf("ra = rb : a = {id %d, hp %2d} ← id だけ b の値になり、hp は元のまま\n", a.id, a.hp);
return 0;
}代入の前 : a = {id 1, hp 10}, b = {id 2, hp 99}
ra = rb : a = {id 2, hp 10} ← id だけ b の値になり、hp は元のままra = rbのあと、aのidは、bの値(2)になりましたが、hpは元の10のままでした。基底クラスの部分だけが代入されて、派生クラスの部分は、置き去りです。aは、idがbのもの、hpが自分のもの、という、どちらでもない中途半端な状態になっています。
防ぎ方
①値ではなく、参照かポインタで受け取る
実験①のとおり、参照渡し(const Enemy&)・ポインタ渡しなら、切り取られません。読むだけならconst Enemy&、所有するなら、unique_ptr<Enemy>です。値渡しとの違いは、値渡し・ポインタ渡し・参照渡しの記事で、実測しています。
②基底クラスを、抽象クラスにする
純粋仮想関数(= 0)を、1つでも持つクラスは、値としては作れません。スライシングが、ビルドエラーになります。
▼e4_abstract_bad.cpp
// 基底クラスを「抽象クラス」にして、値渡し・値コピーをコンパイルエラーにする
struct Enemy
{
virtual ~Enemy() = default;
virtual int attack() const = 0; // 純粋仮想関数: Enemy そのものは作れない
};
struct Dragon : Enemy
{
int attack() const override { return 100; }
};
void byValue(Enemy e) {} // 抽象クラスは値で受け取れない
int main()
{
Dragon d;
byValue(d);
Enemy copy = d; // 値でコピーも、できない
return 0;
}e4_abstract_bad.cpp(13): error C2259: 'Enemy': 抽象クラスをインスタンス化できません。
e4_abstract_bad.cpp(18): error C2259: 'Enemy': 抽象クラスをインスタンス化できません。
e4_abstract_bad.cpp(19): error C2259: 'Enemy': 抽象クラスをインスタンス化できません。
e4_abstract_bad.cpp(19): error C2259: 'Enemy': 抽象クラスをインスタンス化できません。値渡しの引数の宣言と、呼び出し、値でのコピーの、3か所が、C2259で止まりました(行19は、コピーの2か所分が出ています)。基底クラスに、「そもそも、単体では使わない」という性格があるなら、この方法が、いちばん確実です。
③コピーコンストラクタを、protectedにする
基底クラスを、単体でも使いたい(純粋仮想関数を作れない)なら、コピーコンストラクタをprotectedにします。
▼e5_protected_bad.cpp
// 純粋仮想関数が無い基底クラスでは、コピーコンストラクタを protected にして守る
struct Enemy
{
virtual ~Enemy() = default;
virtual int attack() const { return 1; }
protected:
Enemy() = default;
Enemy(const Enemy&) = default; // 外から Enemy をコピーさせない(派生クラスの中からは、使える)
};
struct Dragon : Enemy
{
int attack() const override { return 100; }
};
int main()
{
Dragon d;
Dragon d2 = d; // 派生クラス同士のコピーは、これまでどおり
Enemy copy = d; // 基底クラスへの値コピーは、ビルドが通らない
return 0;
}e5_protected_bad.cpp
e5_protected_bad.cpp(20): error C2248: 'Enemy::Enemy': protected メンバー (クラス 'Enemy' で宣言されている) にアクセスできません。
e5_protected_bad.cpp(8): note: 'Enemy::Enemy' の宣言を確認してください
e5_protected_bad.cpp(2): note: 'Enemy' の宣言を確認してくださいDragon d2 = d;(派生クラス同士のコピー)は通り、Enemy copy = d;だけが、C2248で止まりました。派生クラスの内部からは、基底クラスのコピーコンストラクタを使えるので、派生クラスのコピーは、これまでどおり動きます。
よくある失敗例と対処法
①vectorに、派生クラスを入れる
実験②のとおり、入れた瞬間に切り取られます。ポインタ(unique_ptr、shared_ptr)のvectorにします。
②基底クラスの値を引数にした関数に、派生クラスを渡す
実験①のとおり、仮想関数が、基底クラスの動きに戻ります。引数を、const 基底クラス&にします。
③基底クラスの参照どうしで、代入やswapをする
実験③のとおり、半分だけが代入されます。派生クラスの型が、分かっているなら、その型で代入します。
④コンテナの要素を、基底クラスの値で取り出して、変数に入れる
Enemy e = *ptr;のような書き方も、スライシングです。const Enemy& e = *ptr;と、参照で受けます。
注意点
- 派生クラスを、基底クラスの値で受け取ると、基底クラスの部分だけが残る(スライシング)
- 値渡しだけ、攻撃力が
1になった。参照渡し・ポインタ渡しは、100のまま vector<Enemy>の攻撃力の合計は、正解の117ではなく3。unique_ptrのvectorなら117- 基底クラスの参照どうしの代入は、基底クラスの部分だけが、コピーされる
/W4でも、警告は出ない。ビルドエラーにするには、抽象クラスか、protectedのコピーコンストラクタにする- 今回の検証は、MSVC 19.44(Visual Studio 2022付属、x64、
/O2 /MD /W4)で行った
この記事のポイント
- 継承したクラスは、参照かポインタで扱う。値で扱うと、派生部分が切り取られる
vectorに、多様な敵を入れたいなら、vector<unique_ptr<基底クラス>>- 基底クラスは、可能なら抽象クラスにして、値で使えなくする
- 切り取られても、エラーも警告も出ない。仮想関数が効かないときの、最初の疑いどころ
virtualを書いたのに、基底クラスの動きになってしまうときは、仮想関数の書き方より先に、「その変数は、値で持っていないか」を見てみてください。ほとんどの場合、原因は、&か*が、1文字足りないことにあります。
あわせて読みたい
- 【C++】ポリモーフィズムとは?|virtualとoverrideの使い方を初心者向けに解説
- 【C++】仮想デストラクタを付け忘れると、派生クラスの後始末が呼ばれない?|メモリが増え続ける様子を実測して、unique_ptrとshared_ptrの違いも確かめる
- 【C++】値渡し・ポインタ渡し・参照渡しの違いとは?
- 【C++】継承とは?|protectedの使い方をゲームの敵クラスで初心者向けに解説
- 【C++】スマートポインタ(unique_ptr/shared_ptr)とは?|new/deleteを卒業する安全なメモリ管理
- 【C++】多重継承とダイヤモンド問題とは?|仮想継承で重複を解消する仕組み

