Skip to content

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 时,我们赋值 d1b, 你可能认为结果就是 d1 被拷贝到了d2 —— 如果 b 是 一个Derived 对象,那么结果是这样的。但是 b 是一个 Base 对象,并且 c++ 提供的操作符 = 并不是虚函数。因此,只有 d1 的Base 部分被拷贝到了d2

结果,你会发现 d2 现在有 d1 的 Base 部分和 d2 的 Derived 部分。在这个特定的例子中,出现这个情况并没有问题。(因为 Derived 类并没有它自己的数据),但是在大多数情况下,你将会创建一个 Frankenobject —— 由多个不同的对象的不同部分组成的对象。更糟糕的是,没有容易的方法来阻止这个情况的发生。(除了尽可能避免像这样的赋值操作)

结论

尽管 C++ 支持将 base 对象赋值给 derived 对象,通过对象切割,但是通常这很可能会没啥帮助,除了让你头疼之外,你平时应该避免对象切割的发生。确保你的函数参数是引用(或指针)并且尝试避免任何类型的值传参,当使用派生类的时候。