【C++】18.继承

文章目录

  • 1.继承的概念及定义
    • 1.1 继承的概念
    • 1.2 继承定义
      • 1.2.1定义格式
      • 1.2.2继承关系和访问限定符
      • 1.2.3继承基类成员访问方式的变化
    • 1.3 继承类模板
  • 2.基类和派生类对象赋值转换
  • 3.继承中的作用域
    • 3.1 隐藏规则:
    • 3.2 考察继承作用域相关选择题
  • 4.派生类的默认成员函数
    • 4.1 4个常见默认成员函数
      • 代码运行结果分析
      • 代码解析
        • 1. 构造函数调用分析
          • 构造 `s1`:
          • 拷贝构造 `s2`:
          • 构造 `s3`:
        • 2. 赋值运算符调用分析
          • 赋值 `s1 = s3`:
        • 3. 析构函数调用分析
          • 程序结束时析构顺序:
      • 基类和派生类的构造、拷贝构造、赋值运算符、析构总结
        • 1. 构造函数
        • 2. 拷贝构造函数
        • 3. 赋值运算符
        • 4. 析构函数
      • 总结调用顺序
        • 构造顺序
        • 拷贝构造顺序
        • 赋值运算符顺序
        • 析构顺序
    • 4.2 实现一个不能被继承的类
  • 5.继承与友元
      • 代码解释与问题分析
        • 1. **代码结构及友元关系**
        • 2. **友元关系的作用范围**
        • 3. **编译报错原因**
        • 4. **“友元关系不能继承”的含义**
      • 解决方法
      • 为什么友元关系不能继承?
      • 总结
  • 6. 继承与静态成员
  • 7.复杂的菱形继承及菱形虚拟继承
    • 7.1 继承模型
    • 7.2 虚继承
      • 类层次结构概述
      • 菱形继承问题与虚继承的作用
        • 菱形继承问题
        • 虚继承的作用
      • 代码解析
      • 总结
      • 构造函数的调用顺序
      • 最终结果
    • 7.3 多继承中指针偏移问题?下面说法正确的是( )
      • 问题解析
      • 代码结构
      • 多继承时的内存布局
      • 指针值比较
      • 选项分析
      • 正确答案
    • 7.4 IO库中的菱形虚拟继承
  • 8.继承的总结和反思
  • 9.笔试面试题
      • 继承与组合的区别
      • 何时使用继承?
      • 何时使用组合?


1.继承的概念及定义

1.1 继承的概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。

下面我们看到没有继承之前我们设计了两个类StudentTeacherStudentTeacher都有姓名/地址/电话/年龄等成员变量,都有identity身份认证的成员函数,设计到两个类里面就是冗余的。当然他们也有一些不同的成员变量和函数,比如老师独有成员变量是职称,学生的独有成员变量是学号;学生的独有成员函数是学习,老师的独有成员函数是授课。

class Student
{
public:// 进入校园/图书馆/实验室刷二维码等身份认证void identity(){// ...}// 学习void study(){// ...}
protected:string _name = "peter"; // 姓名string _address; // 地址string _tel; // 电话int _age = 18; // 年龄int _stuid; // 学号
};
class Teacher
{
public:// 进入校园/图书馆/实验室刷二维码等身份认证void identity(){// ...}// 授课void teaching(){//...}
protected:string _name = "张三"; // 姓名int _age = 18; // 年龄string _address; // 地址string _tel; // 电话string _title; // 职称
};
int main()
{return 0;
}

下面我们公共的成员都放到Person类中,Studentteacher都继承Person,就可以复用这些成员,就不需要重复定义了,省去了很多麻烦。

class Person
{public:// 进入校园/图书馆/实验室刷二维码等身份认证void identity(){cout << "void identity()" <<_name<< endl;}protected:string _name = "张三"; // 姓名string _address; // 地址string _tel; // 电话int _age = 18; // 年龄
};
class Student : public Person
{public:// 学习void study(){// ...}protected:int _stuid; // 学号
};
class Teacher : public Person
{public:// 授课void teaching(){//...}protected:string title; // 职称
};
int main()
{Student s;Teacher t;s.identity();t.identity();return 0;
}

1.2 继承定义

1.2.1定义格式

下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类。

c3d983489a03ac0bdae6d775dfba678b


1.2.2继承关系和访问限定符

47ea67cfb9ff4950f86f4c95bb9cbf19

0317626f18957cb6d9c95392c20e6e6f


1.2.3继承基类成员访问方式的变化

类成员/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员在派生类中不可见在派生类中不可见在派生类中不可见

总结:

  1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
  2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
  3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private
  4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
  5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
// 实例演示三种继承关系下基类成员的各类型成员访问关系的变化
class Person
{public :void Print (){cout<<_name <<endl;}protected :string _name ; // 姓名private :int _age ; // 年龄
};
//class Student : protected Person
//class Student : private Person
class Student : public Person
{protected :int _stunum ; // 学号
};

1.3 继承类模板

namespace bit
{//template<class T>//class vector//{};// stack和vector的关系,既符合is-a,也符合has-atemplate<class T>class stack : public std::vector<T>{public:void push(const T& x){//push_back(x);直接这么写会报错// 基类是类模板时,需要指定一下类域,// 否则编译报错:error C3861: “push_back”: 找不到标识符// 因为stack<int>实例化时,也实例化vector<int>了// 但是模版是按需实例化,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();}return 0;
}

