当前位置: 首页 > news >正文

继承与组合:C++面向对象的核心

C++ 继承:从基础到实战,彻底搞懂面向对象的 “代码复用术”

在面向对象编程(OOP)的世界里,“继承” 是实现代码复用的核心机制 —— 就像现实中孩子会继承父母的特征,C++ 的子类也能 “继承” 父类的成员(变量 + 函数),再添加自己的独特功能。对于刚接触 OOP 的开发者来说,继承既是 “利器”,也藏着不少容易踩坑的细节(比如菱形继承、隐藏与重载的区别)。

这篇文章会从 “概念→实战→避坑” 逐步拆解 C++ 继承,用通俗的语言 + 完整代码示例,帮你彻底掌握这一知识点,甚至应对笔试面试中的高频问题。

一、继承的基础:什么是继承?怎么用?

1.1 先搞懂:继承的 “本质” 是什么?

继承的核心是 **“复用已有类的代码,扩展新功能”**。

比如我们有一个Person类(包含姓名、年龄和打印信息的函数),现在要定义StudentTeacher类 —— 这两个类都需要 “姓名、年龄”,没必要重复写,直接 “继承”Person即可,再补充自己的独特成员(如学号、工号)。

看代码更直观:

// 父类(基类):Person
class Person {
public:// 父类的成员函数:复用给子类void Print() {cout << "姓名:" << _name << endl;cout << "年龄:" << _age << endl;}protected:// 父类的成员变量:复用给子类string _name = "peter";  // 姓名int _age = 18;           // 年龄
};// 子类(派生类):Student,继承自Person
class Student : public Person {
protected:int _stuid;  // 子类新增的成员:学号
};// 子类(派生类):Teacher,继承自Person
class Teacher : public Person {
protected:int _jobid;  // 子类新增的成员:工号
};// 测试:子类能直接用父类的Print函数
int main() {Student s;Teacher t;s.Print();  // 输出:姓名:peter,年龄:18(复用Person的Print)t.Print();  // 同样复用,无需重复写代码return 0;
}

1.2 继承的 “语法规则”:3 个关键要素

要正确使用继承,必须掌握「继承方式」和「访问限定符」的搭配 —— 这决定了父类成员在子类中的 “访问权限”。

(1)基本语法格式
class 子类名 : 继承方式 父类名 {// 子类的成员(新增/重定义)
};
  • 父类(基类):被继承的已有类(如Person);

  • 子类(派生类):新定义的类(如Student);

  • 继承方式:public(公有的)、protected(保护的)、private(私有的),默认继承方式:classprivatestructpublic(建议显式写清,避免混淆)。

(2)访问权限的 “黄金表格”

父类成员(public/protected/private)在子类中的权限,由「父类访问限定符」和「继承方式」共同决定,核心规则是:子类访问权限 = min (父类访问限定符,继承方式)(优先级:public > protected > private)。

直接看表格更清晰:

父类成员类型public 继承protected 继承private 继承
public 成员子类 public子类 protected子类 private
protected 成员子类 protected子类 protected子类 private
private 成员子类中不可见子类中不可见子类中不可见
(3)3 个必须记住的结论
  1. 父类 private 成员永远 “不可见”:不是没继承,而是语法禁止子类(无论类内还是类外)访问,相当于 “继承了但用不了”;

  2. protected 是为继承设计的:如果父类成员不想被类外访问,但想让子类用,就定义为protected(这是protectedprivate的核心区别);

  3. 实际开发优先用 public 继承protected/private继承的子类成员只能在类内用,扩展维护性差,几乎不用。

二、基类与派生类:对象的 “赋值转换” 规则

子类对象和父类对象之间能不能互相赋值?这里有个形象的说法叫 **“切片”(切割)** —— 把子类中 “属于父类的部分” 切下来,赋值给父类对象 / 指针 / 引用。

2.1 允许的转换:子类 → 父类(切片)

