C++对象切片:深入理解派生类到基类的赋值行为

让我们回到之前看过的一个示例:

#include <iostream>
#include <string_view>

class Base
{
protected:
    int m_value{};

public:
    Base(int value)
        : m_value{ value }
    {
    }

    virtual ~Base() = default;

    virtual std::string_view getName() const { return "Base"; }
    int getValue() const { return m_value; }
};

class Derived: public Base
{
public:
    Derived(int value)
        : Base{ value }
    {
    }

    std::string_view getName() const override { return "Derived"; }
};

int main()
{
    Derived derived{ 5 };
    std::cout << "derived is a " << derived.getName() << " and has value " << derived.getValue() << '\n';

    Base& ref{ derived };
    std::cout << "ref is a " << ref.getName() << " and has value " << ref.getValue() << '\n';

    Base* ptr{ &derived };
    std::cout << "ptr is a " << ptr->getName() << " and has value " << ptr->getValue() << '\n';

    return 0;
}

在上述示例中,ref 引用、ptr 指向 derivedderived 同时包含一个 Base 部分与一个 Derived 部分。由于 refptr 的类型均为 Base,因此它们只能看到 derivedBase 部分——Derived 部分依然存在,只是无法通过 refptr 访问。然而,借助虚函数,我们仍可调到最派生类(most-derived)版本。于是,程序输出:

derived is a Derived and has value 5
ref is a Derived and has value 5
ptr is a Derived and has value 5

但若我们不是通过 Base 的引用或指针,而是直接把一个 Derived 对象赋给一个 Base 对象,会发生什么?

int main()
{
    Derived derived{ 5 };
    Base base{ derived }; // 这里发生了什么?
    std::cout << "base is a " << base.getName() << " and has value " << base.getValue() << '\n';

    return 0;
}

请记住,derived 包含 Base 部分与 Derived 部分。当我们把一个 Derived 对象赋给 Base 对象时,仅会复制 Derived 对象中的 Base 部分;Derived 部分不会被复制。上例中,base 获得的是 derivedBase 部分的副本,而 Derived 部分已被“切掉”。因此,将派生类对象赋给基类对象的过程被称为“对象切片”(简称切片)。

由于 base 始终只是一个 Base,其虚指针依旧指向 Base,于是 base.getName() 解析为 Base::getName()。程序输出:

base is a Base and has value 5

谨慎使用时,切片可无害;但若使用不当,切片会在多种情形下导致意外结果。让我们检视其中一些情形。


切片与函数

你可能觉得上例有些刻意——谁会直接把 derived 赋给 base?通常不会。然而,切片更可能在函数调用时无意发生。

考虑如下函数:

void printName(const Base base) // 注意:基类按值传递
{
    std::cout << "I am a " << base.getName() << '\n';
}

此函数接受一个 const Base 类型的值参数。若如此调用:

int main()
{
    Derived d{ 5 };
    printName(d); // 调用端未注意到这是按值传递
    return 0;
}

编写程序时,可能未注意到 base 是值参数而非引用。因此,当调用 printName(d) 时,我们或许期望 base.getName() 通过虚机制打印 “I am a Derived”,但事实并非如此。Derived 对象 d 被切片,仅 Base 部分被复制到参数 base。执行 base.getName() 时,即使 getName() 是虚函数,亦无 Derived 部分可供解析,于是程序输出:

I am a Base

在本例中,结果显而易见;但若函数未打印任何标识信息,追踪错误将变得困难。

当然,只需把函数参数改为引用即可避免切片(这也是“类类型参数应优先按引用而非按值传递”的又一例证):

void printName(const Base& base) // 现在按引用传递
{
    std::cout << "I am a " << base.getName() << '\n';
}

int main()
{
    Derived d{ 5 };
    printName(d);
    return 0;
}

输出:

I am a Derived

切片与 std::vector

新手程序员常在尝试用 std::vector 实现多态时踩到切片坑。考虑如下程序:

#include <vector>