代码讲解:

  1. 继承类模板的语法问题
template<class T>
class stack : public std::vector<T>  // 继承标准库的vector
{// ...
};

这里展示了如何继承一个类模板。stack继承了vector,T是模板参数。

  1. 类模板成员函数中访问基类成员的问题
void push(const T& x)
{// 错误写法//push_back(x);  // 编译错误// 正确写法vector<T>::push_back(x);  // 需要指定类域// 或者使用this//this->push_back(x);// 或者使用using声明//using vector<T>::push_back;//push_back(x);
}
  1. 为什么需要指定类域的原因:
template<class T>
class stack : public std::vector<T>
{void push(const T& x){// 当编译器遇到push_back时,会按以下顺序查找:// 1. 在当前类stack中查找// 2. 在当前类的base class list中查找(依赖模板参数T)// 3. 在全局作用域查找// 由于push_back依赖于模板参数T// 编译器不会在第2步中查找// 这就是为什么需要显式指定vector<T>::}
};
  1. 几种解决方案:
template<class T>
class stack : public std::vector<T>
{
public:// 方案1:使用类域限定void push(const T& x){vector<T>::push_back(x);}// 方案2:使用this指针void push(const T& x){this->push_back(x);}// 方案3:使用using声明using vector<T>::push_back;void push(const T& x){push_back(x);}
};

关键点总结:

  1. 继承类模板时,需要完整指定模板参数
  2. 在派生类中访问基类模板的成员时需要指定类域
  3. 这是因为基类成员函数的查找规则涉及到模板的依赖性查找
  4. 可以使用类域限定、this指针或using声明来解决
  5. 这个例子展示了如何通过继承vector来实现一个stack,复用了vector的实现

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

  • 子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片或者切割(或者赋值兼容转换)。寓意把子类中父类那部分切来赋值过去。

  • 父类对象不能赋值给子类对象。

  • 父类的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用。但是必须是父类的指针是指向子类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTimeType Information)的dynamic_cast 来进行识别后进行安全转换。(ps:这个我们后面再讲解,这里先了解一下)

12c1a041e52d1113e958e7fdab4584f7

class Person
{
protected :string _name; // 姓名string _sex; // 性别int _age; // 年龄
};class Student : public Person
{
public :int _No ; // 学号
};int main()
{Student sobj ;// 赋值兼容转换,特殊处理//之前我们讲引用的时候,出现过权限的放大缩小。double d = 1.1;//int& i = d;这么写不可以,因为d赋值给i的时候会产生一个临时变量,临时变量具有常性,所以要加个const防止权限的放大const int& i = d;string s1 = "11111";//string& s2 = "11111";这么写不可以,因为"11111"赋值给s的时候会产生一个临时变量,临时变量具有常性,所以要加个const防止权限的放大const string& s2 = "11111";// 1.子类对象可以赋值给父类的指针/引用Person* pp = &sobj;Person& rp = sobj;//但是这里没有设计权限的放大,这就是赋值兼容转换//这里的pp实际上就是子类中切割出来的父类的那块区域的指针//这里的rp实际上就是子类中切割出来的父类的那块区域的别名// 子类对象可以赋值给父类的对象是通过调用后面会讲解的父类的拷贝构造完成的Person pobj = sobj;//2.父类对象不能赋值给子类对象,这里会编译报错//sobj = pobj;return 0;
}

3.继承中的作用域

3.1 隐藏规则:

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,也就是只访问子类成员,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
  4. 注意在实际中在继承体系里面最好不要定义同名的成员。
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person {
protected:string _name = "小李子";  // Person的成员变量int _num = 111;          // Person的身份证号
};class Student : public Person {
public:void Print() {cout << " 姓名:" << _name << endl;                // 直接访问基类的_namecout << " 身份证号:" << Person::_num << endl;     // 需要用Person::来访问被隐藏的基类_numcout << " 学号:" << _num << endl;                 // 直接访问派生类的_num}
protected:int _num = 999;          // Student的学号,与Person中的_num同名
};int main()
{Student s1;s1.Print();return 0;
};

3.2 考察继承作用域相关选择题

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

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

答案:B

重载要在同一个作用域里面

如果是成员函数的隐藏,只需要函数名相同就构成隐藏。

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

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

答案:A

b.fun();//无法运行,因为父类隐藏了func()

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();//无法运行,因为A::fun()被B::fun(int)隐藏了return 0;
};

问题原因:

  1. 在派生类B中定义的fun(int)会隐藏基类A中的所有同名函数
  2. 包括参数不同的版本
  3. 因此B类对象无法直接访问A::fun()

记住:

  1. 函数隐藏是编译时的特性
  2. 不同于虚函数的多态(运行时特性)
  3. 隐藏会影响所有同名函数,不管参数是否相同
  4. 使用作用域运算符或using声明可以访问被隐藏的函数
  5. 好的设计应该避免函数隐藏带来的问题

4.派生类的默认成员函数

4.1 4个常见默认成员函数

6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调派生类构造。
  6. 派生类对象析构清理先调用派生类析构再调基类的析构。
  7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

