首页 / 资讯中心 / 文章详情

C++面向对象核心:用动物类实例掌握继承、多态与虚函数

C++面向对象核心:用动物类实例掌握继承、多态与虚函数 ★ FEATURED ARTICLE
我刚开始学C那阵子一直有个困惑继承和多态这两个概念书上说得头头是道但真拿到工位上写代码又总觉得隔着一层。直到有一次我带着一个同学A梳理他手头的项目代码他指着屏幕上一堆重复的函数说“你看这些类长得太像了但又没法合并”我当时的第一个念头就是这不就是个Animal问题吗后来我越用越觉得“动物”这个例子简直是为继承和多态量身定做的。今天这篇文章我就完整地把这个实例拆开从类的设计到虚函数机制再到综合案例和容易踩的坑一步步给大家捋清楚。这篇内容主要面向两类人一类是刚接触C面向对象、对继承和多态还停留在“概念背诵”阶段的初学者另一类是已经写过一些类但一旦涉及基类指针、虚函数、抽象类就头皮发麻的开发者。我尽量不堆术语把每一个问题都落到代码上看完之后你可以直接照着写也可以拿去给刚入门的同事做讲解。1. 为什么我推荐用“动物类”开启C面向对象1.1 先理解继承到底解决了什么问题假设你手里有一个需求要表示一只狗、一只猫和一只鸟。不同动物的叫声不一样但每一只动物都有名字和年龄。最原始的做法是各写各的类每个类里都放上名字、年龄再加一个输出叫声的函数。写过十几遍以后你会发现这套代码里充斥着大量重复的成员变量和相同逻辑的构造函数。继承解决的是“抽取公共特征”这件事。你可以先把所有动物共有的特征放进一个基类比如Animal让Dog、Cat、Bird各自继承这个基类然后把差异化的行为比如叫声放到各自的类里去重新实现。这样一来公共的数据和逻辑只需要维护一份派生类各自只保留自己的个性。这就好比公司统一做了一套工牌模板每个人的部门、姓名、照片不一样但工牌的尺寸、材质、挂绳配件都是同一套不需要每来一个新员工就重新设计一种工牌。1.2 为什么Animal比普通的例子更有说服力市面上讲继承的教材爱用“学生类”和“教师类”做例子但这个例子有一个天然的缺点学生和教师虽然有共同点但他们的共同行为往往不是那么明显强行抽象出一个“人”类会让初学者觉得“有点勉强”。Animal这个模型就好在“说人话”猫是动物狗是动物这个“is-a”关系不需要任何解释。更妙的是动物的行为差异足够直观。你不需要理解什么业务规则只要让不同动物发出不同的声音就能清清楚楚地看到同一句调用代码产生完全不同的结果。这种直观性对建立多态的心智模型帮助极大。还有一点很重要的是Animal这个例子能自然地带出构造函数、析构函数、虚函数、抽象类、作用域、访问控制等一系列C特性。你可以从一个最简单的类出发逐步叠加特性而每一步都符合直觉给动物加名字、让动物能说话、让不同动物说不一样的话、让Animal不能直接实例化。整个过程像是在搭积木而不是在背语法。1.3 面向对象三大特性如何在Animal里自然落地面向对象程序设计经常被概括为“封装、继承、多态”三大特性。很多人分开学感觉都懂但合在一起就不知道它们是怎么协作的。用Animal这个例子三者的关系会变得非常清楚。封装发生在每一个类内部Animal把自己的名字、年龄等数据设置成私有或保护成员外部通过公共接口去访问Dog把自己的叫声策略封装在自己的Speak函数里外面的调用方不需要关心狗是怎么叫的。继承发生在类和类之间Dog、Cat、Bird都从Animal派生它们自动拥有了Animal的公共成员和接口。多态则发生在“同一个接口、不同实现”的时刻当一段代码面对Animal指针调用Speak函数时实际执行的是Dog、Cat或者Bird各自版本。这三大特性在动物模型里就像一条流水线封装负责把每只动物的内部细节藏好继承负责把共性沉淀下来多态负责让共性的接口表现出差异化的行为。理解了这条流水线后面再去看真实的C工程项目就会顺畅得多。2. 类的骨架如何搭从Animal基类到Dog、Cat派生类2.1 根据真实需求反推基类的成员设计在写代码之前我们先模拟一个现实场景你要做一个动物园管理系统。系统需要管理若干只动物每一只动物都需要打印一份介绍信息包括名字和年龄同时它还要能“表演”自己的叫声。根据这个需求Animal基类至少应该提供一个名字成员用于保存每只动物的名字一个年龄成员用于保存每只动物的年龄一个获取名字或年龄的接口一个表示“叫一声”的行为函数Speak。这里有一个很多初学者容易犯的错误一上来就想着把所有可能用到的功能全部塞进基类。比如“鸟类会飞”于是给Animal加一个Fly函数。这样做的问题在于狗不会飞猫不会飞基类里放一个“飞”的接口会让所有动物都拥有它们不该有的能力。设计基类时只在里面保留所有派生类共同拥有的东西个性化的能力留给派生类自己去扩展。我的经验是先写出两三个已有的派生类再看哪些成员和方法是它们都需要的把这些提取到基类里去要比凭空设计基类靠谱得多。2.2 基类和派生类的C代码骨架下面是一段可以直接编译运行的基础代码标准是C11我故意写得很朴素便于看清每个细节#include iostream #include string class Animal { public: Animal(const std::string name, int age) : name_(name), age_(age) { } virtual void Speak() const { std::cout name_ 发出了一种声音。 std::endl; } void PrintInfo() const { std::cout 名字: name_ , 年龄: age_ std::endl; } protected: std::string name_; int age_; }; class Dog : public Animal { public: Dog(const std::string name, int age) : Animal(name, age) { } void Speak() const override { std::cout name_ 汪汪叫。 std::endl; } }; class Cat : public Animal { public: Cat(const std::string name, int age) : Animal(name, age) { } void Speak() const override { std::cout name_ 喵喵叫。 std::endl; } };这段代码里有几个非常关键的位置。首先是Dog构造函数的写法Dog(const std::string name, int age) : Animal(name, age) { }这个叫作“初始化列表”它的作用是显式调用基类的构造函数。很多刚开始用C的同学会忘记写这一行导致Dog自己的构造函数里还得手动去给基类成员赋值甚至有些人直接在函数体里写“name_ name”。虽然某些情况下能编译过但一旦基类成员变量是引用类型或者const类型这种做法就会直接报错。C规定派生类的构造函数在函数体执行之前基类的构造函数必须已经执行完毕。写初始化列表是正规做法也让代码意图更清晰。另一个让人困惑的地方是override关键字。它写不写在语法上都不会错但我强烈建议保留。override的作用是告诉编译器“我打算覆盖基类里的虚函数请帮我检查一下签名的正确性。”如果基类里根本不存在这样一个虚函数编译器会直接报错这能帮你在开发早期就发现拼写错误或参数类型不匹配的问题。我在实际项目里见过太多因为少写一个const导致虚函数根本没被重写、却一点报错都没有的案例坑得人半夜还在调Bug。2.3 构造、析构的顺序以及访问修饰符的作用继承关系下对象的创建和销毁是有严格顺序的。创建一只Dog时先从Animal的构造函数开始执行再执行Dog的构造函数销毁时则反过来先执行Dog的析构函数再执行Animal的析构函数。这个顺序可以类比搭乐高积木先搭底座再搭上层拆的时候必须先拆上层才能拆到底座。理解这个顺序非常重要因为如果基类构造函数里调用了某些依赖派生类数据的函数此时派生类的成员还没有初始化程序的行为会完全超出你的预期这一点后面我会单独讲。关于访问修饰符有几个实用原则要记牢private成员只能被本类访问派生类也不能碰protected成员可以被本类和派生类访问但不能被外部随便调用public成员对所有代码可见。在Animal例子里name_和age_用protected是合理的因为派生类Speak函数需要直接输出名字。但如果你希望外部代码无论如何也不能直接修改动物的名字可以考虑把name_放到private里然后专门提供getName这样的接口让派生类调用。继承方式这里用的是public继承这意味着“Dog是一种Animal”对外可见。C还有protected继承和private继承但在面向对象设计中绝大多数情况下你只需要public继承。另外两种继承更多用于特定的实现复用场景初学者如果看到了可以暂时忽略不要被它们干扰注意力。3. 多态的关键机制虚函数与动态绑定到底发生了什么3.1 为什么同一个指针能调用出完全不同的行为现在我们把目光转到一段“神奇”的代码上Animal* pet new Dog(旺财, 3); pet-Speak(); Animal* another new Cat(咪咪, 2); another-Speak(); delete pet; delete another;这里pet和another都是Animal指针但pet-Speak()输出的是“旺财 汪汪叫”another-Speak()输出的是“咪咪 喵喵叫”。也就是说同一个表达式EnglishXML里面的动词Speak因为对象真实类型的不同产生了不一样的行为。这就是多态最通俗的定义同一个调用接口在不同对象上表现出不同形态。如果你把Animal::Speak前面的virtual去掉再运行这段代码输出就全变成了“发出了一种声音”。原因在于没有virtual时调用哪个函数是在编译期根据指针的静态类型决定的编译器看到pet是Animal*就直接调用Animal::Speak有了virtual后函数调用被推迟到运行时根据指针所指内存里的“真实对象”类型来决定。那这个“真实对象类型”是怎么被记下来的这就引出了虚函数表。3.2 虚函数表的原理简解每个含有虚函数的类编译器都会为它生成一张虚拟函数表通常叫vtable表中记录了这个类里所有虚函数的地址。每个对象内部还藏着一个指针叫虚表指针它指向这个对象所属类的vtable。对象构造的时候虚表指针被设置为指向正确的表。Dog对象构造时虚表指针指向Dog的vtable表中Dog::Speak的地址Cat对象构造时虚表指针指向Cat的vtable表中Cat::Speak的地址。当我们通过Animal指针调用Speak时编译器并不知道对象真正是什么类型但它知道Animal一定有虚表指针于是它会生成类似这样的步骤从对象的内存中取出虚表指针在虚表中定位到Speak这个槽位调用该槽位中记录的函数指针。这个过程在C里叫作“动态绑定”也叫“运行时多态”。你可以把它理解为餐厅点餐你对着菜单上“今日例汤”下一单厨师会根据后厨今天实际准备的食材给你做不同的汤。你不需要在点餐时就知道后厨有什么菜但菜端上来时它一定是今天那碗汤。虚函数表这个概念面试时经常被问到实际工作中虽然不必直接操作它但理解了它的存在你就会明白为什么有了虚函数之后对象会额外多出一点内存开销也就能解释为什么“只有指针和引用可以触发多态普通对象赋值不行”这一看似诡异的现象。3.3 纯虚函数与抽象类把“动物”本身变得不可实例化“动物”在现实中只是一个抽象概念你只能说一只是狗、一只是猫很难说“我养了一只动物它就叫动物”。C提供了纯虚函数这个工具专门用来表达这种“抽象概念”。把Speak声明为纯虚函数写法如下class Animal { public: Animal(const std::string name, int age) : name_(name), age_(age) {} virtual void Speak() const 0; void PrintInfo() const { std::cout 名字: name_ , 年龄: age_ std::endl; } protected: std::string name_; int age_; };注意 0这个语法它表示Animal类的Speak函数没有默认实现是一个纯虚函数。含有至少一个纯虚函数的类叫抽象类它不能被实例化。也就是说你没法再写Animal a(某某, 2)这样的代码因为现实世界里的“动物”并不存在某一种具体的声音。但Dog和Cat必须在Speak函数上补全实现它们才能创建对象class Dog : public Animal { public: Dog(const std::string name, int age) : Animal(name, age) {} void Speak() const override { std::cout name_ 汪汪叫。 std::endl; } };这个设计的好处是强制所有继承了Animal的类都必须回答“你到底怎么叫”这个问题。如果有一个派生类忘了实现Speak那它自己也会变成抽象类无法创建对象。编译器会告诉你“你有地方漏了”比起到运行时才发现卫浴缺个功能这种编译期的约束价值极大。在我自己设计接口基类时纯虚函数的用法几乎是标配基类定义契约派生类负责实现。4. 综合案例用“动物园系统”把继承和多态真正串起来4.1 场景设定一个能同时管理多种动物的程序单独讲语法容易流于表面最好还是放到一个完整的场景里。这里我设计一个小型的动物园系统动物园里可能有多只动物不同种类的动物叫声不同饲养员只负责依次让每只动物打招呼将来还可能加入新的动物种类比如鸡、鸭、马而不需要大幅度改动现有代码。在这个需求里最理想的容器是存放Animal指针的数组或vector。因为多态机制允许我们“把不同种类的动物当成同一种动物”来进行统一管理。如果不用多态你很可能得给Dog建一个vector给Cat建一个vector每加入一个新品种管理器就要多写一份对应代码维护成本很高。当代C里我们通常不直接裸用Animal*而是用智能指针unique_ptr或shared_ptr来自动管理对象的生命周期。这里的重点不是智能指针本身而是多态设计带来的扩展性容器里放的是基类指针实际指向的是派生类对象。4.2 完整代码走读一致调用各显其能下面是一段可以完整运行的例子我把前面提到的各部分组合起来#include iostream #include memory #include string #include vector class Animal { public: Animal(const std::string name, int age) : name_(name), age_(age) {} virtual void Speak() const 0; void PrintInfo() const { std::cout 名字: name_ , 年龄: age_ std::endl; } virtual ~Animal() default; protected: std::string name_; int age_; }; class Dog : public Animal { public: Dog(const std::string name, int age) : Animal(name, age) {} void Speak() const override { std::cout name_ 汪汪叫。 std::endl; } }; class Cat : public Animal { public: Cat(const std::string name, int age) : Animal(name, age) {} void Speak() const override { std::cout name_ 喵喵叫。 std::endl; } }; class Duck : public Animal { public: Duck(const std::string name, int age) : Animal(name, age) {} void Speak() const override { std::cout name_ 嘎嘎叫。 std::endl; } }; int main() { std::vectorstd::unique_ptrAnimal zoo; zoo.push_back(std::make_uniqueDog(旺财, 3)); zoo.push_back(std::make_uniqueCat(咪咪, 2)); zoo.push_back(std::make_uniqueDuck(唐老鸭, 4)); for (const auto animal : zoo) { animal-PrintInfo(); animal-Speak(); } return 0; }运行这段代码输出会是名字: 旺财, 年龄: 3 / 旺财 汪汪叫名字: 咪咪, 年龄: 2 / 咪咪 喵喵叫名字: 唐老鸭, 年龄: 4 / 唐老鸭 嘎嘎叫这段代码里整个主程序只用了一个循环就遍历了所有动物并且它们各自输出自己的叫声。新加一种动物时只需要新增一个类然后再往zoo容器里push一个实例。main函数里的循环一句都不用改。这就是面向对象设计里常说的“开闭原则”对扩展开放对修改关闭。4.3 多态在实际项目中的一个典型场景接口与扩展这种“一致调用、各显其能”的模式在真实项目中极其常见。我举一个虚拟的例子某图像处理Demo里需要支持多种图片格式的输出每种格式的编码逻辑完全不一样。架构上就可以定义一个接口类比如叫ImageRenderer里面有一个纯虚函数Render。然后JpgRenderer、PngRenderer各自实现自己的渲染逻辑。上层代码只需要持有一个ImageRenderer指针业务逻辑完全不关心文件格式。后面要加一种WebpRenderer只需要实现Render再在上层注册一下核心逻辑不用动。正是这种场景让我觉得Animal例子虽然简单但它真正能迁移到工作里的方法是一样的。刚开始练习的同学不妨把“动物”替换成“形状”、 “支付通道”、“消息平台”等去感受同一套模式在不同领域的复现。掌握这个抽象思维比背会某几个类API有价值得多。5. 我在继承与多态上踩过、也见别人踩过的那些坑5.1 对象切片把子类对象按值赋给基类对象新手最经常会踩的一个坑可能是这样的Dog dog(旺财, 3); Animal animal dog; // 这里发生了什么 animal.Speak();看起来好像“把狗变成动物了”但实际上赋值的过程中dog对象中属于Dog的那部分成员和行为统统被切掉了只把Animal基类子对象的那一部分复制了过去这个过程在C里叫对象切片。结果就是animal实际上是一只“不知道该怎么叫”的Animal调用animal.Speak()只会执行Animal自己的Speak实现如果Animal是抽象类这种赋值根本无法通过编译。应对办法非常简单使用指针或引用。Animal* animal dog;或Animal animal dog;都不会发生切片多态也能正常工作。所以我始终建议大家一提到多态就要条件反射地想到指针或引用而不是对象本身。5.2 基类析构函数忘了加virtual程序可能在删除时漏掉析构逻辑当Animal基类的析构函数没有声明为virtual但Dog又在自己的析构函数里申请了系统资源或删除了内嵌对象时问题就来了Animal* pet new Dog(旺财, 3); delete pet;由于delete pet的对象类型是Animal*而其析构函数不是虚函数编译器会按Animal的析构函数去清理Dog的析构函数压根不会被调用。Dog内部申请的资源无人释放轻则内存泄露重则在复杂程序里产生不可预估的副作用。这一点在继承体系中是“必改项”。所以在定义基类时无论你有没有感觉到需要我都建议养成给基类写虚析构函数的习惯class Animal { public: virtual ~Animal() default; };这里 default表示让编译器生成一个默认的析构函数实现。如果不喜欢空函数体的写法也没关系但要确保它是虚的。5.3 构造函数里调用虚函数结果不会按你预期往下走这也是一个特别反直觉的场景。在基类的构造函数里调用虚函数你以为会触发派生类的版本但实际执行的是基类版本。原因其实不难理解派生类对象构造期间先执行基类构造函数时派生类成员还没就绪如果此时去调用派生类版本的虚函数使用了尚未初始化的成员变量后果无法想象。C不想为了这种风险埋雷所以规则是构造期间虚函数调用按照当前正在构造的类的解析结果处理不存在动态绑定。我见过同学在调试一个复杂项目时代码是在基类构造函数里调用了某个虚函数希望子类能提前传入一些初始化参数结果每次输出都是基类的默认值查了半天才发现这个机制。正确的做法通常是把所需参数通过构造函数参数传入基类在基类构造函数里做普通函数调用或者提供一个单独的初始化接口在对象构造完成后再显式调用。5.4 虚函数不要和默认参数混用也别把继承当作万能药很多人不知道C里虚函数是可以带默认参数的但这条规则隐藏着一个大坑默认参数是在编译期根据静态类型决定的而不是运行时根据动态类型决定。比如class Animal { public: virtual void Speak(const std::string sound 嘿嘿) const { std::cout sound std::endl; } }; class Dog : public Animal { public: void Speak(const std::string sound 汪汪) const override { std::cout sound std::endl; } }; Animal* pet new Dog(); pet-Speak(); // 输出的是“汪汪”还是“嘿嘿”结果会让你很意外输出的是“嘿嘿”。因为默认参数是在编译期被替换到调用点上的编译器只知道pet是Animal*压根不知道它是Dog。所以它用的是Animal::Speak的默认参数“嘿嘿”而函数体里的动态绑定又导致实际执行了Dog::Speak。最终出来的效果就是默认参数和函数实现来自两个不同的类。为了避免这种诡异的割裂行为我的建议是不要在虚函数里用默认参数。如果想让不同的动物有不同的默认发声应该各自类里面的调用方显式传参或者用单独的函数包装。最后要说的是继承是强大的工具却不是拿来“面向代码复用”的万能钥匙。有时候你只是想让两个类共享一些代码逻辑但它们之间并没有真正的“is-a”关系这时候用组合会更合适一个类里持有另一个类的对象通过接口调用对方的能力。在动物例子里Dog继承Animal很自然但如果有一天你想让“机器狗”也有类似Dog的能力就要谨慎了——机器狗不是生物意义上的狗硬塞进Animal继承体系里会很别扭。好的做法可能是定义一个机器狗的接口然后用组合的方式内置一个“狗叫声模块”。这种判断能力往往比多写几行代码重要得多。如果让我给刚接触这块内容的同学一个建议我会说不要只盯着语法去背而是自己动手写一个至少包含三个派生类的小系统写完以后再想办法往里面加一种新类型感受一下如果设计得不好加新东西会有多痛。这种“痛感”会让你真正理解继承和多态存在的意义。
阅读完成 · 觉得有帮助?
咨询建站