int main()
{
    std::vector<Base> v{};
    v.push_back(Base{ 5 });    // 加入 Base 对象
    v.push_back(Derived{ 6 }); // 加入 Derived 对象

    // 打印所有元素
    for (const auto& element : v)
        std::cout << "I am a " << element.getName() << " with value " << element.getValue() << '\n';

    return 0;
}

程序可正常编译,但运行输出:

I am a Base with value 5
I am a Base with value 6

与之前示例同理,vector 声明为 Base 类型,在 push_back(Derived{6}) 时发生切片。

修复方法略复杂。许多新手尝试创建“引用 vector”:

std::vector<Base&> v{};

这无法通过编译。std::vector 要求其元素可赋值,而引用不可重新赋值(只能初始化)。

一种解决方式是改用指针:

#include <iostream>
#include <vector>

int main()
{
    std::vector<Base*> v{};

    Base b{ 5 };   // b 与 d 不能是匿名对象
    Derived d{ 6 };

    v.push_back(&b); // 加入 Base 对象
    v.push_back(&d); // 加入 Derived 对象

    // 打印所有元素
    for (const auto* element : v)
        std::cout << "I am a " << element->getName() << " with value " << element->getValue() << '\n';

    return 0;
}

输出:

I am a Base with value 5
I am a Derived with value 6

一切正常!注意两点:

  1. 现在 nullptr 是合法元素,是否接受取决于需求。
  2. 必须处理指针语义,这可能带来不便。好处是可以存放动态分配的对象(别忘了手动 delete)。

另一种选择是使用 std::reference_wrapper,它模拟可重新赋值的引用:

#include <functional> // std::reference_wrapper
#include <iostream>
#include <string_view>
#include <vector>

class Base
{
protected:
    int m_value{};

public:
    Base(int value)
        : m_value{ value }
    {
    }
    virtual ~Base() = default;

    virtual std::string_view getName() const { return "Base"; }
    int getValue() const { return m_value; }
};

class Derived : public Base
{
public:
    Derived(int value)
        : Base{ value }
    {
    }

    std::string_view getName() const override { return "Derived"; }
};

int main()
{
    std::vector<std::reference_wrapper<Base>> v{}; // 保存可重新赋值的 Base 引用

    Base b{ 5 };
    Derived d{ 6 };

    v.push_back(b); // 加入 Base 对象
    v.push_back(d); // 加入 Derived 对象

    // 打印所有元素
    // 使用 .get() 从 std::reference_wrapper 取出对象
    for (const auto& element : v) // element 类型为 const std::reference_wrapper<Base>&
        std::cout << "I am a " << element.get().getName() << " with value " << element.get().getValue() << '\n';

    return 0;
}

科学怪人对象(Frankenobject)

前述示例中,切片导致派生部分被切除,结果错误。现在再看一种更危险的情形:派生对象仍然存在!

考虑以下代码:

int main()
{
    Derived d1{ 5 };
    Derived d2{ 6 };
    Base& b{ d2 };

    b = d1; // 这行有问题
    return 0;
}

前三行直截了当:创建两个 Derived 对象,并把 Base 引用绑定到第二个。

第四行出现偏差。b 指向 d2,于是把 d1 赋给 b,你可能以为结果是把 d1 复制到 d2——若 bDerived& 确实如此。然而 bBase&,而 C++ 为类提供的 operator= 默认非虚。于是,仅执行复制 Base 部分的赋值运算,d1Base 部分被复制到 d2

结果,d2 拥有 d1Base 部分与 d2 原有的 Derived 部分。在此例中尚无大碍(因为 Derived 没有自有数据),但多数情况下,你刚刚制造了一个“科学怪人对象”——由多个对象的部分拼接而成。

更糟糕的是,几乎没有简单办法阻止这种情况(除非尽可能避免此类赋值)。

提示

Base 类不应单独实例化(例如仅为接口类),可通过删除 Base 的拷贝构造函数与赋值运算符,使 Base 不可复制,从而避免切片。

结论

尽管 C++ 允许通过对象切片把派生类对象赋给基类对象,但通常只会带来麻烦,应尽量避免。确保函数参数采用引用(或指针),并杜绝派生类的按值传递。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"