【C++】C++ 继承全解析:从基本语法到菱形继承的底层原理

一. 继承的概念及定义

1.继承的概念

继承(inheritance)机制是⾯****向对象程序设计使代码可以复⽤****的最重要的⼿段,它允许我们在保持原有类特性的基础上进⾏扩展增加⽅法(成员函数)和属性(成员变量),**这样产⽣新的类,称派⽣类。继承呈现了⾯向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次** 的复⽤,继承是类设计层次的复⽤

下⾯我们看到没有继承之前我们设计了两个类Student和Teacher,Student和Teacher都有姓名/地址/电话/年龄等成员变量**,都有identity⾝份认证的成员函数,** 设计到两个类⾥⾯就是冗余 的。当然他们也有⼀些不同的成员变量和函数,⽐如⽼师独有成员变量是职称 ,学⽣的独有成员变量是学号 ;学⽣的独有成员函数是学习 ,⽼师的独有成员函数是授课。学校里的学生、老师、后勤等都会有各种信息,我们可以将其中相同的部分(数据、方法等)提取出来,形成一个复用类,这个类就被称为父类(基类)。

代码如下:

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// 基类
class Person
{
public:
    void print()
    {
        cout << "name: " << _name << endl;
        cout << "age: " << _age << endl;
    }

protected:
    string _name = "张三";
    int _age = 18;
};

// 派生类 Student
class Student : public Person
{
protected:
    int _stuid = 2024001;  // 学号
};

// 派生类 Teacher
class Teacher : public Person
{
protected:
    int _jobid = 1001;     // 工号
};

int main()
{
    Person p;
    p.print();

    Student s;
    s.print();

    Teacher t;
    t.print();

    return 0;
}
  • 继承方式class Student : public Person 表示公有继承,基类的 publicprotected 成员在派生类中保持不变

  • 访问权限protected 成员在派生类内部可以访问,但类外面不行。所以 print() 要设为 public 才能在 main 里调用

  • 代码复用StudentTeacher 不需要重复写 _name_ageprint(),直接从 Person 继承就行


2.继承定义

(1)定义格式

下⾯我们看到Person是基类,也称作⽗类。Student是派⽣类,也称作⼦类。(因为翻译的原因,所以既叫基类/派⽣类,也叫⽗类/⼦类)


(2)继承关系和访问限定符

继承后成员的访问权限,同时受基类中该成员的访问限定符派生类的继承方式两方面影响,两者共同决定派生类中该成员的最终访问权限。

基类访问限定符有三种:publicprotectedprivate

派生类继承方式也有三种:publicprotectedprivate

两两组合,一共有 3 × 3 = 9 种情况。


(3)继承基类成员访问⽅式的变化


  • **基类private成员在派⽣类中⽆论以什么⽅式继承都是不可⻅的。**这⾥的不可⻅是指基类的私有成员还是被继承到了派⽣类对象中,但是语法上限制派⽣类对象不管在类⾥⾯还是类外⾯都不能去访问它。
  • 基类private成员在派⽣类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派⽣类中能访问,就定义为protected 。可以看出保护成员限定符是因继承才出现的。
  • 实际上⾯的表格我们进⾏⼀下总结会发现,基类的私有成员在派⽣类都是不可⻅。基类的其他成员在派⽣类的访问⽅式 == Min(成员在基类的访问限定符,继承⽅式),public > protected >private。
  • 使⽤关键字class时默认的继承⽅式是private,使⽤struct时默认的继承⽅式是public,不过最好显示的写出继承⽅式。
  • 在实际运⽤中⼀般使⽤都是public继承,⼏乎很少使⽤protetced/private继承,也不提倡使⽤protetced/private继承,因为protetced/private继承下来的成员都只能在派⽣类的类⾥⾯使⽤,实际中扩展维护性不强。

实例演示三种继承关系下基类成员的各类型成员访问关系的变化:

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// 基类
class Person
{
public:
    void Print()
    {
        cout << _name << endl;
    }

protected:
    string _name = "张三";   // 姓名

private:
    int _age = 18;           // 年龄
};

// 三种继承方式(每次只开一个)
// class Student : public Person      // 公有继承
// class Student : protected Person   // 保护继承
class Student : private Person        // 私有继承
{
protected:
    int _stunum = 2024001;   // 学号
};

如何给基类成员设置合适的访问限定符?(咱们用表格显示)

想要的效果 设成
想让所有人(main、派生类、外部函数)都能访问 public
不想让类外直接访问,但允许派生类访问 protected
既不想让类外访问,也不想让派生类访问 private

简单说:public 对外公开,protected 对子类公开,private 对自己公开。

3.继承类模板

cpp 复制代码
namespace bit
{
    template<class T>
    class stack : public std::vector<T>
    {
    public:
        void push(const T& x)
        {
            // 调用基类的 push_back
            vector<T>::push_back(x);
        }

        void pop()
        {
            vector<T>::pop_back();
        }

        const T& top()
        {
            return vector<T>::back();
        }

        bool empty()
        {
            return vector<T>::empty();
        }
    };
}

int main()
{
    bit::stack<int> st;
    st.push(1);
    st.push(2);
    st.push(3);

    while (!st.empty())
    {
        cout << st.top() << " ";
        st.pop();
    }
    // 输出:3 2 1
    return 0;
}

这代码主要是在干:

vector 当底层容器,封装出一个 stack(栈)

stack 的特点是后进先出,只允许在尾部插入和删除。而 vector 正好有 push_backpop_backback 这些接口,直接把 vector 拿来用,把不需要的接口隐藏掉,就变成栈了。

stackvector 的关系:is-a(栈是一种 vector)还是 has-a(栈有一个 vector)?其实都说得通,这里用的是 public 继承,表示 is-a 的关系。

这里有个坑:vector<T>::push_back 不能直接写

cpp 复制代码
// 如果直接写 push_back(x),编译报错
// error C3861: "push_back": 找不到标识符

