news 2026/6/7 1:38:08

C++ 继承:代码复用的层次之道

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++ 继承:代码复用的层次之道

复用不该靠复制粘贴。继承是 C++ 在类层面给出的答案。

为什么需要继承

写过 C 语言的人一定熟悉这种场景:两个结构体有大量重复字段,处理函数写了几乎一模一样的逻辑。你复制了一份,改了改——然后某天发现一个 Bug,得改两处。

这就是没有继承时的困境。假设要写一个学生管理系统:

// 冗余的写法:Student 和 Teacher 各自维护一份重复信息classStudent{public:voididentity(){/* 身份认证 */}voidstudy(){/* 学习 */}protected:string _name="peter";string _address;string _tel;int_age=18;int_stuid;// 学号——Student 独有};classTeacher{public:voididentity(){/* 身份认证 */}voidteaching(){/* 授课 */}protected:string _name="张三";int_age=18;string _address;string _tel;string _title;// 职称——Teacher 独有};

_name_address_tel_ageidentity()在两个类里一字不差地重复了。C 语言的套路是把公共部分抽成struct Person嵌套进去,但每次访问都要多写一层p.info.name——这本质上是组合,不是继承

继承(Inheritance)做的事更直接:把公共成员放进基类,派生类自动拥有它们,同时还可以扩展自己的独有成员。它呈现的是面向对象程序设计的层次结构——由简单到复杂的认知过程,现在可以直接表达在代码里。

继承的定义与访问控制

定义格式

