📃博客主页: 小镇敲码人
💚代码仓库,欢迎访问
🚀 欢迎关注:👍点赞 👂🏽留言 😍收藏
🌏 任尔江湖满血骨,我自踏雪寻梅香。 万千浮云遮碧月,独傲天下百坚强。 男儿应有龙腾志,盖世一意转洪荒。 莫使此生无痕度,终归人间一捧黄。🍎🍎🍎
❤️ 什么?你问我答案,少年你看,下一个十年又来了 💞 💞 💞
【C++继承解密】:构建层次化设计的艺术
- ♊️ 继承的概念及其定义
- 🕐 概念
- 🕐 继承的定义
- 🤾🏻 基本语法
- 🤾🏻 继承方式和访问限定符对派生类的影响
- ♊️ 派生类和基类的赋值转化
- ♊️ 继承中的作用域
- ♊️ 派生类中的默认成员函数
- 🕐 构造函数
- 🕐 拷贝构造函数
- 🕐 赋值运算符重载函数
- 🕐 析构函数
- ♊️ 继承与静态成员
- ♊️ 继承与友元
- ♊️ 菱形继承与菱形虚拟继承
- 🕐 几种继承
- 🕐 菱形继承所引发的问题
- 🕐 解决办法
- 🤾🏻 使用类域限制变量解决二义性的问题
- 🤾🏻 使用虚拟继承把两个问题全部解决
- 🤾🏻 菱形虚拟继承的原理
- ♊️ 继承总结
- 🕐 组合和继承
前言:继承是C++面向对象的三大特性之一,前面我们学习了类和对象的一些知识,今天我们来了解一下C++继承的概念。
♊️ 继承的概念及其定义
🕐 概念
继承是C++面向对象的三大特性之一,因为我们在实际生活中,某些对象会有一些相同的特征,如果我们都用代码分别创建一个类出来就会导致代码冗余,所以继承是体现了面向对象的层次性,派生类作为子类去继承父类的一些行为和方法,大大减少了代码冗余。
🕐 继承的定义
🤾🏻 基本语法
派生类(子类)如何去继承一个父类呢?
很简单,语法上是这样的:
class A:public B
{
};
上面代码,A为派生类(子类),public
代表着继承方式,B就是我们的父类(基类)。
🤾🏻 继承方式和访问限定符对派生类的影响
我们知道,访问限定符是
private
的时候在类外面是不能访问的,而在子类继承中也是这样,当父类的成员变量或者方法是private
修饰时,子类无论是什么继承方式都是继承不了的。
所以出现了protected
的访问限定符,它可以说是为了继承而生的,因为有些变量和方法我们希望它不能在类外面访问,但是可以通过继承给子类使用,这个时候就可以给它加上protected
访问限定符。
至于继承方式对于子类的成员的访问限定是这样的,pubulic
继承方式不会改变父类的成员在子类中的访问方式,protected
继承方式会把所有父类的成员在子类中的访问方式变成protected
,private
继承方式会把所有的父类的成员在子类中的访问方式变成private
。(注意这里的所有不包括在父类中就是private限定的成员,它们直接不可继承了)。
这里我们列出表格来总结一下(看着较多,其实理解了上面的规则都不需要记忆):
父类成员访问方式/子类继承方式 | public | protected | private |
---|---|---|---|
public | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
protected | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
private | 派生类不可见 | 派生类不可见 | 派生类不可见 |
- 注意:如果继承没有显示的写继承方式,对于
class
来说,默认是private
继承;对于struct
,默认是public
继承。
我们使用代码来演示一下:
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<vector>
#include<map>
#include<string>
#include<stack>
using namespace std;
class A
{
public:
int z = 0;
protected:
int x = 0;
private:
int y = 0;
};
class B: protected A
{
public:
void f()
{
cout << z << endl;
cout << x << endl;
}
};
int main()
{
B().f();
cout << sizeof(B) << endl;
return 0;
}
运行结果:
sizeof(B)
的大小还是12,说明基类的private
对于派生类只是不可见(不可访问),并不是没有继承过来。
♊️ 派生类和基类的赋值转化
派生类的对象可以赋值给基类的对象、引用、和指针,又叫做切片。意思是把派生类的父类部分切出来赋值过去。
派生类和基类的转化要注意:
- 不能把基类的对象赋值给派生类的对象。
- 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用,但是前提是这个基类的指针或者引用原先就指向派生类的对象,或者是派生类对象的别名。
下面我们用代码来演示一下:
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<vector>
#include<map>
#include<string>
#include<stack>
using namespace std;
class A
{
public:
int x = 0;
protected:
int y = 0;
private:
int z = 0;
};
class B :public A
{
public:
int c = 0;
};
void Test1()
{
B b;//创建一个派生类对象
A a1 = b;//派生类对象通过切片可以直接赋值给基类对象
A* a2 = &b;//派生类对象的指针通过切片可以赋值给基类对象的指针
A& a3 = b;//派生类对象的引用也可以通过切片给基类对象的引用
B* b1 = (B*)a2;//指向派生类对象的指针可以通过强制类型转换然后赋值给派生类对象的指针
//B b2 = (B)a1;//但是基类对象不能通过强制类型转换来赋值给派生类对象,即使是切片的也不行
B* b2 = (B*)&a1;//虽然强制类型转换语法上不报错,但是由于不是指向派生类对象而是切片出来的,会存在越界访问的风险
//b2->c = 100;
}
int main()
{
Test1();
return 0;
}
运行结果:
如果把基类的对象的地址强制转换赋值给派生类的指针,会存在越界访问的风险:
运行结果:
♊️ 继承中的作用域
- 派生类和基类的作用域是独立的,它们有各自的作用域
- 当子类和父类中的成员有名字相同时,子类函数会优先去访问子类中的那个成员,这种情况叫做隐藏,也叫重定义,可以用父类类名::成员名来访问,前提是这个成员在子类中是可访问的。
- 对于成员函数而言,只要函数名一样就可以叫做隐藏,而不用管参数和返回值是否相同。
4.实际项目中子类的成员不要和父类的成员同名。
我们用代码来演示一下,继承中的隐藏:
#include <iostream> // 需要包含iostream头文件以使用cout
using namespace std;
// 定义一个类A
class A
{
public:
// 公有成员变量x,可以在类A的外部直接访问
int x = 0;
protected:
// 受保护成员变量num,只能在类A内部和A的派生类内部访问
int num = 1;
private:
// 私有成员变量z,只能在类A的内部访问
int z = 0;
};
// 定义一个类B,它公开继承自类A
class B :public A
{
public:
// 定义一个公有成员函数f
void f()
{
// 访问从类A继承的受保护成员变量num
cout << num << endl; // 输出1
// 显式地通过类名A访问受保护成员变量num,但效果相同
cout << A::num << endl; // 输出1
}
// 在类B中定义了一个新的公有成员变量num,这与从A继承的num不同
int num = 0; // 注意:这可能会导致名称隐藏,如果试图在B的外部通过B的对象访问num,将访问到这个变量而不是A的num
};
// 定义一个函数Test1
void Test1()
{
// 创建一个B的临时对象并调用其f成员函数
B().f(); // 输出两次1,分别对应B中访问的A的num
}
int main()
{
// 调用Test1函数
Test1();
return 0;
}
运行结果:
看下面代码:
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
// 定义一个类A
class A
{
public:
// 公有成员变量x,可以在类A的外部直接访问
int x = 0;
protected:
// 受保护成员变量num,可以在类A内部、A的派生类内部和A的友元函数中访问
int num = 1;
// 受保护成员函数f1,可以在类A内部、A的派生类内部和A的友元函数中调用
void f1(int a = 3)
{
cout << "A::f1()" << std::endl;
}
private:
// 私有成员变量z,只能在类A的内部访问
int z = 0;
};
// 定义一个类B,它公开继承自类A
class B :public A
{
public:
// 公有成员函数f1,与基类A的f1构成重载关系
void f1(int b = 4)
{
cout << "B::f1()" << std::endl;
}
// 公有成员函数f,用于演示如何在B中调用自己的f1和基类的f1
void f()
{
// 调用B自己的f1函数
f1(); // 输出 "B::f1()"
// 显式调用基类A的f1函数
A::f1(); // 输出 "A::f1()"
}
// 在类B中定义了一个新的公有成员变量num,这与从A继承的num不同
// 注意:这会导致名称隐藏,如果试图在B的外部通过B的对象访问num,将访问到这个变量而不是A的num
int num = 0;
};
// 定义一个函数Test1,用于演示类B的使用
void Test1()
{
// 创建一个B的临时对象并调用其f成员函数
B().f(); // 依次输出 "B::f1()" 和 "A::f1()"
}
int main()
{
// 调用Test1函数进行测试
Test1();
return 0;
}
这里的B
中的f1
和A
中的f1
构成隐藏,而不构成重载,重载的函数必须要在同一作用域,但是它们分别属于两个独立的作用域。
运行结果:
♊️ 派生类中的默认成员函数
在C++类和对象部分,我们谈到,C++的默认成员函数一共有6个,它是你不写任何构造函数,编译器会默认生成的。
那在继承中,派生类的默认成员函数有和不同呢?我们来一起看一下:
🕐 构造函数
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
A(int a_ = 1):a(a_)
{
cout << "A(int a_ = 1)" << endl;
}
private:
int a = 0;
};
class B :public A
{
public:
B(int b_ = 1) :b(2)
{
cout << "B(int b_ = 1)" << endl;
}
private:
int b = 0;
};
int main()
{
B();
return 0;
}
运行结果:
可以看到,我们在创建派生类对象时,当基类的构造函数是无参构造时,可以不用显示调用,但是编译器会先去调用基类的构造函数,如果需要传参,要在派生类初始化列表的地方就去显示的调用基类的构造函数。
🕐 拷贝构造函数
请看下面代码:
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
A(int a_ = 3):a(a_)
{
cout << "A(int a_ = 1)" << endl;
}
A(const A& x)
{
cout << "A(const A& x)" << endl;
a = x.a;
}
private:
int a = 0;
};
class B :public A
{
public:
B(int b_ = 1) :A(3),b(2)
{
cout << "B(int b_ = 1)" << endl;
}
B(const B& x)
{
cout << "B(const B& x)" << endl;
b = x.b;
}
private:
int b = 0;
};
int main()
{
B b1;
B b2(b1);
return 0;
}
运行结果:
通过运行结果,我们可以看到是不符合我们的预期的,编译器在调用B
的拷贝构造时,先去调用了A
的构造函数,但实际上,我们希望它去调用的是A
的拷贝构造,所以这个时候,需要我们显式的在B
的拷贝构造的初始化列表处调用A
的拷贝构造函数,传B
对象x
,x
赋值给A
对象类型会切片。
修正以后是这样的(B的拷贝构造部分):
B(const B& x):A(x)
{
cout << "B(const B& x)" << endl;
b = x.b;
}
运行结果:
这样就符合我们的预期了。
🕐 赋值运算符重载函数
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
A(int a_ = 3):a(a_)
{
cout << "A(int a_ = 1)" << endl;
}
A(const A& x)
{
cout << "A(const A& x)" << endl;
a = x.a;
}
A& operator=(const A& x)
{
cout << "A& operator=(const A& x)" << endl;
if (this != &x)
{
a = x.a;
}
return *this;
}
private:
int a = 0;
};
class B :public A
{
public:
B(int b_ = 1) :A(3),b(2)
{
cout << "B(int b_ = 1)" << endl;
}
B(const B& x):A(x)
{
cout << "B(const B& x)" << endl;
b = x.b;
}
B& operator=(const B& x)
{
cout << "B& operator=(const B& x)" << endl;
if (this != &x)
{
A::operator=(x);//显示的调用A的拷贝构造
b = x.b;
}
return *this;
}
private:
int b = 0;
};
int main()
{
B b1;
B b2;
b2 = b1;
return 0;
}
运行结果:
赋值运算符重载函数也需要我们显示的去调用父类的赋值运算符重载函数。
🕐 析构函数
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
A(int a_ = 3) :a(a_)
{
cout << "A(int a_ = 1)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int a = 0;
};
class B :public A
{
public:
B(int b_ = 1) :A(3), b(2)
{
cout << "B(int b_ = 1)" << endl;
}
~B()
{
cout << "~B()" << endl;
}
private:
int b = 0;
};
int main()
{
B b1;
return 0;
}
运行结果:
程序先调用了派生类的析构,再去调用基类的析构函数,这个原因是因为派生类可能使用基类的资源,但是基类不可能使用派生类的资源,所以先释放派生类,再释放基类的资源肯定不会出问题。但是换顺序,那就不能保证了。
♊️ 继承与静态成员
无论派生出多少子类,整个继承体系都只会有一个静态实例。
代码演示:
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
static int a ;
};
int A::a = 2;
class B :public A
{
};
class C :public A
{
};
int main()
{
cout << A::a << endl;
B::a = 3;
cout << A::a << endl;
C::a = 4;
cout << A::a << endl;
return 0;
}
运行结果:
♊️ 继承与友元
父类的友元函数不能访问派生类中的私有成员和保护成员。
#include <iostream> // 包含iostream头文件以使用cout
using namespace std;
class B; // 前置声明类B,因为A的friend函数需要引用B
class A
{
public:
// 声明一个友元函数,该函数可以访问A的protected和private成员
friend void f(const A& x, const B& y);
protected:
int a = 3; // A类的protected成员变量a,初始化为3
};
// B类继承自A类
class B : public A
{
protected:
int b = 2; // B类的protected成员变量b,初始化为2
};
// 友元函数定义
void f(const A& x, const B& y)
{
cout << x.a << endl; // 输出A对象的a成员
cout << y.b << endl; // 输出B对象的b成员
}
int main()
{
A a; // 创建A类的对象a
B b; // 创建B类的对象b(由于继承自A,b也包含了A的成员)
f(a, b); // 调用友元函数f,输出a的a成员和b的b成员
return 0;
}
报错结果:
♊️ 菱形继承与菱形虚拟继承
🕐 几种继承
- 单继承
一个子类只有一个直接父类。
2. 多继承
一个子类有多个直接父类。
3. 菱形继承
是多继承的一种特殊情况。
🕐 菱形继承所引发的问题
为了理解菱形继承所带来的问题,我们用下面代码来演示一下,从而更加直观的去感受:
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
int a = 3;
};
class B :public A
{
public:
int b = 2;
};
class C : public A
{
public:
int c = 1;
};
class D : public B,public C
{
public:
int d = 2;
};
int main()
{
D d;
d.a = 3;
return 0;
}
运行结果:
可以看到,这里产生了访问的二义性,因为我们的D
既继承于B
,又继承于C
,而B
、C
又继承于A
,会创建两份a
变量,名字相同,编译器不知道你要访问哪个a
。
这里我们可以通过内存调试窗口观察一下,是否真的产生了两份a
:
可以看到a
的值为1,确实在内存中存了两份。
🕐 解决办法
🤾🏻 使用类域限制变量解决二义性的问题
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
int a = 3;
};
class B : public A
{
public:
int b = 2;
};
class C : public A
{
public:
int c = 1;
};
class D : public B,public C
{
public:
int d = 4;
};
int main()
{
D d;
d.B::a = 10;
d.C::a = 12;
cout << d.B::a << endl;
cout << d.C::a << endl;
return 0;
}
运行结果:
二义性的问题虽然解决,但是数据的冗余依然存在,同一个D
变量,我们没必要把A
对象模型存两次。
🤾🏻 使用虚拟继承把两个问题全部解决
我们使用虚拟继承来解决上面二义性和数据冗余的问题,虚拟继承要在
B
和C
继承A
的时候使用(不能乱加),因为数据冗余是A
的数据冗余了。关键词virtual
修饰继承方式就表示虚拟继承。
看下面代码并观察运行结果:
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
public:
int a = 3;
};
class B : virtual public A
{
public:
int b = 2;
};
class C : virtual public A
{
public:
int c = 1;
};
class D : public B,public C
{
public:
int d = 4;
};
int main()
{
D d;
d.a = 4;
cout << d.B::a << endl;
cout << d.C::a << endl;
return 0;
}
运行结果:
可以看到,这次我们不仅没有报错,而且还成功运行出了结果,可以看到菱形虚拟继承的出现解决了二义性和数据冗余的问题。
🤾🏻 菱形虚拟继承的原理
我们调试刚刚的代码观察此时d
的地址的内存窗口,观察它此刻的变量模型:
这里为了做区分,将a
的值改为10。
但是B
、C
的对象模型,似乎第一个都是一个指针,最后一个字节存储的是它们自己的变量,然后A
的对象模型放在了最后面。
我们可以通过内存窗口查看B
和C
这个指针里的内容:
为什么要让B
和C
都能找到公共的A
呢?因为当发生赋值切片时,需要知道A
的位置:
D d;
B b = d;
C c = d;
d
需要找到B
、C
中的A
才能赋值过去,所以这样设计不是没有道理的,编译器又不知道你的A
在哪,所以要提前保存偏移量。
菱形虚拟继承的对象模型简化图:
为什么不直接把偏移量的值放在对象模型中呢?因为这样会难以区分是偏移量的值还是对象的值,保存地址既节省空间又方便。
我们把这个保存地址的指针叫做虚基表指针
,这个指针指向的内容叫做虚基表
。
- 总结:菱形虚拟继承通过创建一个公共的
A
(上图菱形继承中父类A)模型来解决菱形继承中二义性和冗余的问题,并在B
、C
子类(菱形继承的中间类)的对象模型中保存一个虚基表指针来指向虚基表(保存偏移量)来找到公共的A
对象模型。
♊️ 继承总结
- 继承作为C++面向对象三大特性之一,是一个难点,同时其多继承的出现导致了菱形继承的二义性和冗余问题,进而菱形虚拟继承的出现解决了这个问题,所以不建议设计出多继承,特别是多继承中的菱形继承,这会使你的代码的底层变得异常复杂。
- 继承和组合的关系:继承和组合都是OO语言中功能复用的常用手段,各有优缺点。
- 继承是
is-a
的关系,也就是说每个派生类对象都是一个基类对象。 - 组合是
has-a
的关系,假设B
组合了A
,每个B
中都有一个A
。 - 关于什么时候使用继承?什么时候使用组合?遵循一个原则,能使用组合就使用组合,因为继承会破坏封装性,耦合度高,但是组合的耦合度低,而且使用组合不会让代码变得很复杂。
- 继承是
🕐 组合和继承
我们可以从对象模型来看一下如果一个类分别使用组合和继承,它的对象模型是否会有所不同。
A
和B
是组合关系
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
//组合
class A
{
protected:
int a = 3;
};
class B
{
public:
A a;
int b = 2;
};
int main()
{
B b;
cout << &b << endl;
cout << sizeof(B) << endl;
return 0;
}
A
和B
是继承关系
#include <iostream> // 引入iostream头文件以使用cout
using namespace std;
class A
{
protected:
int a = 3;
};
class B : public A
{
protected:
int b = 2;
};
int main()
{
B b;
cout << &b << endl;
cout << sizeof(B) << endl;
return 0;
}
运行结果比较(从内存窗口看其内部的存储):
可以看到组合和继承对对象模型并没有太影响,但是组合和继承确实存在不同,继承派生类和基类间的依赖关系很强,耦合度高。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。