为什么会报错?

因为基类 std::vector<T> 是个类模板stack<int> 实例化的时候,vector<int> 也被实例化了,但模板是按需实例化的------用到的成员函数才会被实例化。

push 函数里调用了 push_back,编译器需要知道 push_back 是什么。但 vector<T>::push_back 是依赖模板参数 T 的,编译器在第一次编译 push 函数时,还不知道 T 具体是啥(因为模板还没实例化),所以它不会去 vector<T> 里找 push_back

直接写 push_back(x),编译器在当前类(stack)里找不到,在全局作用域也找不到,就报错了。

那怎么解决呢?

写法一:指定类域

cpp 复制代码
vector<T>::push_back(x);

明确告诉编译器:push_backvector<T> 这个类里的,等实例化了再去查。


写法二:加 this

cpp 复制代码
this->push_back(x);

this 是依赖类型的(依赖于 T),编译器知道等到实例化的时候再去查。


写法三:用 using 把基类成员引入当前作用域

cpp 复制代码
template<class T>
class stack : public std::vector<T>
{
public:
    using std::vector<T>::push_back;   // 把基类的 push_back 引入当前作用域
    using std::vector<T>::pop_back;
    using std::vector<T>::back;
    using std::vector<T>::empty;

    void push(const T& x)
    {
        push_back(x);   // 现在可以直接写了
    }
    // ...
};

补充一个设计上的问题

从设计角度讲,stackpublic 继承 vector 其实不太合适。vector 有很多接口,比如 inserterase,这些接口在 stack 里是不应该暴露给用户的。但 public 继承会把基类的所有 public 成员都暴露出来,用户就能通过 st.insert() 在栈中间插数据,破坏栈的特性。

更合适的做法是用组合(has-a):

cpp 复制代码
template<class T>
class stack
{
private:
    std::vector<T> _v;   // 把 vector 作为成员

public:
    void push(const T& x) { _v.push_back(x); }
    void pop() { _v.pop_back(); }
    const T& top() { return _v.back(); }
    bool empty() { return _v.empty(); }
};

这样 vector 的所有接口都被藏起来了,用户只能用到 stack 提供的 push、pop、top,安全多了。这也是为什么 C++ 标准库里的 stack 用的是组合,而不是继承。

总结

代码演示了继承类模板时的一个典型坑:基类是 std::vector<T>,派生类 stack 里直接调用 push_back(x) 会编译报错,因为编译器在模板实例化第一阶段不认识依赖基类的成员,必须加 vector<T>::this-> 显式指明。设计上,stackpublic 继承 vector 也不合适,会把 inserterase 等接口全暴露出去,破坏栈的封装性,更好的做法是用组合(把 vector 当成员变量)。


二.基类和派生类对象赋值转换

  • public继承的派⽣类对象 可以赋值给基类的指针 / 基类的引⽤。这⾥有个形象的说法叫切⽚或者切割寓意把派⽣类中基类那部分切出来,基类指针或引⽤指向的是派⽣类中切出来的基类那部分。
  • 基类对象不能赋值给派⽣类对象。
  • 基类的指针或者引⽤可以通过****强制类型转换赋值给派⽣类的指针或者引⽤。但是必须是基类的指针是指向派⽣类对象时才是安全的。这⾥基类如果是多态类型,可以使⽤RTTI(Run-Time TypeInformation)的dynamic_cast 来进⾏识别后进⾏安全转换。(简单了解)
cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

class Person
{
protected:
    string _name; // 姓名
    string _sex;  // 性别
    int _age;     // 年龄
};

class Student : public Person
{
public:
    int _id; // 学号
};

void Test()
{
    Student sobj;
    Person pobj;

    // 1. 派生类对象可以赋值给基类对象 / 指针 / 引用(切片)
    Person pobj = sobj;    // 把 sobj 中 Person 的部分切出来
    Person* pp = &sobj;    // pp 只能看到 sobj 中的 Person 部分
    Person& rp = sobj;     // rp 只能访问 sobj 中的 Person 部分

    // 2. 基类对象不能赋值给派生类对象
    // sobj = pobj;        //  编译报错,基类没有派生类的 _id 成员

    // 3. 基类指针可以通过强制类型转换赋值给派生类指针
    pp = &sobj;   // pp 指向派生类对象
    Student* ps1 = (Student*)pp;   //  转换合法,pp 本来就指向 Student
    ps1->_id = 10;                 // 安全,内存里确实有 _id

    pp = &pobj;   // pp 指向基类对象
    Student* ps2 = (Student*)pp;   //  语法允许,但危险!pp 指向的是 Person
    ps2->_id = 10;                 //  越界访问!pobj 里没有 _id 这块空间
}

三.继承中的作⽤域

隐藏规则:

  • 在继承体系中基类和派⽣类都有独⽴的作⽤域。
  • 派⽣类和基类中有同名成员,派⽣类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。(在派⽣类成员函数中,可以使⽤ 基类::基类成员 显⽰访问)
  • 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  • 注意在实际中在继承体系⾥⾯最好不要定义同名的成员。
cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

class Person
{
protected:
    string _name = "小李子"; // 姓名
    int _num = 111;         // 身份证号
};

class Student : public Person
{
public:
    void Print()
    {
        cout << "姓名: " << _name << endl;
        cout << "身份证号: " << Person::_num << endl;  // 访问基类的 _num
        cout << "学号: " << _num << endl;             // 默认访问派生类自己的 _num
    }

protected:
    int _num = 999;         // 学号(和基类 _num 同名)
};

int main()
{
    Student s1;
    s1.Print();
    return 0;
}

考察继承作⽤域相关选择题

代码如下:

cpp 复制代码
class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};
class B : public A
{
public:
	void fun(int i)
	{
		cout << "func(int i)" << i << endl;
	}
};
int main()
{
	B b;
	b.fun(10);
	b.fun();
	return 0;
};