eeacbf7cfb710787afc98f67e35ae266

2fad0eb1c124137f3f5d1f6bcb9ab6bd

class Person
{public :Person(const char* name = "peter"): _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 :Student(const char* name, int num): Person(name ), _num(num ){cout<<"Student()" <<endl;}Student(const Student& s): Person(s), _num(s ._num){cout<<"Student(const Student& s)" <<endl ;}Student& operator = (const Student& s ){cout<<"Student& operator= (const Student& s)"<< endl;if (this != &s){Person::operator =(s);_num = s ._num;}return *this ;} ~Student(){cout<<"~Student()" <<endl;}protected :int _num ; //学号
};
int main()
{Student s1 ("jack", 18);Student s2 (s1);Student s3 ("rose", 17);s1 = s3 ;return 0;
}

以下是代码中,基类 Person派生类 Student 的构造、析构、拷贝构造,以及赋值运算符的行为分析和详细讲解。


代码运行结果分析

运行程序后,打印的输出结果为:

Person()
Student()
Person(const Person& p)
Student(const Student& s)
Person()
Student()
Person operator=(const Person& p)
Student& operator= (const Student& s)
~Student()
~Person()
~Student()
~Person()
~Student()
~Person()

代码解析

1. 构造函数调用分析
构造 s1
Student s1("jack", 18);
  • 调用顺序
    1. 首先调用 Person 类的构造函数 Person(const char* name),因为 Student 类继承自 Person,构造派生类前必须先构造基类。
      • 输出:Person()
    2. 然后执行 Student 类的构造函数 Student(const char* name, int num),初始化 _num 成员变量。
      • 输出:Student()
拷贝构造 s2
Student s2(s1);
  • 调用顺序
    1. 首先调用基类 Person 的拷贝构造函数 Person(const Person& p),因为 Student 继承自 Person,需要先拷贝构造基类部分。
      • 输出:Person(const Person& p)
    2. 然后调用 Student 的拷贝构造函数 Student(const Student& s),完成派生类部分的拷贝(包括 _num 的值)。
      • 输出:Student(const Student& s)
构造 s3
Student s3("rose", 17);
  • 调用顺序
    1. 首先调用基类 Person 的构造函数 Person(const char* name),构造基类部分。
      • 输出:Person()
    2. 然后调用 Student 的构造函数 Student(const char* name, int num),初始化派生类部分。
      • 输出:Student()

2. 赋值运算符调用分析
赋值 s1 = s3
s1 = s3;
  • 调用顺序
    1. 首先调用基类 Person 的赋值运算符 Person& operator=(const Person& p),完成基类部分的赋值。
      • 输出:Person operator=(const Person& p)
    2. 然后调用派生类 Student 的赋值运算符 Student& operator=(const Student& s),完成派生类部分的赋值(包括 _num 的赋值)。
      • 输出:Student& operator= (const Student& s)

3. 析构函数调用分析
程序结束时析构顺序:
  • 对象的析构顺序与构造顺序相反,析构派生类前必须先析构基类。

  • 析构过程

    1. 首先析构 s3
      • 调用 Student 的析构函数。
        • 输出:~Student()
      • 调用 Person 的析构函数。
        • 输出:~Person()
    2. 然后析构 s2
      • 调用 Student 的析构函数。
        • 输出:~Student()
      • 调用 Person 的析构函数。
        • 输出:~Person()
    3. 最后析构 s1
      • 调用 Student 的析构函数。
        • 输出:~Student()
      • 调用 Person 的析构函数。
        • 输出:~Person()

基类和派生类的构造、拷贝构造、赋值运算符、析构总结

1. 构造函数
  • 基类构造函数会在派生类构造函数之前调用。派生类需要通过基类构造函数初始化基类部分的数据成员。
  • 如果派生类的构造函数没有显式指定基类构造函数,则会默认调用基类的无参构造函数(如果存在)。
  • 在这个例子中:
    • Student(const char* name, int num) 显式调用了基类的构造函数 Person(name)
2. 拷贝构造函数
  • 基类的拷贝构造函数会在派生类的拷贝构造函数之前调用。
  • 拷贝构造函数需要显式调用基类的拷贝构造函数来拷贝基类部分的数据成员。
  • 在这个例子中:
    • Student(const Student& s) 显式调用了基类的拷贝构造函数 Person(s)
3. 赋值运算符
  • 基类的赋值运算符会在派生类的赋值运算符之前调用。
  • 派生类在实现赋值运算符时,通常需要显式调用基类的赋值运算符来完成基类部分的赋值。
  • 在这个例子中:
    • Student& operator=(const Student& s) 显式调用了基类的赋值运算符 Person::operator=(s)
4. 析构函数
  • 析构函数调用顺序与构造函数相反。
  • 派生类的析构函数会在基类析构函数之前调用
  • 在这个例子中:
    • Student 的析构函数会先执行,然后调用 Person 的析构函数。

总结调用顺序

构造顺序
  1. 先构造基类部分(调用基类的构造函数)。
  2. 再构造派生类部分(调用派生类的构造函数)。
拷贝构造顺序
  1. 先调用基类的拷贝构造函数。
  2. 再调用派生类的拷贝构造函数。
赋值运算符顺序
  1. 先调用基类的赋值运算符。
  2. 再调用派生类的赋值运算符。
