目录
前言
在学习面向对象语言时,我们绕不开类这个概念,而我们可能都听过类的三大特性,分别为类的封装,继承与多态;封装我们在之前学习类中早已介绍,本文主要介绍类的继承这一特性;如果不了解类的封装特性,可以点击下方链接先去了解;
一、继承的基本概念与使用
1、继承的概念
生活中,我们也有继承的概念,子女继承父亲的财产,同时继承也是面向对象语言代码复用的重要手段;它允许程序员在不破坏原来类的情况下,派生出一个新的类,可以为这个新类自定义化出一些特殊功能;如下面这个Person类,里面有姓名与年龄;我们又想设计出一个新的学生类,也有姓名与年龄,同时也有学号,但是我们不想破坏以前的类,因此我们可以通过继承来获取person类的姓名和年龄,同时不破坏原来的类的基础上增加一个学号;
// 基类(父类)
class Person
{
public:
protected:
string _name;
int _age;
};
// 派生类(子类)
class Student : public Person
{
public:
private:
int _stuid;
};
此时子类Student同时也具有了父类的姓名与年龄的信息;(我们一般把被继承的类称为父类或者基类,把继承的类称为派生类或者子类);
2、继承的定义
继承的定义如下图所示;
3、继承的访问限定符与继承方式
以前我们在学习类的封装时,我们学过public与private两个限定符;本章我们又添加了一个新的访问限定符protected;
protected与private在访问限定这里看起来好像没有任何区别,但其实在继承这就有了很大区别;实际上,这三个限定符不仅是用来修饰访问权限,还可以决定继承方式;
以下为不同的继承方式下不同成员在子类的访问权限;其中protected与private成员在继承中的不同体现出来了;我们发现private成员无论在哪一种情况下都是不可见状态;
综上:对其进行总结解释
1、不可见:所谓不可见即派生类也不能访问的成员,基类的private成员无论以何种继承方式,派生类都不可直接访问,但是可以通过基类提供的一些函数进行间接访问;(保护限定符也因此才出现)
2、对于上述表格,实际上,无需死记硬背,我们大体分为两类,一类为基类的private成员,此类成员无论何种继承方式,派生类都不可见;另一类为基类的public与protected成员,当选择某种继承方式时,其在子类的权限取基类成员修饰权限与继承方式的最小值;如基类的publc成员,选择protected继承方式,那么派生类该成员的权限为其中较小的protected;
3、在定义派生类时,我们的继承权限符可以省略,这时,默认的继承权限与访问默认的权限相同,若为class定义,默认继承权限为private,若为struct定义,默认的继承权限则为public;
二、基类与派生类之间的赋值转换(切片)
派生类对象 / 指针 / 引用 可以赋值给 基类对象/ 指针 / 引用;当基类不可赋值给派生类;这里有个形象的比喻,有人称这里为切片;
int main()
{
Person p1;
Student stu1;
// 派生类对象赋值给基类对象
// stu1 = p1; err
p1 = stu1;
// 派生类指针赋值给基类指针
// Student* ptr2 = ptr1; err
Person* ptr1 = &p1;
// 指针可通过强制类型转换的方式将基类指针赋值给派生类(但会出现越界访问的问题)
Student* ptr3 = (Student*)ptr1;
// 派生类引用赋值给基类引用
// Student& rstu1 = rp1; err
Person& rp = stu1;
return 0;
}
三、继承中的作用域
1、继承中的作用域
基类与派生类并不共用同一个作用域,他们都有着自己各自的作用域;基类成员在基类的类作用域中,派生类成员在派生类的类作用域中;如上面的student类与person类,student类继承了person类中的_age成员,其age位于person类的作用域中;
2、隐藏(重定义)
由于继承中的作用域,如果我们的基类与派生类都存在一个同名对象;此时,派生类屏蔽了对基类同名对象直接访问的现象称为隐藏;如下代码;
class A
{
public:
A(int aa = 1)
:_aa(aa)
{}
int _aa;
};
class B : public A
{
public:
B(int aa = 10, int bb = 20)
:_aa(aa)
,_bb(bb)
{}
int _aa;
int _bb;
};
int main()
{
B b;
// 访问派生类中的_aa
cout << b._aa << endl;
// 指定访问基类类中的_aa
cout << b.A::_aa << endl;
// 调用派生类的print函数
b.print();
// 调用基类被隐藏的print函数
b.A::print();
return 0;
}
注意:只要基类与派生类的函数重名就会构成隐藏;与参数和返回值并没有什么关系;
四、派生类的默认构造函数
派生类也是类,是类就会有默认成员函数;下面我们一一说明在派生类中的默认构造函数分别会做哪些事情;
1、构造函数
派生类的默认构造函数必须先调用基类的构造函数初始化基类的那一部分成员;如果基类没有默认构造函数,则必须在初始化列表中显示调用;
派生类的对象初始化先调用基类的构造函数初始化基类那一部分的成员,再初始化派生类那一部分的成员;
// 基类
class Person
{
public:
// 基类的构造
Person(const string& name = "Jack", int age = 18)
:_name(name)
,_age(age)
{
cout << "Person()" << endl;
}
// 基类的拷贝构造
Person(const Person& p)
{
if (this != &p)
{
_name = p._name;
_age = p._age;
}
cout << "Person(const Person& p)" << endl;
}
// 基类的赋值重载
Person& operator=(const Person& p)
{
_name = p._name;
_age = p._age;
cout << "Person& operator=(const Person& p)" << endl;
return *this;
}
// 析构函数
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name;
int _age;
};
// 派生类
class Student : public Person
{
public:
// 派生类的默认构造
Student(const string& name = "Jack", int age = 18, int stuid = 101)
:Person(name, age)
,_stuid(stuid)
{
cout << "Student()" << endl;
}
protected:
int _stuid;
};
正如我们所说,我们构造一个派生类对象时,先调用了基类的构造,再初始化派生类的那一部分;
2、拷贝构造
派生类的拷贝构造也必须先调用基类的拷贝构造,拷贝基类的那一部分,然后拷贝派生类那一部分;
// 派生类拷贝构造
Student(const Student& stu)
:Person(stu) // 调用基类的拷贝构造(这里有切片)
,_stuid(stu._stuid)
{
cout << "Student(const Student& stu)" << endl;
}
这里调用父类的拷贝构造运用了我们之前介绍过的切片,下面的赋值重载也是如此;
3、赋值重载
赋值重载也是如此;先调用基类的赋值重载;然后对派生类的成员进行赋值拷贝;
// 派生类赋值重载
Student& operator=(const Student& stu)
{
// 这里有切片与隐藏
Person::operator=(stu);
_stuid = stu._stuid;
cout << "Student& operator=(const Student& stu)" << endl;
return *this;
}
注意,这里调用基类的赋值重载有切片与隐藏;我们的复制重载与父类同名,构成隐藏;而调用时,我们传参过程中又发生了切片;
4、析构函数
派生类的析构略有一些复杂,因为后面某些原因,编译器通常会将基类与派生类的析构函数都处理为同一个名字destructor,故派生类与基类的析构函数重名,构成隐藏,而我们想显示调用就得显示写出作用域;如下
~Student()
{
Person::~Person();
cout << "~Student()" << endl;
}
而我们仔细观察发现这里的基类析构调用了两次,实际上,析构的顺序也是先调用派生类的析构,释放资源,然后由由编译器自动调用基类析构,上述代码,我们在派生类析构函数里显示调用了基类析构函数,后来编译器又自己调用了一次,所以这里基类析构调用了两次,所以正常应该这么写析构;
~Student()
{
cout << "~Student()" << endl;
}
五、友元与继承
在继承这一套体系中,友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员;如下代码
class B;
class A
{
public:
friend void print(const A& a, const B& b);
A(int a)
:_a(a)
{}
protected:
int _a;
};
class B : public A
{
public:
B(int a, int b)
:A(a)
, _b(b)
{}
private:
int _b;
};
// 基类的友元
void print(const A& a, const B& b)
{
cout << a._a << endl;
// 并不是子类的友元
//cout << b._b << endl; err
}
int main()
{
A a(1);
B b(10, 20);
print(a, b);
return 0;
}
六、继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。
class A
{
public:
A()
{
_count++;
}
static int GetCount()
{
return _count;
}
protected:
static int _count;
};
int A::_count = 0;
class B : public A
{
};
int main()
{
A a1;
A a2;
B b1;
cout << B::GetCount() << endl;
return 0;
}
由于整个继承体系公共一个static成员,因此当A、B类共用一个static成员变量;
七、菱形继承与菱形虚拟继承
1、菱形继承
所谓菱形继承是多继承引起的一种现象;即一个子类有两个或两个以上直接父类;如下图所示;
其中Student类与Teacher类继承于Person类,而Graduate有同时继承了Student类与Teacher类;类似与相近于一种菱形的形状;
2、菱形继承的问题
菱形继承存在二义性与冗余性;
class Person
{
public:
Person(const string& name = "Jack", int age = 18)
:_name(name)
,_age(age)
{}
string _name;
int _age;
};
class Student : public Person
{
public:
int _stuid;
};
class Teacher : public Person
{
public:
int _workid;
};
class Graduate : public Student, public Teacher
{
public:
int _Graid;
};
int main()
{
Graduate g;
// 二义性
// g._name = "张三"; err
// 冗余性
g.Student::_name = "张三";
g.Student::_age = 18;
g.Teacher::_name = "李四";
g.Teacher::_age = 24;
return 0;
}
观察发现,我们的Graduate对象有两份名字与年龄;并且每次赋值都需要指定特定的类域;这便是分别是菱形继承的二义性与冗余性;
3、菱形虚拟继承
那么C++是如何解决这种菱形继承产生的二义性和冗余性呢?C++采用的是一种虚拟继承的方法,在Student与Teacher类继承方式前加上virtual即可;如下所示;
class Person
{
public:
Person(const string& name = "Jack", int age = 18)
:_name(name)
, _age(age)
{}
string _name;
int _age;
};
class Student : virtual public Person
{
public:
int _stuid;
};
class Teacher : virtual public Person
{
public:
int _workid;
};
class Graduate : public Student, public Teacher
{
public:
int _Graid;
};
此时,所有Graduate中只会有一份name,age;(可通过内存窗口观察,监视窗口仍然存在多份);下面,我们来研究虚拟继承是如何实现这种机制的,我带着大家通过内存窗口来观察;
4、虚拟继承的底层实现原理
以下分别为普通菱形继承与菱形虚拟继承的内存图;
我们看到,虚拟继承玩的是一套虚基表的模型,D类的基类B与C中都会存放一个虚基表指针,它们各自指向各自的虚基表;虚基表中存放了偏移地址,其中我们可以观察到,B类的虚基表中储存了一个偏移地址20,我们从B类起始地址出发,向下偏移20个字节,刚好访问到他们的基类A;而C类中的虚基表中存放了一个偏移地址为12的指针,我们从C类的其实地址出发,向下偏移12个字节,也刚好访问到A类,这时,解决了二义性与数据冗余,他们都会通过自己的虚基表找到对应的偏移地址,从而找到他们的基类;
那么很多问题的小伙伴们就疑惑了,使用虚基表这一套体系,反而会使用更多的字节,从第一张图可以看到,D类占5个字节,而虚基表这套,第二站图,D类占6个字节(不算虚基表所占空间);
是的,在D类这种结构中,确实要多开辟一些空间,但是如果A类的大小不止4个字节呢?如果是8字节甚至更多呢?按照普通菱形继承来看,我们是否需要存几个A类,而虚基表这套体系,只需要储存一个虚机指针,在虚基表中存下基类的偏移地址即可;而且,所有该类实例化出的对象,都只使用这一张虚基表即可;