C++ 对象切片:按值传递如何丢失派生部分
06 对象切片为什么会让派生部分消失
当把派生类对象传递给一个接收基类值参数的函数时,传入的并不是整个派生对象的原样,而是一个新创建的、仅包含基类部分的独立对象。这种现象在 C++ 中被称为对象切片(object slicing)。它并不是一个 bug,而是 C++ 值语义在继承场景下的必然结果,尽管这可能与许多人的直觉预期不符。
#include <iostream>#include <string>class Shape {
std::string name_;
public:
Shape(const std::string& n) : name_(n) {}
std::string name() const { return name_; }
virtual void draw() const { std::cout << "Drawing a generic shape\n"; }
};
class Circle : public Shape {
double radius_;
public:
Circle(const std::string& n, double r) : Shape(n), radius_(r) {}
void draw() const override {
std::cout << "Drawing a circle with radius " << radius_ << '\n';
}
double radius() const { return radius_; }
};
void DescribeByValue(Shape s) {
std::cout << "Shape name: " << s.name() << '\n';
s.draw();
}
void DescribeByReference(const Shape& s) {
std::cout << "Shape name: " << s.name() << '\n';
s.draw();
}
int main() {
Circle c("red circle", 5.0);
DescribeByValue(c); // ① 传值:发生切片DescribeByReference(c); // ② 传引用:完整对象,虚调用有效
}
代码中 DescribeByValue(c) 这一行创建了一个新的 Shape 对象。它不是 Circle,也不是“去掉了半径的 Circle”,而是一个纯粹的 Shape 对象。编译器以 c 中的 Shape 子对象为数据源,调用了 Shape 的拷贝构造函数。这个新 Shape 对象的 draw() 调用(即使 draw 被声明为虚函数)执行的也是 Shape::draw,因为它的动态类型就是 Shape,与源对象曾经是 Circle 毫无关联。相比之下,DescribeByReference(c) 的行为则完全不同:s 是一个绑定在完整 Circle 对象上的 const Shape&,因此 s.draw() 可以通过虚函数机制找到 Circle::draw,并输出包含半径的信息。
切片是怎么发生的
“切片”这个词容易引起误解,让人以为是“原对象的派生部分被切除,原对象遭到了破坏”。实际情况恰恰相反:原对象完好无损,切片发生在构建新对象的过程中。具体来说,当派生类对象用于初始化一个基类对象时(例如传值参数、按值返回、显式构造基类对象),编译器会调用基类的拷贝构造函数。由于基类拷贝构造函数的参数类型是 const Base&,它只能看到并复制基类部分。结果就是产生了一个全新的、独立的基类对象,其内容仅仅是源对象基类部分的副本。源对象的派生部分并未参与这次复制,依然完好地保留在源对象中。
结合上一章介绍的派生对象布局(派生对象内部嵌入了基类子对象),切片的过程可以更直观地理解。当把 Circle c 传给 DescribeByValue(Shape s) 时,形参 s 在栈上(或寄存器中)被创建出来,它的类型是 Shape,占用 sizeof(Shape) 大小的空间。而 Circle 对象比 Shape 更大,多出的部分是 radius_ 成员以及其他可能的派生类数据。编译器使用 c 内部的 Shape 子对象来初始化 s,这一步只复制了 sizeof(Shape) 字节的数据。也就是说,派生对象开头部分的基类子对象内存被逐字节(或遵循基类拷贝构造的语义)复制到了形参所在的空间里。源对象中多出来的字节(radius_)无法进入形参,因为形参只有 sizeof(Shape) 这么大,根本没有存放 radius_ 的位置。这不是编译器在“刻意忽略”派生部分,而是目标对象的物理空间装不下它。
因此,切片并不会破坏原对象。在调用 DescribeByValue(c) 之前和之后,Circle c 的内容完全一致,radius_ 依然有效,它的类型也仍然是 Circle。真正被“切片”的,仅仅是那个新创建的临时 Shape 对象,它内部没有 radius_ 字段,并且从一开始就不曾有过。
从生成的代码来看,切片的本质是编译器忠实地执行了基类的拷贝构造函数:传入 const Shape&,复制基类子对象的所有成员,最终生成一个新的 Shape 对象。编译器没有理由、也没有机制去检测“传入的引用是否实际指向一个派生类对象”,进而改变自身行为。在基类拷贝构造函数的视角下,参数就是一个基类引用,它的唯一职责就是复制好基类部分,不应该也无需关心派生类的存在。C++ 标准中并没有名为“切片”的关键字或专属规则。切片并不是语言层面主动设计的操作,而是值语义、对象内存布局以及拷贝构造函数这三者共同作用下产生的必然结果。
这里还有一个非常重要却容易被忽视的细节:切片时所使用的拷贝构造函数,是由源对象的静态类型(用于参与重载决议)和目标类型共同决定的。当 Circle c 传递给 Shape 参数时,目标类型是 Shape,因此编译器选择调用 Shape 的拷贝构造函数。由于 Shape 拷贝构造函数的参数是 const Shape&,并且 c 可以隐式转换(向上转换)为 const Shape&,这一调用是合法的,且只会复制基类部分。即使派生类定义了自己的拷贝构造函数,在这里也不会被调用,因为需要构造的是 Shape 而不是 Circle。
切片与虚函数:为什么虚调用在切片对象上失效
在之前的例子中,DescribeByValue 内部的 s.draw() 调用的是 Shape::draw 而非 Circle::draw,即使源码里 draw 是虚函数。这种现象常常被误认为是“切片导致虚函数失效”。更准确的理解应该是:切片产生了一个动态类型本身就是基类的新对象,而虚函数机制在这个基类对象上准确地找到了对应的基类实现。虚函数机制并没有失效,它只是作用在了一个毫无派生信息的对象上。
虚函数的运行时分派依赖于对象的动态类型(dynamic type)。当对象是一个完整的派生类(通过引用或指针访问)时,其动态类型就是该派生类,虚调用会找到派生类覆盖的版本。而当对象是切片产生的独立基类对象时,其动态类型就是基类自身,虚调用自然会找到基类的版本。决定虚函数执行走向的,不是对象“曾经是什么类型”,而是它“现在是什么类型”。由于切片改变了后者,虚函数调用的行为自然也会随之改变。
这种差异凸显了一个重要的概念:多态行为(polymorphic behavior)所依赖的并不是“某处定义了一个派生类”,而是“当前表达式所指向的对象是否仍保留着派生类的状态和虚函数表入口”。引用和指针保留了这两者,它们指向的依旧是完整的派生对象。但按值传递的基类对象仅仅保留了基类子对象的拷贝,派生状态和虚函数入口信息在复制过程中并未一同转移。
容器切片:vector<Base> 的陷阱
相比于显式的按值传参,切片更常见于将派生对象放入元素类型为基类的容器中。
#include <vector>#include <memory>
std::vector<Shape> shapes;
shapes.push_back(Circle("red", 5.0)); // 切片:只存了 Shape 部分
shapes.push_back(Circle("blue", 3.0)); // 切片:同上for (const auto& s : shapes) {
s.draw(); // 总是调用 Shape::draw,虽然元素类型是 const Shape&
}
std::vector<Shape> 以值的形式存储元素。容器在堆上分配了一段连续内存,划分为若干个槽位,每个槽位的大小正好是一个 Shape 对象,无法容纳更多或更少的数据。每当 push_back 一个 Circle 对象时,容器都会在内部构造一个新的 Shape 元素:它调用 Shape 的拷贝(或移动)构造函数,并以 Circle 对象中的 Shape 子对象作为数据源。此时 radius_ 数据会丢失,因为目标槽位根本没有多余的空间存放它。在循环中,虽然 const auto& s 是一个引用,但它绑定的是容器内部的元素,而该元素的类型是实打实的 Shape,并非 Circle。因此,s.draw() 总是会调用 Shape::draw。
这种行为不仅限于 std::vector,所有按值存储元素的标准容器(如 std::list、std::deque、std::set 等)在存储基类元素时,都会发生切片。核心原因在于:容器持有的始终是基类的值对象,而不是指向派生类的引用或指针。这种陷阱之所以频发,是因为在 C++ 中写 vector<Base> 非常自然简洁。相比之下,vector<unique_ptr<Base>> 则要求开发者理解智能指针和所有权语义,这使得很多习惯了 Java 或 C# 的开发者更容易想当然地期望 vector<Base> 具备多态特性。在 Java 和 C# 中,容器默认存储对象的引用,元素在运行时的实际类型依然是派生类;而在 C++ 中,容器默认按值存储,元素的类型在编译期就已经固定,运行时没有改变类型的余地。
正确保存多态对象集合的做法是使用指针,通常是通过智能指针来管理对象的生命周期,同时借助基类指针来维持多态调用的能力。
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>("red", 5.0));
shapes.push_back(std::make_unique<Circle>("blue", 3.0));
for (const auto& s : shapes) {
s->draw(); // 多态:每个元素根据实际类型调用对应的 draw
}

