文章目录
- 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
)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
下面我们看到没有继承之前我们设计了两个类Student
和Teacher
,Student
和Teacher
都有姓名/地址/电话/年龄等成员变量,都有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
类中,Student
和teacher
都继承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
是子类,也称作派生类。
1.2.2继承关系和访问限定符
1.2.3继承基类成员访问方式的变化
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public 成员 | 派生类的public 成员 | 派生类的protected 成员 | 派生类的private 成员 |
基类的protected 成员 | 派生类的protected 成员 | 派生类的protected 成员 | 派生类的private 成员 |
基类的private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
总结:
- 基类
private
成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。 - 基类
private
成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected
。可以看出保护成员限定符是因继承才出现的。 - 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式
== Min
(成员在基类的访问限定符,继承方式),public
>protected
>private
。 - 使用关键字
class
时默认的继承方式是private
,使用struct
时默认的继承方式是public
,不过最好显示的写出继承方式。 - 在实际运用中一般使用都是
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-a
template<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;
}
代码讲解:
- 继承类模板的语法问题
template<class T>
class stack : public std::vector<T> // 继承标准库的vector
{
// ...
};
这里展示了如何继承一个类模板。stack继承了vector,T是模板参数。
- 类模板成员函数中访问基类成员的问题
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);
}
- 为什么需要指定类域的原因:
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>::
}
};
- 几种解决方案:
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);
}
};
关键点总结:
- 继承类模板时,需要完整指定模板参数
- 在派生类中访问基类模板的成员时需要指定类域
- 这是因为基类成员函数的查找规则涉及到模板的依赖性查找
- 可以使用类域限定、
this
指针或using
声明来解决- 这个例子展示了如何通过继承vector来实现一个stack,复用了vector的实现
2.基类和派生类对象赋值转换
子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片或者切割(或者赋值兼容转换)。寓意把子类中父类那部分切来赋值过去。
父类对象不能赋值给子类对象。
父类的指针或者引用可以通过强制类型转换赋值给子类的指针或者引用。但是必须是父类的指针是指向子类对象时才是安全的。这里基类如果是多态类型,可以使用
RTTI
(RunTimeType Information
)的dynamic_cast
来进行识别后进行安全转换。(ps:这个我们后面再讲解,这里先了解一下)
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 隐藏规则:
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,也就是只访问子类成员,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类
::
基类成员显示访问)- 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person {
protected:
string _name = "小李子"; // Person的成员变量
int _num = 111; // Person的身份证号
};
class Student : public Person {
public:
void Print() {
cout << " 姓名:" << _name << endl; // 直接访问基类的_name
cout << " 身份证号:" << Person::_num << endl; // 需要用Person::来访问被隐藏的基类_num
cout << " 学号:" << _num << endl; // 直接访问派生类的_num
}
protected:
int _num = 999; // Student的学号,与Person中的_num同名
};
int main()
{
Student s1;
s1.Print();
return 0;
};
3.2 考察继承作用域相关选择题
1.
A
和B
类中的两个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;
};
问题原因:
- 在派生类
B
中定义的fun(int)
会隐藏基类A
中的所有同名函数- 包括参数不同的版本
- 因此
B
类对象无法直接访问A::fun()
记住:
- 函数隐藏是编译时的特性
- 不同于虚函数的多态(运行时特性)
- 隐藏会影响所有同名函数,不管参数是否相同
- 使用作用域运算符或
using
声明可以访问被隐藏的函数- 好的设计应该避免函数隐藏带来的问题
4.派生类的默认成员函数
4.1 4个常见默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 派生类的
operator=
必须要调用基类的operator=
完成基类的复制。- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
- 派生类对象初始化先调用基类构造再调派生类构造。
- 派生类对象析构清理先调用派生类析构再调基类的析构。
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成
destrutor()
,所以父类析构函数不加virtual
的情况下,子类析构函数和父类析构函数构成隐藏关系。
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);
- 调用顺序:
- 首先调用
Person
类的构造函数Person(const char* name)
,因为Student
类继承自Person
,构造派生类前必须先构造基类。- 输出:
Person()
- 输出:
- 然后执行
Student
类的构造函数Student(const char* name, int num)
,初始化_num
成员变量。- 输出:
Student()
- 输出:
- 首先调用
拷贝构造 s2
:
Student s2(s1);
- 调用顺序:
- 首先调用基类
Person
的拷贝构造函数Person(const Person& p)
,因为Student
继承自Person
,需要先拷贝构造基类部分。- 输出:
Person(const Person& p)
- 输出:
- 然后调用
Student
的拷贝构造函数Student(const Student& s)
,完成派生类部分的拷贝(包括_num
的值)。- 输出:
Student(const Student& s)
- 输出:
- 首先调用基类
构造 s3
:
Student s3("rose", 17);
- 调用顺序:
- 首先调用基类
Person
的构造函数Person(const char* name)
,构造基类部分。- 输出:
Person()
- 输出:
- 然后调用
Student
的构造函数Student(const char* name, int num)
,初始化派生类部分。- 输出:
Student()
- 输出:
- 首先调用基类
2. 赋值运算符调用分析
赋值 s1 = s3
:
s1 = s3;
- 调用顺序:
- 首先调用基类
Person
的赋值运算符Person& operator=(const Person& p)
,完成基类部分的赋值。- 输出:
Person operator=(const Person& p)
- 输出:
- 然后调用派生类
Student
的赋值运算符Student& operator=(const Student& s)
,完成派生类部分的赋值(包括_num
的赋值)。- 输出:
Student& operator= (const Student& s)
- 输出:
- 首先调用基类
3. 析构函数调用分析
程序结束时析构顺序:
-
对象的析构顺序与构造顺序相反,析构派生类前必须先析构基类。
-
析构过程:
- 首先析构
s3
:- 调用
Student
的析构函数。- 输出:
~Student()
- 输出:
- 调用
Person
的析构函数。- 输出:
~Person()
- 输出:
- 调用
- 然后析构
s2
:- 调用
Student
的析构函数。- 输出:
~Student()
- 输出:
- 调用
Person
的析构函数。- 输出:
~Person()
- 输出:
- 调用
- 最后析构
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
的析构函数。
总结调用顺序
构造顺序
- 先构造基类部分(调用基类的构造函数)。
- 再构造派生类部分(调用派生类的构造函数)。
拷贝构造顺序
- 先调用基类的拷贝构造函数。
- 再调用派生类的拷贝构造函数。
赋值运算符顺序
- 先调用基类的赋值运算符。
- 再调用派生类的赋值运算符。
析构顺序
- 先析构派生类部分(调用派生类的析构函数)。
- 再析构基类部分(调用基类的析构函数)。
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. 友元关系的作用范围
- 友元关系 只作用于声明它的类本身。因此:
Display
是Person
的友元,所以可以访问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._name
是Person
的受保护成员,Display
是Person
的友元,可以访问。s._stuNum
是Student
的受保护成员,但Display
不是Student
的友元,因此无法访问,导致编译报错。
4. “友元关系不能继承”的含义
- 友元关系是类之间的一种特权关系,它不能通过继承传递。
- 即使
Student
继承自Person
,Display
函数作为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++ 强制开发者显式声明友元关系,避免意外的权限泄露。
总结
这段代码报错的原因在于“友元关系不能继承”:
Display
是Person
的友元,可以访问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
就直接不支持多继承,规避掉了这里的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant
的对象中Person
成员会有两份。
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;
}
让我们详细解析一下上面的代码,特别是其中关于虚继承的部分。
虚继承在菱形继承的腰部,也就是出现数据二义性的地方。
类层次结构概述
首先,让我们看一下类之间的关系:
-
Person 类
class Person { public: string _name; // 姓名 // 其他成员变量如电话、年龄、性别、地址等被注释掉了 };
- 这是一个基类,包含一些基本的个人信息,如姓名等。
-
Student 类(虚继承自 Person)
class Student : virtual public Person { protected: int _num; // 学号 };
Student
类通过 虚继承 方式继承自Person
类。- 这意味着
Student
类不会直接包含Person
的实例,而是与后续的继承关系共享同一个Person
实例。
-
Teacher 类(虚继承自 Person)
class Teacher : virtual public Person { protected: int _id; // 职工编号 };
- 同样,
Teacher
类也通过 虚继承 方式继承自Person
类。 - 这也意味着
Teacher
类不会直接包含Person
的实例,而是与Student
类共享同一个Person
实例。
- 同样,
-
Assistant 类(多重继承自 Student 和 Teacher)
class Assistant : public Student, public Teacher { protected: string _majorCourse; // 主修课程 };
Assistant
类通过 多重继承 同时继承自Student
和Teacher
类。- 由于
Student
和Teacher
都是虚继承自Person
,因此Assistant
类中只包含一个Person
实例,避免了 菱形继承问题。
菱形继承问题与虚继承的作用
菱形继承问题
假设不使用虚继承,类层次结构如下:
Person
/ \
Student Teacher
\ /
Assistant
在这种继承方式下,Assistant
类会通过 Student
和 Teacher
继承两次 Person
,导致:
- 数据冗余:
Assistant
类中会包含两个Person
的实例。 - 二义性:当访问
Person
的成员变量(如_name
)时,会出现二义性,因为编译器不知道要访问哪一个Person
实例。
例如:
Assistant a;
a._name = "peter"; // 编译错误:_name 不明确
编译器会报错,因为 Assistant
中有两个 _name
,一个来自 Student
的 Person
,一个来自 Teacher
的 Person
。
虚继承的作用
通过使用 虚继承,类层次结构变为:
Person
/ \
Student Teacher
\ /
Assistant
在这种继承方式下,Student
和 Teacher
都是虚继承自 Person
,因此:
- 共享基类实例:
Assistant
类中只包含一个Person
的实例。 - 避免二义性:当访问
Person
的成员变量时,不会出现二义性,因为只有一个Person
实例。
例如:
Assistant a;
a._name = "peter"; // 正确
这样,Assistant
类中只有一个 _name
,可以正常访问和赋值。
代码解析
-
虚继承的使用:
Student
和Teacher
都通过virtual public Person
虚继承自Person
。- 这确保了
Assistant
类中只有一个Person
实例。
-
数据成员的访问:
- 在
main
函数中,创建Assistant
对象a
后,可以直接访问_name
,因为Assistant
类中只有一个_name
,来自共享的Person
实例。
- 在
-
构造函数的调用:
- 需要注意的是,当使用虚继承时,最底层的派生类(如
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
的成员变量被正确初始化。
- 需要注意的是,当使用虚继承时,最底层的派生类(如
总结
- 虚继承 解决了多重继承中可能出现的 菱形继承问题,避免了数据冗余和二义性。
- 在你的代码中,
Student
和Teacher
通过虚继承共享同一个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
时,构造函数的调用顺序如下:
- 初始化基类
Person
:- 由于
Student
和Teacher
都是虚继承自Person
,Assistant
的构造函数负责初始化Person
。 - 调用
Person(name3)
,即Person("王五")
,因此_name
被赋值为"王五"
。
- 由于
- 初始化基类
Student
:- 调用
Student(name1, 1)
,即Student("张三", 1)
。 - 但由于
Student
是虚继承,Person
已经被Assistant
的构造函数初始化,所以这一步不会再次初始化Person
。
- 调用
- 初始化基类
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 和 Base2
int main()
{
Derive d; // 定义派生类对象 d
Base1* p1 = &d; // 将 d 转换为指向 Base1 的指针
Base2* p2 = &d; // 将 d 转换为指向 Base2 的指针
Derive* p3 = &d; // 将 d 转换为指向 Derive 的指针
return 0;
}
多继承时的内存布局
-
内存布局示意图
假设Derive
类对象的内存布局如下:[Base1::_b1] [Base2::_b2] [Derive::_d]
Base1
子对象占用一部分内存。Base2
子对象占用另一部分内存。Derive
自己的数据成员占用剩余的内存。
-
指针偏移
p1
指向Base1
子对象的起始地址。p2
指向Base2
子对象的起始地址(相对于Base1
子对象,偏移了Base1
的大小)。p3
指向整个Derive
对象的起始地址。
指针值比较
根据指针的偏移,指针值的关系如下:
-
p1
和p3
p1
指向Base1
子对象的起始地址,而p3
指向整个Derive
对象的起始地址。在多继承情况下,Base1
子对象和Derive
对象的起始地址是相同的,所以p1 == p3
。 -
p2
和p3
p2
指向Base2
子对象的起始地址,而Base2
子对象位于Base1
子对象之后,因此p2 > p3
。 -
p1
和p2
因为p1
指向的是Base1
子对象的起始地址,而p2
指向的是Base2
子对象的起始地址,且Base2
的内存布局在Base1
之后,因此p1 < p2
。
选项分析
-
A:
p1 == p2 == p3
错误。p1
和p2
指向不同的基类子对象,地址不同。 -
B:
p1 < p2 < p3
错误。p1 < p2
是对的,但p1 == p3
,所以p1
不小于p3
。 -
C:
p1 == p3 != p2
正确。p1
和p3
的地址相同,p2
的地址不同于二者。 -
D:
p1 != p2 != p3
错误。p1 == p3
,所以p1 != p3
是错误的。
正确答案
C: p1 == p3 != p2
7.4 IO库中的菱形虚拟继承
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.继承的总结和反思
- 很多人说
C++
语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。- 多继承可以认为是
C++
的缺陷之一,很多后来的语言都没有多继承,如Java
。- 继承和组合
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.笔试面试题
- 什么是菱形继承?菱形继承的问题是什么?
一个类通过多条路径继承自同一个基类,导致基类在最终的派生类中出现多次。
菱形继承有数据冗余和二义性的问题。
- 什么是菱形虚拟继承?如何解决数据冗余和二义性的?
菱形虚拟继承 是通过虚继承机制解决菱形继承问题的方法。
菱形虚拟继承通过虚继承机制,使得最终的派生类中只包含基类的一个实例,从而避免了数据冗余和二义性问题。
- 继承和组合的区别?什么时候用继承?什么时候用组合?
继承与组合的区别
特性 | 继承 (Inheritance) | 组合 (Composition) |
---|---|---|
关系类型 | is-a 关系 | has-a 关系 |
耦合度 | 高耦合 | 低耦合 |
灵活性 | 较不灵活,类层次结构固定 | 灵活,可以动态组合不同的组件 |
复用方式 | 通过继承复用父类的代码 | 通过组合复用其他类的功能 |
多态性 | 支持多态 | 不直接支持多态,但可以通过接口实现 |
封装性 | 继承可能破坏封装性 | 更好的封装性 |
扩展性 | 扩展性较差,修改父类会影响所有子类 | 扩展性好,可以轻松添加或替换组件 |
何时使用继承?
- 表示“is-a”关系:
- 当一个类确实是另一个类的一种类型时。例如,
Dog
是Animal
的一种,Square
是Shape
的一种。
- 当一个类确实是另一个类的一种类型时。例如,
- 需要实现多态性:
- 当需要通过基类引用来操作不同子类对象时,使用继承可以实现多态。
- 需要复用基类的代码:
- 当子类需要复用基类的属性和方法,并且这些方法在子类中不需要进行重大修改时。
何时使用组合?
- 表示“has-a”关系:
- 当一个类包含另一个类的实例作为其组成部分时。例如,
Car
包含一个Engine
,Student
包含一个Address
。
- 当一个类包含另一个类的实例作为其组成部分时。例如,
- 需要更大的灵活性:
- 当需要动态组合不同的组件,或者组件之间的关系是动态变化的时,组合更为合适。
- 需要更好的封装性:
- 当需要更好地封装各个组件的功能,避免继承带来的耦合时,组合是更好的选择。
- 不希望破坏封装性:
- 当不希望子类继承父类的所有方法和属性,或者不希望子类修改父类的行为时,组合可以提供更好的封装性。