"Key Takeaway"
让我们回到我们之前的一个例子:
#include <iostream>
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 }
{
}
const char* 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
,它有一个 Base
部分和一个 derived
部分。因为ref
和ptr
是Base
类型,所以ref
和ptr
只能看到 derived
的 Base
部分——derived
的 derived
部分仍然存在,但不能通过 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
但是,如果Base
不引用或指针指向 Derived
对象,而是简单地将 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
对象时,只会复制派生对象中的Base
部分,而Derived
则不会被赋值。在上面的例子中,base
接收了derived
的base
部分的副本,但不会获取derived
部分的副本,也就是说Derived
部分实际上已经被“切掉了”。因此,将派生类对象赋值给基类对象称为[object-slicing|对象切片]。
因为base
变量没有Derived
部分,base.getName()
只能解析为base::getName()
。
上面的程序打印出:
base is a Base and has value 5
使用得当的话,对象切片是很有用的。然而,如果使用不当,切片会以不同的方式导致意想不到的结果。让我们来看看其中的一些案例。
切片和函数
现在,你可能会认为上面的例子有点傻。毕竟,为什么要这样把 derived
赋值给 base
呢?一般很少会这么做。但是,对于函数来说,对象切片是可能在不经意间发生的。
考虑下面的函数:
void printName(const Base base) // 注意: base 按值传递,而不是引用
{
std::cout << "I am a " << base.getName() << '\n';
}
这是一个非常简单的函数,带有一个按值传递的const base
对象形参。如果我们像这样调用这个函数:
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()
函数进行了虚化,也没有类的Derived
部分供它解析。因此,这个程序输出:
I am a Base
这里可以明显看到问题,但如果函数没有像这样实际打印任何信息,那么该问题会非常难以定位。
当然,通过将函数形参作为引用而不是按值传递,就可以避免切片(这也是为什么按引用而不是按值传递类是一个好主意的另一个原因)。
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;
}
COPY
This prints:
I am a Derived
vector 切片
新程序员时常在使用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) 时,它被切片了。
修复这个问题有点麻烦。许多新程序员最先想到创建一个类型为基类引用的 std::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); // 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;
}
打印:
I am a Base with value 5
I am a Derived with value 6
搞定!对此有几点还需要注意。首先,nullptr 现在也可以被存进去,这可能是你想要的结果也可能不是。其次,你现在必须处理指针语义,这可能会很麻烦。好的方面是,这样一来就可以使用动态内存分配,如果对象需要超出作用域,这是很有用的。
==另一个选择是使用std::reference_wrapper
,它是一个类,可模拟出可赋值的引用类型:==
#include <functional> // for std::reference_wrapper
#include <iostream>
#include <vector>
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 }
{
}
const char* getName() const override { return "Derived"; }
};
int main()
{
std::vector<std::reference_wrapper<Base>> v{}; // 存放 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;
}
缝合怪对象 Frankenobject
在上面的例子中,我们已经看到了对象切片可能导致错误的情况(由于派生类被切掉所引起)。现在让我们来看另一种危险的情况,其中派生对象仍然存在!
考虑下面代码:
int main()
{
Derived d1{ 5 };
Derived d2{ 6 };
Base& b{ d2 };
b = d1; // 问题所在(通过b给d2赋值为d1,注意这里不是给引用重新赋值)
return 0;
}
函数的前三行非常简单。创建两个Derived
对象,并创建一个第二个对象的Base
类型引用。
第四行是出错的地方。因为b
指向d2
,我们把d1
赋值给b
(相当于d2=d1
),你可能认为结果是d1
会被复制到d2
中。但是b
是Base
, 而C++为类提供的操作符=在默认情况下不是虚函数的。因此,只有d1
的Base
部分被复制到d2
中。
就是d2
现在有d1
的基部分和d2
的派生部分。在这个特定的例子中,这不是问题(因为Derived
类本身没有数据),但是在大多数情况下,你实际创建一个缝合怪对象——由多个对象的部分组成。更糟糕的是,没有什么简单的方法可以避免这种情况的发生(只能尽量避免这样的赋值)。
结论
尽管C++支持通过对象切片将派生对象分配给基对象,但这么做多数情况下会带来问题,因此应该尽量避免切片。确保函数形参是引用(或指针),并在派生类中尽量避免任何形式的值传递。