析构顺序
  1. 先析构派生类部分(调用派生类的析构函数)。
  2. 再析构基类部分(调用基类的析构函数)。

4.2 实现一个不能被继承的类

方法1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。

方法2:C++11新增了一个final关键字,final修改基类,派生类就不能继承了。

// C++11的方法
class Base final
{
public:void func5() { cout << "Base::func5" << endl; }
protected:int a = 1;
private:// C++98的方法/*Base(){}*/
};class Derive :public Base //这里的继承会报错
{void func4() { cout << "Derive::func4" << endl; }
protected:int b = 2;
};int main()
{Base b;Derive d;return 0;
}

5.继承与友元

友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员

class Student;
class Person
{
public:friend void Display(const Person& p, const Student& s);
protected:string _name; // 姓名
};class Student : public Person
{
protected:int _stuNum; // 学号
};void Display(const Person& p, const Student& s)
{cout << p._name << endl;cout << s._stuNum << endl;
}int main()
{Person p;Student s;// 编译报错:error C2248: “Student::_stuNum”: 无法访问 protected 成员// 解决方案:Display也变成Student 的友元即可Display(p, s);return 0;
}

代码解释与问题分析

我们来分步分析这段代码以及与“友元关系不能继承”的联系。

1. 代码结构及友元关系
  • 在类 Person 中,Display 函数被声明为友元函数。这意味着 Display 函数可以访问 Person 类的私有或受保护成员(如 _name)。
  • Student 通过公有继承自 Person,但它有自己的受保护成员 _stuNum
2. 友元关系的作用范围
  • 友元关系 只作用于声明它的类本身。因此:
    • DisplayPerson 的友元,所以可以访问 Person 的受保护成员 _name
    • 但是,Display 并不是 Student 的友元,因此无法访问 Student 的受保护成员 _stuNum
3. 编译报错原因
void Display(const Person& p, const Student& s)
{cout << p._name << endl;    // 可以访问,因为 Display 是 Person 的友元cout << s._stuNum << endl; // 报错,因为 Display 不是 Student 的友元
}
  • p._namePerson 的受保护成员,DisplayPerson 的友元,可以访问。
  • s._stuNumStudent 的受保护成员,但 Display 不是 Student 的友元,因此无法访问,导致编译报错。
4. “友元关系不能继承”的含义
  • 友元关系是类之间的一种特权关系,它不能通过继承传递。
    • 即使 Student 继承自 PersonDisplay 函数作为 Person 的友元,并不会自动成为 Student 的友元。
    • 因此,Display 无法访问 Student 类的私有或受保护成员。

解决方法

为了让 Display 函数能够访问 Student 类的受保护成员 _stuNum,需要将 Display 函数显式声明为 Student 的友元:

class Student : public Person
{protected:int _stuNum; // 学号// 显式声明 Display 为友元friend void Display(const Person& p, const Student& s);
};

为什么友元关系不能继承?

这是 C++ 的设计规则,按照 封装性原则访问权限控制 的逻辑:

  • 友元关系是类设计者赋予的特权,而不是类继承链上的默认权限。
  • 如果友元关系能继承,那么继承链上的子类可能会暴露更多的内部实现细节,从而破坏封装性。
  • 通过这种设计,C++ 强制开发者显式声明友元关系,避免意外的权限泄露。

总结

这段代码报错的原因在于“友元关系不能继承”:

  • DisplayPerson 的友元,可以访问 Person 的受保护成员 _name
  • Display 不是 Student 的友元,不能访问 Student 的受保护成员 _stuNum

解决方法是将 Display 显式声明为 Student 的友元,从而允许它访问 _stuNum


6. 继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。

class Person
{
public:string _name;static int _count;
};int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum;
};int main()
{Person p;Student s;// 这里的运行结果可以看到非静态成员_name的地址是不一样的// 说明派生类继承下来了,父派生类对象各有一份cout << &p._name << endl;cout << &s._name << endl;// 这里的运行结果可以看到静态成员_count的地址是一样的// 说明派生类和基类共用同一份静态成员cout << &p._count << endl;cout << &s._count << endl;// 公有的情况下,父派生类指定类域都可以访问静态成员cout << Person::_count << endl;cout << Student::_count << endl;return 0;
}

7.复杂的菱形继承及菱形虚拟继承

7.1 继承模型

单继承:一个派生类只有一个直接基类时称这个继承关系为单继承

多继承:一个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员在放到最后面。

菱形继承:菱形继承是多继承的一种特殊情况。菱形继承的问题,从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中Person成员会有两份。支持多继承就一定会有菱形继承,像Java就直接不支持多继承,规避掉了这里的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

df895d13374f21e3b0502a6f0e75b26a

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

f14184225b44d51de60e8f5650408d4d

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

2b8f014f2cb5d51a232c7acf815feb08

菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份。

41fe40779f230190735c19b9b497d5d5

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()
{// 编译报错:error C2385: 对“_name”的访问不明确Assistant a;a._name = "peter";// 需要显示指定访问哪个基类的成员可以解决二义性问题,但是数据冗余问题无法解决a.Student::_name = "xxx";a.Teacher::_name = "yyy";return 0;
}

7.2 虚继承