子类对象可以直接赋值给父类的对象、指针、引用,无需强制转换:

class Person {
protected:string _name;  // 姓名int _age;      // 年龄
};class Student : public Person {
public:int _stuid;    // 学号
};void Test() {Student sobj;  // 子类对象// 1. 子类对象 → 父类对象(切片:只赋值父类部分)Person pobj = sobj;// 2. 子类对象地址 → 父类指针(指向子类的父类部分)Person* pp = &sobj;// 3. 子类对象 → 父类引用(引用子类的父类部分)Person& rp = sobj;
}

2.2 禁止的转换:父类 → 子类

父类对象不能直接赋值给子类对象 —— 因为子类比父类多了成员(如_stuid),父类没有这部分数据,无法填充子类的新增成员,语法直接禁止:

void Test() {Person pobj;Student sobj;// sobj = pobj;  // 报错:父类不能赋值给子类
}

2.3 危险的转换:父类指针 → 子类指针

父类指针可以通过强制转换赋值给子类指针,但只有一种情况安全:父类指针原本指向的是子类对象(此时指针实际指向的是子类的父类部分,强制转换后能访问子类新增成员)。

如果父类指针指向的是父类对象,强制转换后访问子类成员会导致越界访问(父类对象没有子类成员的内存),非常危险:

void Test() {Student sobj;Person pobj;Person* pp;// 情况1:父类指针指向子类对象 → 强制转换安全pp = &sobj;Student* ps1 = (Student*)pp;ps1->_stuid = 10;  // 安全:pp实际指向子类,有_stuid内存// 情况2:父类指针指向父类对象 → 强制转换危险(越界)pp = &pobj;Student* ps2 = (Student*)pp;ps2->_stuid = 10;  // 危险:pobj没有_stuid,越界访问内存
}

三、继承中的 “作用域”:小心 “隐藏” 陷阱

基类和子类是独立的作用域,这会导致一个常见问题:隐藏(重定义) —— 子类和父类有同名成员时,子类成员会 “屏蔽” 父类成员的直接访问。

3.1 成员变量的隐藏

子类和父类有同名成员变量时,子类中直接访问该变量,默认是子类的,父类的需要用父类名::显式访问:

