ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

从最初学C++到现在带了N个新人,我发现一个现象:讲继承和多态,其他例子都得铺垫半天,唯独拿“Animal”当例子,几乎不用多说,大家都能秒懂。为什么?因为“猫会喵、狗会汪”这种日常常识,本身就完美对应了面向对象里“同一个行为、不同表现”的核心矛盾。今天这个例子,咱们不搞抽象概念轰炸,直接把Animal、Dog、Cat写成能编译、能跑起来的C++代码,把继承、虚函数、纯虚函数、虚表、对象切片这些词一个一个落到代码里。看完你就能明白:为什么有些函数前面非要加个virtual,为什么基类析构函数必须写virtual,为什么按值传参数会“切掉”派生类的那部分数据。不需要你有深厚基础,只要会写最简单的类,就能跟上。

1. 为什么非要用“Animal”来学这两个特性

1.1 一个到处都是“会叫的动物”的场景

设想你要做一个动物园管理系统,系统里要记录狗、猫、鸟,每种动物都有自己的叫声。比如狗是“汪汪汪”,猫是“喵喵喵”,鸟是“叽叽喳喳”。领导告诉你:以后可能还会加入鸭子、仓鼠,甚至恐龙,你最好别每加一种动物就把所有代码改一遍。

这个需求听着很日常,但它本质上就是面向对象设计的经典考题。它考的就是“继承”和“多态”:能不能把每种动物共有的属性(名字、年龄、饲养员)放到一个公共的位置,让子类去复用;能不能让系统在面对“一只不知道具体类型的动物”时,依然能正确地调用到它真正的叫声。

你当然可以写三个互不相干的类,每个类里都放一份“名字”字段,都写一个speak函数。三个类能工作,三十个类就会崩溃——复制粘贴的代码会在某个深夜的修改中漏掉一半。继承解决的就是“公共部分抽出来”,多态解决的是“公共部分怎么被不同对象以不同方式执行”。这两个东西一旦用上,新增一种动物就只需要写一个新的子类,其他逻辑一概不动。

1.2 面向对象三件套中,继承和多态解决什么问题

封装、继承、多态是面向对象的三块基石。封装是把数据和操作绑定在一起,并且对外隐藏实现细节;这一步大多数人第一次写类就摸到了。继承解决的是复用和层级关系:Dog是一个Animal,Cat也是一个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++里应该设计成“抽象基类”,让它不能直接实例化,只作为所有具体动物的蓝图。

用纯虚函数就能做到。写法是在虚函数的声明后面加= 0:

virtual 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::vector<std::unique_ptr<Animal>> zoo; zoo.push_back(std::make_unique<Dog>("旺财")); zoo.push_back(std::make_unique<Cat>("咪咪")); for (const auto& animal : zoo) { animal->speak(); } return 0; }

输出:

旺财 汪汪汪地叫了。 咪咪 喵喵喵地叫了。

这里我使用了智能指针std::unique_ptr<Animal>,原因是现代C++不推荐手动new/delete,智能指针可以在离开作用域时自动释放对象。注意make_unique<Dog>返回的是unique_ptr<Dog>,把它放到vector<unique_ptr<Animal>>里,会发生一次派生类指针到基类指针的隐式转换——这也是继承关系支持的正常行为,而且不会产生对象切片。整个容器看起来装的是Animal,实际每个元素都是具体的Dog、Cat。

如果还没接触智能指针,用裸指针也可以:

std::vector<Animal*> 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 dtor,data_ 泄漏 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只是建议写,其实我对此的态度更强硬:C++11之后,凡是覆盖基类虚函数的子类函数,都应该写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,这个设计是紧凑且自然的。如果你发现你的继承层次里,子类只是“为了复用代码”才继承,而非“天然是一种基类”,那就停下来重新设计。保持继承关系的语义清晰,比多写两层抽象更有价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进