☰
C++面向对象核心:用动物类实例掌握继承、多态与虚函数
2026/10/17 0:36:46 网站建设 项目流程

我刚开始学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++代码骨架

下面是一段可以直接编译运行的基础代码,标准是C++11,我故意写得很朴素,便于看清每个细节:

#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一定有虚表指针,于是它会生成类似这样的步骤:

  1. 从对象的内存中取出虚表指针;
  2. 在虚表中定位到Speak这个槽位;
  3. 调用该槽位中记录的函数指针。

这个过程在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::vector<std::unique_ptr<Animal>> zoo; zoo.push_back(std::make_unique<Dog>("旺财", 3)); zoo.push_back(std::make_unique<Cat>("咪咪", 2)); zoo.push_back(std::make_unique<Duck>("唐老鸭", 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继承体系里会很别扭。好的做法可能是定义一个机器狗的接口,然后用组合的方式内置一个“狗叫声模块”。这种判断能力,往往比多写几行代码重要得多。

如果让我给刚接触这块内容的同学一个建议,我会说:不要只盯着语法去背,而是自己动手写一个至少包含三个派生类的小系统;写完以后,再想办法往里面加一种新类型,感受一下如果设计得不好,加新东西会有多痛。这种“痛感”会让你真正理解继承和多态存在的意义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询