在这里,std::vector<std::unique_ptr<Shape>> 的元素是指向 Shape 的独占智能指针。容器存储的是大小固定且可移动的指针,而指针所指向的完整对象依然存在于堆中,其动态类型依然是 Circle。当通过指针调用虚函数时,虚函数机制能够根据实际类型进行正确分派。由于 unique_ptr 禁止拷贝,容器本身的复制操作也被阻止,这恰恰契合了“多态对象不应被随意复制”的设计原则。如果确实需要“复制”整个多态集合,应通过专门设计的 Clone 接口来实现,而不是依赖容器的默认元素复制。
按值返回基类类型:切片的另一个入口
除了传值参数,返回基类类型的函数也会导致切片。
Shape MakeCircle() {
Circle c("green", 4.0);
return c; // 切片:返回的是 Shape,不是 Circle
}
Shape s = MakeCircle(); // s 是 Shape,丢失了 Circle 的所有信息
在这个例子中,return c 构造了一个 Shape 类型的返回值对象,并使用 c 的基类子对象对其进行初始化。调用方最终得到的只会是一个纯粹的 Shape 对象,无法再访问 Circle 的任何专有成员。同样的规则也适用于异常处理:如果 throw 了一个派生类对象,而 catch 语句将其捕获为基类值类型,那么捕获到的依然是切片后的基类副本。因此,捕获多态异常的正确写法是使用引用:catch (const Base& e)。
切片并不总是错误
尽管切片在多态场景中常常引发 bug,但这并不意味着任何按值使用基类对象的操作都是错误的。如果某个基类在设计之初就没打算用于多态场景(即它没有虚函数,也没有期望派生类去覆盖的行为),那么按值使用它就是完全合理的。例如,一个统计数据聚合类可能从某个基础数据结构继承了一些通用字段和逻辑,但使用者从未打算通过基类引用或指针去操作它。此时,提取基类部分进行处理是一种有意的“数据投影”,而非意外丢失了派生信息。
再来看一个非多态继承的例子:假设 Address 基类包含省市区字段和格式化逻辑,而 ShippingAddress 派生类增加了收件人和电话字段。如果物流系统当前只需打印省市区信息,将 ShippingAddress 切片为 Address 并调用其格式化函数,这在逻辑上是完全成立的。这一操作明确表达了“我只需要地址的地理信息,无需收件人信息”。在这种情况下,切片变成了一种高效的数据视图转换,而不是 bug。
判断切片是否合理的关键在于“设计意图”。如果类型的接口中包含虚函数,说明设计者期望通过基类的指针或引用来多态地操作派生类对象。此时若发生切片,就会破坏这一意图,导致虚函数调用回退到基类版本,派生行为无法触达。相反,如果类型不是多态基类(既无虚函数,也不期望派生类重写),即使存在继承关系,切片也不会引发语义问题,因为根本不存在“本该执行派生类行为,却错误执行了基类行为”的风险。这种基于设计意图的判断,比单纯依靠“是否有虚函数”更为通用,也更具指导意义:有虚函数的类型,必须以引用或指针的形式操作;没有虚函数的类型,是否允许切片取决于你需要的是整个对象还是仅仅是基类部分。
有一种工程场景需要特别留意:有时你明明知道某个基类不会用于多态,但仍将其析构函数声明为 virtual,以防未来的派生类可能需要进行多态删除。这种防御性的设计本身不会改变当前类的切片安全性,因为目前没有代码会通过基类指针删除派生对象,也没有其他虚函数需要动态分派。然而,虚析构函数的存在确实暗示了这个类“可能”被用于多态场景。面对这种情况,团队应在类的使用边界上达成明确约定,而非让调用方去猜测。
进一步来看,切片是否错误的深层逻辑在于接口的设计承诺。如果基类的公开接口完全围绕值语义设计(例如构造函数、赋值运算符、比较运算符都按照独立对象的逻辑工作),而派生类仅仅是增加了一些内部优化字段或辅助数据,未曾改变基类接口的外部契约。那么在接口层面按值使用基类是完全合理的,切片也是预期之内的功能。比如,一个容器类型在内部使用了派生类策略(针对大小数据采用不同存储布局),但对外统一暴露基类接口,可能会通过切片来消除多态带来的性能开销。此时的切片是出于性能考量的有意设计,调用方只认基类接口,派生类仅仅是实现细节。
反之,如果基类接口的核心行为依赖虚函数实现(例如图形系统的 Shape 基类暴露了虚函数 Draw,且外部总是通过 Shape& 或 Shape* 操作系统),那么接口对调用方的承诺就是“通过基类入口可以执行具体图形的绘制逻辑”。在这种约定下,任何按值使用 Shape 的行为都会破坏这一承诺,因为值语义无法兑现“行为随类型变化”的特性。此时,切片就不再是功能,而是对接口契约的破坏。
这个判断框架更加贴近工程实践:虚函数是编译器层面实现覆盖的机制,而接口契约是设计者给出的行为边界。两者多数时候是统一的,但并非总是完全重合。决定切片是否安全的,不是代码里有几个 virtual 关键字,而是设计者希望类型以何种方式被使用。如果在文档或团队约定中明确指出某类型应当通过引用或指针操作,那么就绝对不要把它作为按值容器的元素,不要用作值参数,也不要通过按值返回来传递。总而言之,使用类型的方式必须与它所承诺的契约保持一致。
多态复制:Clone 模式
既然默认的拷贝构造函数按静态类型工作,无法保留动态类型信息,那当我们需要复制一个多态对象时该怎么办?标准的做法是提供一个虚函数 Clone。
class Shape {
public:
virtual ~Shape() = default;
virtual std::unique_ptr<Shape> Clone() const = 0;
// ...
};
class Circle : public Shape {
double radius_;
public:
std::unique_ptr<Shape> Clone() const override {
return std::make_unique<Circle>(*this); // 利用 Circle 的拷贝构造
}
};
在这个模式中,Clone 函数返回 std::unique_ptr<Shape>,即一个指向基类的独占智能指针。每一个派生类都在自己的 Clone 实现中调用自身的拷贝构造函数,从而在堆上生成一个与源对象动态类型一致的新对象。尽管调用方拿到的是基类指针,但它实际指向的类型是正确的,后续的虚函数调用也能正常分派。这种模式巧妙地将“如何复制自身”的逻辑封装在各个具体类型内部,并通过虚接口向外暴露。
C++ 允许在覆盖虚函数时使用协变返回类型(covariant return type):只要 Circle 公开继承自 Shape,派生类的 Clone 就可以返回 std::unique_ptr<Circle> 而不是 std::unique_ptr<Shape>。当调用方的静态类型已经是 Circle 时,这能提供更精确的类型信息;而在通过 Shape 接口调用时,仍能维持多态的正确性。是否采用协变返回类型取决于具体的设计考量:如果调用方经常持有 Circle 并期望直接获取 Circle 指针,协变可以省去手动 dynamic_cast 的麻烦;如果调用统一通过 Shape 接口进行,那么统一返回 std::unique_ptr<Shape> 已经足够清晰。
需要注意的是,Clone 模式解决的是“多态复制”(即从一个多态对象克隆出另一个动态类型相同的多态对象),而非“容器复制”。如果想要复制一个 vector<unique_ptr<Shape>> 容器,必须遍历每个元素,分别调用 Clone,并将结果收集到新容器中。标准库并没有提供“深拷贝智能指针容器”的快捷方式,这部分逻辑需要开发者显式编写。这也恰好印证了本章的核心观点:编译器只能基于静态类型进行拷贝,多态类型的信息必须借助虚函数接口来显式传递。
引用语义建立多态通道,值语义复制当下状态
切片的存在绝非 C++ 的设计缺陷,而是语言同时支持值语义和引用语义的必然产物。在没有继承的普通类型(如 int、std::string、std::vector)中,值语义意味着“复制即创建独立的等价对象”,这运行得十分完美。然而,当继承介入后,值语义与运行时多态便产生了冲突:值语义依赖于静态类型(编译器根据变量的声明类型生成拷贝代码),而运行时多态依赖于动态类型(虚调用根据运行时的实际类型进行分派)。当某个操作既需要触发拷贝(值语义)又想保留动态类型(多态需求)时,这两种机制就会产生天然的矛盾。拷贝机制无从知晓也不该关心动态类型,而多态机制同样无法保证拷贝操作的正确性。
解决这一矛盾的方法,并不是试图在拷贝构造函数里写特殊的逻辑,而是要在设计层面明确抉择:“这个对象究竟应该按值传递,还是按引用/指针传递?”值传递适用于“我不在乎你的派生身份,我只要基类部分”的场景,此时切片是正常功能;而引用/指针传递适用于“我需要保留完整类型,并通过接口调用派生行为”的场景,此时多态是核心目标。代码出错的原因,往往是开发者自以为在走引用路径,而实际上写的却是值传递的代码,然后对着“为什么虚函数没生效”产生困惑。最常见的失误就是漏写了函数参数的 & 符号。
要避免意外的切片,无需使用复杂的技巧,只需在三个关键位置保持警惕即可:
- 函数参数声明:需要多态行为时,使用
const Base&而非Base。 - 容器元素类型:需要保存多态集合时,使用
unique_ptr<Base>或shared_ptr<Base>而非Base。 - 返回值类型:需要将派生对象从函数中传出时,返回
unique_ptr<Base>而非Base。
这三条规则足以覆盖实际工程中绝大多数的切片问题,并且在代码审查中非常容易验证。
下一章我们将深入探讨虚函数调用的完整机制:动态类型如何在运行时决定实际调用的函数版本,编译器是如何通过虚函数表将基类接口与派生类实现关联起来的,以及在构造和析构阶段虚函数调用的特殊行为。
阅读导航




