ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++继承详解:三种方式、构造析构顺序与实战避坑指南

C++继承详解:三种方式、构造析构顺序与实战避坑指南

如果你已经可以用class写出一个完整的类,但面对“继承”两个字还有点含糊,这篇就是为你准备的。继承是C++面向对象里最绕不开的一环,表面上语法只有一行class Child : public Parent,但后面隐藏的是构造顺序、访问级别、名字隐藏等一系列规则。很多人写几个示例都没问题,一到真实项目里就翻车,问题多半出在只记住了语法形态,没有理解规则背后的取舍。这一篇,我们顺着继承这条线,从为什么需要它讲起,把三种继承方式、构造析构顺序、名字隐藏、完整实战案例和一份踩坑速查表全部串起来。适合刚学完类与对象、想系统过一遍继承的读者,也适合面试前快速找回知识体系的开发者。我会刻意用能直接编译运行的代码来讲,边看边敲效果最好。很多坑都是我从实际工程里踩过以后才真正理解的,这篇一并标给你看。

1. 为什么需要继承——先别急着写代码

1.1 没有继承时,代码是怎么重复的

假设你要做一个游戏里的宠物系统,里面有狗和猫。狗有名字、年龄、会吃饭、会睡觉、会叫;猫有名字、年龄、会吃饭、会睡觉、会叫,只是叫声不一样。没有继承的写法也很简单,写两个类,把eat()、sleep()的代码各自复制一份。

刚开始看不出来有什么问题,但系统一旦要改需求,风险就来了。比如你有一天要给所有宠物加上“吃饭前先检查饱腹度”的逻辑,就得把狗和猫两个类里的eat()各改一遍;宠物种类从2个增加到10个,就要改10处,而且只要漏改一处,线上就会出现行为不一致的bug。这种例子听起来很基础,但在真实项目里,多份代码同步维护永远是事故高发点。

继承的做法是把公共部分抽出来,放到一个叫Animal的基类里,狗和猫通过继承Animal来复用这些成员,自己只需要写差异部分。公共逻辑只维护一份,修改一处,所有派生类全部生效。这就是“复用”最朴素的价值,也是面向对象设计之所以要谈继承的第一个理由。

1.2 “is-a”还是“has-a”:继承和组合的选择

很多初学者的第一个疑问是:继承到底什么时候该用?这里有一个非常直观的判断标准:继承用来表达“is-a(是一个)”的关系。狗是一个动物,经理是一个员工,正方形是一个形状;组合则用来表达“has-a(有一个)”的关系。汽车有一个引擎,班级有一群学生,电脑有一块硬盘。

如果把不该用继承的地方硬套继承,代码会越写越别扭。比如你想表达“猫有胡子”,写个class Cat : public Whisker就明显不对劲,因为猫不是一个胡子。正确的做法是在Cat里加一个胡子属性,或者组合一个胡子对象。判断关系时多问自己一句:派生类那个对象,真的能替代基类对象吗?能替代,才是继承;不能替代,优先用组合。这个原则在后续做类设计时非常重要,一开始养成习惯,后面能少走很多弯路。

1.3 继承带来的三种能力

继承不是简单的代码复制粘贴,它提供了三种能力:

  • 复用:基类的成员变量和成员函数,派生类直接拿来用,不需要重复定义。
  • 扩展:派生类可以在继承的基础上增加自己的成员。狗除了吃饭睡觉之外,还会叫,这个bark()就是它独有的扩展。
  • 覆盖:派生类可以重新实现基类的某个函数。如果基类有一个makeSound(),狗可以把它重写成“汪汪”,猫可以重写成“喵喵”。这里的“覆盖”是理解后面多态概念的基础,这一篇先认识它,下一篇讲虚函数时再展开。

明白了这三点,你再看继承语法,就不会觉得它只是“冒号后面加个public”那么简单了。

2. 基础语法与三种继承方式

2.1 从一段能跑的代码开始:public继承

先写一个最小例子,注意class Dog : public Animal这一行,这就是继承的入口语法。

