类的继承的概念及定义
继承的概念
类的继承即对类设计层次的复用,可以在保持原有类结构的基础上对类进行进一步拓展,完成类的继承后,原有的类称为基类/父类,继承的类称为派生类/子类
如何定义一个继承类
定义一个继承类的基本语法如下
class Person {};
class Student :public Person {
};
这里的Person是基类,派生类Student继承了基类Person类;这里的public表示的是继承方式,同访问限定符类似,分为public,protected,private三种继承方式,之间的关系就是取最小的访问限定域,如下图所示:
继承的用法
基类于派生类对象的赋值转换
这里就是指派生类可以直接赋值给基类,称之为切片。这里的基类,可以指基类的对象、指针或引用
注意,基类对象是不可以赋值给派生类的,下面举一个例子加以说明
#include <iostream>
class Person {
private:
size_t _age;
const char* _sex;
size_t _height;
public:
Person()
:_age(20),
_sex("男"),
_height(180)
{}
};
class Student :public Person {
private:
const char* _number;
const char* _name;
public:
Student()
:_number("0000"),
_name("张三")
{}
};
void test() {
Student s;
// s = p;
Person com_p = s;
Person* ptr_p = &s;
Person& refe_p = s;
// 当然,也可以通过强制类型转换来实现基类对派生类的赋值操作
ptr_p = &s;
Student* ptr_s = (Student*)ptr_p;
}
int main(void) {
test();
return 0;
}
在VS2022中,编译器会报出警告,提示不要使用切片,因为切片作为一种隐式类型转换可能会导致较为严重的后果,下面是摘录的原文:
继承中的作用域
- 基类和派生类有着自己独立的作用域;
- 对于同名成员,派生类会对基类的同名成员进行访问的限制,这种情况称之为隐藏/重定义;
- 注意,如果是成员函数的隐藏,不同于函数重载(要求参数列表和函数名均相同),这里只是要求函数名相同即可;
- 所以,为了避免某些问题,应该避免在继承中出现同名成员的出现。
下面是应用实例:
#include <iostream>
class Person {
public:
Person(size_t age = 0, size_t height = 0)
:_age(age),
_height(height)
{}
protected:
size_t _age;
size_t _height;
};
class Student :public Person {
public:
Student(size_t age = 20,size_t height = 180)
:_age(age),
_height(height)
{}
void show() {
std::cout << "_age = " << _age << std::endl << "_height = " << _height << std::endl;
}
private:
size_t _age;
size_t _height;
};
void Func1() {
Student s1;
s1.show();
}
int main(void) {
Func1();
return 0;
}
结果如下:
可以看见,这里调用的是派生类中的_age和_height成员,说明派生类对基类的同名成员进行了访问限制,实现了隐藏;
那如果想要去访问基类中的成员该怎么实现呢?
只需在访问成员前声明器所在的基类作用域即可:
void show() {
std::cout << "Person::_age = " << Person::_age << std::endl << "_height = " << _height << std::endl;
}
派生类的默认成员函数
- 对于派生类中的基类成员部分,必须调用基类的构造函数进行初始化;
- 如果基类没有默认构造函数,则需要显式调用基类的其他构造函数完成初始化;
- 如果基类不存在任何构造函数(比如利用C++11语法,通过delete关键词禁止构造函数的生成),当想创建一个派生类对象时,编译器会报错;
- 派生类的拷贝构造、operator=赋值均会调用基类的函数版本完成派生类中基类成员的相应操作;
- 派生类的析构函数会在调用完成后自动调用基类的析构函数完成对派生类中基类成员的析构,保证先派生类后基类的析构顺序(符合栈先进后出的顺序);
- 派生类对象初始化,先调用基类构造,再调派生类构造;
- 派生类对象析构清理,先调用派生类析构,再调用基类析构;
代码实现如下:
#include <iostream>
class Person{
public:
Person(){
std::cout<<"Person()"<<std::endl;
}
~Person(){
std::cout<<"~Person()"<<std::endl;
}
protected:
size_t _element;
};
class Student:public Person{
public:
Student(){
std::cout<<"Student()"<<std::endl;
}
~Student(){
std::cout<<"~Student()"<<std::endl;
}
private:
size_t _element;
};
void Func1(){
Student s1;
}
int main(void){
Func1();
return 0;
}
结果如下:
可以看到,创建一个Student对象,先调用了基类Person的构造函数,随后是派生类Student的构造函数,析构时先是对Student后对Person进行析构。
继承与友元
友元关系不能继承!
下面是代码演示:
#include <iostream>
class Person {
friend void show();
public:
Person() {
std::cout << "Person()" << std::endl;
}
~Person() {
std::cout << "~Person()" << std::endl;
}
private:
size_t P_element;
};
class Student :public Person {
public:
Student() {
std::cout << "Student()" << std::endl;
}
~Student() {
std::cout << "~Student()" << std::endl;
}
private:
size_t S_element;
};
void show() {
Person p1;
Student s1;
std::cout << p1.P_element << std::endl;
// std::cout << s1.S_element << std::endl;
}
void Func1() {
Student s1;
}
int main(void) {
Func1();
return 0;
}
这里,第三十二行的访问被定义为非法,因为友元关系不可继承,即基类的友元不可以访问派生类的私有或保护成员。
继承与静态成员
基类可以通过定义一个静态的static成员,无论创建多少个派生类,该静态成员只有一个(注意,声明一个静态成员变量时,需要在类外部对该成员变量进行初始化,否则会出现链接错误)。
下面是代码演示:
#include <iostream>
class Person {
public:
Person() {
++_count;
std::cout << "Person()" << std::endl;
}
~Person() {
std::cout << "~Person()" << std::endl;
}
size_t Get_conut() {
return _count;
}
static size_t _count;
};
class Student :public Person {
public:
Student() {
++_count;
std::cout << "Student()" << std::endl;
}
~Student() {
std::cout << "~Student()" << std::endl;
}
};
size_t Person::_count = 0; // 类外对静态变量_count进行了初始化
void Func1() {
Person p1;
Student s1;
std::cout << s1.Get_conut() << std::endl;
}
int main(void) {
Func1();
return 0;
}
结果如下:
可以看到,_count是一个独立于派生类的存在,只有一个_count实例。
菱形继承和菱形虚拟继承
什么是菱形继承
菱形继承,是多继承的一种特殊情况。
菱形继承的问题
菱形继承存在两个问题,分别是数据冗余和二义性:
- 数据冗余指对于class D,其内部存在两份class Base的数据,从而造成数据的冗余问题;
- 二义性指如果访问class A和class B中的Base类的数据,无法得知具体通过哪个类来访问基类中的数据,从而导致二义性的出现;
这里的二义性,我们可以通过上面的指定作用域访问来解决,但是仍然存在数据冗余的问题。
由此,我们引出虚拟继承的方式来解决数据冗余问题:
虚拟继承
虚拟继承的关键字是virtual(注意,之后的多态也使用了virtual关键字,但并不是虚拟继承的意义,指的是虚函数,不要混淆了)
#include <iostream>
class Base {
public:
size_t _base;
};
class A : public Base {
public:
size_t _a;
};
class B : public Base {
public:
size_t _b;
};
class D :public A, public B {
public:
size_t _d;
};
void Func1() {
D d;
d.A::_base = 1;
d.B::_base = 2;
d._a = 3;
d._b = 4;
d._d = 5;
}
int main(void) {
Func1();
return 0;
}
可以看见,使用菱形继承时会发生数据冗余问题;
这里说明一下我对于冗余的理解,正如之前解释所说,冗余是指对d对象中包含了两个Base类中的_base数据,但如果我分别对A和B中的_data数据赋予不同的值,此时它们具有不同的意义,这时我们还能称他们是冗余的吗?
这就是我当时产生的疑问,当我赋予它们不同的意义时,难道它们还是冗余的吗?于是,我查询了相关文章,最终得到了以下结论:
- 如果对Base中的数据赋予了不同意义的值,我们可以认为此时数据并不是完全冗余;(注意,是数据,下文有对应)
- 然而,菱形继承中通常提到的冗余问题更侧重于内存空间的使用。在没有使用虚继承的情况下,如果 A 和 B 都包含了 Base 的数据,而 D 又同时继承了 A 和 B,就会导致 D 中存在两份相同的 Base 数据。这种情况下,虽然这两份数据可能被赋予不同的值,但在内存中占用了两份空间,这被称为内存空间的冗余;
- 虚继承通过共享虚基类的实例来解决这个问题,确保在内存中只有一份共同的基类数据。这并不影响在派生类中对这些数据的操作,但确保了更有效的内存使用,特别是在多重继承和菱形继承的情况下;
- 因此,提到数据冗余通常是指内存空间的重复使用而不是数据内容的重复;
- 所以,针对菱形继承的数据冗余问题,我们可以稍微改变一下名称,称它为“内存冗余”;
所以如何解决也迫在眉睫,上文已经提到,采用虚继承,下面将重点介绍虚继承。
用虚继承重新写一下上面的函数:
#include <iostream>
class Base {
public:
size_t _base;
};
class A : virtual public Base {
public:
size_t _a;
};
class B : virtual public Base {
public:
size_t _b;
};
class D :public A, public B {
public:
size_t _d;
};
void Func1() {
D d;
d.A::_base = 1;
d.B::_base = 2;
d._a = 3;
d._b = 4;
d._d = 5;
}
int main(void) {
Func1();
return 0;
}
与上面类似,只不过在声明继承是加了virtual关键字(注意只是对A和B进行虚继承,对D无需虚继承)
下面解释一下为什么虚继承可以解决数据冗余问题:
可以看到,这里的内存存储结构已经不同于直接继承了,新增了指针的地址,而这就是解决数据冗余的关键所在,C++通过虚基表和偏移量来解决数据冗余,而内存中存储的指针,称为虚基表指针,用于指向存储偏移量的地址
比如图二就是通过图一的虚基表指针指向的偏移量地址,也就是类A的偏移量地址;
而图三就是类B的偏移量地址;
偏移量:我们再次观察内存的结构,可以看到这次的基类Base被置于d的最下端,而偏移量就是指此时类距离最下端的类Base的距离(按字节算),于是通过偏移量,派生类也可以找到Base类的所在位置,毕竟派生类都继承了基类的数据,不能因为虚继承丢失其位置。
那为什么要去找到Base类的地址呢?
因为当我们想去创建一个类D的对象时,必须通过类A或B对类Base进行初始化,所以通过偏移量记录基类的地址是非常有必要的。
继承与组合
由上述可见,多继承带来的弊端需要花费不小的代价来解决,由此引出了另一种常用的面向对象的方法——对象组合。
什么是对象组合
下面是代码实例:
#include <iostream>
class Person{
public:
Person(){
std::cout << "Person()" << std::endl;
}
~Person(){
std::cout << "~Person()" << std::endl;
}
};
class Student{
public:
Student(){
std::cout << "Student()" << std::endl;
}
~Student(){
std::cout << "~Student()" << std::endl;
}
Person p1;
};
void Func1(){
Student s1;
}
int main(void){
Func1();
return 0;
}
结果如下:
可以看到,对象组合同继承类似,都是先基类构造后派生类构造,析构相反;
对象组合,就是在一个类中创建一个其他类的实例对象,称为对象组合。
它们的区别如下:
因为继承的高聚合度,导致多继承(尤其时菱形继承)时,发生的一系列问题;
而对象组合不同,因其较低的耦合度,使得各个类对象之间的聚合度较小,有效的避免了多继承带来的问题
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。 继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关 系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对 象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse), 因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系, 耦合度低。优先使用对象组合有助于你保持每个类被封装。
对于继承或对象组合之间的选择问题,要注意对象之间是从属关系(is-a)还是简单的包含关系(has-a),再进行区分选择。