2026/10/16 17:59:14

C++继承与多态:用Animal例子彻底搞懂虚函数与对象切片

C++继承与多态:用Animal例子彻底搞懂虚函数与对象切片 从最初学C到现在带了N个新人我发现一个现象讲继承和多态其他例子都得铺垫半天唯独拿“Animal”当例子几乎不用多说大家都能秒懂。为什么因为“猫会喵、狗会汪”这种日常常识本身就完美对应了面向对象里“同一个行为、不同表现”的核心矛盾。今天这个例子咱们不搞抽象概念轰炸直接把Animal、Dog、Cat写成能编译、能跑起来的C代码把继承、虚函数、纯虚函数、虚表、对象切片这些词一个一个落到代码里。看完你就能明白为什么有些函数前面非要加个virtual为什么基类析构函数必须写virtual为什么按值传参数会“切掉”派生类的那部分数据。不需要你有深厚基础只要会写最简单的类就能跟上。1. 为什么非要用“Animal”来学这两个特性1.1 一个到处都是“会叫的动物”的场景设想你要做一个动物园管理系统系统里要记录狗、猫、鸟每种动物都有自己的叫声。比如狗是“汪汪汪”猫是“喵喵喵”鸟是“叽叽喳喳”。领导告诉你以后可能还会加入鸭子、仓鼠甚至恐龙你最好别每加一种动物就把所有代码改一遍。这个需求听着很日常但它本质上就是面向对象设计的经典考题。它考的就是“继承”和“多态”能不能把每种动物共有的属性名字、年龄、饲养员放到一个公共的位置让子类去复用能不能让系统在面对“一只不知道具体类型的动物”时依然能正确地调用到它真正的叫声。你当然可以写三个互不相干的类每个类里都放一份“名字”字段都写一个speak函数。三个类能工作三十个类就会崩溃——复制粘贴的代码会在某个深夜的修改中漏掉一半。继承解决的就是“公共部分抽出来”多态解决的是“公共部分怎么被不同对象以不同方式执行”。这两个东西一旦用上新增一种动物就只需要写一个新的子类其他逻辑一概不动。1.2 面向对象三件套中继承和多态解决什么问题封装、继承、多态是面向对象的三块基石。封装是把数据和操作绑定在一起并且对外隐藏实现细节这一步大多数人第一次写类就摸到了。继承解决的是复用和层级关系Dog是一个AnimalCat也是一个Animal“是一个”的关系用继承来表达多态解决的是扩展性系统里到处写着“对Animal进行操作”的代码运行时却可以执行到Dog或Cat自己的行为。没有多态继承就只是省了复制粘贴有了多态继承才真正变成一种“可扩展框架”的设计工具。这两者放在一起就是“Animal”例子最有教学价值的地方。它把继承Dog extends Animal和多态Animal指针指向Dog调用speak时执行Dog版本串成一个闭环你只需要观察一次运行结果就能把整个机制看懂。2. 动手搭基类先把Animal定义干净2.1 基类里该放哪些成员在设计基类时我习惯先问自己三个问题。第一所有子类共有的数据是什么这里的“共有”指逻辑上一定存在而不是某些子类恰好也有。动物必须有名字这个可以放到基类。第二所有子类共有的接口是什么每只动物都要“叫”那speak就应该成为基类接口。第三哪些行为是“公共实现、不允许子类改”的比如获取名字这个操作所有动物都一样用普通函数即可哪些行为是“不同子类各有表现”的比如叫声必须声明成虚函数甚至纯虚函数。这样分完之后基类的轮廓就出来了一个存名字的私有成员一个构造函数一个获取名字的普通成员函数一个speak虚函数。注意基类里不要塞太多东西。我知道有人习惯把“吃”“睡”“跑”全放进去但如果你暂时想不清子类会不会以完全不同的方式实现它们就不要急着放进基类。基类越臃肿子类越难写。2.2 第一个可编译的Animal版本先写一个最朴素的版本这个版本还没有多态只是把数据和行为做了一个干净的组织#include iostream #include string class Animal { public: explicit Animal(const std::string name) : name_(name) {} std::string getName() const { return name_; } void speak() const { std::cout name_ 发出了一些声音。 std::endl; } private: std::string name_; }; int main() { Animal a(无名氏); a.speak(); return 0; }这里有几个基础点值得说明。explicit修饰构造函数是为了防止Animal a xxx这种隐式转换在只接受一个参数的构造函数上我建议养成写explicit的习惯。getName()后面那个const表示这个函数不会修改对象这样const Animal类型的引用也能调用它。成员变量name_放在private里外界只能通过getName()读取这就是封装的第一步。运行结果只有一行“无名氏 发出了一些声音。” 别嫌它简单后面所有坑和所有核心机制都要在这个基础上加。3. 继承用起来Dog、Cat一夜之间都有了“身份”3.1 三个最简单的派生类现在写Dog和Cat。注意继承的写法class Dog : public Animal { public: explicit Dog(const std::string name) : Animal(name) {} void speak() const { std::cout getName() 汪汪汪地叫了。 std::endl; } }; class Cat : public Animal { public: explicit Cat(const std::string name) : Animal(name) {} void speak() const { std::cout getName() 喵喵喵地叫了。 std::endl; } };class Dog : public Animal中的public表示公有继承意味着Animal的公有成员在Dog里依然是公有的。构造函数的写法: Animal(name)是向基类构造函数传参。因为Animal没有默认构造函数只有带名字参数的构造函数所以Dog和Cat必须在初始化列表里显式调用它。这一步很容易忘记很多初学者写了空构造函数后发现编译不过其实编译器提示已经说得很清楚了。在这个阶段Dog和Cat都还没有写“override”关键字它们只是隐藏了基类的speak。类里出现同名同参函数时派生类会覆盖基类版本但你用一个Animal指针去调调到的还是Animal的版本。这一点很重要是理解虚函数价值的前提。3.2 构造和析构的顺序代码能告诉你答案继承关系下对象创建和销毁的顺序有固定的规则构造时先基类后派生类析构时先派生类后基类。这个顺序可以打印出来看#include iostream class Animal { public: Animal() { std::cout Animal 构造 std::endl; } virtual ~Animal() { std::cout Animal 析构 std::endl; } }; class Dog : public Animal { public: Dog() { std::cout Dog 构造 std::endl; } ~Dog() override { std::cout Dog 析构 std::endl; } }; int main() { Dog d; return 0; }输出顺序是Animal 构造 Dog 构造 Dog 析构 Animal 析构你可能会问为什么先构造的是基类因为派生类对象的内存布局里基类子对象就在最前面编译器必须先把它构建好派生类才能在这个基础上添砖加瓦。反过来析构时先执行派生类自己的析构逻辑再去销毁基类部分这样才不至于出现“基类已经被销毁派生类还要访问基类成员”的危险局面。这个顺序看起来简单实际调试的时候作用很大。如果你在某个类里看到一批奇怪的初始化输出顺序不对第一反应就应该是检查是否有人错误地在成员变量初始化上写了依赖于“先派生后基类”的代码。3.3 继承的访问控制public、protected、private继承方式一共有三种public继承、protected继承、private继承。绝大多数工程场景只用public继承因为“Dog是一个Animal”这种关系必须使用public继承才能完整保留Animal的对外接口。protected继承和private继承表达的是“实现复用”而不是“是一种”属于比较冷门的存在日常几乎接触不到。我建议初学阶段就记住一句话公开继承代表“是一个”如果你的子类不能被当作基类使用那就说明继承关系可能选错了更有可能应该用组合而不是继承。作为反面教材我见过有人为了复用一段输出日志的代码让业务类去继承一个日志类结果业务类的接口里莫名多出了一堆日志操作后期维护非常痛苦。4. 真正的主角virtual让一个函数有多种表现4.1 我先去掉virtual演示一下“惨案”现在关键来了。把Dog和Cat的speak写好了但main里如果用Animal的指针去调会怎样在最上面的代码里Dog和Cat的speak并没有加virtual所以会出现一个经典结果void callSpeak(const Animal a) { a.speak(); } int main() { Dog dog(旺财); Cat cat(咪咪); callSpeak(dog); // 输出旺财 发出了一些声音 callSpeak(cat); // 输出咪咪 发出了一些声音 return 0; }没错虽然你传入的是一只狗和一只猫但函数内部调用的是Animal版本的speak。这是C静态绑定静态联编的结果编译器看到参数类型是const Animal就认为你调用的是Animal的成员函数编译期直接确定了函数地址。别急着骂编译器它其实很严谨因为它还不能假设你传入的一定是Dog或Cat——它只知道这一定是一个Animal而Animal里speak的地址是确定的。这就是没有虚函数时“多态不存在”的事实。你写了Dog::speak也写了Cat::speak但是系统却只能把它们当作Animal来看这种行为在编程里叫“隐藏”hiding而不是“覆盖”overriding。注意这两个词的区别后面排查问题会用到。4.2 加上virtual之后调用产生了质变要给Animal的speak加上virtual最简单的方式是在基类声明处加关键字class Animal { public: explicit Animal(const std::string name) : name_(name) {} std::string getName() const { return name_; } virtual void speak() const { std::cout name_ 发出了一些声音。 std::endl; } private: std::string name_; }; class Dog : public Animal { public: explicit Dog(const std::string name) : Animal(name) {} void speak() const override { std::cout getName() 汪汪汪地叫了。 std::endl; } }; class Cat : public Animal { public: explicit Cat(const std::string name) : Animal(name) {} void speak() const override { std::cout getName() 喵喵喵地叫了。 std::endl; } };请注意派生类里我写了override。这个关键字不是语法必需的但强烈建议写。它告诉编译器“我要覆盖基类的虚函数”如果函数签名和基类不一致编译器立刻报错。没有它你可能写错了参数类型结果编译器认为你在“隐藏”一个虚函数既不报错运行结果也错得莫名其妙。再跑上面那段callSpeak旺财 汪汪汪地叫了。 咪咪 喵喵喵地叫了。这一次传狗就是狗的叫声传猫就是猫的叫声。调用同一个函数行为随实际对象类型变化这就是“动态绑定”也就是运行时多态。C里选择哪种绑定只看一个条件这个虚函数是不是通过指针或引用调用的。只要满足这个条件就按实际对象类型走否则仍然走静态绑定。这也是为什么后面要特别小心“按值传递”带来的切片。4.3 虚函数表编译器在背后给C开了个“后门”virtual为什么能从“按指针类型调用”变成“按实际类型调用”这里面藏着一张表叫虚函数表英文是vtable。每个含有虚函数的类编译器都会给它生成一张函数指针表表里记录了该类的虚函数实际地址。每个对象内部还会悄悄多出一个指针这个指针一般叫vptr指向所属类的那张vtable。当通过Animal调用speak()时程序做的事情其实不是直接调用而是取出对象的vptr跳到vtable从表里找到speak()对应槽位的函数指针再调用那个指针指向的函数。如果是Dog对象它的vptr指向Dog的vtable对应的槽位存的是Dog::speak的地址如果是Cat对象就指到Cat的vtable自然调用了Cat::speak。整个过程用一句话说就是调用的是“对象真正的类型”所提供的函数。代价是什么相比普通成员函数虚函数多用了一次指针间接跳转有人统计过这个开销通常只是一条call指令多一些在现代CPU上几乎可以忽略。但要注意另一个更实际的代价对象里多了vptr后体积会变大通常多8字节取决于平台而且由于内存布局改变对象不再具有“平凡可拷贝”的特性。所以遇到需要把对象直接序列化、按字节拷到网络包里的场景虚函数会给你添麻烦。这是性能与设计之间的权衡不是单纯为了炫技而用。5. 再进一步纯虚函数和抽象基类5.1 “动物”本该是一个抽象概念回到我们的例子你真的会有“一只动物”出现在系统里吗没有实际来的都是“狗”“猫”“鸟”这些具体物种。“动物”是一个抽象概念它规定了所有动物都要有名字、都要会叫但“怎么叫”却没有统一答案。这种类在C里应该设计成“抽象基类”让它不能直接实例化只作为所有具体动物的蓝图。用纯虚函数就能做到。写法是在虚函数的声明后面加 0virtual void speak() const 0;一旦基类里有纯虚函数这个类就成了抽象类。抽象类不能创建对象比如Animal a(x)在编译期就会报错。它会逼着所有派生类必须实现speak否则派生类自己也不能实例化。这在团队协作里是一个强约束你定义了接口所有实现方都得遵守契约。5.2 把Animal改造成抽象基类改造后的Animal可以是这个样子class Animal { public: explicit Animal(const std::string name) : name_(name) {} virtual ~Animal() default; std::string getName() const { return name_; } virtual void speak() const 0; private: std::string name_; }; class Dog : public Animal { public: explicit Dog(const std::string name) : Animal(name) {} void speak() const override { std::cout getName() 汪汪汪地叫了。 std::endl; } };与前面版本相比speak变成了纯虚函数Animal本身不能实例化了。这反而更贴合真实世界“动物”永远是一个类型身份而不是一个具体实体。你用Animal定义接口Dog、Cat负责实现细节这就是面向对象里所谓的“面向接口编程”。系统里别的模块只要依赖Animal这个接口就永远不会被新增的动物种类影响这就是开闭原则在日常代码中最直观的体现。有人会问纯虚函数是不是一点实现都不能有其实C允许纯虚函数拥有函数体你可以给纯虚函数写一个默认实现派生类可以选择不覆盖它而直接调用Animal::speak()。不过这个特性用得很少初学阶段知道即可不要被它迷惑保持“纯虚接口”的直觉就好。5.3 接口设计与“框架思维”当你开始把Animal做成抽象基类并且让其他代码只依赖Animal时你其实就在进行“框架设计”了。以一个最简单的例子来说你可能会写一个工作人员模块void visitAnimal(const Animal animal) { animal.speak(); }这个模块只依赖于Animal抽象完全不认识Dog、Cat。你以后加入“鸭子类”时也完全不需要改动visitAnimal一行代码。这种“扩展而不修改”的能力就是多态带来的核心价值。它不是让代码少写几行而是让代码在面对变化时不需要被反复开刀。我见过不少项目在初期完全没有抽象所有的逻辑都写死在具体类型上结果每次加新功能就要复制一大段if-else。到后期抽象基类可以做得非常克制像Animal这样只定义“行为契约”反而比把所有方法都堆在基类里更能保持代码的健康度。6. 多态落地容器里装一群会叫的动物6.1 用基类指针管理不同对象多态最常见的使用场景是把不同的派生类对象放进同一个容器。一个动物园里当然会有不同动物你会希望把它们都放到一个数组或vector里统一管理。这里的关键是容器里不能直接放“Animal对象”因为Animal现在是抽象类也不能放“Dog对象和Cat对象混合的vector”因为类型不同。正确做法是放“指向Animal的指针或智能指针”。#include iostream #include memory #include vector int main() { std::vectorstd::unique_ptrAnimal zoo; zoo.push_back(std::make_uniqueDog(旺财)); zoo.push_back(std::make_uniqueCat(咪咪)); for (const auto animal : zoo) { animal-speak(); } return 0; }输出旺财 汪汪汪地叫了。 咪咪 喵喵喵地叫了。这里我使用了智能指针std::unique_ptrAnimal原因是现代C不推荐手动new/delete智能指针可以在离开作用域时自动释放对象。注意make_uniqueDog返回的是unique_ptrDog把它放到vectorunique_ptrAnimal里会发生一次派生类指针到基类指针的隐式转换——这也是继承关系支持的正常行为而且不会产生对象切片。整个容器看起来装的是Animal实际每个元素都是具体的Dog、Cat。如果还没接触智能指针用裸指针也可以std::vectorAnimal* zoo; zoo.push_back(new Dog(旺财)); zoo.push_back(new Cat(咪咪)); for (auto a : zoo) { a-speak(); } for (auto a : zoo) { delete a; }只是每一条new都得配上delete一旦中间抛出异常或者逻辑提前返回就很容易泄漏。所以能上智能指针就上智能指针。6.2 以函数参数传入让代码不写死在具体类型除了容器函数参数也是多态的经典阵地。把“接收具体对象”改成“接收基类引用”函数的适用范围立刻扩大void makeSound(const Animal a) { a.speak(); } int main() { Dog dog(旺财); Cat cat(咪咪); makeSound(dog); // 旺财 汪汪汪地叫了。 makeSound(cat); // 咪咪 喵喵喵地叫了。 return 0; }这里最需要注意的是参数必须用引用const Animal如果你为了简单写成了Animal a转身就会遇到“对象切片”。为什么不能用值传递因为Animal是一个抽象基类本身根本不能按值创建对象编译器会直接报错。那如果我把纯虚函数去掉Animal可以实例化了用值传递是不是就可以可以编译但会失去多态。按值传递时编译器会试图把Dog对象“拷贝成一个Animal对象”这会调用Animal的拷贝构造函数Dog特有的部分会被无情丢弃。这个过程像把一整块蛋糕切成“只剩下面粉和水”的压缩饼干名字还在性格没了。这个现象就叫对象切片。6.3 什么情况该管什么情况该少用多态确实强大但它不是万能的。性能敏感的代码路径上虚函数间接跳转会造成影响所以写热循环里的小对象时我一般不轻易引入虚函数。另一个问题是多态设计往往鼓励把类型变成指针这会让内存管理变得复杂也容易让代码从一个极端走到另一个极端——什么东西都套一层接口导致读代码时要跳很多次才能找到真正实现。更好的思路是只对“确实存在多种行为”的抽象建立多态关系不要为单行代码强行引入继承层次。比如Animal例子里“发出声音”确实有多种行为值得做虚函数“获取名字”每个动物都一样没必要做成虚函数。如果你把getName也声明成virtual那基本上是给未来的自己挖坑因为所有子类都得跟着维护却没有任何收益。7. 实战踩坑继承多态的几个典型翻车现场7.1 析构函数没加virtual程序“悄悄泄漏”这是实践中遇到最多、也最隐蔽的问题。按继承关系Dog析构函数会先执行然后调用Animal析构函数。但当你通过基类指针去delete一个派生类对象时如果基类析构函数不是虚函数编译器会按照静态类型来调用析构函数也就是只调用Animal的析构函数完全跳过了Dog的析构。Dog里如果申请了堆内存、持有外部资源这部分就没人释放了。class Animal { public: Animal() default; ~Animal() { std::cout Animal dtor std::endl; } }; class Dog : public Animal { public: Dog() { data_ new int[1000]; } ~Dog() { delete[] data_; std::cout Dog dtor std::endl; } private: int* data_ nullptr; }; int main() { Animal* p new Dog(); delete p; // 只打印 Animal dtordata_ 泄漏 return 0; }解决办法就是给基类析构函数加上virtual。规则很简单只要类里声明了虚函数就把析构函数一起写成虚函数即使你是抽象基类也建议把析构函数声明成虚函数哪怕实现为空。7.2 构造函数里调用虚函数得到的是基类版本构造函数里调用虚函数不会按“实际类型”分派。想想为什么创建Dog对象时先进入Animal构造函数此时Dog部分还没有初始化vptr还指向Animal的vtable所以即便你在构造函数里调用speak也只能按Animal版本来执行。这不算C的缺陷而是“部分构造对象”时的一种保护机制。class Animal { public: Animal() { speak(); // 这里其实调用的是 Animal::speak() } virtual void speak() const { std::cout Animal speak std::endl; } };写代码时尽量别在构造函数里调用可覆盖的虚函数。如果真的要做初始化阶段的不同行为可以选择把初始化推迟到派生类构造函数完成之后再显式触发或者用模板方法模式总之不要在基类构造函数里触发出“看似多态”的调用。7.3 按值传递导致的“对象切片”对象切片前面已经提过这里给出一个完整的教训版void makeSound(Animal a) { // 按值传递切片现场 a.speak(); } int main() { Dog dog(旺财); makeSound(dog); // Animal 发声 return 0; }如果Animal不是抽象类这段代码能编译但dog传入函数时被“截断”成一个Animal多态完全失效。更有意思的是它还会引发隐患Animal和Dog的拷贝构造行为不同如果Dog里有指向堆内存的指针切片后的临时Animal只会复制指针值等于生成一个悬空指针。我在实际代码里见过因为这个导致的double-free排查过程极其痛苦。建议禁掉自己的手误凡是需要接收派生类对象的函数参数一律写成基类的引用或指针。保持“传引用/指针”的肌肉记忆可以帮你避开这个坑。7.4 忘记override关键字签名错了还浑然不知前面说override只是建议写其实我对此的态度更强硬C11之后凡是覆盖基类虚函数的子类函数都应该写override。最常见的一个错误是基类写的是speak() const子类漏掉了const签名就对不上。没有override时编译器认为你在子类里定义了一个新函数speak()这是一个“隐藏”而不是覆盖。代码能编译运行结果却完全不是多态的效果。加了override后编译器会在编译期明确告诉你“这个函数没有覆盖任何基类虚函数”把错误提前到编译阶段。我整理过一个小对比表方便检查情况是否加override编译表现运行效果签名匹配覆盖虚函数加override正常动态绑定生效签名匹配覆盖虚函数不加override正常动态绑定生效签名不匹配如少const不加override正常但隐藏了基类函数按静态类型调用多态失效签名不匹配如少const加override报编译错误无运行机会所以别偷懒override三个字能把一类难查的bug直接消灭在编译期。另外还有一个容易踩的坑是函数签名完全一致但基类函数没有virtual。这时候子类加override也会报错因为编译器会告诉你“没有可覆盖的虚函数”。这不是坏事它逼你去检查基类设计说不定基类本来就该有virtual。8. 个人实践中的一些体会这个Animal例子我在多种场合讲过也在真实项目里反复验证过。我最深的体会是学继承和多态不要停留在背诵“什么是什么”一定要亲手把代码改几个版本亲自看看去掉virtual会发生什么把析构函数去掉virtual再delete看看内存情况把引用改成值传递再看输出。这些看似“反向操作”的尝试才是最有效的学习方式。因为C多态的规则并不复杂难的是记住“为什么必须这样做”而“为什么”只有在你踩到坑、看到错误结果的那一刻才会真正内化。另外一个实用建议是写完基类后先给析构函数加上virtual再把所有要被覆盖的函数统一加上virtual和override最后检查是否有纯虚函数需要声明。这个顺序看起来简单但它能防住绝大多数初学者翻车。如果在团队项目里别人看到一个抽象基类连虚析构都没有第一反应就是设计没过关因为后面大概率要为此重写代码。最后说一句别被“继承越多越高级”误导能用组合解决的问题就先别急着继承能用非虚函数解决的行为也别硬加virtual。Animal例子里Dog和Cat继承了Animal的name并且重写了speak这个设计是紧凑且自然的。如果你发现你的继承层次里子类只是“为了复用代码”才继承而非“天然是一种基类”那就停下来重新设计。保持继承关系的语义清晰比多写两层抽象更有价值。