很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂,性能也会有一些损失,所以最好不要设计出菱形继承。多继承可以认为是C++的缺陷之一,后来的一些编程语言都没有多继承,如Java

class Person
{
public:string _name; // 姓名/*int _tel;int _age;string _gender;string _address;*/// ...
};// 使用虚继承Person类
class Student : virtual public Person
{
protected:int _num; //学号
};// 使用虚继承Person类
class Teacher : virtual public Person
{
protected:int _id; // 职工编号
};// 教授助理
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修课程
};int main()
{// 使用虚继承,可以解决数据冗余和二义性Assistant a;a._name = "peter";return 0;
}

让我们详细解析一下上面的代码,特别是其中关于虚继承的部分。

虚继承在菱形继承的腰部,也就是出现数据二义性的地方。


类层次结构概述

首先,让我们看一下类之间的关系:

  1. Person 类

    class Person
    {
    public:string _name; // 姓名// 其他成员变量如电话、年龄、性别、地址等被注释掉了
    };
    
    • 这是一个基类,包含一些基本的个人信息,如姓名等。
  2. Student 类(虚继承自 Person)

    class Student : virtual public Person
    {
    protected:int _num; // 学号
    };
    
    • Student 类通过 虚继承 方式继承自 Person 类。
    • 这意味着 Student 类不会直接包含 Person 的实例,而是与后续的继承关系共享同一个 Person 实例。
  3. Teacher 类(虚继承自 Person)

    class Teacher : virtual public Person
    {
    protected:int _id; // 职工编号
    };
    
    • 同样,Teacher 类也通过 虚继承 方式继承自 Person 类。
    • 这也意味着 Teacher 类不会直接包含 Person 的实例,而是与 Student 类共享同一个 Person 实例。
  4. Assistant 类(多重继承自 Student 和 Teacher)

    class Assistant : public Student, public Teacher
    {
    protected:string _majorCourse; // 主修课程
    };
    
    • Assistant 类通过 多重继承 同时继承自 StudentTeacher 类。
    • 由于 StudentTeacher 都是虚继承自 Person,因此 Assistant 类中只包含一个 Person 实例,避免了 菱形继承问题

菱形继承问题与虚继承的作用

菱形继承问题

假设不使用虚继承,类层次结构如下:

      Person/    \Student  Teacher\    /Assistant

在这种继承方式下,Assistant 类会通过 StudentTeacher 继承两次 Person,导致:

  • 数据冗余Assistant 类中会包含两个 Person 的实例。
  • 二义性:当访问 Person 的成员变量(如 _name)时,会出现二义性,因为编译器不知道要访问哪一个 Person 实例。

例如:

Assistant a;
a._name = "peter"; // 编译错误:_name 不明确

编译器会报错,因为 Assistant 中有两个 _name,一个来自 StudentPerson,一个来自 TeacherPerson

虚继承的作用

通过使用 虚继承,类层次结构变为:

      Person/    \Student  Teacher\    /Assistant

在这种继承方式下,StudentTeacher 都是虚继承自 Person,因此:

  • 共享基类实例Assistant 类中只包含一个 Person 的实例。
  • 避免二义性:当访问 Person 的成员变量时,不会出现二义性,因为只有一个 Person 实例。

例如:

Assistant a;
a._name = "peter"; // 正确

这样,Assistant 类中只有一个 _name,可以正常访问和赋值。

代码解析

  1. 虚继承的使用

    • StudentTeacher 都通过 virtual public Person 虚继承自 Person
    • 这确保了 Assistant 类中只有一个 Person 实例。
  2. 数据成员的访问

    • main 函数中,创建 Assistant 对象 a 后,可以直接访问 _name,因为 Assistant 类中只有一个 _name,来自共享的 Person 实例。
  3. 构造函数的调用

    • 需要注意的是,当使用虚继承时,最底层的派生类(如 Assistant)负责调用基类(Person)的构造函数。
    • 因此,Assistant 的构造函数应调用 Person 的构造函数。例如:
    class Assistant : public Student, public Teacher
    {
    protected:string _majorCourse; // 主修课程
    public:Assistant(string name, int num, int id, string majorCourse): Person(name), Student(num), Teacher(id), _majorCourse(majorCourse) {}
    };
    
    • 这样可以确保 Person 的成员变量被正确初始化。

总结

  • 虚继承 解决了多重继承中可能出现的 菱形继承问题,避免了数据冗余和二义性。
  • 在你的代码中,StudentTeacher 通过虚继承共享同一个 Person 实例,使得 Assistant 类中只有一个 Person 实例。
  • 这允许你在 Assistant 对象中直接访问 Person 的成员变量,如 _name,而不会产生冲突。

通过合理使用虚继承,可以设计出更为清晰和高效的类层次结构。


我们可以设计出多继承,但是不建议设计出菱形继承,因为菱形虚拟继承以后,无论是使用还是底层都会复杂很多。当然有多继承语法支持,就一定存在会设计出菱形继承,像Java是不支持多继承的,就避开了菱形继承。

