"Animal"这个例子,在C++面向对象编程里几乎快被讲烂了,但它确实是把继承和多态这两个概念讲明白的最短路径。毕竟狗会叫、猫会叫、动物都会吃东西,这种天然的"父子关系"和"同名不同行为"的现实映射,几乎就是为继承和多态量身定做的场景。这篇文章我打算用一份完整的C++代码,把Animal类家族从设计到落地拆开揉碎了讲,包括继承怎么设计、多态怎么触发、虚函数表到底是什么,以及我在实际测试中踩过的几个经典坑。不管你是刚学OOP的新手,还是写了一阵子C++但总对多态有点含糊,这篇都适合当一份带注释的参考。
1. 面向对象编程的继承,为什么非用Animal开头不可
1.1 先看一段没有继承的笨办法
假设现在我让你写一个程序,管理一个动物园。第一版需求很简单:有狗,有猫,每种动物都有名字、年龄,都会吃东西、睡觉、发出叫声。大部分人第一反应是写两个独立的类:
class Dog { public: Dog(std::string name, int age) : name_(name), age_(age) {} void eat() { std::cout << name_ << " is eating." << std::endl; } void sleep() { std::cout << name_ << " is sleeping." << std::endl; } void speak() { std::cout << name_ << " says: Wang!" << std::endl; } private: std::string name_; int age_; }; class Cat { public: Cat(std::string name, int age) : name_(name), age_(age) {} void eat() { std::cout << name_ << " is eating." << std::endl; } void sleep() { std::cout << name_ << " is sleeping." << std::endl; } void speak() { std::cout << name_ << " says: Miao!" << std::endl; } private: std::string name_; int age_; };这段代码能跑,但你看一眼就会发现两个问题。第一,name_、age_、eat()、sleep()几乎是完全重复的,只不过类名换了一下。第二,假如后天动物园里要加一只鸭子,你得把这整段"复制-改名-改叫声"再来一遍。代码量随着动物种类线性膨胀,而且一旦要加一个公共行为,比如所有动物都要增加一个play()方法,你得跑到每个类里各改一遍,漏改一个就是噩梦。
这还只是两个类,现实项目里如果有几十个类共享某些字段和行为,这种复制粘贴式写法会让维护成本直接爆表。继承要解决的问题,从最简单的层面上说,就是"把共性抽出来,把差异留给子类"。
1.2 继承是什么:is-a 关系
继承表达的是"is-a"关系。狗是一只动物,猫也是一只动物,所以 Dog 和 Cat 是 Animal 的派生类,Animal 是它们的基类。用C++的术语说,叫"派生类继承基类"。
class Animal { public: Animal(std::string name, int age) : name_(name), age_(age) {} void eat() { std::cout << name_ << " is eating." << std::endl; } void sleep() { std::cout << name_ << " is sleeping." << std::endl; } void speak() { std::cout << name_ << " makes a sound." << std::endl; } protected: std::string name_; int age_; };Dog和Cat现在只需要写自己特有的部分:
class Dog : public Animal { public: Dog(std::string name, int age) : Animal(name, age) {} void speak() { std::cout << name_ << " says: Wang!" << std::endl; } }; class Cat : public Animal { public: Cat(std::string name, int age) : Animal(name, age) {} void speak() { std::cout << name_ << " says: Miao!" << std::endl; } };注意我在这里把name_和age_放到了protected区域。这是继承设计里一个很关键的取舍:如果它们是private,派生类无法直接访问;如果它们是public,外部任何人都能改,破坏了封装。protected的含义是"对子类可见,对外部不可见",正好卡在中间。
不过要额外提醒一句:protected成员本质上仍然是类接口的一部分,用得太多会让类变得难维护。更现代的做法是提供getName()、getAge()这样的访问器。但是在教学例子里,直接protected更容易看清继承机制的运作,所以先用它,后面写工程代码时再逐步收紧。
1.3 公有继承、保护继承、私有继承,到底该选哪个
继承方式有三种,public、protected、private,初学者通常会忽略它们的区别。简单记一个结论:日常写业务代码,90% 以上用public继承,因为"子类就是基类的一种"这个语义成立。protected继承和private继承更多是表达"实现复用"而不是"类型从属",实际工程项目里用得很少,而且往往可以用组合替代。
| 继承方式 | 基类 public 成员 | 基类 protected 成员 | 基类 private 成员 | 典型语义 |
|---|---|---|---|---|
| public 继承 | 变成子类的 public | 变成子类的 protected | 不可访问 | is-a |
| protected 继承 | 变成子类的 protected | 变成子类的 protected | 不可访问 | 子类内部复用 |
| private 继承 | 变成子类的 private | 变成子类的 private | 不可访问 | has-a 的变体 |
让我把表格里最容易被误解的一点拆开说。很多人以为private继承下基类的private成员会被继承下来,只是子类不能用——这个理解不准确。C++ 标准里,private成员始终是"存在于对象中,但子类不可见"。也就是说,一个Dog对象里确实有一块内存存放Animal的私有数据,只是你从Dog的成员函数里访问不到它。这一点和 Java 有明显的差异,也是C++的继承模型比较底层的体现。
2. 继承实战:Animal类家族是怎样设计出来的
2.1 基类成员设计的先后顺序
设计基类时,我习惯按照"属性 -> 构造 -> 行为"的顺序来推敲。
属性方面,Animal 至少要有名字和年龄,所以定义std::string name_和int age_。这里有个很多人刚开始学时会困惑的点:成员变量命名结尾为什么加下划线?没有硬性规定,但下划线后缀是我比较推荐的一种命名习惯,它能把成员变量和局部变量、函数参数一目了然地分开,避免在构造函数里出现name = name这种自赋值歧义。
构造方面,Animal 需要一个构造函数来初始化这两个成员:
Animal(std::string name, int age) : name_(std::move(name)), age_(age) {}这里我用了std::move(name),把字符串的拷贝变成移动,省掉一次不必要的复制。如果你还没学到移动语义,先写成const std::string& name然后name_(name)也完全没问题,核心是理解初始化列表的语法:name_(name)是在对象构造阶段直接初始化成员,而不是先默认构造再赋值。这两者的区别对内置类型(比如int)不明显,但对std::string这种带资源管理的类型,直接初始化可以避免一次无谓的默认构造开销。
行为方面,eat()和sleep()这类所有动物都通用的函数,直接放在基类里让子类继承即可。speak()则比较特殊,因为每种动物叫声不同,这就为后面的多态埋下了伏笔。在第一版里我暂时让它输出一句笼统的 "makes a sound",后面再改成纯虚函数。
2.2 派生类构造函数,初始化列表为何必须调用基类构造
写Dog的构造函数时,初始化列表里先调用了Animal(name, age)。这个调用的顺序是C++强制规定的:先构造基类部分,再构造派生类自己的成员,最后执行派生类构造函数体。
Dog(std::string name, int age) : Animal(name, age) {}如果你想偷懒,不写: Animal(name, age),那么编译器会自动调用基类的默认构造函数(也就是无参的那种)。如果基类没有定义默认构造函数,而只定义了带两个参数的构造函数,编译就会直接报错。这个报错信息可能有点绕,比如"no matching function for call to 'Animal::Animal()'"——看到这种报错,第一反应就应该是"派生类没有正确调用基类的带参构造"。
另外还有一个细节:派生类构造函数体内的代码,这时候基类部分已经构造完成。所以如果你想在函数体里访问name_做某些操作,是安全的。
2.3 同名函数的遮蔽问题
把speak()写进Dog类之后,实际上发生了一件初学者容易忽略的事:Dog::speak()把Animal::speak()遮蔽了。注意我用的是"遮蔽"(hiding),而不是"覆盖"(overriding)。这是两个完全不同的概念,后面讲多态时会重新提到。
Dog dog("Wangcai", 3); dog.speak(); // 调用 Dog::speak() dog.Animal::speak(); // 显式调用基类版本第一种调用大家都能理解,第二种显式调用基类版本就不太常用了。但在某些场景下它确实有用:比如子类的speak()想先执行基类speak()里的公共逻辑,再补充自己的逻辑。这时候你可以这么写:
void speak() override { Animal::speak(); // 先让基类做基础输出 std::cout << " and then barks." << std::endl; }遮蔽的另一个麻烦在于,如果派生类里定义了一个和基类同名但是参数列表不同的函数,基类的所有同名重载都会被遮蔽,而不仅仅是那个参数匹配的版本。我用个例子说明:
class Example { public: void func(int) {} void func(double) {} }; class Derived : public Example { public: void func(std::string) {} }; Derived d; d.func(1); // 编译错误!基类的 func(int) 被遮蔽了新手经常在这里踩坑,以为func(int)还能用,结果报错找不到匹配函数。解决办法是显式引入基类的重载:using Example::func;,写在使用之前即可。
3. 多态性:基础概念与C++实现机制
3.1 什么是多态性
多态这个词听起来抽象,翻译成大白话就是:同一个函数调用,作用在不同对象上,表现出不同的行为。C++里的多态主要分两类:编译期多态和运行期多态。
编译期多态包括函数重载、运算符重载、模板。它们的特点是编译时就已经确定了到底调用哪个函数,速度快,但没有"灵活性"。
运行期多态才是 OOP 教材里讲的多态,通过虚函数来实现。它的核心奥义是:调用哪个函数,在运行期根据对象的实际类型决定,而不是根据指针或引用的静态类型决定。这句话值得反复读三遍,它是理解多态的钥匙。
我们来看一个具体场景。假设我写一个函数:
void makeSound(Animal& animal) { animal.speak(); }如果speak()不是虚函数,那么makeSound(dog)调用的永远是Animal::speak(),哪怕传入的是 Dog 对象。但如果把speak()声明为 virtual,那传入 Dog 就调 Dog 的版本,传入 Cat 就调 Cat 的版本,函数本身一行都不用改。
这就是多态的价值:写一遍通用逻辑,就能处理一个类族里所有类型,未来加新类型也不需要修改现有代码。
3.2 虚函数与 override 的正确写法
把 Animal 的speak()声明成虚函数:
class Animal { public: virtual void speak() const { std::cout << name_ << " makes a sound." << std::endl; } // ... }; class Dog : public Animal { public: void speak() const override { std::cout << name_ << " says: Wang!" << std::endl; } };两个细节要解释。
第一,override关键字是 C++11 引入的,它本身不是必需的,但没有它容易出问题。假设派生类里把函数签名写错了,比如少写一个const:
void speak() override; // 如果基类是 void speak() const,这里就编译失败如果写了override,编译器会立刻提醒你"没有匹配的基类虚函数"。如果不写,编译器不会报错,只是默默地把这个函数变成一个普通的新函数,导致本来期望的多态失效。这种 bug 非常隐蔽,所以我个人的建议是:凡是意图覆盖基类虚函数的地方,一律加上 override,它是你的安全网。
第二,虚函数签名必须完全匹配,包括参数列表、const 限定符、引用限定符。返回类型可以协变,但那是另一个话题,初学阶段可以先不深究。
3.3 虚函数表:多态背后的一点真相
讲了这么多,虚函数到底是怎么实现多态的?标准里没有规定具体机制,但几乎所有主流编译器用的都是"虚函数表"方案,也就是 vtable。
简单说,每个包含虚函数的类,编译器都会为它生成一张表,表里存的是这个类所有虚函数的实际地址。每个对象内部会隐藏一个指针叫 vptr,指向自己所属类的虚函数表。调用虚函数时,程序先通过 vptr 找到虚函数表,再从表里取出对应的函数指针来调用。
我用 Dog 和 Cat 的继承关系画个文字版说明:
Animal 对象: [vptr] -> &Animal::speak() Dog 对象: [vptr] -> &Dog::speak() Cat 对象: [vptr] -> &Cat::speak()所以当你写animal.speak()时,实际执行的是(*(animal.vptr[i]))(),那个 i 是 speak 在虚函数表里的槽位。Dog 对象里存的数据是 Dog 自己的,但它的 vptr 也是 Dog 自己的,所以即使你用一个Animal&引用去调用,最终仍然跳到 Dog 的 speak 上。这就是运行期多态的底层逻辑。
代价也很直白:每个带虚函数的对象都要多占用 8 字节(64 位平台下)来存 vptr。一个 Animal 对象如果原本只有 40 字节,有了虚函数可能会变成 48 字节。对一个对象数量巨大的系统来说,这个开销需要考虑;而虚函数调用本身也比普通函数调用多一次间接跳转,性能上略有损耗。绝大多数业务场景可以忽略,但做底层库时就要掂量了。
4. 完整实操:把 Animal 家族升级成可直接运行的动物园
4.1 用纯虚函数升华抽象层次
上一版 Animal 的speak()虽然写了句 "makes a sound",但你仔细想想,现实里不存在一种泛泛的"动物"会发出泛泛的声音。每一个具体动物的叫声都是独特的。这给我们一个启示:Animal 这个类的speak()根本不该有实现,因为它在概念上就是"未定义的"。
C++ 专门为这种情况设计了纯虚函数,写法是在虚函数声明后面加= 0:
class Animal { public: virtual void speak() const = 0; // 纯虚函数 virtual ~Animal() = default; // 虚析构函数,后面讲 };一旦类里有了纯虚函数,这个类就成了抽象类,不能直接实例化。你不能再写Animal animal("some", 1),编译会直接报错。只能创建 Dog、Cat 这种实现了speak()的具体派生类。
这个语法其实和 Java 里的 abstract method 是同一个思想:定义接口,具体行为交给子类。但从设计角度说,"把基类做成抽象类"不是一个选择题,而是一个合理性判断题。如果你发现基类里某个虚函数你自己都编不出一个合理的默认实现,那就说明它应该是个纯虚函数。
4.2 一份可以跑起来的完整代码
下面是我整理的一版完整程序,你复制到编译器里可以直接运行。为了展示多态,我加了一个全局函数makeSound,接收Animal&类型。
#include <iostream> #include <string> #include <vector> #include <memory> class Animal { public: Animal(std::string name, int age) : name_(std::move(name)), age_(age) {} virtual void speak() const = 0; void eat() const { std::cout << name_ << " is eating." << std::endl; } void sleep() const { std::cout << name_ << " is sleeping." << std::endl; } std::string getName() const { return name_; } int getAge() const { return age_; } virtual ~Animal() = default; private: std::string name_; int age_; }; class Dog : public Animal { public: Dog(std::string name, int age) : Animal(std::move(name), age) {} void speak() const override { std::cout << getName() << " says: Wang! Wang!" << std::endl; } }; class Cat : public Animal { public: Cat(std::string name, int age) : Animal(std::move(name), age) {} void speak() const override { std::cout << getName() << " says: Miao~" << std::endl; } }; class Duck : public Animal { public: Duck(std::string name, int age) : Animal(std::move(name), age) {} void speak() const override { std::cout << getName() << " says: Quack!" << std::endl; } }; void makeSound(Animal& animal) { animal.speak(); } int main() { Dog dog("Wangcai", 2); Cat cat("Kitty", 3); Duck duck("Donald", 1); std::vector<std::unique_ptr<Animal>> zoo; zoo.push_back(std::make_unique<Dog>("Xiaohei", 2)); zoo.push_back(std::make_unique<Cat>("Xiaobai", 3)); zoo.push_back(std::make_unique<Duck>("Xiaohuang", 1)); for (const auto& animal : zoo) { animal->speak(); } makeSound(dog); makeSound(cat); makeSound(duck); return 0; }运行结果:
Xiaohei says: Wang! Wang! Xiaobai says: Miao~ Xiaohuang says: Quack! Wangcai says: Wang! Wang! Kitty says: Miao~ Donald says: Quack!4.3 为什么这里必须用指针或引用
注意我在main里用了std::unique_ptr<Animal>来存对象,而不是std::vector<Animal>。这是有讲究的。
std::vector<Animal>要求 Animal 是可以实例化的对象数组,但 Animal 是抽象类,本身就不能这样用。退一步说,就算 Animal 不是抽象类,std::vector<Animal>存 Dog 也会触发"对象切片"问题:Dog 对象被拷贝进一个 Animal 大小的空间,Animal 部分被保留,Dog 特有的数据被切掉,虚函数表指针也被替换成 Animal 的。丢进去的狗,取出来就只能当普通动物用了,多态彻底失效。
对象切片是C++新手最容易踩的隐形坑之一。来看一个最小复现场景:
void processByValue(Animal animal) { animal.speak(); } Dog dog("Wangcai", 2); processByValue(dog); // 永远调用 Animal::speak(),而不是 Dog::speak()所以规则可以记成一句话:只要你想用多态,基类参数就必须写成引用或指针。传值必然切片,无一例外。
4.4 虚析构函数:多态的收尾动作
我在 Animal 类里写了virtual ~Animal() = default;,这一行看着不起眼,但少写它会造成真实的内存泄漏。
假设你写的是:
class Animal { public: virtual void speak() const = 0; // 注意:没有虚析构函数! }; class Dog : public Animal { public: Dog() = default; private: std::unique_ptr<int> data_ = std::make_unique<int>(42); }; Animal* ptr = new Dog(); delete ptr; // 危险!delete ptr时,因为基类析构函数不是 virtual,编译器会调用Animal::~Animal(),而不会调用Dog::~Dog()。结果data_这个成员永远没有机会被释放,内存泄漏就发生了。规则是:只要基类里有虚函数,就应当在基类里提供虚析构函数。哪怕是空实现= default都行。
5. 常见问题与排查技巧实录
5.1 派生类里忘了写 override 会发生什么
我在前面提过签名不匹配的问题,这里用完整例子展示后果:
class Dog : public Animal { public: void speak() const override { ... } // 正确 // void speak() override { ... } // 错误:少了 const,编译失败 };如果不写 override,第二个版本会静默通过编译,但同时它不会覆盖基类虚函数,而是引入一个新的Dog::speak()。这时候多态失效,而你根本不知道问题出在哪。排查这类问题的第一手段就是全局搜一下代码里哪些函数意图覆盖基类函数,然后逐个补上override,让编译器来帮你验证。
5.2 构造函数里调用虚函数,多态为什么失效
这是个很经典的问题:
class Animal { public: Animal() { speak(); // 想在这里调用子类的 speak() } virtual void speak() const = 0; };你期望创建 Dog 时,构造函数调用的是 Dog::speak(),但实际调用的是 Animal 版本。原因在于:对象构造时,先构造基类部分,此时派生类部分还不存在,动态类型就是 Animal 本身。在基类构造函数执行期间,虚函数分派只作用于当前正在构造的这个类的级别。
如果你在构造函数里依赖多态行为,几乎可以肯定设计有误。更合理的做法是,把初始化工作移到子类构造函数里,或者在构造完成之后再调用一个单独方法。
5.3 虚函数表指针是否会被继承
很多初学者会问:vptr 是继承下来的,还是每个类都有自己的?准确的说法是:每个包含虚函数的类都有自己的虚函数表和自己的 vptr,但派生类对象的 vptr 在内存布局上和基类的是同一个位置,继承相当于"占用了基类 vptr 的位置",只不过指向了派生类自己的虚函数表。
这也解释了为什么对象切片会破坏多态:切完之后的临时 Animal 对象,vptr 已经被重置为 Animal 虚函数表的地址。
5.4 常见问题速查表
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 派生类函数没被调用,调用的是基类版本 | 忘了加 virtual 或 override,签名不匹配 | 基类加 virtual,子类加 override |
| 用 vector<Animal> 存 Dog,多态失效 | 对象切片 | 改用 vector<unique_ptr<Animal>> 或指针数组 |
| delete 基类指针后内存泄漏 | 基类没有虚析构函数 | 给基类加 virtual ~Animal() |
| 派生类构造时调用的虚函数不对 | 构造期间动态类型未完成 | 不在构造函数中依赖多态 |
| 派生类里定义了同名不同参函数,基类重载不可用 | 名字遮蔽 | 在派生类里用 using Base::func; 引入基类重载 |
5.5 几个我实测后想额外叮嘱的细节
代码写多之后你会发现,虚函数和默认参数搭配会有奇怪行为。默认参数是静态绑定的,意思是即便调用的是派生类的虚函数,默认参数却取自静态类型(基类)的定义。举例:
class Animal { public: virtual void speak(int times = 3) const { std::cout << times << std::endl; } }; class Dog : public Animal { public: void speak(int times = 5) const override { std::cout << times << std::endl; } }; Animal& animal = dog; animal.speak(); // 输出 3,不是 5原因就是默认参数在编译期就写死在调用点上了。这个坑很难从代码表面看出来,我的建议是虚函数不写默认参数,或者保证基类和派生类的默认参数完全一致,避免产生视觉混淆。
另一个细节是关于final关键字。如果某个类不想再被继承,可以直接class Dog final : public Animal,或者某个虚函数不想再被子类覆盖,写成virtual void speak() const override final。这相当于把设计意图明确地告诉编译器,也能在犯错时提早暴露问题。
最后说一句关于这个例子本身的体会。Animal 、Dog、Cat 这个教学案例,从语法层面看很短,但它覆盖了继承访问控制、构造顺序、名字遮蔽、虚函数、纯虚函数、虚析构、对象切片这么一堆东西。真正把它跑通并且把每一个"为什么"都解释清楚之后,你对C++的整个对象模型都会有一个质的认识。我个人的建议是,不要只看代码就觉得自己会了,把你见到的每个类都手动加一个派生类,多试试不同的继承方式和调用方式,哪怕故意写错,把编译器报错读明白,才是把知识变成手艺的过程。