1.A和B类中的两个func构成什么关系()

A. 重载 B. 隐藏 C.没关系

2.下⾯程序的编译运⾏结果是什么()

A. 编译报错 B. 运⾏报错 C. 正常运⾏

第一题

cpp 复制代码
class A
{
public:
    void fun()
    {
        cout << "func()" << endl;
    }
};

class B : public A
{
public:
    void fun(int i)
    {
        cout << "func(int i)" << i << endl;
    }
};
答案:B. 隐藏

解析:

基类 Afun() 和派生类 Bfun(int i) 函数名相同,分别在基类和派生类两个不同的作用域中。根据规则,派生类的成员函数会隐藏基类的同名成员函数,只要名字相同就构成隐藏,跟参数没关系。所以选 B。

A 选项重载是指在同一作用域中函数名相同、参数不同,这里不在同一作用域,不构成重载。C 选项没关系也不对,它们确实有继承关系下的名字冲突。

第二题

cpp 复制代码
int main()
{
    B b;
    b.fun(10);
    b.fun();
    return 0;
}

答案:A.

解析:

b.fun(10) 能正常调用,因为 B 中确实有 fun(int i)

b.fun() 会编译报错,因为 B 中的 fun(int) 把基类 Afun() 隐藏了,编译器在 B 中找到了 fun 这个名字,就不会再去基类里找了。然而 B 中的 fun 需要传一个 int 参数,调用时没传参,参数匹配不上,所以报错。

总结:

  • 基类和派生类中的同名函数(参数可以不同)构成隐藏关系,不是重载,因为不在同一作用域。

  • 一旦隐藏,派生类对象默认只能调用派生类自己的版本,基类的同名函数被隐藏了,需要加 A::fun()using 声明才能调。

  • 隐藏只要求函数名相同,和参数、返回值都没关系。


四.派⽣类的默认成员函数

派生类的6个默认成员函数"默认"就是自己不写,编译器自动生成的那个。

1.构造函数

派生类构造函数必须调基类构造函数来初始化继承来的成员。

  • 继承的基类成员:当整体处理,自动调基类的默认构造

  • 内置类型成员(int、char、指针等):不处理(除非声明时给了缺省值)

  • 自定义类型成员(string、vector等):调它们自己的默认构造

注意:如果基类没有默认构造(比如自己写了带参构造),派生类必须在初始化列表里显式调用基类的构造,不然编不过。

cpp 复制代码
class Person {
public:
    Person(string name) : _name(name) {}  // 有参构造,无默认构造
protected:
    string _name;
};

class Student : public Person {
public:
    // 必须显式调基类构造
    Student(string name, int id) : Person(name), _id(id) {}
private:
    int _id;
};

构造顺序:先基类 --> 再自定义类型成员 --> 最后内置类型成员(按声明顺序)。


2.派生类的拷贝构造

继承来的基类成员 :作为整体,必须显式调用基类的拷贝构造,不能指望编译器自动调。你必须在初始化列表里写成 Person(s),这样基类那部分成员才能被正确拷贝过来。如果不写,编译器会去调基类的默认构造,基类成员就拿不到值了,拷出来全是默认值。

内置类型成员(int、char、指针等):编译器自动完成值拷贝,就跟普通类一样,不用管。

自定义类型成员(string、vector等):自动调用它们自己的拷贝构造完成拷贝,也不用管。

cpp 复制代码
Student(const Student& s)
    : Person(s)          // 显式调基类拷贝构造
    , _id(s._id)         // 内置类型,值拷贝
    , _course(s._course) // 自定义类型,调它自己的拷贝构造
{}

3.赋值重载

派生类的**operator= 必须显式调用基类的 operator=**,把基类那部分成员先赋值好,再给自己新加的成员赋值。不调的话基类成员就不会被赋值。

cpp 复制代码
Student& operator=(const Student& s)
{
    if (this != &s)
    {
        Person::operator=(s);   // 调基类的赋值
        _id = s._id;            // 自己的成员
        _course = s._course;    // 自定义类型调自己的赋值
    }
    return *this;
}
  • 继承的基类成员 :作为一个整体,显式调用基类的 operator= 来完成赋值

  • 内置类型成员:值拷贝

  • 自定义类型成员:调它们自己的赋值重载


4.析构函数

派生类的析构函数执行完自己的清理后,编译器会自动调用基类的析构函数,不需要我们手动写。这样才能保证先清理派生类自己的,再清理基类的,顺序符合"先构造的后析构"。

cpp 复制代码
~Student()
{
    // 先清理自己的资源
    delete[] _ptr;
    // 然后编译器自动调用 ~Person()
}
  • 继承的基类成员:派生类析构执行完后,自动调基类析构

  • 内置类型成员:不处理(没有资源要释放)

  • 自定义类型成员:自动调它们自己的析构

特别注意:析构函数名在底层会被统一处理成 destructor(),所以基类和派生类的析构函数名相同,不构成函数重载,而是构成隐藏。这也是为什么多态场景下要把基类析构函数设为 virtual,否则通过基类指针 delete 派生类对象时,只会调用基类析构,派生类的析构就不会被调。


5.构造和析构的顺序

  • 初始化顺序:先基类构造 -->再派生类构造

  • 清理顺序:先派生类析构 -->再基类析构

因为派生类对象里先有基类那部分,再有派生类自己那部分,构造时先造基类的,析构时反过来。

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

// 基类
class Person {
public:
    // 构造函数
    Person(const char* name)
        : _name(name)
    {
        cout << "Person()" << endl;
    }

    // 拷贝构造函数
    Person(const Person& p)
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }

    // 赋值重载函数
    Person& operator=(const Person& p)
    {
        cout << "Person& operator=(const Person& p)" << endl;
        if (this != &p)
            _name = p._name;
        return *this;
    }

    // 析构函数
    ~Person()
    {
        cout << "~Person()" << endl;
    }