class Person
{
public:Person(const char* name):_name(name){}string _name; // 姓名
};
class Student : virtual public Person
{
public:Student(const char* name, int num):Person(name),_num(num){}
protected:int _num; //学号
};
class Teacher : virtual public Person
{
public:Teacher(const char* name, int id):Person(name), _id(id){}
protected:int _id; // 职工编号
};
// 不要去玩菱形继承
class Assistant : public Student, public Teacher
{
public:Assistant(const char* name1, const char* name2, const char* name3):Person(name3),Student(name1, 1),Teacher(name2, 2){}
protected:string _majorCourse; // 主修课程
};
int main()
{// 思考一下这里a对象中_name是"张三", "李四", "王五"中的哪一个?Assistant a("张三", "李四", "王五");return 0;
}

构造函数的调用顺序

当创建 Assistant 对象 a 时,构造函数的调用顺序如下:

  1. 初始化基类 Person
    • 由于 StudentTeacher 都是虚继承自 PersonAssistant 的构造函数负责初始化 Person
    • 调用 Person(name3),即 Person("王五"),因此 _name 被赋值为 "王五"
  2. 初始化基类 Student
    • 调用 Student(name1, 1),即 Student("张三", 1)
    • 但由于 Student 是虚继承,Person 已经被 Assistant 的构造函数初始化,所以这一步不会再次初始化 Person
  3. 初始化基类 Teacher
    • 调用 Teacher(name2, 2),即 Teacher("李四", 2)
    • 同样,由于 Teacher 是虚继承,Person 已经被 Assistant 的构造函数初始化,所以这一步不会再次初始化 Person

最终结果

因此,a 对象中的 _name 最终是 "王五",因为 Assistant 的构造函数中调用了 Person(name3),即 Person("王五")


7.3 多继承中指针偏移问题?下面说法正确的是( )

A:p1 == p2 == p3

B:p1 < p2 < p3

C:p1 == p3 != p2

D:p1 != p2 != p3

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;
}

问题解析

在 C++ 中,当一个类通过多继承继承多个基类时,每个基类的子对象在派生类中会占用不同的内存地址。这导致了指针偏移问题。我们通过代码逐步分析。


代码结构

class Base1 { public: int _b1; };  // 定义基类 Base1
class Base2 { public: int _b2; };  // 定义基类 Base2
class Derive : public Base1, public Base2 { public: int _d; };  // Derive 类多继承自 Base1 和 Base2int main()
{Derive d;          // 定义派生类对象 dBase1* p1 = &d;    // 将 d 转换为指向 Base1 的指针Base2* p2 = &d;    // 将 d 转换为指向 Base2 的指针Derive* p3 = &d;   // 将 d 转换为指向 Derive 的指针return 0;
}

多继承时的内存布局

  1. 内存布局示意图
    假设 Derive 类对象的内存布局如下:

    [Base1::_b1] [Base2::_b2] [Derive::_d]
    
    • Base1 子对象占用一部分内存。
    • Base2 子对象占用另一部分内存。
    • Derive 自己的数据成员占用剩余的内存。
  2. 指针偏移

    • p1 指向 Base1 子对象的起始地址。
    • p2 指向 Base2 子对象的起始地址(相对于 Base1 子对象,偏移了 Base1 的大小)。
    • p3 指向整个 Derive 对象的起始地址。

指针值比较

根据指针的偏移,指针值的关系如下:

  • p1p3
    p1 指向 Base1 子对象的起始地址,而 p3 指向整个 Derive 对象的起始地址。在多继承情况下,Base1 子对象和 Derive 对象的起始地址是相同的,所以 p1 == p3

  • p2p3
    p2 指向 Base2 子对象的起始地址,而 Base2 子对象位于 Base1 子对象之后,因此 p2 > p3

  • p1p2
    因为 p1 指向的是 Base1 子对象的起始地址,而 p2 指向的是 Base2 子对象的起始地址,且 Base2 的内存布局在 Base1 之后,因此 p1 < p2


选项分析

  • A: p1 == p2 == p3
    错误。p1p2 指向不同的基类子对象,地址不同。

  • B: p1 < p2 < p3
    错误。p1 < p2 是对的,但 p1 == p3,所以 p1 不小于 p3

  • C: p1 == p3 != p2
    正确。p1p3 的地址相同,p2 的地址不同于二者。

  • D: p1 != p2 != p3
    错误。p1 == p3,所以 p1 != p3 是错误的。


正确答案

C: p1 == p3 != p2


7.4 IO库中的菱形虚拟继承

00edb13108b598057f2310163992c318

template<class CharT, class Traits = std::char_traits<CharT>>class basic_ostream : virtual public std::basic_ios<CharT, Traits>{};
template<class CharT, class Traits = std::char_traits<CharT>>class basic_istream : virtual public std::basic_ios<CharT, Traits>{};

8.继承的总结和反思

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
  2. 多继承可以认为是C++的缺陷之一,很多后来的语言都没有多继承,如Java
  3. 继承和组合
  • public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。

  • 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。

  • 优先使用对象组合,而不是类继承 。

  • 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。

  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。

  • 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

