
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-aprotected 继承变成子类的 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关键字是 C11 引入的它本身不是必需的但没有它容易出问题。假设派生类里把函数签名写错了比如少写一个constvoid 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 专门为这种情况设计了纯虚函数写法是在虚函数声明后面加 0class 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::vectorstd::unique_ptrAnimal zoo; zoo.push_back(std::make_uniqueDog(Xiaohei, 2)); zoo.push_back(std::make_uniqueCat(Xiaobai, 3)); zoo.push_back(std::make_uniqueDuck(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_ptrAnimal来存对象而不是std::vectorAnimal。这是有讲究的。std::vectorAnimal要求 Animal 是可以实例化的对象数组但 Animal 是抽象类本身就不能这样用。退一步说就算 Animal 不是抽象类std::vectorAnimal存 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_ptrint data_ std::make_uniqueint(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用 vectorAnimal 存 Dog多态失效对象切片改用 vectorunique_ptrAnimal 或指针数组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的整个对象模型都会有一个质的认识。我个人的建议是不要只看代码就觉得自己会了把你见到的每个类都手动加一个派生类多试试不同的继承方式和调用方式哪怕故意写错把编译器报错读明白才是把知识变成手艺的过程。