【C++】派生クラスを値で渡すと「切り取られる」原因とは?|オブジェクトスライシングで仮想関数が効かなくなる現象を実測して防ぐ

【C++】派生クラスを値で渡すと「切り取られる」原因とは?|オブジェクトスライシングで仮想関数が効かなくなる現象を実測して防ぐ C++

敵クラスを継承して、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に入れた攻撃力の合計。値で扱うと基底クラスの値に戻る
値で扱うと、攻撃力は基底クラスの値(1)に戻る。合計は、正解の117ではなく3になった

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文字足りないことにあります。

あわせて読みたい