// Tire(轮胎)和Car(车)更符合has-a的关系
class Tire {protected:string _brand = "Michelin"; // 品牌size_t _size = 17; // 尺寸
};
class Car {protected:string _colour = "白色"; // 颜色string _num = "陕ABIT00"; // 车牌号Tire _t1; // 轮胎Tire _t2; // 轮胎Tire _t3; // 轮胎Tire _t4; // 轮胎
};
class BMW : public Car {public:void Drive() { cout << "好开-操控" << endl; }
};
// Car和BMW/Benz更符合is-a的关系
class Benz : public Car {public:void Drive() { cout << "好坐-舒适" << endl; }
};
template<class T>class vector{};
// stack和vector的关系,既符合is-a,也符合has-a
template<class T>class stack : public vector<T>{};
template<class T>class stack{public:vector<T> _v;};
int main()
{return 0;
}

9.笔试面试题

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

一个类通过多条路径继承自同一个基类,导致基类在最终的派生类中出现多次。

菱形继承有数据冗余和二义性的问题。

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

菱形虚拟继承 是通过虚继承机制解决菱形继承问题的方法。

菱形虚拟继承通过虚继承机制,使得最终的派生类中只包含基类的一个实例,从而避免了数据冗余和二义性问题。

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

继承与组合的区别

特性继承 (Inheritance)组合 (Composition)
关系类型is-a 关系has-a 关系
耦合度高耦合低耦合
灵活性较不灵活,类层次结构固定灵活,可以动态组合不同的组件
复用方式通过继承复用父类的代码通过组合复用其他类的功能
多态性支持多态不直接支持多态,但可以通过接口实现
封装性继承可能破坏封装性更好的封装性
扩展性扩展性较差,修改父类会影响所有子类扩展性好,可以轻松添加或替换组件

何时使用继承?

  1. 表示“is-a”关系
    • 当一个类确实是另一个类的一种类型时。例如,DogAnimal 的一种,SquareShape 的一种。
  2. 需要实现多态性
    • 当需要通过基类引用来操作不同子类对象时,使用继承可以实现多态。
  3. 需要复用基类的代码
    • 当子类需要复用基类的属性和方法,并且这些方法在子类中不需要进行重大修改时。

何时使用组合?

  1. 表示“has-a”关系
    • 当一个类包含另一个类的实例作为其组成部分时。例如,Car 包含一个 EngineStudent 包含一个 Address
  2. 需要更大的灵活性
    • 当需要动态组合不同的组件,或者组件之间的关系是动态变化的时,组合更为合适。
  3. 需要更好的封装性
    • 当需要更好地封装各个组件的功能,避免继承带来的耦合时,组合是更好的选择。
  4. 不希望破坏封装性
    • 当不希望子类继承父类的所有方法和属性,或者不希望子类修改父类的行为时,组合可以提供更好的封装性。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/pingmian/66460.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

PCL 分段线性函数

文章目录 一、简介二、实现代码三、实现效果参考资料一、简介 假设我们有一个分段线性函数,并且我们希望在某个区间内对这个函数进行均匀采样,生成一系列的点。相对通用一些的思路就是对这个函数进行参数化,方法有很多,这在其他的博客中也有提到,不过PCL也为我们提供了一种…

PostgreSQL学习笔记(二):PostgreSQL基本操作

PostgreSQL 是一个功能强大的开源关系型数据库管理系统 (RDBMS)&#xff0c;支持标准的 SQL 语法&#xff0c;并扩展了许多功能强大的操作语法. 数据类型 数值类型 数据类型描述存储大小示例值SMALLINT小范围整数&#xff0c;范围&#xff1a;-32,768 到 32,7672 字节-123INTE…

html + css 顶部滚动通知栏示例

前言 在现代网页设计中&#xff0c;一个吸引人的顶部滚动通知栏不仅能够有效传达重要信息&#xff0c;还能提升用户体验。通过使用HTML和CSS&#xff0c;我们可以创建既美观又功能强大的组件&#xff0c;这些组件可以在不影响网站整体性能的情况下提供实时更新或紧急通知。 本…

Bi-Encoder vs. Cross-Encoder

Bi-Encoder vs. Cross-Encoder Bi-Encoder 和 Cross-Encoder 是两种常见的模型架构&#xff0c;主要用于自然语言处理&#xff08;NLP&#xff09;中的文本匹配、问答、检索等任务。它们的主要区别在于如何处理输入文本以及计算相似度的方式。 1. Bi-Encoder&#xff08;双编…

excel如何将小数转换为百分比

在 Excel 中&#xff0c;将百分比格式的数字取消“%”并恢复为小数&#xff0c;可以按以下几种方法操作&#xff1a; 方法 1&#xff1a;直接更改格式 选中需要取消百分比格式的单元格。点击 **“开始”**选项卡中的 **“数字”**组。将单元格格式从“百分比”改为“常规”或“…

PyQt5 UI混合开发,控件的提升

