25-9 对象切片
让我们回到之前讨论过的例子:
#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,该类包含基类部分和派生类部分。由于 ref 和 ptr 的类型为基类 Base,它们只能访问 derived 的基类部分——派生类部分依然存在,但无法通过 ref 或 ptr 访问。然而,通过使用虚函数,我们可以访问函数的最派生版本。因此,上述程序输出:

但如果我们不将基类引用或指针赋值给派生类对象,而是直接将派生类对象赋值给基类对象,会发生什么情况?
int main()
{
Derived derived{ 5 };
Base base{ derived }; // what happens here?
std::cout << "base is a " << base.getName() << " and has value " << base.getValue() << '\n';
return 0;
}
请记住,派生类包含基类部分和派生部分。当我们将派生对象赋值给基类对象时,仅复制派生对象的基类部分,派生部分不会被复制。在上例中,基类接收的是派生对象基类部分的副本,而非派生部分。该派生部分实际上已被“切除”。因此,将派生类对象赋值给基类对象的行为被称为对象切片object slicing(简称切片)。
由于 base 始终是基类对象,其虚指针仍指向基类。故 base.getName() 实际解析为 Base::getName()。
上述示例输出:

若使用得当,切片操作本无大碍。但若操作不当,切片可能以多种方式引发意想不到的后果。让我们来分析其中几种情况。
切片与函数
现在,你可能会觉得上面的例子有点傻。毕竟,为什么要这样将派生类型赋值给基类呢?你大概不会这么做。然而,在函数中,切片更容易意外发生。
考虑以下函数:
void printName(const Base base) // note: base passed by value, not reference
{
std::cout << "I am a " << base.getName() << '\n';
}
编写此程序时,您可能未注意到 base 是值参数而非引用参数。因此当以 printName(d) 形式调用时,虽然我们预期 base.getName() 会调用虚拟化的 getName() 函数并输出“I am a Derived”,但实际并非如此。相反,Derived对象d会被切片处理,仅其基类部分被复制到base参数中。当base.getName()执行时,尽管getName()函数已被虚拟化,但此时已不存在可解析的派生类部分。最终程序输出:

在此情况下,问题原因显而易见,但若函数并未像这样输出任何识别信息,追踪错误就变得相当棘手。
当然,只要将函数参数改为引用传递而非值传递,此处的切片问题便能轻松避免(这再次印证了类采用引用传递而非值传递的合理性)。
void printName(const Base& base) // note: base now passed by reference
{
std::cout << "I am a " << base.getName() << '\n';
}
int main()
{
Derived d{ 5 };
printName(d);
return 0;
}
This prints:

切片向量
新手程序员在切片方面遇到的另一个问题是尝试使用std::vector实现多态性。请看以下程序:
#include <vector>
int main()
{
std::vector<Base> v{};
v.push_back(Base{ 5 }); // add a Base object to our vector
v.push_back(Derived{ 6 }); // add a Derived object to our vector
// Print out all of the elements in our vector
for (const auto& element : v)
std::cout << "I am a " << element.getName() << " with value " << element.getValue() << '\n';
return 0;
}
该程序编译完全正常。但运行时,它会输出:

与前例类似,由于 std::vector 被声明为 Base 类型的向量,当向量中添加 Derived(6) 时,该对象被切片处理。
修复此问题稍显复杂。许多新手程序员会尝试创建引用对象的 std::vector,例如:
std::vector<Base&> v{};
遗憾的是,这段代码无法编译。std::vector的元素必须可赋值,而引用无法被重新赋值(仅可初始化)。

解决此问题的一种方法是创建一个指针向量:
#include <iostream>
#include <vector>
int main()
{
std::vector<Base*> v{};
Base b{ 5 }; // b and d can't be anonymous objects
Derived d{ 6 };
v.push_back(&b); // add a Base object to our vector
v.push_back(&d); // add a Derived object to our vector
// Print out all of the elements in our vector
for (const auto* element : v)
std::cout << "I am a " << element->getName() << " with value " << element->getValue() << '\n';
return 0;
}
这将输出:

这确实可行!关于此方案有几点说明:首先,nullptr 现已成为有效选项,其适用性因场景而异。其次,您需要处理指针语义,这可能带来操作不便。但其优势在于:使用指针能将动态分配的对象放入向量(只需记得显式删除即可)。
另一种方案是使用std::reference_wrapper,该类模拟可重新赋值的引用:
#include <functional> // for 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{}; // a vector of reassignable references to Base
Base b{ 5 }; // b and d can't be anonymous objects
Derived d{ 6 };
v.push_back(b); // add a Base object to our vector
v.push_back(d); // add a Derived object to our vector
// Print out all of the elements in our vector
// we use .get() to get our element out of the std::reference_wrapper
for (const auto& element : v) // element has type const std::reference_wrapper<Base>&
std::cout << "I am a " << element.get().getName() << " with value " << element.get().getValue() << '\n';
return 0;
}

弗兰肯斯坦对象The Frankenobject
在上面的例子中,我们看到过切片导致错误结果的情况,因为派生类已被切除。现在让我们看看另一个危险的情况——派生对象仍然存在!
考虑以下代码:
int main()
{
Derived d1{ 5 };
Derived d2{ 6 };
Base& b{ d2 };
b = d1; // this line is problematic
return 0;
}
函数的前三行代码相当直白:创建两个派生类对象,并将基类引用指向第二个对象。
问题出在第四行。由于变量b指向d2,而我们将d1赋值给b,你可能会认为结果是将d1复制到d2——如果b是派生类的话确实如此。但b实为Base类型,而C++为类提供的赋值运算符默认并非虚函数。因此系统调用了复制Base类的赋值运算符,仅将d1的Base部分复制到d2。
最终你会发现:d2既包含d1的Base部分,又保留了d2自身的派生部分。在这个特定示例中,这并不构成问题(因为派生类本身没有数据),但在多数情况下,你将创建一个由多个对象部分组成的“弗兰肯斯坦对象”。
更糟糕的是,除了尽可能避免此类赋值操作外,没有简单方法能防止这种情况发生。
提示:
如果基类本身不支持实例化(例如它只是接口类),可通过禁用基类的可复制性(删除基类的复制构造函数和赋值运算符)来避免切片操作。
结论
尽管C++支持通过对象切片将派生对象赋值给基类对象,但通常这只会带来麻烦,因此应尽量避免使用切片。确保函数参数采用引用(或指针)形式,并尽量避免在涉及派生类时采用任何形式的值传递。

浙公网安备 33010602011771号