classPerson{public:voididentity(){cout<<"void identity()"<<_name<<endl;}protected:string _name="张三";string _address;string _tel;int_age=18;};classStudent:publicPerson{// public 继承public:voidstudy(){/* ... */}protected:int_stuid;// 学号};classTeacher:publicPerson{public:voidteaching(){/* ... */}protected:string _title;// 职称};

StudentTeacher不再需要重复定义姓名、地址、电话——继承自Person的部分直接可用。

💡 背景补充:C 语言中类似的复用手段是在struct B中嵌入struct A,然后通过b.a.field访问。这其实是组合(has-a),不是继承(is-a)。继承让派生类就是一个基类,而不只是包含一个基类。

基类成员在派生类中的访问权限

这不是简单的"继承过来还是原来的权限"。实际情况由两个因素共同决定:成员在基类中的访问限定符继承方式。规则可以用一句话概括:

派生类中成员的访问权限 =min(基类访问限定符, 继承方式)

其中public > protected > private

基类成员访问限定符public 继承protected 继承private 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可见不可见不可见

几个要点:

  1. private 成员在任何继承方式下都不可见。它们确实被继承到了派生类对象中(占据内存),但语法上派生类无论在类内还是类外都无法访问。
  2. protected 限定符因继承而生。如果基类成员不想被外部直接访问,但又需要派生类能访问——就用protected
  3. 实际工程中几乎只用 public 继承。protected/private 继承让派生类拿到的成员只能在类内部使用,扩展维护性差。
  4. class关键字默认继承方式是privatestruct默认是public显式写出继承方式,不要依赖默认。

继承中的作用域:隐藏规则

基类和派生类各自拥有独立的作用域。当派生类中定义了与基类同名的成员(变量或函数),派生类的成员会隐藏(Hide)基类的同名成员——即使函数签名不同也构成隐藏。

classPerson{protected:string _name="小李子";int_num=111;// 身份证号};classStudent:publicPerson{public:voidPrint(){cout<<"姓名:"<<_name<<endl;cout<<"身份证号:"<<Person::_num<<endl;// 必须显式指定基类作用域cout<<"学号:"<<_num<<endl;// 访问的是 Student::_num}protected:int_num=999;// 学号——隐藏了 Person::_num};

调用s1.Print()输出:

姓名:小李子 身份证号:111 学号:999

对于成员函数,只需要函数名相同就构成隐藏——参数列表不同也不行。这和重载(同一作用域内函数名相同但参数不同)是完全不同的概念。所以:

⚠️ 在继承体系中,不要再定义同名成员。隐藏不是功能——是技术债。

破解隐藏:using 声明

如果确实需要在派生类中为基类的重载函数集添加新版本(而非全部隐藏),C++ 提供了using声明:

classBase{public:virtualvoidmf1()=0;virtualvoidmf1(int);// mf1 的重载版本virtualvoidmf2();voidmf3();voidmf3(double);// mf3 的重载版本};classDerived:publicBase{public:usingBase::mf1;// 让基类所有名为 mf1 的函数在派生类中可见usingBase::mf3;// 让基类所有名为 mf3 的函数在派生类中可见virtualvoidmf1()override;// 只重写无参版本,mf1(int) 也可见voidmf3();// 只重写无参版本,mf3(double) 也可见};Derived d;d.mf1();// OK: Derived::mf1d.mf1(5);// OK: Base::mf1(int) —— 没有被隐藏d.mf3();// OK: Derived::mf3d.mf3(1.0);// OK: Base::mf3(double) —— 没有被隐藏

📖 参考:《Effective C++》条款33;《C++ Primer》第15章

这个技巧在实际工程中很实用——比如派生类想让基类的print(ostream&)print(FILE*)两个重载都保持可见,同时又想追加一个print(const string&)

派生类的默认成员函数

这可能是初学者最困惑的部分。编译器为派生类自动生成的 6 个默认成员函数,每个都跟基类有耦合关系:

派生类默认函数必须调用的基类部分关键点
构造函数基类构造函数(初始化列表)基类无默认构造时必须显式调用
拷贝构造函数基类拷贝构造(初始化列表)Student(const Student& s) : Person(s), _num(s._num) {}
operator=基类 operator=构成隐藏,需Person::operator=(s);显式调用
析构函数基类析构函数(自动调用)先析构派生类,再析构基类——严格逆序

完整示例:

classPerson{public:Person(constchar*name="peter"):_name(name){cout<<"Person()"<<endl;}Person(constPerson&p):_name(p._name){cout<<"Person(const Person& p)"<<endl;}Person&operator=(constPerson&p){cout<<"Person operator="<<endl;if(this!=&p)_name=p._name;return*this;}~Person(){cout<<"~Person()"<<endl;}protected:string _name;};classStudent:publicPerson{public:Student(constchar*name,intnum):Person(name)// 在初始化列表中调用基类构造,_num(num){cout<<"Student()"<<endl;}Student(constStudent&s):Person(s)// 调用基类拷贝构造——派生类对象可以赋值给基类引用,_num(s._num){cout<<"Student(const Student& s)"<<endl;}Student&operator=(constStudent&s){cout<<"Student operator="<<endl;if(this!=&s){Person::operator=(s);// 显式调用基类赋值——构成隐藏,必须指定作用域_num=s._num;}return*this;}~Student(){cout<<"~Student()"<<endl;}// 执行完后自动调用 ~Person()protected:int_num;};

初始化顺序:基类构造 → 派生类成员(按声明顺序)→ 派生类构造函数体。析构顺序:严格反过来。

还有一个容易被忽略的事实:编译器会将所有析构函数名统一处理成destructor。所以在不加virtual的情况下,基类和派生类的析构函数构成隐藏关系——而非重写。这对后面的多态至关重要。

基类与派生类的转换

public 继承下,派生类对象可以赋值给基类的指针或引用——这个过程常被称为切片(Slicing):只把派生类中属于基类的那部分"切"出来。

classPerson{protected:string _name;string _sex;int_age;};classStudent:publicPerson{public:int_No;// 学号};Student sobj;Person*pp=&sobj;// ✅ 派生类指针 → 基类指针Person&rp=sobj;// ✅ 派生类引用 → 基类引用Person pobj=sobj;// ✅ 通过基类拷贝构造完成(切片)// sobj = pobj; // ❌ 编译报错:基类对象不能赋值给派生类对象

基类指针/引用要转回派生类指针,需要强制类型转换——而且只有在基类指针本来就指向派生类对象时才安全。

这里引出一个重要的概念区分:静态类型(Static Type)动态类型(Dynamic Type)

  • 静态类型:变量声明时的类型,编译期确定,永远不变
  • 动态类型:变量实际指向/引用的对象类型,运行期确定,可以变化

只有指针和引用有动态类型的区分。普通对象没有:

Student st;Person*ptr=&st;// ptr 的静态类型 = Person*,动态类型 = Student*Person&ref=st;// ref 的静态类型 = Person&,动态类型 = Student&Person obj=st;// obj 的静态类型和动态类型都是 Person——派生类部分已被切掉!obj.BuyTicket();// 调用 Person::BuyTicket,不是 Student::BuyTicket

C++ 提供了dynamic_cast来做安全的向下转型(Downcasting)——运行时检查基类指针是否真的指向某个派生类对象:

voidf(Person*ptr){if(Student*sp=dynamic_cast<Student*>(ptr)){// ptr 确实指向 Student,sp 非空cout<<"学号: "<<sp->_No<<endl;}else{// ptr 不是 Studentcout<<"不是学生"<<endl;}}

dynamic_cast对指针失败时返回nullptr,对引用失败时抛出std::bad_cast。不过要注意——频繁使用dynamic_cast通常暗示设计有问题,应当优先考虑用虚函数替代类型分支。

📖 参考:《C++ Primer》第15章

多继承与菱形继承

多继承

C++ 允许一个派生类同时继承多个基类:

classPerson{public:string _name;};classStudent:publicPerson{protected:int_num;};classTeacher:publicPerson{protected:int_id;};classAssistant:publicStudent,publicTeacher{protected:string _majorCourse;};

内存模型:先继承的基类在前,后继承的基类在后,派生类自己的成员在最后。

菱形继承问题

上面Assistant的继承关系形成了一个菱形:PersonStudentTeacher分别继承,Assistant又同时继承两者。这带来两个问题:

  1. 二义性a._name = "peter";编译报错——编译器不知道你要访问从Student来的_name还是从Teacher来的。
  2. 数据冗余Assistant对象中有两份Person的成员。
Assistant a;// a._name = "peter"; // ❌ 编译报错:对"_name"的访问不明确a.Student::_name="xxx";// 只能显式指定路径——但数据冗余无法解决a.Teacher::_name="yyy";

虚继承:解决菱形问题

virtual关键字修饰继承,可以让共同基类在最终派生类中只保留一份:

classStudent:virtualpublicPerson{/* ... */};classTeacher:virtualpublicPerson{/* ... */};classAssistant:publicStudent,publicTeacher{/* ... */};Assistant a;a._name="peter";// ✅ 现在只有一份 _name,没有二义性

但虚继承有代价。底层实现更复杂,性能有损耗。C++ 支持多继承,就必然可能出现菱形继承——Java 通过直接禁止多继承绕过了这个问题。

⚠️ 不要主动设计菱形继承。如果已经陷入,虚继承是解药——但它不是让你随意设计菱形结构的通行证。

虚继承还有一个值得注意的细节:最终派生类负责初始化虚基类

Assistant(constchar*name1,constchar*name2,constchar*name3):Person(name3)// Assistant 直接初始化虚基类 Person,Student(name1,1)// Student 的 Person(name1) 被忽略,Teacher(name2,2)// Teacher 的 Person(name2) 被忽略{}// a 对象中的 _name 是 "王五"(来自 Person(name3))

继承 vs 组合:is-a 还是 has-a

这是面向对象设计中最需要厘清的一对概念。

继承(public)组合
关系is-a(是一种)has-a(有一个)
复用方式白箱复用——基类内部细节对派生类可见黑箱复用——对象内部细节不可见
耦合度。基类改变,派生类受影响。只依赖公开接口
封装性一定程度上破坏了基类封装保持良好封装
// 组合:Car has-a TireclassTire{protected:string _brand="Michelin";size_t _size=17;};classCar{protected:string _colour="白色";string _num="陕ABIT00";Tire _t1,_t2,_t3,_t4;// Car 由 4 个 Tire 组合而成——has-a};// 继承:BMW is-a CarclassBMW:publicCar{public:voidDrive(){cout<<"好开-操控"<<endl;}};

📖 参考:《高质量C++/C编程指南》第10章;《Effective C++》条款38

设计时遵循优先级:能用组合就用组合。只有当类之间的关系明确符合 is-a(“B 是 A 的一种”),且基类的所有功能和属性对派生类都有意义时,才使用继承。要实现多态,继承是必须的——但那是下一篇文章的话题。

组合的另一种语义:is-implemented-in-terms-of

组合除了表达"有一个"(has-a),还有一层更底层的意思——“根据某物实现出”(is-implemented-in-terms-of)。这种场景下,新类并不"拥有"旧类的实例作为语义组件,而是利用旧类的内部机制来实现自身功能。

// is-implemented-in-terms-of:用 std::list 实现 Settemplate<classT>classSet{public:boolcontains(constT&item)const{returnfind(_data.begin(),_data.end(),item)!=_data.end();}voidinsert(constT&item){if(!contains(item))_data.push_back(item);}private:std::list<T>_data;// Set 不是 List,但可以用 List 实现};

Set<T>std::list<T>之间显然不是 is-a 关系——Set 不允许重复元素、不关心顺序。用组合而非继承,既利用了 list 的存储能力,又没有把 list 的接口暴露给 Set 的用户。

private 继承:当组合不够用时

C++ 还有private 继承——这同样是 is-implemented-in-terms-of,但比组合更"重"。基类的 public 和 protected 成员在派生类中全部变为 private,对外部完全不可见。

能用组合就不要用 private 继承,除非:

  1. 需要访问基类的protected成员
  2. 需要重写基类的虚函数
  3. 需要利用空白基类最优化(EBO)——空类通过 private 继承不占额外空间
classTimer{public:virtualvoidonTick()const{/* ... */}};classWidget:privateTimer{// private 继承private:virtualvoidonTick()constoverride{/* Widget 的定时处理 */}// Timer 的 public 接口对外部完全隐藏——这是实现细节,不是接口};

📖 参考:《Effective C++》条款39

其他细节速览

不能被继承的类:C++11 用final关键字直接禁止继承。C++98 则需要把构造函数私有化——派生类无法调用基类构造,自然无法实例化。

classBasefinal{/* ... */};// C++11:简洁明了// class Derived : public Base {}; // ❌ 编译报错

友元关系不继承:基类的友元不能访问派生类的私有和保护成员。如果确实需要,必须把友元也声明为派生类的友元。

静态成员全局唯一:基类中定义的static成员在整个继承体系中只有一份。无论派生多少个子类,都共享同一个静态成员实例。

类模板继承:派生类继承模板基类时,访问基类成员需要指定类域(如vector<T>::push_back(x)),因为模板按需实例化——编译器在第一次扫描时看不到基类模板的成员。

继承的构造函数(C++11):派生类可以用using Base::Base;直接"继承"基类的所有构造函数(除默认构造、拷贝构造、移动构造外),免去手动写转发构造函数的重复劳动:

classBase{public:Base(inti);Base(inti,doubled);};classDerived:publicBase{public:usingBase::Base;// 继承 Base 的所有构造函数// 等价于编译器自动生成:// Derived(int i) : Base(i) {}// Derived(int i, double d) : Base(i, d) {}};

📖 参考:《C++ Primer》第15章

本节要点

  • 继承解决类层次的代码复用,不是取代组合的万能工具
  • 访问权限 = min(基类限定符, 继承方式);private 成员在任何继承方式下都不可见
  • 同名即隐藏,不管函数签名是否相同——不要在继承体系中定义同名成员
  • 派生类默认成员函数必须调用基类对应函数;构造/析构顺序严格遵循"先基类后派生、析构反过来"
  • 菱形继承用虚继承解决,但最好从一开始就不要设计菱形结构
  • 优先组合,其次继承——is-a 用继承,has-a 用组合,模棱两可用组合

📖 参考:《高质量C++/C编程指南》第9章(类的构造/析构/赋值)、第10章(继承与组合);《Effective C++》条款32-40(继承与面向对象设计);《C++ Primer》第15章(面向对象程序设计);《C++ Primer Plus》第13章(类继承)


📎 下一篇:C++ 多态:同一调用,不同行为 —— 继承为多态铺路,虚函数让同一行代码产生不同行为。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/7 1:37:51

告别混乱!为GD32F4系列构建统一RT-Thread BSP框架的完整心路历程

从零构建GD32F4系列RT-Thread BSP框架的工程实践当第一次接触GD32系列MCU的RT-Thread BSP时&#xff0c;许多开发者都会面临一个共同的困境&#xff1a;社区中分散的BSP实现风格各异&#xff0c;代码冗余严重&#xff0c;缺乏统一标准。这种碎片化状态不仅增加了学习成本&#…

作者头像 李华
网站建设 2026/6/7 1:37:51

静态路由拓展配置。

根据以下五步进行静态路由拓展配置。一. 除R5的环回地址固定以外&#xff0c;整个其他所有网段基于192.168.1.0/24进行合理的ip地址划分 二.R1-R4每个路由器存在两个环回接口&#xff0c;用于模拟连接PC的网段&#xff0c;地址也在192.168.1.0/24这个范围内 三.R1-R4上不能直接…

作者头像 李华
网站建设 2026/6/7 1:36:46

免费下载器跑出70M/S,迅雷速度真的被比下去了

最近有同事问我为什么他的迅雷下载只有二十几MB&#xff0c;我打开 Aria2 跑了个同链接的测试——直接跑到 70M/S&#xff0c;办公网的带宽跑满了。他沉默了一会儿&#xff0c;然后问我这软件叫啥。这就是今天要说的&#xff1a;Aria2&#xff0c;一个完全免费、绿色免安装的下…

作者头像 李华
网站建设 2026/6/7 1:35:59

【零基础学Python-收尾】10-Python第三方库的安装介绍

&#x1f3af; 你正在阅读「Python 从零摸索日记」系列文章 &#x1f3af; &#x1f525; 弹简特 个人主页 ❄️ 个人专栏直通车&#xff1a; &#x1f4bb; 软件测试入门记&#x1f50c; 接口测试从入门到跑路☕ 一个后端的 JavaEE 续命指南&#x1f6dc; 网络原理续命手册…

作者头像 李华