protected:
    string _name;
};


// 派生类
class Student : public Person {
public:
    // 1. 构造函数
    Student(const char* name, int id)
        : Person(name)   // 显式调用基类构造,因为基类没有默认构造
        , _id(id)
    {
        cout << "Student()" << endl;
    }

    // 2. 拷贝构造函数
    Student(const Student& s)
        : Person(s)      // 必须显式调基类拷贝构造,否则会调基类默认构造(没有就报错)
        , _id(s._id)
    {
        cout << "Student(const Student& s)" << endl;
    }

    // 3. 赋值重载函数
    Student& operator=(const Student& s)
    {
        cout << "Student& operator=(const Student& s)" << endl;
        if (this != &s)
        {
            Person::operator=(s);   // 必须显式调基类赋值重载,加类域指定
            _id = s._id;
        }
        return *this;
    }

    // 4. 析构函数
    ~Student()
    {
        cout << "~Student()" << endl;
        // 先清理自己的资源
        // 然后编译器会自动调用基类析构 ~Person()
    }

protected:
    int _id;
};


int main()
{
    Student s1("张三", 1001);
    Student s2(s1);          // 测试拷贝构造
    Student s3("李四", 1002);
    s3 = s1;                 // 测试赋值重载
    return 0;
}

解析如下:

这段代码展示了派生类如何显式调用基类的构造、拷贝构造、赋值重载和析构,重点演示了"基类没有默认构造时派生类必须显式调用"这个规则。


1.基类 Person

cs 复制代码
class Person {
public:
    Person(const char* name) : _name(name) {}  // 有参构造
    Person(const Person& p) : _name(p._name) {} // 拷贝构造
    Person& operator=(const Person& p) { _name = p._name; return *this; } // 赋值
    ~Person() {}
protected:
    string _name;
};

基类只提供了有参构造,没有无参默认构造。这意味着派生类在初始化时必须显式调用基类的有参构造,否则编译报错。

派生类构造时,必须先完成基类部分的初始化。基类没有默认构造,编译器不知道用哪个构造来初始化基类成员,所以必须让你自己指定。

2.派生类 Student

复制代码
class Student : public Person {
cpp 复制代码
public:
    // 1. 构造函数
    Student(const char* name, int id)
        : Person(name)   // 显式调用基类的有参构造,初始化基类部分
        , _id(id)        // 初始化自己的成员
    {}

    // 2. 拷贝构造函数
    Student(const Student& s)
        : Person(s)      // 调用基类拷贝构造,切片:把 s 中的 Person 部分拷过来
        , _id(s._id)     // 拷贝自己的成员
    {}

    // 3. 赋值重载
    Student& operator=(const Student& s)
    {
        if (this != &s) {
            Person::operator=(s);  // 必须显式调基类赋值,加类域指定
            _id = s._id;
        }
        return *this;
    }

    // 4. 析构函数
    ~Student()
    {
        // 先清理自己的资源
        // 编译器自动调用基类析构
    }

protected:
    int _id;
};

3.关于切片的说明

代码注释里反复提到了"切片",意思是:把派生类对象当作基类对象来使用时,编译器会自动把派生类对象中属于基类的那一部分"切"出来,只传递基类成员,派生类自己新增的成员会被"切掉"。

cpp 复制代码
Student s;
Person p = s;        // 切片:只拷贝 s 中的 Person 部分给 p
Person* pp = &s;     // 切片:pp 只看到 s 中的 Person 部分
Person& rp = s;      // 切片:rp 只访问 s 中的 Person 部分

4.几个关键点

关键点 说明
构造必须调基类构造 基类没有默认构造时,必须在初始化列表显式调用
拷贝构造必须调基类拷贝构造 否则编译器会去调基类默认构造,基类没有就报错
赋值重载必须加类域 基类和派生类的 operator= 同名,构成隐藏 ,必须 Person:: 指定
析构不用管 编译器会自动在派生类析构结束后调用基类析构

5.构造和析构顺序

cpp 复制代码
// 创建 Student 对象时
Person(name)   // 先基类构造
_id(id)        // 再派生类构造

// 销毁 Student 对象时
~Student()     // 先派生类析构
~Person()      // 再基类析构(编译器自动调)

小结一下:

基类没有默认构造时,派生类必须在初始化列表里显式调用基类构造来初始化基类成员。拷贝构造和赋值重载同理,都得显式调基类对应版本。析构不用操心,编译器会自动帮你在派生类析构之后调基类析构。


实现一个不能被继承的类

方法1:C++98的做法------构造函数私有化

把基类的构造函数设成私有的,派生类构造的时候必须调基类构造,但私有成员派生类看不见,调不了,所以派生类就没法实例化对象了。

cpp 复制代码
class Base
{
public:
    void func() { cout << "Base::func" << endl; }

private:
    Base() {}   // 构造函数私有
};

// 报错:基类构造函数私有,派生类调不了
class Derive : public Base
{};

int main()
{
    Base b;     //  报错,构造函数私有,外部创建不了对象
    Derive d;   //  报错,Derive 没法实例化
    return 0;
}

不过这样基类自己也创建不了对象,有点尴尬,可以加个静态方法绕一下:

cpp 复制代码
class Base
{
public:
    static Base Create() { return Base(); }   // 静态方法创建对象
    void func() { cout << "Base::func" << endl; }

private:
    Base() {}
};

int main()
{
    Base b = Base::Create();   //  通过静态方法创建
    return 0;
}

方法2:C++11的做法------final关键字

直接在类名后面加个 final,意思就是"这个类到此为止,不能再被继承了"。

cpp 复制代码
class Base final   // 加个 final,禁止继承
{
public:
    void func() { cout << "Base::func" << endl; }
};

// 报错:Base 被 final 修饰了,不让继承
class Derive : public Base
{};

int main()
{
    Base b;     //  可以正常创建
    Derive d;   //  报错
    return 0;
}

两种对比:

对比项 C++98 私有构造 C++11 final
写法 把构造放 private 类名后加 final
基类创建对象 麻烦,得用静态方法 正常创建
好不好理解 绕弯子 一眼就懂

总结一下:**** 现在写代码直接用 final 就行,简单粗暴还不影响基类自己创建对象。C++98那种私有构造的方法了解一下就够了。


五.继承与友元

友元关系不能继承。 也就是说,基类的友元不会自动成为派生类的友元,它只对基类自己有效。

cpp 复制代码
#include <iostream>
using namespace std;

class Student;  // 前置声明

class Person
{
public:
    friend void Display(const Person& p, const Student& s);  // 声明 Display 是 Person 的友元

protected:
    string _name = "张三";
};

class Student : public Person
{
protected:
    int _stuNum = 1001;
};

// Display 是 Person 的友元,能访问 Person 的 protected 成员
// 但 Display 不是 Student 的友元,所以访问 Student 的 protected 成员会报错
void Display(const Person& p, const Student& s)
{
    cout << p._name << endl;    // 可以访问,Display 是 Person 的友元
    cout << s._stuNum << endl;  // 编译报错!Display 不是 Student 的友元
}

int main()
{
    Person p;
    Student s;
    Display(p, s);
    return 0;
}

编译报错:


解决方法:

让 Display 也变成 Student 的友元。

cpp 复制代码
class Student; 

class Person
{
public:
    friend void Display(const Person& p, const Student& s);
protected:
    string _name = "张三";
};

class Student : public Person
{
public:
    friend void Display(const Person& p, const Student& s);  // 也声明成 Student 的友元
protected:
    int _stuNum = 1001;
};

void Display(const Person& p, const Student& s)
{
    cout << p._name << endl;    //  Person 的友元,能访问
    cout << s._stuNum << endl;  //  Student 的友元,也能访问了
}

int main()
{
    Person p;
    Student s;
    Display(p, s);
    return 0;
}

或者更简单点,把 Display 同时声明为两个类的友元就行:

cpp 复制代码
class Person
{
public:
    friend void Display(const Person& p, const Student& s);  // 声明一次
protected:
    string _name = "张三";
};

class Student : public Person
{
public:
    friend void Display(const Person& p, const Student& s);  // 再声明一次
protected:
    int _stuNum = 1001;
};

核心结论

  • 友元关系不能继承:Person 的友元函数,不自动是 Student 的友元

  • 解决办法:谁要访问,就声明成谁的友元

  • 本质原因:友元是"单向授权",不是"家族传承",爹的朋友不代表儿子的朋友

通俗理解:爹的朋友还是爹的朋友,不是你(子类)的朋友。想访问子类的私有成员,得让子类也声明一下。


六.继承与静态成员

**基类定义了static静态成员,**则整个继承体系⾥⾯只有⼀个这样的成员。⽆论派⽣出多少个派⽣类,都只有⼀个static成员实例。

cpp 复制代码
#include <iostream>
using namespace std;

class Animal
{
public:
    Animal() { ++_count; }
    static int _count;   // 统计动物总数
};
int Animal::_count = 0;


class Cat : public Animal
{
    // 继承了 Animal 的静态成员 _count
};


class Dog : public Animal
{
    // 也继承了 Animal 的静态成员 _count
};


int main()
{
    Cat c1;
    Cat c2;
    Dog d1;
    Dog d2;
    Dog d3;

    cout << "总共创建了 " << Animal::_count << " 只动物" << endl;  // 输出 5
    cout << "Cat::_count = " << Cat::_count << endl;              // 输出 5
    cout << "Dog::_count = " << Dog::_count << endl;              // 输出 5

    return 0;
}

Animal 里有一个静态成员 _count,用来统计总共创建了多少只动物。CatDog 都继承了 Animal,但它们并没有自己独立的 _count,而是共用 Animal 的那一份。

不管通过 Animal::_countCat::_count 还是 Dog::_count 访问,拿到的都是同一个变量。

静态成员是"全家族共用"的,继承多少层都不会复制出一份新的。


七.多继承及其菱形继承问题

1.单继承

1.单继承:一个子类只有一个直接父类时,这个继承关系就叫单继承

2.多继承

2.多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。

多继承中指针偏移问题

问题 :下面这段代码中,p1p2p3 三个指针的关系是( )

cpp 复制代码
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };

int main()
{
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    return 0;
}

A:p1 == p2 == p3

B:p1 < p2 < p3

C:p1 == p3 != p2

D:p1 != p2 != p3

Derive 继承了 Base1Base2,对象在内存中按继承顺序排列:先是 Base1 的部分,再是 Base2 的部分,最后是 Derive 自己的部分。

p1 指向 Base1 部分,也就是对象的起始地址。p3 指向整个 Derive 对象,也是起始地址,所以 p1p3 相等。p2 指向 Base2 部分,在 Base1 后面,所以 p2 的地址比 p1p3 大一个 Base1 的大小(4 字节)。

答案 C:p1 == p3 != p2

3.菱形继承

3.菱形继承:菱形继承是多继承的一种特殊情况。

菱形继承的主要问题就是数据冗余二义性。从内存布局来看,Assistant 对象里会包含两份 Person 的数据,一份来自 Student,一份来自 Teacher。这就导致两个麻烦:一是访问 Person 的成员时编译器不知道你指的是哪一份,必须加类域指定路径;二是同一份数据存了两遍,浪费内存不说,改的时候还容易只改了一处漏了另一处,造成数据不一致。


cpp 复制代码
class Person
{
public :
    string _name ; // 姓名
};
 
class Student : public Person
{
protected :
    int _num ; //学号
};
 
class Teacher : public Person
{
protected :
    int _id ; // 职工编号
};
 
class Assistant : public Student, public Teacher
{
protected :
    string _majorCourse ; // 主修课程
};
 
