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 访问。然而,通过使用虚函数,我们可以访问函数的最派生版本。因此,上述程序输出:

image

但如果我们不将基类引用或指针赋值给派生类对象,而是直接将派生类对象赋值给基类对象,会发生什么情况?

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()。

上述示例输出:

image

若使用得当,切片操作本无大碍。但若操作不当,切片可能以多种方式引发意想不到的后果。让我们来分析其中几种情况。


切片与函数

现在,你可能会觉得上面的例子有点傻。毕竟,为什么要这样将派生类型赋值给基类呢?你大概不会这么做。然而,在函数中,切片更容易意外发生。

考虑以下函数:

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()函数已被虚拟化,但此时已不存在可解析的派生类部分。最终程序输出:

image

在此情况下,问题原因显而易见,但若函数并未像这样输出任何识别信息,追踪错误就变得相当棘手。

当然,只要将函数参数改为引用传递而非值传递,此处的切片问题便能轻松避免(这再次印证了类采用引用传递而非值传递的合理性)。

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:

image


切片向量

新手程序员在切片方面遇到的另一个问题是尝试使用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;
}

该程序编译完全正常。但运行时,它会输出:

image

与前例类似,由于 std::vector 被声明为 Base 类型的向量,当向量中添加 Derived(6) 时,该对象被切片处理。

修复此问题稍显复杂。许多新手程序员会尝试创建引用对象的 std::vector,例如:

std::vector<Base&> v{};

遗憾的是,这段代码无法编译。std::vector的元素必须可赋值,而引用无法被重新赋值(仅可初始化)。
image

解决此问题的一种方法是创建一个指针向量:

#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;
}

这将输出:

image

这确实可行!关于此方案有几点说明:首先,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;
}

image


弗兰肯斯坦对象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++支持通过对象切片将派生对象赋值给基类对象,但通常这只会带来麻烦,因此应尽量避免使用切片。确保函数参数采用引用(或指针)形式,并尽量避免在涉及派生类时采用任何形式的值传递。

posted @ 2026-02-04 06:24  游翔  阅读(0)  评论(0)    收藏  举报