继承 机制是面向对象程序设计使代码可以复用 的最重要的手段,它允许程序原在保持原有类特性的基础上进行扩展 ,增加功能,这样产生新的类,称为派生类 。继承 呈现了面向对象程序设计的层次结构 ,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用 。
比方说我们现在定义一个Person类,它主要的属性如下,当我们在定义一个Student类时,就可以直接继承Person类,利用Person类中已有的属性和方法,减少代码的冗余:
编写如下代码进行测试查看其测试结果:
#include
#include
#include
using namespace std;
typedef long long LL;
class Person {
public:
Person()
{
cout << "Person()" << endl;
}
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name = "张三"; //姓名
LL _phone = 123456798; //电话
int _age = 18; //年龄
int _height = 25; //身高
};
class Student:public Person {
public:
void Print()
{
cout << _name << " " << _phone << " " << _age << " " << _height << endl;
}
private:
int _score; //成绩
};
int main()
{
Student s;
s.Print();
cout << "sizeof(Person):" << sizeof(Person) << endl;
cout << "sizeof(s):" << sizeof(s) << endl;
return 0;
}
观察上面的代码和运行结果,我们可以发现,子类继承了父类之后可以在子类中使用父类的成员(可否还有继承关系和访问限定符的约束下面会讲)。
观察Person类的大小和Student创建对象后对象的大小,我们可以得知Person类也就是父类中的成员会成为子类的一部分。
总结:
下面我们看到Person是父类,也称为基类。Student是子类,也称作派生类。(基类和派生类会在本文中多次提到)
有关于继承关系的修饰符,于我们所熟知的面向对象的三种限定类成员的限定符相同,有以下三种组成:
而使用不同的继承方式继承父类,在面对父类中不同的访问限定符修饰的成员,会产生不同的结果,如下:
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
以横轴为protected继承 ,纵轴为基类的protected成员 为例,表示使用protected符号继承,并且父类中使用protected修饰的成员相当于在子类中使用protected修饰的成员一样。
编写如下代码,演示不同访问限定符下,使用不同的继承关系继承的结果:
class Person {
public:
void Pub()
{
cout << "public" << endl;
}
protected:
void Pro()
{
cout << "protected" << endl;
}
private:
void Pri()
{
cout << "private" << endl;
}
};
//class Student : private Person
//class Student : protected Person
class Student : public Person
{
public:
void Print()
{
Pub();
Pro();
//Pri();//private成员,调用就会报错
}
};
总结:
public > protected > private
。
派生类的对象 可以赋值给基类的对象/基类的指针/基类的引用 。这里有个形象的说法叫切片或切割。寓意把派生类中父类的那部分切来赋值过去。
编写如下测试代码:
class Person {
protected:
Person()
{
cout << "Person" << endl;
}
private:
int _age;
};
class Student : public Person{
private:
int Stu_id;
};
int main()
{
Student s;
Person p = s;
Person& rp = s;
Person* ptrp = &s;
return 0;
}
该代码能正常运行,其结果如下:
只显示了一个Person表示只调用了一次Person类构造函数,即Student类对象创建时调用(关于构造函数下面会讲)
所以派生类的对象 赋值给基类的对象/基类的指针/基类的引用 根据切片 可以看作是进行了如下操作:
测试1:基类的指针/基类的引用指向子类包含的父类成员
我们在测试子类对象赋值给父类的引用,通过引用修改父类的成员变量的值,在查看子类对象对应的值是否改变来测试上图是否正确:
class Person {
public://访问限定符改为public,方便引用访问成员函数
Person()
{
cout << "Person" << endl;
}
void change_age(int age)
{
_age = age;
}
void Print_age()
{
cout << "_age:" << _age << endl;
}
private:
int _age = 1;
};
class Student : public Person{
private:
int Stu_id;
};
int main()
{
Student s;
Person& rp = s;
rp.change_age(4);//修改父类的成员变量
s.Print_age();//查看父类的成员变量是否修改
return 0;
}
子类对应的父类的成员变量改变,证明引用只是引用子类包含的父类的成员,同时也能证明父类指针类型指向的是子类包含的父类的成员(这里的道理是相通的)。
测试2:没有临时变量
对于下面的代码,变量b引用变量a时,因为类型的不同会进行隐式类型转换,此时会产生一个临时变量,来存放变量a类型转换后的值,而临时变量具有常性,需要增加const
修饰。
double a = 1.1;
const int& b = a;
而对于我们上面的测试代码中,同样时引用,同样是不同的类型,而没有使用const
修饰,证明没有临时变量的产生。
测试3:派生类的对象赋值给基类的对象,调用父类的拷贝构造,在下面的派生类的默认成员函数中会讲。
基类对象不能赋值给派生类对象。
子可以给父,因为子继承了父亲的成员,而当父类要赋值给子类时,子类包含父类的成员和自己的成员,而父类只有自己的成员,赋值注定是不对等的。
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast来进行识别后进行安全转换。
注意:
父类接收子类这种情况,只要在继承关系为
public
的时候才会有效,因为只有继承关系是public那保存在子类中的父类成员的权限都不会发生改变,而当继承关系是其它类型比如说protected
,此时存在子类中原本public限定符访问权限会改为protected,原始的权限发生改变,在经过赋值就是将protected的权限改为public这是不被允许的。 之所以不被允许,是因为当你在创建一个子类时只需要使用
protected
继承,只在类内修改和使用父类的成员,而经过赋值之后,获得子类对应的父类成员的变量就可以肆无忌惮的访问和修改对应的父类的成员,这与最初的编写代码时的目的背道而驰。
在继承体系中基类 和派生类 都有独立的作用域。
子类和父类中允许有同名成员(作用域不同),子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。 (在子类成员函数中,可以使用基类::基类成员 显示访问 )
class Person {
protected:
int _age = 18;
int _identity = 123456;
};
class Student:public Person {
public:
void Print()
{
cout << "Student-_age:" << _age << endl; //子类中的成员变量
cout << "Person-_age:" << Person::_age << endl; //父类中的成员变量
}
private:
int _age = 20;
};
int main()
{
Student s;
s.Print();
return 0;
}
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。(不需要考虑参数,也不需要考虑返回值)
class Father {
public:
void Function()
{
cout << "Father Function" << endl;
}
};
class Child:public Father {
public:
void Function(int a)
{
cout << "Child Function " << a << endl;
}
};
int main()
{
Child c;
c.Function(10); //同名函数构成隐藏,调用子类的函数
c.Father::Function(); //声明调用父类的函数
//错误写法,缺少参数,同名函数以及构成隐藏,只有子类的该函数可以直接,父类的需要声明
//c.Function();
return 0;
}
注意在实际中,继承体系 最好不要定义同名成员 。
定义同名成员构成隐藏会存在隐患,最好不要给自己找麻烦
6个默认成员函数,“默认”的意思就是指我们不写,编译器会帮我们自动生成一个,那么派生类中,这几个成员函数是如何生成的呢?
destructor
造成重名,我们只能使用基类::基类析构函数
的方式调用,并且在我们自己手动调用后,编译器仍会再次调用父类的析构函数,所以析构函数我们不要取显示调用。编写如下测试代码:
class Father {
public:
Father(const int age = 10)
:_age(age)
{
cout << "Father()" << endl;
}
Father(const Father& f)
{
cout << "Father(const Father& f)" << endl;
}
Father& operator=(const Father& f)
{
cout << "Father& operator=(const Father& f)" << endl;
return *this;
}
~Father()//父类析构
{
cout << "~Father()" << endl;
}
protected:
int _age;
};
class Child:public Father {
public:
Child(const int age = 10,const int num = 20)
:Father(age) //调用父类的构造函数
,_num(num)
{
cout << "Child()" << endl;
}
Child(const Child& c)
:Father(c) //调用父类的拷贝构造
,_num(c._num)
{
cout << "Child(const Child& c)" << endl;
}
Child& operator=(const Child& c)
{
if (this != &c)
{
Father::operator=(c);//调用父类的赋值重载
_num = c._num;
}
cout << "Child& operator=(const Child& c)" << endl;
return *this;
}
~Child()//子类析构
{
cout << "~Child()" << endl;
}
private:
int _num;
};
int main()
{
Child c1;
Father f = c1;
Child c2 = c1;
c2 = c1;
return 0;
}
总结:
虽然继承中,子类包含了父类的成员,但在进行赋值拷贝等操作时,仍然需要父类的相关构造函数的帮助,子类无法对父类的成员进行这方面的操作。
简单的说,就是子类完成子类的工作,父类完成父类的工作。
这一块的知识我们不要局限与子类包含了父类的成员,毕竟在初始化调用构造函数和析构时,都需要父类的参与,那其它情况下,也必然需要父类来完成它所属的成员的对应操作。
友元关系不能继承 ,也就是说基类友元不能访问子类私有和保护成员。
想要让友元函数或是友元类也能访问子类,只能子类中添加对应的友元关系:
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。
编写如下测试代码查看,父子类是否共用一个静态成员,查看它们的静态成员地址是否相同
class Father {
public:
int age = 10;
static int count;
};
int Father::count = 0;
class Child :public Father{
private:
string _name = "张三";
};
int main()
{
Father f;
Child c;
cout << &(f.age) << endl;
cout << &(c.age) << endl;
cout << &(f.count) << endl;
cout << &(c.count) << endl;
return 0;
}
小问题:
如何创建一个无法被继承的类?
答:使用private限定符修饰其构造函数
使用private修饰构造函数那我们也无法使用这个类创建对象,该如何解决?
答:在类内public修饰的区域创建一个static修饰的函数
CreateObject
,返回值为该类的类型,函数体内返回该类的匿名对象,即在类内调用构造函数,我们可以在类外根据类名调用这个函数创建对象。子类能否利用这个函数完成调用构造函数?
答:不能,子类在创建对象时需要调用父类的构造函数,而不是创建出一个父类的对象。
代码如下:
class A {
public:
static A Create_object(const int age = 10)
{
return A(age);
}
void Print()
{
cout << _age << endl;
}
private:
A(int age = 10)
:_age(age)
{}
int _age;
};
int main()
{
A b = A::Create_object(20);
b.Print();
return 0;
}
事实上,上面的内容掌握后,C++继承的内容已经基本搞定,但是C++语言在创建时却为继承留下了一个大坑,需要我们明白它的问题所在即解决方法,如下:
C++的继承是即允许单继承也允许多继承,单继承就是一个子类有一个父类,多继承则是一个子类有多个父类。
一个子类只有一个直接父类时这个继承关系称之为单继承
一个子类有两个或两个以上的直接父类时称这个继承关系为多继承。
一个子类是多继承关系,而它的父类们却继承了同一个或多个类,这样的关系称之为菱形继承。
观察下面的对象成员模型:
菱形继承有数据冗余和二义性的问题,在Child的对象中Person成员会有两份。
class Person {
public:
Person(){}
Person(string name, int age)
:_name(name)
,_age(age)
{}
protected:
string _name;
int _age;
};
class Father : public Person{
public:
Father(){}
Father(string name, int age, int weight)
:Person(name, age)
, _weight(weight)
{}
protected:
int _weight;
};
class Mather : public Person {
public:
Mather(){}
Mather(string name, int age, int height)
:Person(name,age)
,_height(height)
{}
protected:
int _height;
};
class Child :public Father,public Mather{
public:
Child(){}
Child(string name,int age,int weight,int height,string feature)
:Father(name,age,weight)
,Mather(name,age,height)
,_feature(feature)
{}
protected:
string _feature;
};
其中,二义性我们可以为其赋予相同的值来勉强解决它,若是使用其它方法为其赋予不同的值,在访问时会编译器会报错。
int main()
{
Child c("张三",18,120,180,"篮球");
return 0;
}
而数据冗余,也就是空间浪费无法使用常规的方法解决,这里我们要使用virtual
关键字,修饰最底层子类的父类,也就是中间层(这里只说结果,具体的细节在下面):
将Child两个父类的声明修改如下:
class Father : virtual public Person{}
class Mather : virtual public Person{}
观察使用virtual
关键字前后,Child类的大小变化:
int main()
{
cout << "sizeof(Child):" << sizeof(Child) << endl;
return 0;
}
使用virtual后,Child类大小有了明显的变化。
像上面子类继承添加
virtual
关键字的父类我们称之为虚拟继承 ,虚拟继承可以同时解决菱形继承的冗余和二义性的问题.
下面我们在来看一下虚拟继承解决这两个问题的原理:
首先,我们先写一个简化的菱形继承体系,如下:
class A {
public:
int _a;
};
class B : public A {
public:
int _b;
};
class C : public A {
public:
int _c;
};
class D :public B,public C{
public:
int _d;
};
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
return 0;
}
运行代码查看不使用虚拟继承的时候,D类中个成员在内存中的情况:
很明显,d对象中的_a
存在二义性,有两个值。
接着,我们为B、D类
添加virtual
修饰,运行代码,再来查看D类中个成员在内存中的情况:
原本存放B、C类所对应的_a成员的值的空间都变为了一个地址(我使用的编译器为VS2019,X86下,存储方式为小端存储)。
这两个地址所指向的空间中(是一个只占8个字节,前后四个字节各表示一个值的表:虚基表 ),第一块所占四个字节的空间我们不去讨论和本文关系不大(与多态有关,会在下一篇博客中讲解),第二个空间存放的是一个数字。
比如:
0x00CFFD3C
(d对象的第一块空间)存放的的空间地址为0x001d9be0
,所以虚基表的第一块空间地址为0x001d9be0
,内容为0
(16进制),第二块空间为0x001d9be4
,值为14
(16进制)该数字表示目前距离公共父类第一个成员的偏移量 :
比如:
0x00CFFD3C
的偏移量为0x001d9be4
的值14(16进制),增加偏移量后所代表的地址为0x00CFFD50
,空间内存放的值为2
- 最终,菱形继承二义性的问题得以解决,公共父类的成员只有一个值。
注意: 上述代码中B类和C类有共同的父类A类(A类通常被叫做:虚基类),所以存放偏移量的地址B和C类各有一个,在菱形继承中,这种地址的存在与否只和是否继承虚基类有关 ,若是出现更复杂的情况,那存放偏移量的地址就会变得更复杂,如下图:
F、G有指向相同位置的偏移量存储地址,B、C有指向相同位置的偏移量存储地址,C、E有指向相同位置的偏移量存储地址
相信大家看到这里一定有很多问题,比如说为什么_a
的值为2,而不是1等等,下面我们在虚继承的状态下,调试运行一下上述的代码,相信很多问题,都会迎刃而解。
问题:
B、C类中存储偏移量的两个四字节的空间是则么创建的?
如上图,在对象d创建后,编译器根据菱形继承中B、C类继承了A类的情况,为它们分别分配了两个空间,用来存放指向A类第一个成员偏移量。
为什么最后A类成员_a的值为2?
观察上图,在运行完30行代码后,A类成员_a的值为1,运行完31行后,A类成员_a的值变为2,公共父类的成员在菱形继承中不在存在冗余,只有一份,那它和普通成员无异,以最后一次修改的为主 ,所以最后_a的值为2,而不是1.
这里为了方便解说,我们按照上面的代码将A类定义为初始父类,B,C类虚拟继承A类,D类继承B、C类。
虚拟继承的虚基表是固定的,不论创建出多少D类对象,虚基表都是相同的。
如上图,所有D类对象存放虚基表的空间都为0x1d9be0
和 0x001d9be8
,所以当对象足够多时这种独立出去的空间完全可以忽略不记。
当D类对象d1所用的父类A或B的成员空间大,D类对象d2所用的父类A或B空间少时,偏移量也是固定不变的。
假设d1所用成员_c消耗50字节空间,d2所用成员_c消耗4字节空间,B、C的偏移量还是相同的。编译器会将_c中多余的空间在其它地方开辟一个空间出来存放数据,而在原始的位置上存放指向新开辟的空间的地址。
我们编写如下测试代码:
class A {
public:
int _a;
};
class B :virtual public A {
public:
string _b;
};
class C :virtual public A {
public:
int _c;
};
class D :public C,public B{
public:
int _d;
};
int main()
{
D d;
d._b = "123456789101112134515645864948";
d._a = 1;
d._c = 4;
d._d = 5;
D d1;
d1._b = "123456";
d1._a = 2;
d1._c = 4;
d1._d = 5;
return 0;
}
其中对象d比对象d1占用的空间一定要大,我们通过调试来查看它们的内存情况,如下图:
注意:
(以d对象为例)d对象和d1对象中B类所属的第二块空间中,d对象这块空间的地址为0x0113FCDC,其中的数据为0x0001550e07,为一块地址,所指向的空间内数据为0x0113FCDC,又指了回来,所以大家不必在乎这块空间
大家注意观察应该会发现,我们从解决二义性到解决冗余共展示了两次菱形虚拟继承的内存情况,其中第一次是先有B类空间,其次是C类,而现在是先有C类空间,其次是B类(如上方内存图),这是因为两次查看内存所使用的代码中,我将D类的继承顺序调整
//第一次 class D :public B,public C; //第二次 class D :public C,public B;
自然是先继承的类相当于先声明,所以先申请出空间,先调用构造函数 ,这一点看似普通确是笔试中经常会考到的点。
上述测试代码中为了方便观察,所以A类的成员数量不多,占用空间不多,当A类的成员变多时,占用的空间变大,那虚拟继承的优势也就彻底体现出来了。
比方说在上面菱形继承的问题 最后代码冗余那块,我们测试了一下使用菱形继承和菱形虚拟继承时子类所占用的空间,就明显发现了占用空间变少。
承上启下:
通过调试观察菱形虚拟继承的内存,大家应该会发现,它的内存分布主要以继承的父类来分布,不同的父类所占据一块连续的内存,这正是我们在之前基类和派生类对象赋值转换 中提到的
切片
运行的原理,在父类对象接收子类对象时,只复制或指向子类对象中特定的区域。
class A {
public:
A(string s) { cout << s << endl; }
~A(){}
};
class B :virtual public A
{
public:
B(string s1,string s2):A(s1) { cout << s2 << endl; }
~B(){}
};
class C : virtual public A
{
public:
C(string s1, string s2) :A(s1) { cout << s2 << endl; }
~C() {}
};
class D :public B, public C
{
public:
D(string s1, string s2, string s3, string s4)
:B(s1, s2)
, C(s1, s3)
, A(s1)
{
cout << s4 << endl;
}
~D(){}
};
int main()
{
D d("A", "B", "C", "D");
return 0;
}
这道题一般是以选择题的形式给出,给出代码,选择最后的输出结果是什么,这里我们直接给出结果,在来分析一下为什么结果是这样的。
首先,我们观察代码可以发现这是一个菱形虚拟继承
既然是继承那一定是先调用最上层的类,也就是公共父类A,所以A是先打印的。最后调用最下层的类,也就是子类D,所以D是最后打印的。
注意: 调用A的地方是在D的初始化列表中,而不再B和C类中,因为菱形虚拟继承解决了数据冗余和二义性,那A类的成员一定放在D类对象所属空间的最下方,所以直接在D类内调用A类的构造函数更好,使B和C类直接指向已经创建的A类即可。
既然是菱形虚拟继承,那所有的数据只有一份,也就是A类不会被调用多次,只会出现一次。
其次就是中间B和C的输出顺序了,在上面解决冗余的部分中我们提到,先继承的就是先声明的,而初始化列表的执行顺序于其出现拜访顺序无关,只与声明顺序有关,所以经常顺序就是构造函数的执行顺序。 所以是B先打印其次是C。
下面这种在一个类中将另一个类的对象作为自己的成员变量的方式即为组合 。
class A {};
class B {
private:
A _a;
};
public继承(最长见的基础,private和protected基础基本不会看到),是一种is-a 的关系。
就是说每个派生类对象都是一个基类对象
组合是一种has-a 的关系。
假设B组合了A,每个B对象中都有一个A对象。
继承允许你根据基类的实现来定义派生类的实现。这种通过派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:
在继承方式中,基类的内部细节对子类可见。继承一定程度破坏了基类的封装,基类的改变(比如说修改访问限定符),对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高 。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低 。优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地,有些关系就适合继承那就用继承,另外要实现多态(多态是基于继承实现的),也必须要继承,类之间的关系可以用继承,可以用组合,就用组合。
比如说:
考虑是否需要从新类上溯造型回基类。若必须上溯,就需要继承;否则用组合。
答案都在文中,这里不在一一讲解。