int main()
{
    Assistant a ;
    a._name = "Peter"; //这个时候_name其实只有一个
 
    // 这下面两个显示访问其实访问的都是一个变量
    a.Student::_name = "hjq";
    a.Teacher::_name = "lkj";
 
    return 0;
}

虚拟继承用来解决菱形继承的二义性和数据冗余问题,在腰部(Student 和 Teacher 继承 Person 时)加 virtual,底层 Assistant 里就只有一份 Person 数据了。注意 virtual 加在中间层,不是最底层,而且虚拟继承不要乱用,只在解决菱形继承时使用。

cpp 复制代码
class Person
{
public :
    string _name ; // 姓名
};
 
class Student : virtual public Person
{
protected :
    int _num ; //学号
};
 
class Teacher : virtual public Person
{
protected :
    int _id ; // 职工编号
};
 
class Assistant : public Student, public Teacher
{
protected :
    string _majorCourse ; // 主修课程
};
 
int main()
{
    Assistant a ;
    a._name = "Peter"; //这个时候_name其实只有一个
 
    // 这下面两个显示访问其实访问的都是一个变量
    a.Student::_name = "hjq";
    a.Teacher::_name = "lkj";
 
    return 0;
}

4.虚继承

很多人说 C++ 语法复杂,多继承就是一个典型的体现。

有了多继承,就存在菱形继承;有了菱形继承,为了解决数据冗余和二义性问题,又引入了虚继承。虚继承底层实现复杂,对象布局里要额外维护虚基类指针和虚基类表,访问成员时多了一层间接寻址,有一定性能损耗。

所以虚继承能用,但最好别用------不是因为它不好,而是因为需要它本身就说明继承设计已经复杂到该重新考虑了

后来的很多编程语言比如 Java,干脆直接取消了多继承,只支持单继承+接口,从根源上就把菱形继承这类问题规避掉了。多继承可以算是 C++ 的历史包袱之一。

虚拟继承的原理(观察内存布局)

为了研究虚拟继承的原理,下面用一个简化的菱形继承体系,通过内存窗口观察对象成员的模型。

cpp 复制代码
#include <iostream>
using namespace std;

class A
{
public:
    int _a = 1;
};

class B : virtual public A   // 虚继承
{
public:
    int _b = 2;
};

class C : virtual public A   // 虚继承
{
public:
    int _c = 3;
};

class D : public B, public C
{
public:
    int _d = 4;
};

int main()
{
    D d;
    d._a = 10;   // 直接访问,没有二义性

    cout << sizeof(A) << endl;  // 4
    cout << sizeof(B) << endl;  // 16(vs下是12,看平台)
    cout << sizeof(D) << endl;

    // 查看地址
    cout << "&d: " << &d << endl;
    cout << "&d._a: " << &d._a << endl;

    return 0;
}

运行结果如下:

&d 是对象的起始地址,&d._aA 类成员 _a 的地址。

_a 的地址(0x...F620)比对象起始地址(0x...F5F8)大了 0x28(即 40 字节)。

说明 _a 被放在了对象内存布局的末尾,距离对象起始位置偏移 40 个字节。

如何通过内存窗口和监视窗口观察虚继承的内存布局

现在是内存窗口

监视窗口:

数据如下:

变量 地址
&d._a 0x...8c0 {1}
&d._b 0x...8a0 {2}
&d._c 0x...8b0 {3}
&d._d 0x...8b8 {4}

从地址就能看出来:&d._b(0x...8a0)和 &d._a(0x...8c0)相差 0x20(32 字节),说明 _a 被放在了对象末尾,偏移 32 字节。


虚基类表怎么来的(怎么出现在内存里的)

虚基类表是编译器在编译期间生成好的,直接放在可执行文件的只读数据段(.rdata)里。程序启动时,操作系统把整个可执行文件加载到内存,这个表也就跟着被加载到内存中的某个地址了。

当程序运行到创建 D 对象时,编译器生成的构造函数里会往对象里填值------把虚基类表的地址填到 vbptr(虚基类指针)里。那个表在内存里只有一个副本,但每个虚继承的对象里都会存一个指向它的指针。

所以你在内存窗口里看到的 20 bc 4e cb f7 7f 00 00,其实就是编译器在编译时确定好的虚基类表的地址,被构造函数写进了 B 子对象的开头位置。

简单说: 虚基类表是编译时生成的,存在代码段(只读数据段),程序运行时被加载到内存固定位置。对象构造时,编译器生成的代码把该表的地址填到对象的 vbptr 里。所以你看到的 vbptr 不是程序运行时算出来的,而是编译器在编译时就定好了的。

那我上面的的内存窗口里,对象起始地址 0x...FF898 的前 8 个字节是:

cpp 复制代码
20 bc 4e cb f7 7f 00 00

这就是 B对象**(它存的是一个地址,指向内存中某个地方------那个地方放着一张表,叫虚基类表(vbtable),表里记录了 A 的数据在当前对象中的偏移量)** 的 vbptr(虚基类指针),按小端存储读出来是 0x00007ff7cb4ebc20

