继承与组合: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;          // 子类继承的_name
        cout << "身份证号:" << Person::_num << endl;// 显式访问父类_num
        cout << "学号:" << _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,总数=3
    cout << "对象总数:" << Person::_count << endl;  // 输出3
    Student::_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. 优先选择组合而非继承,降低代码耦合度。

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

相关推荐
潮汐退涨月冷风霜3 小时前
数字图像处理(1)OpenCV C++ & Opencv Python显示图像和视频
c++·python·opencv
长河4 小时前
Java开发者LLM实战——LangChain4j最新版教学知识库实战
java·开发语言
第七序章4 小时前
【C++STL】list的详细用法和底层实现
c语言·c++·自然语言处理·list
Cyan_RA94 小时前
SpringMVC @RequestMapping的使用演示和细节 详解
java·开发语言·后端·spring·mvc·ssm·springmvc
逆小舟6 小时前
【Linux】人事档案——用户及组管理
linux·c++
喵手6 小时前
玩转Java网络编程:基于Socket的服务器和客户端开发!
java·服务器·网络
再见晴天*_*7 小时前
SpringBoot 中单独一个类中运行main方法报错:找不到或无法加载主类
java·开发语言·intellij idea
lqjun08278 小时前
Qt程序单独运行报错问题
开发语言·qt
hdsoft_huge10 小时前
Java & Spring Boot常见异常全解析:原因、危害、处理与防范
java·开发语言·spring boot