那时候大家刚学完 C。
手里最顺的一招。
就是“按值传”。
不想碰指针。
不想挨空指针的骂。
更不想半夜被段错误叫醒。
所以你很自然会想。
把对象拷一份进函数里。
我用我的。
你用你的。
互不干扰。
听起来挺靠谱。
后来 C++ 把“继承”“虚函数”这些东西带进来。
坑就埋在这里。
而且埋得很安静。
当年的背景:C 时代的“安全感”是怎么来的
在 C 里,struct 就是一块数据。你把它按值传给函数,就是拷贝一份字节。函数里怎么折腾,都不会影响外面。
很多老代码就是靠这个思路保持“边界清晰”。出了问题也好定位:谁拿到谁负责,出了函数就两清。
七八十年代,Bjarne Stroustrup 在 Bell Labs 做 C with Classes。他想把 Simula 那种“用类型组织代码”的办法带进 C,又不想让大家为了一个新概念重写工具链。
于是很多 C 的习惯,被顺手带了进来。
尤其是“按值传参更安全”。
在没有多态的时候,这句话大多数时候都对。
先别急着抛概念:我们把坑复现出来
你写了一个基类。
再写一个派生类。
你希望它能“多态”。
#include <iostream>
struct Shape {
virtual const char* name() const {
return "Shape";
}
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override {
return "Circle";
}
};
static void print_by_value(Shape s) {
std::cout << s.name() << "\n";
}
int main() {
Circle c;
print_by_value(c);
}
你可能以为会打印 Circle。
它会打印 Shape。
而且没有任何报错。
这就是它最阴的地方。
这时候到底发生了什么
你写 print_by_value(Shape s),它的真实含义是:我需要一个独立的 Shape 对象。
所以编译器会把传进来的 Circle,拷成一个新的 Shape。
问题在于:Circle 比 Shape 大。你要求的目标只有 Shape 那么大,派生类那部分没地方放,就被丢了。
名词登场:这就叫“对象切片”
对象切片(object slicing)。
你可以把它想成一句大白话:把一个“更大的派生类对象”,按着基类的形状切成一块“只有基类部分”的小对象。
切完以后,它就真的只剩 Shape 了。虚函数当然也只能按 Shape 来调用。
不是多态失灵。
是你已经不再拥有那个 Circle 了。
按值到底干了啥:它真的在“构造一个新对象”
你可能会以为“按值传参就是拷贝一份内存”。
在 C 里很多时候是这个感觉。
但在 C++ 里,更准确的说法是:它会调用拷贝构造,构造出一个新对象。
下面这段代码,只做一件事:打印到底调用了谁的拷贝构造。
#include <iostream>
struct Shape {
Shape() = default;
Shape(const Shape&) {
std::cout << "copy Shape\n";
}
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
Circle() = default;
Circle(const Circle& other) : Shape(other) {
std::cout << "copy Circle\n";
}
const char* name() const override { return "Circle"; }
};
static void f(Shape s) {
std::cout << s.name() << "\n";
}
int main() {
Circle c;
f(c);
}
你会看到:只打印 copy Shape。
这不是 Circle 不想拷贝。
而是你要求的参数类型就是 Shape。
它只能按 Shape 的方式构造。
新手最容易卡住的几个点
如果你刚学完 C,我猜你会在这几句上反复皱眉。
第一句是:
“我传进去的是 Circle,怎么进去以后就变成 Shape 了?”
因为你写的是 按值接收。
按值的意思是:函数里有一个新对象。
新对象的类型,写死在参数里。
第二句是:
“我都写了 virtual,它怎么不帮我?”
virtual 只能在“你还拿着原来那个对象”的时候工作。
一旦你把它拷贝成了一个纯 Shape。
virtual 再厉害,也只能对着一个 Shape 做动态派发。
第三句是:
“那我能不能再把它变回 Circle?”
不行。
切片不是‘临时换个视角’。
它是‘真丢了’。
顺手把两个“类型”分清楚
这也是新手最常混在一起的两件事。
代码里写在变量上的类型,叫静态类型。
对象实际是什么,叫动态类型。
#include <iostream>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
int main() {
Circle c;
Shape& r = c; // r 的静态类型是 Shape&,动态类型是 Circle
std::cout << r.name() << "\n";
}
你用引用/指针时。
静态类型是 Shape& 或 Shape*。
动态类型还活着。
所以 virtual 才有发挥空间。
再来一刀:派生类的数据也会悄悄消失
给 Circle 加点自己的字段。
#include <iostream>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
int r = 10;
const char* name() const override { return "Circle"; }
};
static void debug(Shape s) {
std::cout << s.name() << "\n";
}
int main() {
Circle c;
debug(c);
}
r 没有“被改坏”。
它是直接“没进来”。
因为 debug 里根本没有 Circle。
只有一个新拷贝出来的 Shape。
关键结论(一句话版)
多态对象,千万别按值传递。
你可能会想:那我改成指针/引用,是不是就是“共享同一份对象”
对。
Shape s 是一个对象。
Shape& s 是对某个对象的引用,你可以把它先理解成“别名”。
Shape* s 是指针,你可以把它先理解成“地址”。
引用和指针本身都不包含“对象内容”。
它们只是把你带到那个对象面前。
所以它们不会触发那次“拷贝成 Shape”。
那应该怎么写
如果你要的是“多态”,你要保留对象的真实身份。
那就别拷贝它,拿引用。
#include <iostream>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
static void print_by_ref(const Shape& s) {
std::cout << s.name() << "\n";
}
int main() {
Circle c;
print_by_ref(c);
}
现在会打印 Circle。
因为你没有创建新对象。
你只是“通过基类视角看同一个对象”。
auto:看起来省事,最容易把“引用”变成“值”
这也是我见过很多次的切片来源。
你明明已经拿到了引用。
结果一个 auto,又把它拷贝成值了。
#include <iostream>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
int main() {
Circle c;
Shape& r = c;
auto x = r; // 注意:x 的类型是 Shape(值),这里会切片
auto& y = r; // y 的类型是 Shape&(引用),这里不会切片
std::cout << x.name() << "\n";
std::cout << y.name() << "\n";
}
如果你刚学 C++。
你可以先记一个朴素规则:
auto 默认会“把引用抹掉”。
你想保留引用,就写 auto&。
还有几个更隐蔽的切片场景
很多切片不是发生在“函数参数”这里。
而是藏在一些你以为“很普通”的语句里。
赋值:Shape s = c;
#include <iostream>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
int main() {
Circle c;
Shape s = c;
std::cout << s.name() << "\n";
}
这里没有函数。
但切片照样发生。
因为 Shape s = c; 的意思就是:创建一个新的 Shape 值。
返回值:Shape make()
#include <iostream>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
static Shape make() {
return Circle{};
}
int main() {
Shape s = make();
std::cout << s.name() << "\n";
}
这段很多新手会觉得“语义上很合理”。
但它返回类型写死了是 Shape。
所以你得到的也只能是一个 Shape。
std::optional<Shape>:看起来像“可空对象”,其实还是按值
#include <iostream>
#include <optional>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
int main() {
std::optional<Shape> x = Circle{};
std::cout << x->name() << "\n";
}
optional<Shape> 里装的还是 Shape 值。
所以还是会切。
如果你需要“可空”,通常你会用 std::unique_ptr<Shape>。
空指针表示“没有对象”。
有指针表示“有一个真实的派生类对象”。
新手最常见的第二个坑:容器里也会切
这个更容易在项目里悄悄出现。
比如你想存一组 Shape,就顺手写了一个 std::vector<Shape>。
#include <iostream>
#include <vector>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
int main() {
std::vector<Shape> v;
v.push_back(Circle{});
std::cout << v[0].name() << "\n";
}
它也会打印 Shape。
原因一样。
vector<Shape> 里放的是 Shape 值,你把 Circle 塞进去时,它就已经被切成 Shape 了。
你可能会好奇:为什么 vector<Shape> 天然存不下 Circle
你可以先把 vector 理解成一排格子。
每个格子的大小,等于 sizeof(Shape)。
这不是 C++ 爱刁难人。
这是它必须这样。
vector 要支持下标访问。
第 i 个元素在哪,它得能算出来。
所以每个元素都得同样大。
而 Circle 往往比 Shape 大。
你硬塞进去。
就只能塞得下那一段 Shape。
容器该怎么存多态对象
要存“多态对象”,通常存指针。
更现代一点,存智能指针。
#include <iostream>
#include <memory>
#include <vector>
struct Shape {
virtual const char* name() const { return "Shape"; }
virtual ~Shape() = default;
};
struct Circle : Shape {
const char* name() const override { return "Circle"; }
};
int main() {
std::vector<std::unique_ptr<Shape>> v;
v.push_back(std::make_unique<Circle>());
std::cout << v[0]->name() << "\n";
}
这次你存的是“指向对象的地址”。
对象本体没有被拷贝。
也就不会被切。
你可能会再问一句:std::unique_ptr 到底是什么?我还没学会
没关系。
你可以先把它当成一个很朴素的东西:
它里面装着一个地址。
它负责在自己“离开作用域”时,把那块对象释放掉。
所以你不用手写 delete。
std::make_unique<Circle>() 的意思是:
帮我在某个地方创建一个 Circle。
然后把它交给 unique_ptr 管。
你先不用纠结“某个地方”到底是堆还是栈。
你只要记住:对象本体没有被拷贝。
所以不会被切。
还有一种“我就想要值”的路:类型集合封闭时用 std::variant
如果你的类型只有几种。
而且以后也不会随便加。
那你也可以不用继承。
直接把类型放进一个“联合体盒子”。
#include <variant>
struct Circle { int r; };
struct Rect { int w, h; };
using Shape = std::variant<Circle, Rect>;
这条路不一定适合新手马上上手。
但它能解释一个现实:
想要“值语义”,就别走“继承多态”。
你换一种表示方式,矛盾就少很多。
你可能还会想到:那我用 dynamic_cast 把它转回去行不行?
如果你手里拿的是 Shape& 或 Shape*,而它背后真的是 Circle。
那 dynamic_cast 有时能帮你安全地向下转型。
但这里最大的问题是:你按值传进来以后,已经没有 Circle 了。
你手里只剩一个新拷贝出来的 Shape。
这时候你再怎么 cast。
也不可能凭空“长回派生类那部分”。
如果你真的很想保留“值语义”,怎么办
我也理解这种冲动。
值语义很舒服。
谁也别影响谁。
但多态的前提是“有一个真实对象”。
这两件事天然就有点拧巴。
工程上常见的做法,有两类。
第一类是:把“值”换成“拥有指针”。
也就是你前面看到的 std::unique_ptr<Shape>。
它不是值对象。
但它是“拥有权的值”。
你可以移动它。
它离开作用域会自动释放。
第二类是:提供一个 clone()。
你想拷贝的时候,不是拷贝 Shape。
而是让对象自己拷贝自己。
#include <memory>
struct Shape {
virtual std::unique_ptr<Shape> clone() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
std::unique_ptr<Shape> clone() const override {
return std::make_unique<Circle>(*this);
}
};
这招不神秘。
它只是把“拷贝该怎么做”这件事,交还给真实类型。
你复制的是 unique_ptr。
对象自己负责复制出一份“同类型的新对象”。
一个顺手的洞见:值语义没错,错的是把“接口”当成“值”
很多人第一次踩切片,会误以为:“C++ 的多态好危险。”
其实危险的不是多态。
是你把“当接口用的基类”,拿去做“值类型”了。
值语义很适合具体类型,比如 std::string、std::vector<int>。但一个用来当接口的基类,通常只该通过引用/指针使用。
如果你想更狠一点。
甚至可以直接禁止它被拷贝。
让编译器替你拦住切片。
struct Shape {
Shape() = default;
Shape(const Shape&) = delete;
Shape& operator=(const Shape&) = delete;
virtual ~Shape() = default;
virtual void draw() const = 0;
};
这不是“教条”。
更像一种工程自保。
你把危险动作关进编译器。
比写在团队 wiki 里靠谱。
小结
对象切片不神秘。
它就是一次普通的按值拷贝,只不过拷贝的目标类型是基类,所以派生类那部分被你亲手丢掉了。
以后你看到函数参数写成 Shape s(按值接收一个基类),脑子里可以立刻响一句:这里可能在切片。