接下来要做的是:

  • 把这 8 个字节复制出来(20 bc 4e cb f7 7f 00 00

  • 在内存窗口地址栏输入这个地址(直接输 0x00007ff7cb4ebc20

  • 回车,看跳转过去的内容

  • 你会在那里看到一个偏移量,应该是 28 00 00 00(40 字节)


小端存储,读取方式如下:

  • 前 4 个字节 00 00 00 00:表头信息(可能是预留字段,不需要管)

  • 接着 4 个字节 28 00 00 00:读作 0x00000028,就是 40

这个 0x28 就是偏移量。

它告诉编译器:你要找的 A 类数据,在 D 对象里偏移 0x28(40 字节)的位置。

然后你再往后看,_a = 101 00 00 00)在偏移 0x28 的位置(从 0x...FF8980x...FF8C0,差了 40 个字节)。虚基类表里存的 0x28 就是告诉你这个数。B 的 vbptr 指向这个表,表里给了你偏移量,按照偏移量去找,就能找到 A 的数据。

这样就串起来了:vbptr -->指向虚基类表 --> 表里存着偏移量 0x28 --> 通过偏移量找到对象末尾的 _a。中间的那串 20 bc 4e cb f7 7f 00 00 是链接,你可以把它当作路标,找到那个存着偏移量的表。


虚基类表查看流程

从内存窗口里看到的 20 bc 4e cb f7 7f 00 00 就是 B 的 vbptr(虚基类指针),它存储的是一个地址,指向内存中的虚基类表。把这个地址输入到内存窗口的地址栏,就能看到虚基类表里的内容,其中存放着 _a 在对象中的偏移量(0x28,即 40 字节)。这个偏移量告诉 B:你要访问的 A 数据不在你身边,需要往后偏移 40 个字节才能找到。

与此同时,D 对象末尾的 01 00 00 00 就是 _a,它的地址正好比对象起始地址大 40 个字节。B 和 C 各自拥有一个 vbptr,分别指向各自的虚基类表,但两个表里存储的偏移量都是 0x28,最终指向的是同一份 _a 数据。通过这样一层间接寻址,虚继承将 A 的数据共享给了 B 和 C,既解决了二义性问题,也消除了数据冗余。虽然代价是对象体积变大和访问速度略微下降,但换来的是菱形继承场景下的正确性和一致性。


问题补充:

(1) 为什么需要偏移量?

指针是无法识别自己指向的是哪个类的对象,即可能指向自己,也可能指向子类。比如 B 对象和 D 对象中的虚基类成员 _a 的偏移量是不一样的,B 对象里 _a 可能紧跟在 B 的成员后面,而 D 对象里 _a 被放到了对象末尾,偏移 40 个字节。所以编译器只能通过偏移量来计算出 _a 的位置,没办法写死一个固定的偏移值。无论是 B 或 C 的对象、对象指针、对象引用,访问继承的虚基类 A 中的成员 _a 时,都要通过 vbptr 取偏移量,再计算出 _a 的实际位置。


(2) 为什么不在对象里直接存储偏移量?

直接存偏移量也是可以的,但虚基类表不止存偏移量这一个东西。还有其他场景------比如引入多态时,虚基类表里还需要存其他信息(比如虚函数表指针的相关数据)。所以编译器选择用一张表来统一管理,而不是在对象里单独存一个偏移量,这样更灵活,扩展性也更好。


(3) virtual 已经能解决菱形继承的问题了,为什么还是不建议使用?

虚继承在解决问题的同时,效率也下降了。普通继承下,成员在对象中的偏移在编译时就固定了,访问时直接算地址就行。而虚继承必须通过 vbptr 找到虚基类表,从表里取出偏移量,再与当前地址相加才能找到目标数据,多了一层间接寻址,访问速度变慢。

从内存窗口也能直观地看到这个代价:加了 virtual 后,D 对象从 20 字节变成了 48 字节,多了两个 vbptr(各 8 字节)和对齐填充。对象变大了,访问也慢了,代码也变复杂了。所以虚继承能用,但最好别主动设计出需要它的继承结构。


(4) 从内存窗口看 vbptr 如何找到 A

根据两个窗口可以分析出 D 对象将 A 放到了对象组成的最下面,这个 A 同时属于 B 和 C。那么 B 和 C 如何去找到公共的 A 呢?

这里是通过 B 和 C 中的两个指针------虚基表指针(vbptr),指向各自的一张表------虚基表(vbtable)

从内存窗口可以看到,B 和 C 中各多了 8 个字节(64 位下)的地址。通过内存窗口查看这两个地址指向的内容,发现里面存储的是偏移量。比如你之前的数据里,B 的 vbptr 指向的虚基表里存着 0x28(40 字节),C 的 vbptr 指向的虚基表里也存着 0x28(40 字节)。

这两个偏移量的含义是:

cpp 复制代码
B 的 vbptr 指向的地址 + 0x28 = A 在 D 对象中的位置
C 的 vbptr 指向的地址 + 0x28 = A 在 D 对象中的位置

B 和 C 各自用自己的 vbptr 找到虚基表,从表里取出偏移量,再加上自己当前的位置,就能定位到同一个 A。

那完整链路总结一下下

cpp 复制代码
vbptr(虚基类指针)--> 指向 --> 虚基类表(vbtable)-> 存着 -> 偏移量(0x28)--> 找到 --> A 的数据(_a = 1)

B 和 C 各自有一套 vbptr + 虚基表,但两套机制最终定位到的是同一份 A 的数据。这就是虚继承解决二义性和数据冗余的完整底层实现。


我们用监视窗口+内存窗口们验证了虚拟继承的底层实现。

  • 监视窗口 看地址和值:&d._a = 0x...8C0 {1}&d._b = 0x...8A0 {2},相差 0x20(32 字节)

  • 内存窗口 看原始内存:对象末尾 01 00 00 00 出现一次,vbptr 在开头

两个窗口配合,确认了 _a 只存一份,在对象末尾,偏移 40 字节。BC 各有一个 vbptr,通过虚基类表找到同一份 _a。这就是虚继承的底层原理------用 vbptr 共享基类数据,解决二义性和数据冗余,代价是对象变大、访问变慢。


下面是上面的 Person 关系菱形虚拟继承的原理解释:

5. IO库中的菱形虚拟继承(了解)

C++ 标准库中的 IO 流体系是一个典型的菱形虚拟继承案例。下面这张图展示了 stringstreamfstream 的继承关系:

对应到代码层面:

cpp 复制代码
// basic_ostream 虚继承 basic_ios
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits>
{};

// basic_istream 虚继承 basic_ios
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits>
{};

// basic_iostream 同时继承 ostream 和 istream
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_iostream : public basic_ostream<CharT, Traits>,
                       public basic_istream<CharT, Traits>
{};

如果不使用虚继承,basic_iostream 对象里会包含两份 basic_ios 的数据(一份来自 basic_ostream,一份来自 basic_istream),导致二义性和数据冗余。通过虚继承,basic_ostreambasic_istream 共享同一份 basic_ios,这样 basic_iostream 里就只有一份 basic_ios 数据了。

这就是我刚刚研究的虚继承在实际标准库中的一个经典应用:stringstreamfstream 这些类能同时做输入和输出,底层靠的就是菱形虚拟继承来保证 basic_ios 只存一份。


八.继承和组合

public 继承是一种 is-a 的关系,意思是每个派生类对象都是一个基类对象,派生类是一种基类。比如 Student 是一种 PersonCar 是一种 Vehicle

组合是一种 has-a 的关系,意思是每个类对象里包含另一个类的对象,这个类有一个那个类。比如 Car 有一个 EngineComputer 有一个 CPU

继承是白箱复用。因为继承允许派生类访问基类的 protected 成员,基类的内部细节对派生类是可见的。这种复用方式破坏了基类的封装性,基类的改变会直接影响派生类,派生类和基类之间的依赖关系很强,耦合度高,代码维护起来比较麻烦。

组合是黑箱复用。组合只要求被组合的对象提供良好的接口,对象的内部细节对外不可见。组合类之间的依赖关系弱,耦合度低,修改一个类不会影响到另一个类,代码维护性更好。所以优先使用组合有助于保持每个类的封装性。

代码如下:

Car 和 Tire 更符合 has-a 的关系:车有轮胎。

cpp 复制代码
// Tire(轮胎)和Car(车)更符合has-a的关系
class Tire {
protected:
    string _brand = "Michelin";  // 品牌
    size_t _size = 17;           // 尺寸
};

class Car {
protected:
    string _colour = "白色";      // 颜色
    string _num = "陕ABIT00";    // 车牌号
    Tire _t1;                    // 轮胎1
    Tire _t2;                    // 轮胎2
    Tire _t3;                    // 轮胎3
    Tire _t4;                    // 轮胎4
};

Car 和 BMW/Benz 更符合 is-a 的关系:宝马是一种车,奔驰也是一种车。

cpp 复制代码
// Car和BMW更符合is-a的关系
class BMW : public Car {
public:
    void Drive() { cout << "好开-操控" << endl; }
};

// Car和Benz更符合is-a的关系
class Benz : public Car {
public:
    void Drive() { cout << "好坐-舒适" << endl; }
};

stack 和 vector:既可以用继承也可以用组合,优先选组合

cpp 复制代码
// 组合(推荐):只暴露栈需要的接口
template<class T>
class stack {
public:
    void push(const T& x) { _v.push_back(x); }
    void pop() { _v.pop_back(); }
    const T& top() { return _v.back(); }
    bool empty() { return _v.empty(); }
private:
    vector<T> _v;
};

// 继承(不推荐):会暴露 vector 的所有接口
template<class T>
class stack : public vector<T>
{};

继承和组合的选择

优先使用组合,而不是继承。组合的耦合度低,代码维护性好。不过也不是绝对的:类之间的关系确实适合继承(is-a)就用继承,需要实现多态也必须要用继承。如果类之间的关系既适合继承也适合组合,那就优先用组合。

总结

优先用组合,组合耦合度低,维护性好。只有确实是 is-a 关系,或者需要多态时,才用 public 继承。既适合继承也适合组合时,优先组合。如果你不确定该用继承还是组合,默认选组合。


九.继承的总结与反思

很多人吐槽 C++ 语法复杂,多继承就是罪魁祸首之一。因为多继承会带来菱形继承,而菱形继承又引出了虚继承,虚继承的底层实现极其复杂,对象里多了虚基类指针,访问成员时也多了查表取偏移量的过程,对象体积变大,访问速度变慢,理解成本也直线上升。所以尽量不要设计出多继承,更不要设计出菱形继承多继承可以说是 C++ 的一个历史包袱,后来的很多语言比如 Java,干脆直接去掉多继承,只保留单继承加接口,从根源上避开了这个麻烦。

继承和组合怎么选?

  • public 继承表示 is-a 的关系,比如学生是一个人,宝马车是一辆车。

  • 组合表示 has-a 的关系,比如车有轮胎,电脑有 CPU。

继承是"白箱复用",因为子类能看到父类的内部细节,父类一改子类就得跟着改,耦合度很高。组合是"黑箱复用",对象之间只通过接口交互,内部细节互不可见,改一个不影响另一个,耦合度低,维护起来轻松很多。

所以优先使用组合,而不是继承。如果两个类的关系确实符合 is-a,或者需要用到多态,那就用继承。如果既可以用继承也可以用组合,那就选组合。不确定的时候就选组合,大概率不会错。

相关推荐
数行拙笔8 小时前
C++客户端---String类型
开发语言·c++·bootstrap
影寂ldy9 小时前
C# Task 进阶:WaitAll / WaitAny / WhenAll / WhenAny
开发语言·c#
左左右右左右摇晃9 小时前
手写Tomcat原理整理
java·开发语言·笔记·tomcat
孫治AllenSun9 小时前
【JVM】四大引用类型分析
java·开发语言·jvm
看-是灰机9 小时前
使用go语言实现对接
linux·开发语言·后端·docker·语言模型·golang·飞书
草莓熊Lotso9 小时前
【Linux网络】深入理解Linux IO多路复用:从本质到select服务器实战
linux·运维·服务器·c语言·网络·数据库·c++
人间凡尔赛18 小时前
Next.js 16 生产级实战:Cache Components + View Transitions 完整指南
开发语言·javascript·ecmascript
selfsongs20 小时前
指针、内存管理、现代 C++ 特性
c++
吃着火锅x唱着歌21 小时前
Effective C++ 学习笔记 条款37 绝不重新定义继承而来的缺省参数值
c++·笔记·学习