让我们回到之前看过的一个示例:
#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 指向 derived;derived 同时包含一个 Base 部分与一个 Derived 部分。由于 ref 与 ptr 的类型均为 Base,因此它们只能看到 derived 的 Base 部分——Derived 部分依然存在,只是无法通过 ref 或 ptr 访问。然而,借助虚函数,我们仍可调到最派生类(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 获得的是 derived 的 Base 部分的副本,而 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
一切正常!注意两点:
- 现在 nullptr是合法元素,是否接受取决于需求。
- 必须处理指针语义,这可能带来不便。好处是可以存放动态分配的对象(别忘了手动 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——若 b 是 Derived& 确实如此。然而 b 是 Base&,而 C++ 为类提供的 operator= 默认非虚。于是,仅执行复制 Base 部分的赋值运算,d1 的 Base 部分被复制到 d2。
结果,d2 拥有 d1 的 Base 部分与 d2 原有的 Derived 部分。在此例中尚无大碍(因为 Derived 没有自有数据),但多数情况下,你刚刚制造了一个“科学怪人对象”——由多个对象的部分拼接而成。
更糟糕的是,几乎没有简单办法阻止这种情况(除非尽可能避免此类赋值)。
提示
若 Base 类不应单独实例化(例如仅为接口类),可通过删除 Base 的拷贝构造函数与赋值运算符,使 Base 不可复制,从而避免切片。
结论
尽管 C++ 允许通过对象切片把派生类对象赋给基类对象,但通常只会带来麻烦,应尽量避免。确保函数参数采用引用(或指针),并杜绝派生类的按值传递。
