ARTICLE DETAIL

资讯详情

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

C++多态详解:虚函数、动态绑定与虚函数表实战

C++多态详解:虚函数、动态绑定与虚函数表实战 1. 从一次支付回调的崩溃说起多态到底解决了什么问题多态这个词第一次听的人大多会愣一下因为它不像继承那样字面就能猜到含义继承就是把父类的东西传下来也不像封装那样有个直观的打包感。它是面向对象三大特性里最抽象、也最容易被背定义应付面试的那一个。我见过太多人能把同一接口不同实现这八个字倒背如流但真让他写一段代码遇到需要动态选择行为的地方还是老老实实写了一长串if-else或者switch。这篇文章要做的就是把这个概念彻底讲透并且给你能直接落地复现的代码。先说个我印象很深的真实场景。几年前做一个订单系统支付渠道有微信、支付宝、银行卡三个。最开始代码是这样的一个PaymentService类里一个pay方法方法体里if (channel wechat) {...} else if (channel alipay) {...} else if (channel bank) {...}。当时只有三个渠道写得还算干净。后来业务扩张加到了七八个渠道还要加上退款、查询、对账每个方法里都复制一遍这段if-else。再后来运营说要加一个换优惠券的逻辑改一个渠道的支付流程结果我在五个不同的文件里找那段分支改漏了两处线上出了事故。那次事故之后我重构了这个模块把每个渠道封装成一个独立的类让它们都实现同一个支付接口调用方只管调pay()具体走哪个渠道由运行时决定。加新渠道的时候我只需要新建一个类其他代码一行不动。这个调用方只管调、具体行为运行时决定的能力就是多态。用一句人话总结多态让同一段调用代码能根据对象的实际类型执行不同的行为。调用的人不需要知道对象具体是谁只需要知道它支持什么操作。就像你去餐厅点一份牛排服务员不会问你后厨是哪位厨师做的、用哪个灶他只需要知道牛排这个统一的口令谁接单谁负责把对应的做法端上来。从专业角度讲多态Polymorphism这个词源自古希腊语poly是多morph是形态合起来就是多种形态。它的核心价值在于解耦调用方和实现方通过一个抽象的约定接口或基类连接而不是硬编码地绑在一起。这直接对应设计原则里的开闭原则——对扩展开放对修改关闭。你新增功能时是加代码而不是改老代码这对维护期的项目来说是救命的。那多态适合谁来学如果你是刚接触面向对象的初学者这篇会帮你从背概念过渡到能写能用如果你已经写了几年代码但一直靠if-else打天下这篇会告诉你多态怎么帮你把代码拆干净如果你是准备面试的这里会覆盖面试官真正会追问的底层细节比如虚函数表、动态绑定、对象切片这些。2. 编译期多态与运行期多态两类机制的本质区别很多人一提到多态脑子里只有虚函数和重写这一套其实那只是多态的一种。真正完整的分类里多态分成两大阵营编译期多态也叫静态多态和运行期多态也叫动态多态。分清楚这两类你才不会在选型的时候用错工具。2.1 编译期多态在代码生成阶段就定好了编译期多态指的是程序在编译的时候编译器就已经根据你写的参数类型、代码上下文把具体该调用哪个函数确定下来了。最常见的三种形式是函数重载、运算符重载和模板。函数重载很好理解同名函数参数列表不同参数个数、类型或顺序不一样编译器根据你传进来的实参去匹配对应版本。比如void print(int value) { /* 打印整数 */ } void print(double value) { /* 打印浮点数 */ } void print(const std::string value) { /* 打印字符串 */ } print(42); // 编译器直接绑定到 print(int) print(3.14); // 绑定到 print(double) print(hello); // 绑定到 print(const std::string)这三个调用在编译完成后实际上已经是三段彼此独立的、目标地址明确的调用了运行时没有任何选择的过程所以它快。模板也是同理std::vectorint和std::vectordouble在编译期就展开了两套完全独立的代码这叫泛型编程本质上也是编译期多态的一种——同一份模板代码对不同类型产生不同的具体实现。这里有个坑要注意重载决议是编译期行为靠的是静态类型。也就是说如果你手里拿的是一个基类指针指向派生类对象编译器看不到派生类里那些同名但参数不同的重载函数它只会在基类里找。这个问题后面讲名字隐藏时我会展开。2.2 运行期多态等到程序真的跑起来才知道运行期多态才是大多数人口中多态的默认所指它的标志性特征是虚函数。核心机制是基类里声明一个virtual函数派生类重写它通过基类指针或引用调用这个函数时具体执行哪个版本要看指针/引用实际指向的对象的动态类型而不是它声明的静态类型。class Animal { public: virtual void speak() { std::cout 某种声音 std::endl; } }; class Dog : public Animal { public: void speak() override { std::cout 汪汪 std::endl; } }; class Cat : public Animal { public: void speak() override { std::cout 喵喵 std::endl; } }; void makeSound(Animal a) { // 参数是基类引用 a.speak(); } Dog dog; Cat cat; makeSound(dog); // 输出汪汪 makeSound(cat); // 输出喵喵关键点在于makeSound这个函数在编译的时候编译器压根不知道传进来的到底是Dog还是Cat它只保证Animal有这个speak接口。真正调用哪个版本是在运行时通过一个叫虚函数表的结构查出来的。这就是动态绑定也是运行期多态名字的由来。我把两者的差异整理成一张表选型的时候可以直接对照维度编译期多态静态运行期多态动态典型形式重载、模板、运算符重载虚函数、接口实现决策时机编译阶段运行阶段调用开销几乎为零可直接内联有一次查表虚表指针开销通常无法内联灵活性类型必须在编译期已知类型可在运行时才确定扩展方式新增类型需重新编译新增类型无需改动调用方代码常见语言C、Java重载部分C、Java、C#、Python选型逻辑其实很简单如果你在编译时就知道所有可能的类型且追求极致性能用编译期多态如果你需要运行时才知道对象是什么、需要框架级的可扩展性用运行期多态。像策略模式、插件系统、事件回调这些场景基本都是运行期多态的地盘。2.3 为什么动态多态必须靠指针或引用初学者经常踩的一个认知坑是我直接用基类对象接收派生类对象为什么多态没生效看这段代码Animal a Dog(); // 对象切片多态失效 a.speak(); // 输出某种声音而不是汪汪因为这里发生的是值拷贝Dog对象被切片成了Animal派生类独有的那部分数据和行为全被丢掉了。多态能工作的前提是编译器通过指针或引用间接访问对象这样才能保留对象的真实身份。记住一句话动态多态认指针和引用不认值。这也是后面第 5 章要重点讲的对象切片问题的根源。3. 虚函数表与动态绑定C多态在内存里到底发生了什么面试被问到多态很多人能答出重写、虚函数、动态绑定但一旦面试官追问那它底层是怎么实现的虚函数表长什么样基类指针是怎么找到派生类函数的就开始卡壳。这一章我们把运行期多态的实现机制拆到内存层面搞清楚之后你对空指针调用虚函数、构造析构里调用虚函数这些诡异现象就能一眼看穿。3.1 虚函数表与虚表指针一张全局的函数地址清单C 标准本身其实并没有规定必须用虚函数表来实现多态它只规定了行为。但几乎所有主流编译器GCC、Clang、MSVC都采用了虚函数表vtable 虚表指针vptr这套方案。具体布局是这样的只要一个类里有虚函数编译器就会为这个类在只读数据段生成一张虚函数表表里按声明顺序存放着这个类所有虚函数的地址。同时这个类的每个对象在内存的最开始具体位置各家实现略有差异但通常是头部会被悄悄塞进一个隐藏的指针叫虚表指针vptr指向所属类的虚函数表。当你写Dog dog;的时候dog对象内存里就有[vptr][Dog 自己的数据成员]。而vptr指向的这张表里speak那一栏填的是Dog::speak的地址。Cat对象的vptr指向的表里同一栏填的是Cat::speak的地址。同一个槽位不同的表填了不同的函数地址——这就是多种形态在内存里的物理含义。调用a.speak()时实际发生的是三步先通过对象里的vptr找到它所属类的虚表再在表里定位到speak对应的槽位最后取出那里的地址调用。这个过程因为是运行期完成的所以叫动态绑定。相比之下普通函数的调用地址在编译链接时就已经写死在指令里了这叫静态绑定。3.2 用代码验证虚表的存在光说理论有点虚我们用代码实际看一眼对象的布局#include iostream class Base { public: virtual void f() {} virtual void g() {} }; class Derived : public Base { public: void f() override {} void g() override {} }; int main() { std::cout sizeof(Base) sizeof(Base) std::endl; std::cout sizeof(Derived) sizeof(Derived) std::endl; }在 64 位环境下这两个输出基本都是 8。而Base里明明没有任何数据成员按理说应该是 0 或者 1为什么是 8因为这 8 个字节就是那个隐藏的虚表指针。这多出来的 8 字节就是多态在内存上的成本之一。如果你想更直观地看到虚表内容可以用 GCC 的扩展来打印 vtable 地址或者用gdb配合set print vtbl on查看甚至用-fdump-class-hierarchy参数让编译器把类的层次结构导出来。这些手段在排查为什么这个虚函数没被调用到这类疑难问题时特别好用。我强烈建议你亲手做一次这个实验定义一个基类两个虚函数派生类只重写其中一个然后把对象切开看虚表里两个槽位分别指向哪里。做完之后你对重写和动态绑定的理解会从纸面变成肌肉记忆。3.3 覆盖、重载、隐藏三个词到底差在哪围绕虚函数有三个经常被混为一谈的概念这里必须掰清楚因为它们直接决定了运行时到底调的是哪个函数。覆盖Override派生类重新实现了基类的虚函数函数签名完全一致返回类型协变除外这是多态真正生效的场景。重载Overload同一个作用域内同名但参数列表不同的函数靠的是编译期匹配和虚函数没关系。隐藏Hide派生类里定义了一个和基类同名的函数哪怕参数不同、哪怕基类那是个虚函数派生类的作用域就把基类所有同名函数都挡住了。这时候用派生类对象调用默认只看派生类那一版。隐藏是最容易出事的。举一个很多人栽过的例子class Base { public: virtual void doWork(int x) { /* 处理整数 */ } }; class Derived : public Base { public: void doWork(double x) { /* 处理浮点 */ } // 注意这不是覆盖 }; Derived d; d.doWork(3.14); // 调用 Derived::doWork(double) d.doWork(5); // 仍然调用 Derived::doWork(double)5 被隐式转换 Base* p d; p-doWork(5); // 这才调到 Base::doWork(int)Derived::doWork(double)因为参数类型和基类不一样根本没构成覆盖它只是把基类的doWork(int)给隐藏了。于是d.doWork(5)这个看起来再正常的调用编译器会先做隐式类型转换把它变成doWork(5.0)走派生类版本。这种 bug 极其隐蔽编译器不会报错行为却和你想的完全不同。防御手段有两个一是重写虚函数时永远加上override关键字编译器会在你签名不匹配时报错二是如果想在派生类里引入重载版本同时保留基类版本用using Base::doWork;显式把基类的名字拉进来。这两个习惯我建议你从现在开始强制自己养成能省掉无数小时的调试。4. 动手实现从零搭一个可扩展的图形计算模块光讲原理不解渴这一章我们完整实现一个面积/周长计算模块用多态把它写成加图形不用改调用方的样子。这个案例足够小你能直接抄下来跑又足够典型几乎覆盖了多态落地的所有关键步骤。4.1 需求分析与抽象基类设计需求很简单给定一组图形计算每个图形的面积和周长最后求和。如果你用if-else的思路大概是判断一个type枚举然后分别算。问题是每次加图形都要改代码。用多态的思路第一步是抽出所有图形的公共行为定义一个抽象基类。注意抽象的着眼点应该是调用方需要什么能力而不是图形都有什么字段。调用方需要的是能算面积能算周长知道自己叫什么那我们就定义这三个接口#include iostream #include string #include vector #include memory class Shape { public: virtual ~Shape() default; // 关键虚析构见第5章 virtual double area() const 0; // 纯虚函数无实现 virtual double perimeter() const 0; virtual std::string name() const 0; };这里的 0表示这是纯虚函数Shape因此成为抽象类不能直接实例化只能被继承。这一设计的深意在于它强迫所有图形都必须提供这三个能力调用方拿到一个Shape就一定能调area()契约是可靠的。用纯虚函数而不是给个默认实现是因为图形这个东西你没法写出一个合理的默认面积。没有默认本身就是一种语义表达——你必须自己实现。4.2 派生类的实现与 override 保护有了基类各图形各自实现class Circle : public Shape { double r_; public: explicit Circle(double r) : r_(r) {} double area() const override { return 3.141592653589793 * r_ * r_; } double perimeter() const override { return 2 * 3.141592653589793 * r_; } std::string name() const override { return 圆; } }; class Rectangle : public Shape { double w_, h_; public: Rectangle(double w, double h) : w_(w), h_(h) {} double area() const override { return w_ * h_; } double perimeter() const override { return 2 * (w_ h_); } std::string name() const override { return 矩形; } };每个override都是一道防线。如果哪天我把area()的const忘了写编译器会立刻报错而不是默默生成一个隐藏了基类版本的新函数导致多态悄悄失效。这个细节新手极容易忽略const的丢失是覆盖失效的头号原因。4.3 用基类指针统一管理调用方只认接口真正体现多态价值的是调用方代码int main() { std::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle(2.0)); shapes.push_back(std::make_uniqueRectangle(3.0, 4.0)); double totalArea 0, totalPerimeter 0; for (const auto s : shapes) { std::cout s-name() 面积 s-area() 周长 s-perimeter() std::endl; totalArea s-area(); totalPerimeter s-perimeter(); } std::cout 总面积 totalArea 总周长 totalPerimeter std::endl; }注意这个循环它完全不知道容器里装的是圆还是矩形它只管调area()和perimeter()。这就是多态的威力——调用逻辑和具体类型彻底解耦了。4.4 加一个三角形验证零改动扩展现在见证时刻到了。业务要求加一个三角形。我只需要新增一个类class Triangle : public Shape { double a_, b_, c_; public: Triangle(double a, double b, double c) : a_(a), b_(b), c_(c) {} double area() const override { double p (a_ b_ c_) / 2; return std::sqrt(p * (p - a_) * (p - b_) * (p - c_)); // 海伦公式 } double perimeter() const override { return a_ b_ c_; } std::string name() const override { return 三角形; } };然后在main里加一行push_back就够了。其余所有统计、打印、求和逻辑一行都没动。这就是开闭原则的落地扩展是加代码修改是零。那种加个图形要翻遍五个文件改if-else的日子到此为止。提示这里用std::unique_ptrShape而不是直接存Shape值原因就是第 2.3 节讲的——存值会触发对象切片多态直接失效。用智能指针既避开了切片又自动管理了生命周期是现代 C 的推荐写法。如果你的项目环境不支持 C11退而求其次用裸指针但一定要自己管好释放。5. 多态落地时的典型翻车点与规避手段代码能跑起来只是开始多态真正的坑都藏在边界情况里。这一章我把这些年在项目里和面试里见过的翻车场景集中列出来每一个都配了复现方式和修复方案你可以对着自己的代码逐一排查。5.1 虚析构函数不写它的代价是内存泄漏这是 C 多态最经典、也最致命的坑。看这段class Base { public: ~Base() { std::cout ~Base std::endl; } // 非虚析构 }; class Derived : public Base { public: ~Derived() { std::cout ~Derived std::endl; } }; Base* p new Derived(); delete p; // 只输出 ~Base~Derived 根本没被调用当基类指针指向派生类对象而析构函数不是虚函数时delete p只会调用基类的析构派生类那部分资源堆内存、文件句柄、锁全部泄漏。只要一个类打算被多态使用有虚函数它的析构函数就必须是虚的。修复方式就是给基类析构加上virtualvirtual ~Base() default;我个人的经验是写抽象基类时virtual ~Base() default;这一行几乎是条件反射般地敲出来比main函数还熟练。如果你的类不允许被继承比如某些工具类可以反过来用final关键字封死它也是一种表达设计意图的方式。5.2 构造和析构函数里调用虚函数多态会失灵这是另一个反直觉的点。在基类构造函数里调用虚函数不会走到派生类的版本class Base { public: Base() { init(); } // 危险 virtual void init() { std::cout Base::init std::endl; } }; class Derived : public Base { public: void init() override { std::cout Derived::init std::endl; } }; Derived d; // 输出 Base::init而不是 Derived::init原因在于构造顺序构造Derived时先跑基类Base的构造函数这时Derived那部分还没被初始化对象的虚表指针还指向基类的表。如果此时允许调用派生类虚函数那个函数访问派生类成员就是访问未初始化内存是未定义行为。所以语言规定构造/析构期间虚函数退化成静态绑定只认当前正在构造/析构的那一层。规避方式很直接不要在构造和析构函数里调用虚函数也不要把这类调用设计成多态的扩展点。如果确实需要构造后初始化用两阶段初始化构造完再显式调用一个init()或者工厂方法模式把对象完全构造好再交出去。5.3 对象切片多态在赋值那一刻就死了前面提过这里展开讲。切片发生在把派生类对象按值赋给基类对象或按值传参时void process(Shape s); // 按值传参切片 process(circle); // circle 被切成 Shapearea() 变回基类版本解决方案是统一用引用或指针传递void process(const Shape s);或void process(const Shape* s);。我在 code review 里看到多态相关的函数参数类型是值传递的基本会直接打回因为它几乎必然是个 bug。记住那条铁律动态多态认指针和引用。5.4 名字隐藏与默认参数的静态绑定5.3 说的是切片这里说两个更隐蔽的陷阱。第一个是名字隐藏第 3.3 节已经详细讲过核心对策是overrideusing。第二个是默认参数是静态绑定的这个坑非常刁钻class Base { public: virtual void show(int x 10) { std::cout Base: x std::endl; } }; class Derived : public Base { public: void show(int x 20) override { std::cout Derived: x std::endl; } }; Base* p new Derived(); p-show(); // 输出 Derived: 10而不是 Derived: 20函数体走的是派生类版本动态绑定但默认参数值用的是基类的静态绑定。默认参数不参与多态。我的建议是在虚函数里永远不要用默认参数需要默认值就写个重载的非虚包装函数或者干脆显式传参。这条经验能帮你避开一个特别难查的 bug。5.5 性能开销到底有多大总有人说虚函数慢能用就别用。这话放到今天基本是过时了。虚函数调用的开销主要就是一次指针间接寻址读 vptr读虚表槽位现代 CPU 的分支预测对热点虚调用往往还能预测正确实际开销在绝大多数业务场景里可以忽略不计。真正会踩性能问题的场景是超高频调用比如每秒千万次以上且调用点类型不稳定这时候间接跳转会破坏 CPU 的指令流水和缓存局部性。如果实测下来确实是瓶颈有几种优化方向把热路径改为编译期多态模板或 CRTP即奇异递归模板模式或者用final关键字告诉编译器这个类不会再被继承帮助它做去虚化devirtualization优化。但请记住顺序先写对、先写清晰用性能分析工具确认瓶颈后再优化别上来就为了省一次查表把架构写乱。我给这五个坑做了个速查表方便你对照排查陷阱症状修复非虚析构派生类析构不执行资源泄漏基类析构加virtual构造/析构调用虚函数调到的永远是当前层版本别在这两个函数里调虚函数对象切片多态静默失效走了基类版本用引用或指针传参名字隐藏同名函数遮蔽基类全部同名函数加override需要时using虚函数用默认参数函数体动态绑定、参数静态绑定虚函数不用默认参数6. 多态与封装、继承的协作关系及常见误用多态从来不是孤立存在的它和封装、继承构成一个整体但这三者的关系经常被初学者搞反——很多人以为多态必须要继承其实继承只是实现多态的一种手段而且是最容易用错的那一种。这一章把三者关系理清楚再讲讲那些看着像多态、实际是误用的写法。6.1 三大特性不是并列的而是有依赖关系的先纠正一个常见误解封装、继承、多态不是三个平行的知识点它们之间有明确的协作逻辑。封装是基础。它把数据和对数据的操作打包在类里对外只暴露必要的接口。多态之所以能工作前提就是调用方只能通过公共接口访问对象而封装的访问控制正好保证了这一点。如果没有封装字段全公开调用方直接操作数据多态那套只认接口不认细节的设计就没有立足之地。继承是手段。它让派生类可以复用基类的接口约定从而建立is-a的关系。但注意继承实现的多态本质上是接口继承——你继承的是基类有什么能力的契约而不一定是它的实现。当基类的方法全是纯虚函数时这个类就退化成了纯接口像 Java 的interface、C# 的接口都是这个思路。多态是目的。它让接口继承真正产生价值调用方依赖接口实现方自由扩展。三者串起来就是一句话用封装隐藏细节用继承建立契约用多态实现解耦。理解了这层依赖你就明白为什么多态必须靠继承是个伪命题了。用接口纯抽象类实现的多态、用模板实现的编译期多态、用函数指针/回调实现的多态都能达到同样的解耦效果继承只是其中最传统的一条路。6.2 组合优于继承什么时候不该用多态多态虽好但继承用得过多会带来继承地狱类层次越来越深、基类一改牵动全身、钻石继承让人抓狂。有一条经验法则值得记牢优先考虑组合has-a其次才是继承is-a。判断标准很直接如果两个类之间确实是是一个的关系圆是一个图形、狗是一个动物用继承如果只是有一个的关系汽车有一个引擎、订单有一个支付方式用组合。更关键的是如果某种变化是应该被封装进对象内部的细节而不是暴露给调用方的扩展点那就不该用多态去抽象它。举个反例我见过有人把数据导出设计成一堆派生类CsvExporter、JsonExporter、XmlExporter都继承一个Exporter。这本身没错但如果导出的只是格式差异、逻辑差异极小用策略模式配合函数对象或者直接用一个带format参数的函数代码量可能只有前者的三分之一。多态的代价是引入了类型层次和虚调用如果它能带来的扩展性你根本不需要那就是过度设计。我的一般建议是先写最简单的实现一个函数、一个if当变化点出现第二次、第三次并且你明显感觉到每次加需求都要改同一段代码时再引入多态抽象。过早抽象和完全不抽象一样有害。6.3 接口继承与实现继承想清楚你继承的到底是什么最后讲一个很多人没意识到的区分也是 Lundi 那本经典书《C 编程规范》里重点强调的接口继承interface inheritance和实现继承implementation inheritance是两码事。接口继承指的是派生类只继承函数签名具体怎么实现完全自己负责对应的是纯虚函数。实现继承指的是派生类直接复用基类写好的函数体对应的是非纯虚函数。问题在于很多人把两者混在一起用基类里既定义了行为逻辑又开放给派生类重写结果基类的逻辑和派生类的实现纠缠在一起改基类时小心翼翼生怕踩坏某个派生类的隐含假设。健康的做法是尽量把二者分开基类要么定义纯粹的行为契约全是纯虚函数即接口要么定义稳定的、不希望被改写的通用实现普通成员函数且不加virtual。如果某个函数既要有默认实现、又允许派生类重写那它的默认实现必须写得极其谨慎且必须把契约写清楚——比如调用前必须保证 X返回值为 Y 时代表什么否则派生类的重写会变成踩雷游戏。class Exporter { public: virtual ~Exporter() default; // 接口继承纯虚强制子类实现 virtual std::string serialize(const Data d) const 0; // 实现继承稳定的通用逻辑明确不建议重写 void exportToFile(const Data d, const std::string path) const { std::string content serialize(d); // 调用纯虚交给子类 // ... 统一的写文件逻辑子类不必关心 } };这个写法叫模板方法模式是接口继承和实现继承协作的典范serialize是留给子类的扩展点exportToFile是基类提供的不变流程。子类只关注怎么序列化而不必重复实现怎么落盘。这种设计既给了扩展自由又守住了通用逻辑的一致性比那种所有函数都开放重写的基类健康得多。6.4 跨语言视角多态在不同语言里的样子最后简单横向对比一下帮你建立全局视野。C 用虚函数表实现动态绑定需要显式virtual是默认静态、手动开启动态Java 里非static、非final、非private的方法默认就是虚的靠接口和抽象类实现多态行为更接近默认动态C# 需要用virtual/override关键字显式声明语义和 C 类似但更严格Python 则是鸭子类型——不看类型看行为只要对象有对应的方法就能调用压根不需要继承同一个基类这是运行期多态在动态语言里的另一种形态。理解这些差异的意义在于多态是一个普适的编程思想虚函数、接口、鸭子类型只是它在不同语言里的实现载体。思想是稳定的语法是变化的。你真正要掌握的是什么时候该用多态解耦这个判断力而不是死记某门语言的某个关键字。我在实际迁移项目、跨语言协作的时候感受特别深一个在 C 里靠继承加虚函数解决的问题到了 Python 里可能一个__getattr__或者简单的协议Protocol就够了。别被具体语法框住思路回到多态的本质——用统一的接口容纳变化的实现——你会发现能用的工具一下子就多了。
返回列表