class Person {
protected:string _name = "小李子";int _num = 111;  // 父类:身份证号
};class Student : public Person {
public:void Print() {cout << "姓名:" << _name << endl;          // 子类继承的_namecout << "身份证号:" << Person::_num << endl;// 显式访问父类_numcout << "学号:" << _num << endl;            // 子类自己的_num}
protected:int _num = 999;  // 子类:学号(与父类_num同名,隐藏父类)
};void Test() {Student s1;s1.Print();  // 输出:姓名:小李子;身份证号:111;学号:999
}

3.2 成员函数的隐藏(易混淆点)

成员函数的隐藏规则更 “严格”:只要函数名相同,就构成隐藏,不管参数列表、返回值是否相同(这和 “重载” 完全不同 —— 重载要求同一作用域、参数列表不同)。

比如父类Afun(),子类Bfun(int),这两个函数是隐藏关系,不是重载:

class A {
public:void fun() {cout << "fun()" << endl;}
};class B : public A {
public:// 函数名相同,构成隐藏(不管参数)void fun(int i) {A::fun();  // 显式访问父类fun()cout << "fun(int i) → " << i << endl;}
};void Test() {B b;b.fun(10);  // 调用子类fun(int),输出:fun();fun(int i) → 10// b.fun();  // 报错:父类fun()被隐藏,需用A::fun()访问
}

3.3 避坑建议

实际开发中,永远不要在继承体系中定义同名成员—— 隐藏会导致代码可读性差、容易误调用,排查 bug 成本高。

四、派生类的 “默认成员函数”:规则要记牢

C++ 类有 6 个默认成员函数(编译器会自动生成的函数),但派生类的默认成员函数有特殊规则 ——必须先初始化 / 清理父类部分,再处理子类部分

重点关注 4 个核心函数:构造、拷贝构造、赋值重载、析构(取地址重载几乎不用,忽略)。

4.1 派生类的构造函数

  • 规则:派生类构造函数必须调用父类构造函数,初始化父类部分;

  • 特殊情况:如果父类没有 “默认构造函数”(无参、全缺省),必须在派生类构造函数的初始化列表中显式调用父类构造函数。

示例:

class Person {
public:// 父类:带参构造(无默认构造)Person(const char* name) : _name(name) {cout << "Person(const char*)" << endl;}
protected:string _name;
};class Student : public Person {
public:// 子类构造:必须在初始化列表显式调用父类构造Student(const char* name, int stuid) : Person(name)  // 先初始化父类, _stuid(stuid) // 再初始化子类{cout << "Student(const char*, int)" << endl;}
protected:int _stuid;
};void Test() {Student s("jack", 1001); // 输出顺序:Person(const char*) → Student(const char*, int)
}

4.2 派生类的拷贝构造函数

  • 规则:派生类拷贝构造必须调用父类拷贝构造函数,拷贝父类部分的数据;

  • 注意:默认生成的派生类拷贝构造会自动调用父类拷贝构造,但如果自己实现,必须显式调用。

示例:

class Person {
public:Person(const Person& p) : _name(p._name) {cout << "Person(const Person&)" << endl;}
protected:string _name;
};class Student : public Person {
public:// 子类拷贝构造:显式调用父类拷贝构造Student(const Student& s): Person(s)       // 父类拷贝构造(s切片给Person), _stuid(s._stuid){cout << "Student(const Student&)" << endl;}
protected:int _stuid;
};void Test() {Student s1("jack", 1001);Student s2(s1);  // 拷贝构造// 输出:Person(const Person&) → Student(const Student&)
}

4.3 派生类的赋值重载(operator=)

  • 规则:派生类赋值重载必须调用父类赋值重载,否则父类部分的数据不会被赋值(浅拷贝问题);

  • 注意:赋值重载不会自动调用父类的,必须显式用父类名::operator=调用。

示例:

class Person {
public:Person& operator=(const Person& p) {if (this != &p) {  // 防止自赋值_name = p._name;}cout << "Person::operator=" << endl;return *this;}
protected:string _name;
};class Student : public Person {
public:Student& operator=(const Student& s) {if (this != &s) {Person::operator=(s);  // 显式调用父类赋值重载_stuid = s._stuid;}cout << "Student::operator=" << endl;return *this;}
protected:int _stuid;
};

4.4 派生类的析构函数

  • 规则 1:派生类析构函数会在自己执行完后,自动调用父类析构函数,保证 “先清理子类,再清理父类”(和构造顺序相反);

  • 规则 2:析构函数名会被编译器统一处理成destructor(),所以父类和子类的析构函数构成隐藏(不加virtual的情况下,后面多态会讲virtual的作用)。

示例:

class Person {
public:~Person() {cout << "~Person()" << endl;}
};class Student : public Person {
public:~Student() {cout << "~Student()" << endl;}
};void Test() {Student s;// 析构顺序:~Student() → ~Person()(自动调用父类析构)
}

五、继承的 “特殊情况”:友元和静态成员

5.1 友元不能继承

父类的友元函数 / 类,不能访问子类的私有 / 保护成员—— 友元关系是 “单向的”,只针对父类,不传递给子类。

示例:

class Student;  // 前置声明class Person {// 父类友元:Display可以访问Person的私有/保护成员friend void Display(const Person& p, const Student& s);
protected:string _name = "peter";
};class Student : public Person {
protected:int _stuid = 1001;  // 子类保护成员
};void Display(const Person& p, const Student& s) {cout << p._name << endl;  // 可以:访问父类保护成员// cout << s._stuid << endl;  // 报错:友元不能继承,无法访问子类保护成员
}

5.2 静态成员在继承体系中 “唯一”

父类定义的静态成员(static),整个继承体系中只有一个实例—— 不管派生出多少子类,所有类和对象共用这一个静态成员(相当于 “全局变量”,但属于类)。

示例:统计继承体系中对象的总数:

class Person {
public:Person() { ++_count; }  // 构造时计数+1
public:static int _count;  // 静态成员:统计对象总数
protected:string _name;
};// 静态成员必须在类外初始化
int Person::_count = 0;class Student : public Person {};
class Graduate : public Student {};  // 子类的子类void Test() {Student s1, s2;Graduate g1;// 所有对象共用_count,总数=3cout << "对象总数:" << Person::_count << endl;  // 输出3Student::_count = 0;  // 子类也能修改,因为共用cout << "对象总数:" << Person::_count << endl;  // 输出0
}

六、继承的 “老大难”:菱形继承与虚拟继承

这是 C++ 继承的 “痛点”,也是面试高频考点 —— 菱形继承是多继承的特殊情况,会导致数据冗余二义性,而虚拟继承是解决这一问题的方案。

6.1 先理清:单继承、多继承、菱形继承

  • 单继承:子类只有一个直接父类(如Student → Person);

  • 多继承:子类有两个及以上直接父类(如Assistant → Student + Teacher);

  • 菱形继承:多继承的特殊情况 —— 两个子类继承同一个父类,又有一个子类继承这两个子类(形成 “菱形” 结构)。

结构示意图:

        Person(顶层父类)/      \
Student        Teacher(中间子类,都继承Person)\      /Assistant(底层子类,继承Student和Teacher)

6.2 菱形继承的 “坑”:数据冗余 + 二义性

看代码示例,Assistant对象会有两份Person的成员_name),导致两个问题:

  1. 二义性:直接访问_name时,不知道是Student继承的还是Teacher继承的;

  2. 数据冗余:两份_name占用额外内存,且逻辑上应该只有一份(一个助教也是一个人,只需要一个姓名)。

示例:

class Person {
public:string _name = "peter";  // 顶层父类成员
};class Student : public Person { protected: int _stuid; };
class Teacher : public Person { protected: int _jobid; };// 菱形继承:Assistant继承Student和Teacher
class Assistant : public Student, public Teacher {
protected:string _major;
};void Test() {Assistant a;// a._name = "jack";  // 报错:二义性(Student::_name还是Teacher::_name?)// 显式指定可以解决二义性,但无法解决数据冗余(仍有两份_name)a.Student::_name = "jack";a.Teacher::_name = "tom";cout << a.Student::_name << " " << a.Teacher::_name << endl;  // 输出jack tom
}

6.3 解决方案:虚拟继承(virtual)

中间子类StudentTeacher)继承Person时,加上virtual关键字,即可解决菱形继承的问题。

(1)使用方式

只需修改中间子类的继承方式:

class Person {
public:string _name = "peter";
};// 中间子类:用virtual继承Person
class Student : virtual public Person { protected: int _stuid; };
class Teacher : virtual public Person { protected: int _jobid; };// 底层子类正常继承
class Assistant : public Student, public Teacher { protected: string _major; };void Test() {Assistant a;a._name = "jack";  // 正常:无歧义,_name只有一份cout << a._name << endl;  // 输出jack
}
(2)虚拟继承的 “原理”(通俗版)

虚拟继承的核心是:让中间子类(StudentTeacher)不再直接存储父类(Person)的成员,而是通过 **“虚基表指针”** 指向 **“虚基表”**,虚基表中存储了 “父类成员相对于当前类的偏移量”,通过偏移量找到唯一的父类成员。

简单理解:

  • 中间子类(Student)多了一个 “虚基表指针”(指向虚基表);

  • 虚基表中存着 “到Person成员的距离”;

  • 底层子类(Assistant)通过两个中间子类的虚基表指针,找到同一份Person成员,避免冗余和二义性。

不用深入底层内存细节,记住 “虚拟继承让顶层父类成员在底层子类中唯一” 即可。

(3)注意事项

只在菱形继承的中间子类中使用虚拟继承,其他场景不要用 —— 虚拟继承会增加内存开销(虚基表指针)和计算开销(偏移量查找),没必要。

七、继承的 “终极思考”:继承 vs 组合,该怎么选?

很多开发者滥用继承,导致代码耦合度高、难以维护。实际上,C++ 社区有个共识:优先使用组合,而非继承

7.1 继承:is-a 关系(是一种)

继承体现的是 “is-a”(是一种)的逻辑 —— 比如BMW是一种CarStudent是一种Person

  • 优点:直接复用父类代码,支持多态(后面讲);

  • 缺点:耦合度高(子类依赖父类实现),破坏父类封装(子类能访问父类protected成员,父类修改会影响所有子类),属于 “白箱复用”(子类知道父类内部细节)。

7.2 组合:has-a 关系(有一个)

组合体现的是 “has-a”(有一个)的逻辑 —— 比如Car有一个Tire(轮胎),Phone有一个Battery(电池)。

  • 优点:耦合度低(只需依赖被组合类的接口,不用知道内部细节),封装性好,属于 “黑箱复用”(被组合类的修改不影响组合类);

  • 缺点:需要手动调用被组合类的接口,代码量略多。

7.3 选择原则

  1. 用继承的场景
  • 存在明确的 “is-a” 关系(如BMW → Car);

  • 需要实现多态(必须用继承 +virtual)。

  1. 用组合的场景
  • 存在 “has-a” 关系(如Car → Tire);

  • 没有明确的 “is-a” 关系,只是想复用代码;

  • 追求低耦合、高维护性的场景(大多数业务场景)。

示例对比:

// 继承:BMW is a Car
class Car { /* ... */ };
class BMW : public Car { /* ... */ };// 组合:Car has a Tire
class Tire { /* ... */ };
class Car {
protected:Tire _tire;  // 组合:Car有一个Tire
};

八、笔试面试高频题(附答案)

  1. 什么是菱形继承?菱形继承的问题是什么?

    答:菱形继承是多继承的特殊情况:两个子类继承同一个顶层父类,又有一个底层子类继承这两个子类(形成菱形结构)。问题是数据冗余(底层子类有两份顶层父类成员)和二义性(访问顶层父类成员时无法确定来源)。

  2. 什么是菱形虚拟继承?如何解决数据冗余和二义性?

    答:在菱形继承的中间子类(继承顶层父类的子类)中,用virtual关键字进行继承,即为菱形虚拟继承。解决原理是:中间子类通过 “虚基表指针” 指向 “虚基表”,虚基表存储顶层父类成员的偏移量,让底层子类只保留一份顶层父类成员,从而解决数据冗余和二义性。

  3. 继承和组合的区别?什么时候用继承?什么时候用组合?

    答:区别在于关系和耦合度:

  • 继承是 “is-a” 关系,耦合度高(子类依赖父类实现,破坏封装),白箱复用;

  • 组合是 “has-a” 关系,耦合度低(依赖接口,不依赖实现),黑箱复用。

    使用场景:

  • 继承:is-a 关系、需要多态时;

  • 组合:has-a 关系、追求低耦合时(优先选择)。

九、总结

继承是 C++ 面向对象的核心,但也是一把 “双刃剑”:用得好能大幅复用代码,用得不好会导致耦合高、bug 多。这篇文章从基础到复杂,帮你理清了继承的核心规则、避坑点和最佳实践,关键记住三点:

  1. 优先用public继承,避免同名成员导致的隐藏;

  2. 远离菱形继承,万不得已时用虚拟继承;

  3. 优先选择组合而非继承,降低代码耦合度。

建议你动手写代码测试本文的示例,比如菱形继承的问题、虚拟继承的效果、构造析构的调用顺序,只有实践才能真正掌握~


文章转载自:

http://ZHFuwp6c.grtwn.cn
http://KxkuCU95.grtwn.cn
http://rNhe2Of6.grtwn.cn
http://JXAdNEsF.grtwn.cn
http://f1zpcfV6.grtwn.cn
http://Qqauvmhm.grtwn.cn
http://ZP6zGaAn.grtwn.cn
http://4ZKz6kx3.grtwn.cn
http://Nn1PlYF0.grtwn.cn
http://2wyM5k5z.grtwn.cn
http://t9W6jU8f.grtwn.cn
http://JGHWxIRm.grtwn.cn
http://SS8alHeh.grtwn.cn
http://jLuI2Iml.grtwn.cn
http://tf1kHCtC.grtwn.cn
http://Fb9qbcwK.grtwn.cn
http://ou244WPg.grtwn.cn
http://iTcEa6qR.grtwn.cn
http://iK0fxQAs.grtwn.cn
http://ApSjf67s.grtwn.cn
http://i1uZRzPR.grtwn.cn
http://GWuolkKl.grtwn.cn
http://PrKEsLRK.grtwn.cn
http://P5HFui78.grtwn.cn
http://dKs9xQg5.grtwn.cn
http://GW7JpL8r.grtwn.cn
http://xvS3QBS8.grtwn.cn
http://l7CAFDzR.grtwn.cn
http://w0xhGn7F.grtwn.cn
http://gE8BGwSu.grtwn.cn
http://www.dtcms.com/a/385218.html

相关文章:

  • Java进阶教程,全面剖析Java多线程编程,多线程的实现方式,继承Thread类方式,笔记03
  • 猫头虎开源AI分享:一款CSV to Chat AI工具,上传CSV文件提问,它可以即时返回统计结果和可视化图表
  • Android中怎么使用C语言, 以及打包/使用SO动态库
  • 信刻光盘加密刻录系统,保护光盘分发数据安全保密!
  • 自由学习记录(99)
  • 【开题答辩全过程】以 C语言程序设计课程网站为例,包含答辩的问题和答案
  • RocketMQ 消息幂等性实战经验分享
  • [SC]SystemC中,一个namespace中调用了其他namespace中的函数,需要显示include那个函数所在的.h文件吗?
  • Origin气泡图画相关性系数图
  • 基于SpringBoot+Uniapp的儿童疫苗接种预约小程序(qq邮箱、二维码识别)
  • 基于HugeGraph构建法律知识图谱(一)
  • C语言常用字符串函数
  • 【STM32项目开源】STM32单片机智能饮水机控制系统
  • 新质生产力背景下基于“开源链动2+1模式+AI智能名片+S2B2C商城小程序”的商业机会挖掘研究
  • html隐藏文本利用原理,实现点击隐藏功能
  • Java vs Python Web 开发深度对比:从传统同步到现代异步的全面演进
  • Redis 不只是缓存:深入解析 Redis Stack 与实时 AI 推理
  • IPv4地址类型
  • Deepin 25 系统安装 Docker:完整教程 + 常见问题解决
  • 虚拟机因网络导致域名解析出现问题
  • 群内靶机-Next
  • 【系统分析师】2025年上半年真题:论文及解题思路
  • 绿色出行新选择:圆梦交通联合卡的环保实践
  • 协程+连接池:高并发Python爬虫的底层优化逻辑
  • 深入理解 CAS:并发编程的原子操作基石
  • 矿用本安三电车变频器绝缘监测
  • 如何录制带解说的教学视频?屏幕录制工具推荐ASCOMP Screencapt Pro
  • 多模态视频理解领域 Benchmark 与 Leaderboard 整理
  • 《投资-54》元宇宙
  • OpenLayers数据源集成 -- 章节十四:WKT图层详解:标准几何文本格式的精确解析与渲染方案