18.8 对象切割¶
什么是对象切割¶
让我们回到之前的一个例子
class Base
{
protected:
int m_value{};
public:
Base(int value)
: m_value{ value }
{
}
virtual const char* getName() const { return "Base"; }
int getValue() const { return m_value; }
};
class Derived: public Base
{
public:
Derived(int value)
: Base{ value }
{
}
virtual const char* getName() const { 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 指针 指向了具有 Base 部分和衍生部分的对象。因为 ref 和 ptr 的类型是 Base,ref 和 ptr 只能看到(access)派生的基部分——派生的派生部分仍然存在,但不能通过 ref 或 ptr 看到。但是,通过使用虚函数,我们可以访问函数的最派生版本。因此,上述程序打印:
derived is a Derived and has value 5
ref is a Derived and has value 5
ptr is a Derived and has value 5
但是如果不设置引用或者指针,我们直接把 Derived 对象赋值给基 Base 对象会发生什么?
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;
}
记住派生对象有一个基部分和一个派生部分,当我们赋值Derived对象到基对象,只有 Base 部分会被复制,Derived 部分则不会。在上方的例子中,基对象接收了一个 Derived 对象的 Base 部分的copy,但是忽略了 Derived 部分。派生部分被切掉 (sliced off)了。
因此,这种对派生对象到基对象的赋值叫做,对象切割。
因为 Base 没有包含 Derived 部分,因此 base.getName()
会决定调用 Base::getName()
。
以上例子会打印:
base is a Base and has value 5
用的仔细,切割会是一种优秀的操作。然而若粗心使用会酿成大祸,切割会造成不可预料的结果,以很多不同的方式,让我们来验证这些例子。
糟糕的用法¶
Slicing and functions¶
现在你可能认为上方的例子有点蠢,毕竟,为什么会像那样把派生类赋值到基类呢?你可能不会那么做,然而切割常常意外的发生,当配合函数食用时。
思考下面的函数
void printName(const Base base) // note: base 通过值传参,而不是引用
{
std::cout << "I am a " << base.getName() << '\n';
}
这是一个相当简单的带有常量基对象参数,并且通过值传参,如果我们像这样调用这个函数:
int main()
{
Derived d{ 5 };
printName(d); // oops, 没有意识到这是通过值传参。
return 0;
}
当你写下这个程序的时候,你可能没意识到 base 是一个值参数,而不是一个引用。因此当我们调用 printName(d)
时,我们也许会期待 base.getName()
来调用虚函数 getName()
并且打印 "I am a Derived",但是那不会发生。相反,Derived 的对象d
会被切割,只有 Base 部分被拷贝作为 base 参数。当 base.getName()
执行时,尽管 getName()
函数是一个虚函数,但是这个对象没有派生部分来解析,因此,这个程序会打印如下:
I am a Base
在这个例子中,显而易见发生了什么,但是如果你的函数实际上没有打印任何这样的识别信息,差错将变得非常有挑战。
当然,这里发生的切割,可以很容易的通过设置函数参数为引用来避免,而不是传值。(另一个原因为什么建议传引用的good idea)。
void printName(const Base &base) // note: base 通过引用传参
{
std::cout << "I am a " << base.getName() << '\n';
}
int main()
{
Derived d{ 5 };
printName(d);
return 0;
}
这将打印
I am a Derived
切割Vector (Slicing vectors)¶
Yet another area where new programmers run into trouble with slicing is trying to implement polymorphism with std::vector. Consider the following program:
另一个程序新手在使用 std::vector
来实现多态性时常常遇到的问题是。思考下列程序:
#include <vector>
int main()
{
std::vector<Base> v{};
v.push_back(Base{ 5 }); // 添加一个 Base 对象到vector
v.push_back(Derived{ 6 }); // 添加一个 Derived 对象到vector
// 打印所有vector中的对象
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
和之前的例子相似,因为 std::vector
被声明为 Base 类型,当 Derived(6)
添加到vector时,它已经被切割了。
修复这个问题有一些困难,很多新手程序员尝试创建一个 引用类型的 std::vector
像这样。
std::vector<Base&> v{};
不幸的是,这将不会编译,std::vector
的对象必须是可赋值的,然而引用不能被赋值(只有在初始化时引用能被赋值)
解决这个这个问题的一个方式就是创建指针类型的 std::vector
# include <iostream>
# include <vector>
int main()
{
std::vector<Base*> v{};
Base b{ 5 }; // b and d 不能是匿名对象,必须先实例化出来
Derived d{ 6 };
v.push_back(&b); // 添加一个 Base 对象到 vector
v.push_back(&d); // 添加一个 Derived 对象到 vector
// 打印所有
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
它工作了!这里说一些关于这个例子的一些说明,第一 ,nullpter 现在是一个合法的选项,也许或也许不合你的使用场景。 第二,你现在不得不进行指针操作,可能是笨重(awkward)的。但是从好的方面来说,这也允许动态分配内存的可能性,如果你的对象超出范围那将是非常有用的。(But on the upside, this also allows the possibility of dynamic memory allocation, which is useful if your objects might otherwise go out of scope.)
The Frankenobject¶
在上方的例子中,我们看到了对象切割由于派生类部分被切除造成的一些坑。现在让我们看一下当派生对象仍然存在的另一个危险情况!
思考如下代码:
int main()
{
Derived d1{ 5 };
Derived d2{ 6 };
Base &b{ d2 };
b = d1; // 这行就是问题所在
return 0;
}
开始三行很简单直接,创建两个对象,并且创建一个引用到 d2
第四行就是导致错误的一行,当 b
指向 d2
时,我们赋值 d1
给 b
, 你可能认为结果就是 d1
被拷贝到了d2 —— 如果 b
是 一个Derived 对象,那么结果是这样的。但是 b
是一个 Base 对象,并且 c++
提供的操作符 = 并不是虚函数。因此,只有 d1
的Base 部分被拷贝到了d2
。
结果,你会发现 d2
现在有 d1
的 Base 部分和 d2
的 Derived 部分。在这个特定的例子中,出现这个情况并没有问题。(因为 Derived 类并没有它自己的数据),但是在大多数情况下,你将会创建一个 Frankenobject —— 由多个不同的对象的不同部分组成的对象。更糟糕的是,没有容易的方法来阻止这个情况的发生。(除了尽可能避免像这样的赋值操作)
结论¶
尽管 C++ 支持将 base 对象赋值给 derived 对象,通过对象切割,但是通常这很可能会没啥帮助,除了让你头疼之外,你平时应该避免对象切割的发生。确保你的函数参数是引用(或指针)并且尝试避免任何类型的值传参,当使用派生类的时候。