对象切片(Object slicing)
本节阅读量:
让我们回到前面看到的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
#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,所以它们只能看到派生对象中的 Base 部分——Derived 部分仍然存在,只是不能通过 ref 或 ptr 访问。然而,通过虚函数,我们可以访问函数的最底层派生版本。因此,上面的程序打印:
1
2
3
|
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 对象,会发生什么?
1
2
3
4
5
6
7
8
|
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 对象时,只会复制派生对象中的 Base 部分,不会复制 Derived 部分。在上面的示例中,base 接收了 derived 的 Base 部分副本,但没有接收 Derived 部分副本。这个对象实际上已经被“切片”了。
因此,将派生类对象赋值给基类对象称为对象切片(或简称切片)。因为 base 是一个 Base 对象,所以 base 的虚函数指针仍然指向 Base。因此,base.getName() 会解析为 Base::getName()。
上面的示例打印:
1
|
base is a Base and has value 5
|
谨慎使用时,切片也可以有用。然而,如果使用不当,切片可能以许多不同的方式导致意外结果。让我们来看其中的一些情况。
切片和函数
现在,您可能会认为上面的示例有点奇怪。毕竟,为什么要这样把派生类赋值给基类?然而,切片更常见的情况是在函数调用中意外发生。
考虑以下函数:
1
2
3
4
|
void printName(const Base base) // 注: 按值传递,而不是按引用传递
{
std::cout << "I am a " << base.getName() << '\n';
}
|
上面的函数很简单,但如果我们像下面这样调用它呢?
1
2
3
4
5
6
7
|
int main()
{
Derived d{ 5 };
printName(d); // 哦,可能会有疏漏,没有意识到这里是按值传递的对象
return 0;
}
|
编写此程序时,您可能没有注意到 base 是按值传递的参数,而不是按引用传递的参数。因此,当调用 printName(d) 时,虽然我们可能期望 base.getName() 调用虚函数 getName() 并打印 “I am a Derived”,但实际并非如此。相反,Derived 对象 d 会被切片,只有 Base 部分会被复制到 base 参数中。当 base.getName() 执行时,即使 getName() 是虚函数,也已经没有 Derived 部分可供解析。
因此,该程序打印:
在这个示例中,发生了什么很明显。但如果函数实际上没有打印这类标识信息,追踪这个错误可能会很困难。
当然,只要将函数参数改为引用而不是按值传递,就可以很容易地避免这里的切片。
1
2
3
4
5
6
7
8
9
10
11
12
|
void printName(const Base& base) // 注: 现在是按引用传递
{
std::cout << "I am a " << base.getName() << '\n';
}
int main()
{
Derived d{ 5 };
printName(d);
return 0;
}
|
这打印出:
切片和数组
新程序员在切片方面遇到的另一个常见问题,是尝试用 std::vector 实现多态。考虑以下程序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#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;
}
|
这个程序可以顺利编译。但在运行时,它会打印:
1
2
|
I am a Base with value 5
I am a Base with value 6
|
与前面的示例类似,因为 std::vector 被声明为 Base 类型的数组,当 Derived(6) 被添加到数组中时,它会被切片。
解决这个问题有点困难。许多新程序员尝试创建包含引用的std::vector,如下所示:
1
|
std::vector<Base&> v{};
|
不幸的是,这无法编译。std::vector 的元素必须是可赋值的,而引用只能初始化一次,不能重新赋值。
解决此问题的一种方法是创建指针数组:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
#include <iostream>
#include <vector>
int main()
{
std::vector<Base*> v{};
Base b{ 5 }; // b 和 d 不能是匿名对象
Derived d{ 6 };
v.push_back(&b); // 将Base对象添加到vector
v.push_back(&d); // 将Derived对象添加到vector
// 打印vector中的所有元素
for (const auto* element : v)
std::cout << "I am a " << element->getName() << " with value " << element->getValue() << '\n';
return 0;
}
|
这打印:
1
2
|
I am a Base with value 5
I am a Derived with value 6
|
这很有效!但是,首先,nullptr 现在也成为了一个有效值,这可能符合需求,也可能不符合。其次,您必须处理指针语义,这有时会比较麻烦。好处是,使用指针允许我们将动态分配的对象放入向量中(只是不要忘记显式删除它们)。
另一个选择是使用 std::reference_wrapper。它是一个模拟可重新赋值引用的类:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
#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{}; // Base 中包含可 reference_wrapper
Base b{ 5 }; // b 和 d 不能是匿名对象
Derived d{ 6 };
v.push_back(b); // 将Base对象添加到vector
v.push_back(d); // 将Derived对象添加到vector
// 打印vector中的所有元素
// 使用 .get() 获取 std::reference_wrapper 中的元素
for (const auto& element : v) // element 的类型为 std::reference_wrapper<Base>&
std::cout << "I am a " << element.get().getName() << " with value " << element.get().getValue() << '\n';
return 0;
}
|
破碎的对象
在上面的例子中,我们看到了切片导致错误结果的情况。现在,来看另一个潜在的危险情况!
考虑以下代码:
1
2
3
4
5
6
7
8
9
10
|
int main()
{
Derived d1{ 5 };
Derived d2{ 6 };
Base& b{ d2 };
b = d1; // 这一行有问题
return 0;
}
|
函数中的前三行非常简单:创建两个 Derived 对象,并让 Base 引用绑定到第二个对象。
第四行是问题出现的地方。由于 b 引用 d2,我们将 d1 赋给 b,你可能会认为结果是 d1 被复制到 d2 中。但 b 是一个 Base 引用,而 C++ 为类提供的 operator= 默认不是虚函数。因此,这里会调用 Base 的赋值运算符,并且只会将 d1 的 Base 部分复制到 d2 中。
因此,您会发现 d2 现在具有 d1 的 Base 部分和 d2 原本的 Derived 部分。在这个特定示例中,这不是问题(因为 Derived 类没有自己的数据),但在大多数情况下,您会创建出一个破碎的对象——它由多个对象的不同部分拼在一起。
更糟糕的是,没有简单的方法来防止这种情况发生(除了尽可能避免编写这样的代码)。
如果基类不是设计为需要实例化的(例如,它只是一个接口类),则可以通过使基类不可复制来避免切片(通过删除基类拷贝构造函数和赋值运算符)。
结论
尽管 C++ 支持通过对象切片将派生对象赋值给基类对象,但一般来说,这通常只会带来麻烦,应该尽量避免。涉及派生类时,请确保函数参数使用引用(或指针),并尽量避免任何形式的按值传递。