ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

《C++》【多态:概念 + 实现 + 拓展 + 原理】

《C++》【多态:概念 + 实现 + 拓展 + 原理】 前言多态polymorphism这个词在 C 里经常被笼统地概括成同一个接口不同的行为。这句话本身没错但它把两件完全不同的事混在了一起编译期就能确定的静态多态函数重载、模板和必须等到运行期、通过虚函数表才能确定的动态多态。初学者最常见的误解是只要写了virtual才叫多态于是把重载、模板特化、类型转换全都算进同一个筐里被追问时又说不清各自的开销在哪里。第二个常见误解是认为动态多态免费。实际上它要求对象里塞一个虚表指针vptr要求调用走间接跳转还要求编译器放弃对内联的乐观假设——这些都是实打实的成本。本文会先澄清概念边界再讲清虚函数表这套机制的实现方式最后给出两种扩展方向用 CRTP 把多态搬到编译期用类型擦除type erasure把多态从继承体系里解耦出来。需要提前说明的是虚函数表的具体布局vptr 放在对象头部还是尾部、RTTI 指针的位置、多重继承下的调整 thunk属于实现细节不是标准规定。本文描述的是 Itanium C ABIGCC、Clang 采用的通行做法MSVC 的实现与之不同但语言层面保证的语义是等价的。一、概念静态多态与动态多态C 支持的多态大致分四类它们解决的是同一个问题——同一段调用代码适配多种类型但代价与时机完全不同。函数重载overloading在编译期由参数列表决定调用哪一个编译器直接生成对具体函数的调用没有间接跳转。模板template同样是编译期展开实例化出针对每个类型的一份代码因此可以完全内联。这两类统称静态多态static polymorphism也叫编译期多态。虚函数virtual function构成动态多态dynamic polymorphism也叫运行期多态。调用哪个函数由对象的动态类型dynamic type决定而这个类型在编译期往往不可知——例如从容器里取出一个基类指针时。第四类是类型擦除典型代表是std::function。它在外部表现为一个统一的类型内部通过模板构造函数记住了具体类型用一小段间接层把实现藏起来。它既不是纯运行期的继承体系也不是纯编译期的模板展开而是用模板把类型信息擦掉。形态决议时机典型设施是否可内联是否要求继承函数重载编译期同名不同参数可以不要求模板编译期template参数可以不要求虚函数运行期virtual/ vtable通常不能要求类型擦除运行期std::function通常不能不要求需要强调的是只有虚函数构成的才是 C 标准意义上的多态运行时机制重载和模板属于编译期决议。把两者混为一谈是很多面试问答答偏的根源。二、实现虚函数表与虚析构当一个类含有虚函数时编译器会为这个类生成一张虚函数表virtual table简称 vtable表里按固定顺序存放各虚函数的地址。每个该类的对象里额外保存一个指向虚表的指针通常称为vptr。调用p-area()时编译器生成的是取出 vptr按固定偏移索引到槽位间接调用这三步。这个偏移是编译期就定死的因此运行时开销主要来自一次间接跳转和一次内存读取而不是查表找名字——虚函数表不是按名字查找的字典。单一继承single inheritance是最简单的情形派生类的虚表与基类布局一致被重写的函数槽位被替换成派生类的实现未重写的槽位仍指向基类版本。多重继承下会出现第二张虚表并在派生类对象里出现多个 vptr跨基类指针调用时需要调整this指针的 thunk。这一段在不同 ABI 下差异很大写代码时依赖它是不安全的。关于虚析构如果打算通过基类指针delete一个派生类对象基类的析构函数必须是虚的。否则delete只会调用基类析构派生类的资源不会被释放——这是 UB标准不保证任何行为不是只泄漏一点点这么温和。#include iostream #include memory #include string #include vector class Shape { public: virtual ~Shape() default; // 多态基类必须有虚析构 virtual double area() const 0; virtual std::string name() const { return Shape; } }; class Circle : public Shape { public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.141592653589793 * r_ * r_; } std::string name() const override { return Circle; } private: double r_; }; class Rect : public Shape { public: Rect(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } std::string name() const override { return Rect; } private: double w_, h_; }; void report(const std::vectorstd::unique_ptrShape shapes) { double total 0.0; for (const auto sp : shapes) { std::cout sp-name() area sp-area() \n; total sp-area(); } std::cout total total \n; } int main() { std::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle(1.0)); shapes.push_back(std::make_uniqueRect(2.0, 3.0)); report(shapes); }这段代码在 GCC 13、Clang 17、MSVC 19.3x 下以 C17 编译均可通过。它同时展示了三件事抽象类不能直接实例化、override让拼写错误变成编译错误、unique_ptr配合虚析构完成正确的多态销毁。一个值得自己动手验证的现象是对象大小。你可以打印sizeof(Circle)它会比sizeof(double)多出一个指针的宽度多出来的就是 vptr。具体多几个字节、vptr 放在对象开头还是结尾由编译器实现决定不要写成跨平台的断言。三、拓展CRTP 与类型擦除CRTPCuriously Recurring Template Pattern奇异递归模板模式是把多态搬到编译期的经典手法派生类把自己的类型作为模板参数传给基类基类用static_cast把this转成派生类型从而在不需要 vtable 的情况下调用派生类的方法。#include iostream template typename Derived class Countable { public: void show() const { // 编译期决议可内联对象里没有 vptr static_castconst Derived*(this)-impl(); } }; class Dog : public CountableDog { public: void impl() const { std::cout Dog\n; } }; class Cat : public CountableCat { public: void impl() const { std::cout Cat\n; } }; template typename T void callShow(const CountableT obj) { obj.show(); } int main() { Dog d; Cat c; callShow(d); callShow(c); }CRTP 的代价是每个派生类型各有一份基类代码无法把CountableDog和CountableCat装进同一个容器——它们不是同一个类型。这正是动态多态擅长而静态多态不擅长的场景。类型擦除解决的是另一个问题既要统一的容器又不想强迫实现者继承某个基类。std::function是标准库给出的现成工具#include functional #include iostream #include string #include vector int main() { std::vectorstd::functionstd::string() tasks; std::string prefix id-; int counter 0; tasks.push_back([prefix] { return prefix std::to_string(1); }); tasks.push_back([] { return std::string(lambda); }); tasks.push_back([counter] { return std::to_string(counter); }); for (auto t : tasks) { std::cout t() \n; } }std::function的实例只要求可调用对象满足调用签名不要求任何共同基类。它内部通常使用小对象优化small buffer optimization避免小闭包堆分配但这个缓冲区多大、什么条件触发堆分配都是实现细节libstdc、libc、MSVC STL 各不相同写成断言会误导读者。选择标准很直接需要在运行时决定具体类型、需要异构容器 → 虚函数类型在编译期已知、追求零开销 → CRTP 或模板两者都要 → 类型擦除。常见坑点❌ 基类析构函数不写virtual却通过基类指针delete。✅ 多态基类一律写virtual ~Base() default;若确定不会被多态删除至少声明为protected非虚析构并注释原因。❌ 函数参数按值接收基类void draw(Shape s)。传Circle进去会发生对象切片slicings只保留基类部分虚调用退化为基类版本。✅ 改为void draw(const Shape s)或void draw(const Shape* s)。❌ 在构造函数或析构函数里调用虚函数以为会走到派生类实现。✅ 这两个阶段对象的动态类型是当前正在构造/析构的类虚调用只会解析到该类自己的版本派生部分尚未建立或已销毁。❌ 重写时参数写成int而基类是long或者漏写const结果没有重写而是隐藏hiding编译器一声不吭。✅ 派生类重写一律加override让签名不匹配在编译期就报错。❌ 以为虚函数调用永远是多态调用obj.f()对象直接调用和构造函数内部调用都是静态绑定。✅ 只有通过指针或引用调用才走虚表想强制禁用重写可在基类写virtual void f() final;。❌ 对没有虚函数的类使用dynamic_cast或拿它当普通类型转换用。✅dynamic_cast只能用于多态类型转换失败时对指针返回空指针对引用抛std::bad_cast不要忽略返回值。❌ 把std::vectorDerived直接赋给std::vectorBase或对基类容器做memcpy。✅ 这种转换本身就不成立容器之间没有转换关系存指针如unique_ptrBase才是正确做法。❌ 在基类里同时声明纯虚函数和非虚析构并且依赖派生类的清理逻辑。✅ 纯虚函数只保证不能实例化不解决析构问题虚析构必须单独加上。总结关注点静态多态动态多态类型擦除决议时机编译期运行期运行期对象额外开销无一个 vptr一次间接层能否内联能通常不能通常不能能否放入同一容器不能能能是否要求继承否是否典型代表重载、模板、CRTPvirtual函数std::function多态的本质是把类型决议推迟到最晚必要的时刻。虚函数提供了最通用的形式代价是一个指针和一次间接调用CRTP 与模板把决议提前到编译期换来零开销但也换来类型上的不通用类型擦除则用一层间接把两者缝合起来。真正需要记住的不是多态等于虚函数而是这张表里每种形态的时机与代价然后按场景选最合适的那一个。
返回列表