PromoteLabelTest.py 提升的类 import sys from PyQt5.QtWidgets import QApplication, QWidget,QVBoxLayout,QTextEdit,QPushButton,QHBoxLayout,QFileDialog,QLabelclass PromoteLabel(QLabel):def __init__(self,parent None):super().__init__(parent)self.setText("…

A/B实验之置信检验(一):如何避免误判 (I类) 和漏报 (II类)

假设检验的依据&#xff1a;如何避免误判和漏报 A/B实验系列相关文章&#xff08;置顶&#xff09; 1.A/B实验之置信检验&#xff08;一&#xff09;&#xff1a;如何避免误判和漏报 2.A/B实验之置信检验&#xff08;二&#xff09;&#xff1a;置信检验精要 引言 在数据驱动…

Spring Boot整合Minio实现文件上传

Spring Boot整合Minio后&#xff0c;前端的文件上传有两种方式&#xff1a; 文件上传到后端&#xff0c;由后端保存到Minio 这种方式好处是完全由后端集中管理&#xff0c;可以很好的做到、身份验证、权限控制、文件与处理等&#xff0c;并且可以做一些额外的业务逻辑&#xf…

金融租赁系统助力行业转型与升级的创新之路

内容概要 在当今快速发展的商业环境中&#xff0c;金融租赁系统逐渐成为企业转型与升级的重要工具。它通过整合大数据与自动化技术&#xff0c;不仅提升了风险管理的准确性&#xff0c;还加快了审批流程&#xff0c;让企业在激烈的市场竞争中游刃有余。这个系统就像是一位聪明…

Postman接口测试02|接口用例设计

目录 六、接口用例设计 1、接口测试的测试点&#xff08;测试维度&#xff09; 1️⃣功能测试 2️⃣性能测试 3️⃣安全测试 2、设计方法与思路 3、单接口测试用例 4、业务场景测试用例 1️⃣分析测试点 2️⃣添加员工 3️⃣查询员工、修改员工 4️⃣删除员工、查询…

Python标准库之SQLite3

包含了连接数据库、处理数据、控制数据、自定义输出格式及处理异常的各种方法。 官方文档&#xff1a;sqlite3 --- SQLite 数据库的 DB-API 2.0 接口 — Python 3.13.1 文档 官方文档SQLite对应版本&#xff1a;3.13.1 SQLite主页&#xff1a;SQLite Home Page SQL语法教程&a…

HTML 迷宫游戏

HTML 迷宫游戏 相关资源文件已经打包成压缩文件&#xff0c;可双击index.html直接运行程序&#xff0c;且文章末尾已附上相关源码&#xff0c;以供大家学习交流&#xff0c;博主主页还有更多Python相关程序案例&#xff0c;秉着开源精神的想法&#xff0c;望大家喜欢&#xff0…

【Linux】上传、下载、压缩、解压

一、上传、下载 1.1 FinalShell文件系统 我们可以通过FinalShell工具&#xff0c;方便的和虚拟机进行数据交换。 在FinalShell软件的下方窗体中&#xff0c;提供了Linux的文件系统视图&#xff0c;可以方便的&#xff1a; 浏览文件系统&#xff0c;找到合适的文件&#xff0…

以柔资讯-D-Security终端文件保护系统 logFileName 任意文件读取漏洞复现

0x01 产品简介 D-Security终端文件保护系统是一套专注于企业文件管理效率与安全的解决方案,统对文件进行全文加密,而非仅在文件表头或特定部分进行加密,从而大大提高了文件的安全性,降低了被破解的风险。D-Security终端文件保护系统是被政府和国安局等情报单位唯一认定的安…

关于重构一点简单想法

关于重构一点简单想法 当前工作的组内&#xff0c;由于业务开启的时间正好处于集团php-》go技术栈全面迁移的时间点&#xff0c;组内语言技术栈存在&#xff1a;php、go两套。 因此需求开发过程中通常要考虑两套技术栈的逻辑&#xff0c;一些基础的逻辑也没有办法复用。 在这…

新的 WhoisXML API 白皮书重点分析了主要 gTLD 和 ccTLD 注册趋势

任何寻求建立在线存在的人似乎都可以选择无限多的互联网域名注册服务。然而&#xff0c;问题依然存在&#xff1a;哪些提供商更受注册者青睐&#xff1f;WhoisXML API 的研究团队通过分析主要 gTLD&#xff08;通用顶级域&#xff09;和 ccTLD&#xff08;国家或地区顶级域&…

zabbix(二)

zabbix 1.zabbix监控的模式 主动和被动模式都是对于客户端而言 主动模式 客户端主动将数据发送给server或者是代理服务器 被动模式 服务端或者proxy(代理服务器)主动找客户端索要数据------------>默认方式 被动模式在成规模的集群&#xff08;成百上千台的&#xff09;&…

Kubernetes Ingress:流量管理的利器

在 Kubernetes 集群中&#xff0c;服务之间的通信和外部流量的引入通常是至关重要的。虽然 NodePort 和 LoadBalancer 是最常见的解决方案&#xff0c;但当集群内部的服务逐渐增多时&#xff0c;管理不同服务的流量变得复杂。这个时候&#xff0c;Ingress 作为一种强大的流量管…

2012mfc,几种串

串,即是由符组成的串,在标准C,标准C,MFC中串这一功能的实现是不相同的,C完全兼容了C. 1.标准C中的串 在标准C中没有串数据类型,C中的串是有符类型的符数组或符类型的符指针来实现的.如: char name[26]"This is a Cstyle string"; //或char *name"This is a…

任务调度之Quartz(二):Quartz体系结构

1、Quartz 体系结构 由上一篇的Quartz基本使用可以发现&#xff0c;Quartz 主要包含一下几种角色&#xff1a; 1&#xff09;Job&#xff1a;也可以认为是JobDtetail&#xff0c;表示具体的调度任务 2&#xff09;Trigger&#xff1a;触发器&#xff0c;用于定义任务Job出发执行…