让我们回到之前看过的一个示例:
#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++ 允许通过对象切片把派生类对象赋给基类对象,但通常只会带来麻烦,应尽量避免。确保函数参数采用引用(或指针),并杜绝派生类的按值传递。