#include <iostream> #include <string> class Animal { public: void eat() { std::cout << "Animal is eating." << std::endl; } int age = 0; }; class Dog : public Animal { public: void bark() { std::cout << "Woof! Woof!" << std::endl; } }; int main() { Dog dog; dog.age = 2; dog.eat(); dog.bark(); return 0; }

这段代码里,Dog类自己只写了一个bark(),但主函数里dog.age和dog.eat()都是可用的,因为它们从Animal那里继承了下来。程序输出两行,先是“Animal is eating.”,再是“Woof! Woof!”。这就是public继承最直观的效果:基类的公有成员,在派生类里仍然是公有的,外部对象可以直接访问。

2.2 三种继承方式逐个看:public、protected、private

继承方式写在类名和基类之间,总共有三种。它们影响的是基类成员在派生类中的最终访问级别。对照表如下:

基类中的访问级别public继承后在派生类中protected继承后在派生类中private继承后在派生类中
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可直接访问不可直接访问不可直接访问

注意第三行的“不可直接访问”:基类的private成员在三种继承方式下都不能被派生类直接使用,但基类自己的成员函数内部仍然可以访问它。也就是说,“私有”并没有彻底消失,只是不向派生类开放。

这三种方式里,public继承用得最多,因为它完整保留了“is-a”关系。protected继承会让基类公有成员变成保护级别,外部对象不能再访问了,但再往下一层派生类还能用。private继承则让基类所有可访问成员都变成派生类自己的私有部分,连下一层都不能再用,这基本相当于“用继承来实现组合”,属于比较少见的高级玩法。第一次学继承,先把public和protected搞明白,private继承可以放一放。

2.3 新手最容易栽的坑:默认继承方式

C++有一个历史包袱:用class定义类时默认是private继承,用struct定义类时默认是public继承。初学者最容易踩的坑是写class Child : Parent,漏写public,然后编译器报出一堆访问权限错误,半天也查不出原因。

正确做法是永远显式写出继承方式。比如class Child : public Parent,哪怕你用的是struct也一样把public写出来,让读代码的人一眼看明白你的意图。这个习惯能减少很多莫名其妙的编译问题。

3. 构造与析构在继承链中的执行顺序

3.1 派生类构造函数如何调用基类构造函数

先记住一条规则:创建派生类对象时,基类构造函数一定会在派生类构造函数体之前执行。如果派生类的构造函数没有显式指定基类构造函数,编译器会自动调用基类的默认构造函数。问题就出在“默认构造函数”这五个字上。一旦基类没有默认构造函数,编译器就不知道该怎么做了,只能报错。

正确写法是在派生类的初始化列表里显式调用基类构造函数:

#include <iostream> #include <string> class Animal { public: Animal(const std::string& name) : name_(name) { std::cout << "Animal constructor: " << name_ << std::endl; } protected: std::string name_; }; class Dog : public Animal { public: Dog(const std::string& name) : Animal(name) { std::cout << "Dog constructor: " << name << std::endl; } }; int main() { Dog dog("Buddy"); return 0; }

程序输出:

Animal constructor: Buddy Dog constructor: Buddy

先打印Animal,再打印Dog。这是因为对象构造的次序是:先调用基类构造函数,再构造派生类自己的成员变量,最后执行派生类构造函数体的代码。Dog构造函数里Animal(name)这一行,作用就是给基类传参。如果你写过Java,这个语法有点像super(name),只不过C++要求把函数调用写在冒号后面的初始化列表里,而不是写在构造函数体内。

3.2 析构顺序与构造顺序相反

有构造就有析构,析构的顺序严格和构造顺序相反:先执行派生类析构函数,再执行基类析构函数。

这个设计是有道理的。派生类构造时依赖基类先完成基础环境,派生类析构时,基类的基础环境也要保证最后被清理。可以理解为搭积木:先搭地基,再盖房子;拆的时候,先拆房子,再拆地基。如果顺序反过来,房子还没拆完,地基先被抽走了,整个结构就塌了。所以虽然C++没有强制你在派生类析构函数里手动调用基类析构函数,但编译器在背后一定会保证这个顺序正确。

3.3 “基类没有默认构造函数”的经典报错

当基类只有带参数的构造函数、没有默认构造函数时,派生类必须显式调用带参构造函数。下面这段代码就是一种很常见的错误写法:

class Base { public: Base(int x) { value_ = x; } private: int value_; }; class Derived : public Base { public: Derived() { // 这里没有调用Base构造函数,编译器不知道value_怎么初始化 } };

编译时会报类似no matching function for call to 'Base::Base()'的错误。解决办法是把基类构造函数的调用写进初始化列表:

class Derived : public Base { public: Derived() : Base(0) { // 显式调用基类带参构造函数 } };

在实际工程里,如果一个类的构造函数需要依赖基类传入一些配置,最稳妥的风格就是老老实实在初始化列表里把基类构造写得明明白白,不要靠编译器猜。

4. 名字隐藏与访问控制:几条反直觉的规则

4.1 名字隐藏(Name Hiding)

接着上面的风格,看看在派生类里写一个和基类同名的函数会发生什么。

class Base { public: void print() { std::cout << "Base print()" << std::endl; } void print(int x) { std::cout << "Base print(int): " << x << std::endl; } }; class Derived : public Base { public: void print() { std::cout << "Derived print()" << std::endl; } }; int main() { Derived d; d.print(); // d.print(10); // 错误:基类的print(int)直接被隐藏了 return 0; }

很多人的第一反应是:Derived里写了一个print(),基类有两个print(),这应该构成重载吧?不构成。名字隐藏规则是这样的:只要在派生类中声明了一个同名名字(函数、变量、类型都算),基类中所有同名的名字都会被遮住,哪怕参数列表完全不一样。所以在main里调用d.print(10),编译器找不到匹配的那个print(int),因为它已经被隐藏了。

想解决有两种办法。一种是在调用时写全限定名:

d.Base::print(10);

另一种是在Derived类里用using声明,把基类的print全部引进来:

class Derived : public Base { public: using Base::print; void print() { std::cout << "Derived print()" << std::endl; } };

这样Derived里既有自己版本的print(),又保留基类的print(int),重载关系就正常了。这个细节在工程代码里经常遇到,尤其是当你重构基类成员函数、而派生类刚好也写了同名函数时,一定要警惕。

4.2 protected到底能干什么

protected介于public和private之间。基类里的protected成员,对派生类是可见的,对类外部的代码是不可见的。看这个例子:

class Base { protected: int secret_; public: void setSecret(int v) { secret_ = v; } }; class Derived : public Base { public: void increaseSecret() { ++secret_; // 可以直接访问 } }; int main() { Derived d; d.secret_ = 3; // 错误:外部不能访问protected成员 return 0; }

也就是说,protected成员是“传给子孙后代,但不传给路人”的。你在设计基类时,如果某个成员需要被子类修改,但不能暴露给外部,就可以设为protected。

但我要多说一句:protected并不是越多越好。它在一定程度上破坏了封装性,因为外部代码虽然碰不到,可派生类却能随意操作,类内部的数据约束很容易被绕过。因此很多风格指南建议尽量不设protected数据成员,而是提供protected的成员函数让派生类使用。这只是建议,具体怎么选要看项目风格,但至少你要知道:不要把类里所有成员都设成protected。

5. 完整实战:员工信息管理类的继承写法

5.1 需求与类设计

光看语法容易飘,我们做一个完整的例子。假设要写一个小型员工信息模块:所有员工都有姓名、工号、月薪,可以打印基本信息;部门经理是特殊员工,除了基本信息之外还多一个团队人数,打印时要把团队人数也带上去。

类关系很清晰:经理“是一个”员工,所以Manager继承自Employee。Employee存放公共信息,Manager在基类基础上增加一个teamSize_成员,并重新实现打印逻辑。

5.2 逐步编写完整代码

先写基类Employee:

#include <iostream> #include <string> class Employee { public: Employee(const std::string& name, int id, int salary) : name_(name), id_(id), salary_(salary) {} void printInfo() const { std::cout << "Name: " << name_ << ", ID: " << id_ << ", Salary: " << salary_ << std::endl; } protected: std::string name_; int id_; int salary_; };

这里把name_、id_、salary_设为protected,目的是让后面的Manager可以直接访问并打印,不需要再绕一层getter。在这个小例子里,这么写最直观;如果放在更大的项目里,我通常更倾向用getter函数来维护封装性,但今天还是以讲清继承为主,采用最清晰的方案。

再写派生类Manager:

class Manager : public Employee { public: Manager(const std::string& name, int id, int salary, int teamSize) : Employee(name, id, salary), teamSize_(teamSize) {} void printInfo() const { Employee::printInfo(); std::cout << "Team size: " << teamSize_ << std::endl; } private: int teamSize_; };

在Manager的初始化列表里,第一项就显式调用Employee(name, id, salary),把基类部分先初始化好。printInfo里先调用Employee::printInfo()打印公共信息,再补一行“Team size”。这里必须写Employee::printInfo(),如果直接写printInfo(),会无限递归调用Manager自己的printInfo,造成栈溢出。用Employee::前缀限定调用基类版本,是继承中很常用的写法。

最后写主函数:

int main() { Manager manager("Alice", 1001, 50000, 8); manager.printInfo(); return 0; }

运行结果:

Name: Alice, ID: 1001, Salary: 50000 Team size: 8

整个过程最核心的两行,其实就是class Manager : public Employee和初始化列表里的Employee(...)。这两处看懂,继承的基本用法就掌握大半了。

5.3 这个例子里藏着的一个伏笔

细心的读者会发现,我把printInfo写成了非虚函数。所以即使你写一个Employee*指针指向一个Manager对象,调用printInfo()时调到的依然是基类的版本,而不是经理的版本。原因是非虚函数在编译期就根据指针的静态类型决定调用谁了。

要让经理版本能通过基类指针正确被调用,需要让printInfo变成virtual。这是后面一篇关于多态要讲的核心内容,今天先留一个伏笔,写到这里刚好。

6. 常见问题与排查技巧

6.1 编译错误速查表

错误提示关键词常见原因解决思路
no matching function for call to 'Base::Base()'基类没有默认构造函数,派生类也没显式调用在派生类初始化列表里调用基类带参构造函数
'int Base::value_' is private直接访问了基类private成员改成protected,或者通过基类提供的公有接口间接访问
'void Base::print(int)' is inaccessible派生类同名函数隐藏了基类重载用using Base::print;引入基类同名函数,或用Base::print(n)显式调用
派生类对象无法访问基类公有成员误用了private或protected继承确认类关系语义,如无必要就改成public继承
只输出基类信息,没有派生类信息非虚函数静态绑定,指针类型决定调用哪个版本等待下一篇讲virtual,或提前设计为虚函数

6.2 四个避坑心得

第一,不要依赖默认继承方式。class默认private继承,struct默认public继承,很多人在代码里写class B : A,本意是public,实际因为默认private,外部调不到基类的公有函数,编译器还只报一个含含糊糊的访问错误。显式写public,哪怕你某天改成protected或private,也让审代码的人一眼能看出设计意图。

第二,基类构造函数要写在初始化列表里,不要写在函数体内。构造函数体内没有给基类传参的语法,函数体内最多只能是用基类构造函数的结果来赋值成员,但那已经是另一回事了。曾经有人把Base(10)写在构造函数体第一行,编译器直接报语法错误,就是这个原因。

第三,同名函数隐藏是重灾区。在实际工程代码里,我见过因为基类加了一个重载函数,导致所有派生类调用该函数的地方全部编译失败的情况。排查时第一反应要想到name hiding,然后用using声明一把解决,而不是把所有调用点都去改一遍。

第四,继承关系别滥用。有一条经验我一直记着:如果这个类之间说不清“is-a”关系,就不要硬写继承。继承会把基类的所有接口、生命周期、构造约束一并带给你,用错了很难改。宁可多用组合,也不要让继承关系在后续迭代里成为你的负担。

6.3 下一步:多态和虚函数

这一篇的内容相对基础,但它建立的是理解多态的前置条件。你现在已经在完整例子里看到了基类和派生类的同名函数,也知道了什么叫名字隐藏,下一篇我会接着讲virtual关键字、虚函数表的工作机制,以及什么时候该把析构函数写成虚函数。学完那篇,才算真正把C++继承的所有能力都用起来。

返回列表