C++认知继承

Posted Booksort

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C++认知继承相关的知识,希望对你有一定的参考价值。

对于继承,这是C++中相当重要的语法,
学习此语法可以更好的认知C++这个恢弘的世界。

介绍继承

C++作为一个面向对象的语言,而面向对象的编程的主要目的之一就是提供可重用的代码

当开发大型项目时,重用经过测试的代码,可是比新编写的代码要可靠好多。

比如,使用经过深海实战的深潜器可是比用新技术制造的崭新深潜器要可靠的多(嘻嘻,参考龙族3,凯撒小队使用迪里雅斯特号进入神葬所)

已有代码经过多次测试调整,bug已经极大程度的减少了。对于项目和小组成员的血压比较友好。

在C++中,有类模板,函数模板,可以重复使用,降低开发者血压。

而对于C++的类而言,也提供了更高层次的重用。
有类库的概念,类库是由类声明和实现构成的,组合了数据表示和类方法。
C++提供了类继承,来为开发者提供对类的修改和拓展的方法。

类继承能够从已有的类派生出新的类,而派生类继承了原有类(基类)的特征,包括方法

正如 继承一笔财产 远比自己 白手起家 容易成功。
通过继承派生出的类远比自己重新设计一个类容易。
(话糙理不糙)

类继承举例

  • 某个字符串类,可以派生出一个类,添加指定字符串显示颜色的数据类型。
  • 在已有的类中添加功能(成员函数)
  • 航空公司给普通乘客提供基础服务可以看作一个类,而给商务舱乘客提供跟高级的服务可以在普通服务类派生出一个商务舱服务类,并添加更多服务方法。

通过对原始类进行修改来满足开发者的更多需求,而继承机制只需要提供新特性,且不需要访问源代码就能派生出新类。

类继承的认知

当两个类中有大量重复的信息(类成员变量或成员函数),仅存在少量差别时,可以使用类继承,对类进行复用。

举例:

#include <iostream>
#include <string>
#include <vector>
using namespace std;

class Player
{
protected:
	string _name;//名字
	bool hasTable;//是否有比赛场地

};

class Member_Player:public Player
{
protected:
	vector<int> History_Score;//历史得分
	double Rating;//获胜比
};
int main(void)
{
	Player Tom;//普通玩家Tom
	Member_Player Jack;//VIP(办卡)玩家Jack
	return 0;
}

继承关系和访问限定符

private/public/protected是C++中的关键字,他们描述了对类成员的访问控制。
描述了对类成员的访问权限大小,

public > protected >= private
对于没有涉及到继承时,protected与private的权限是一样的,类外都是不能直接访问其所修饰的成员。

public是完全开放的的权限,任何成员都能在类外直接被访问。

而继承方式,也是靠这三个关键字来控制。
列了一张表

这里面,派生类中的public成员、protected成员、private成员的意思,是指,从基类(父类)中继承的成员,在派生类(子类)中的访问限定是这些权限。protected和private者两权限是一样的,都是不能直接访问,但是可以通过public的成员函数去访问。

private访问与protected访问的区别

但是,
protected修饰

private修饰

这就说明protected与private在继承中还是存在一些差别。
我的理解是这样的,protected访问限定,在基类中功能和private是一样的,阻止类外访问成员。而继承后,在派生类中,privates是完全阻止在类(基类)外进行访问,在派生类中通过成员函数去访问也是不允许的,
也就是说基类中的private成员正在派生类中是完全不可见的,
但是在派生类中是存在的,只是派生类中是完全没有权限去访问的。

总结一下:
对基类的外部而言,protected成员与private成员相似;
但对于派生类中,protected成员与基类中的public成员相似。
对于基类中private成员,派生类中在逻辑(语法)上是不可访问,不可见的,是不存在的;
但从内存(物理)的角度来说,基类中的private成员是存在于派生类中。

分析访问限定与继承方法的排列组合

总的来说,对于继承方式与访问权限都是
公开(public)> 保护(protected)> 私有(private),最后在派生类中,对于基类中的成员,都是权限遵循较小的那个权限。
如果是private继承,那么基类中的成员在派生类中都是不可见、无法访问的,无论在基类中的公开还是保护成员(逻辑上),但是在物理上还是依旧在派生类中(参见上)。

也就是说,对于派生类继承基类的成员而言,要经历继承方式访问限制。而这些都存在一定的权限,所以,大概总结一下,最后派生类中,对于基类的成员的继承权限(访问权限),找继承方式于、与访问限制中较小的权限,作为派生类继承来的权限。

public(公有) > protected(保护) > private(私有)

赋值兼容规则

提问:基类与派派生类创建的对象是否可以互相赋值

答:派生类对象可以给基类对象赋值
但,基类对象不能给派生类对象赋值

这就存在一个切割赋值的概念了。

派生类对象给基类对象赋值

基类对象给派生类对象赋值

指针与引用

基类和派生类的指针与引用。
其中这两者的关系是,

基类指针和引用可以指向派生类的对像,
但是派生类的指针和引用不能指向基类。

为什么

在我学习C语言的时候,认识到一个概念,指针其实就是计算机内存中的地址,指针是计算机内存中的一个变量空间,变量空间的大小取决于计算机是多少位机。
指针是一个变量空间,该空间储存的值就是计算机中的某个空间的地址(非法的或合法的)。
但是指针并不止步于此,指针还决定了访问空间的字节大小,这是取决于指针的类型。也就是说,指针还要考虑能够在指向空间中访问的字节大小。这是非常关键的概念。
当基类指针指向派生类对象时,基类指针其实也就只能访问,派生类中,属于基类中的成员,对于派生类新增的成员是无法访问的。

也就说,对于这个指针是合法的,不存在任何访问问题。

但是对于派生类指针而言,就存在越界访问的问题(可能,只是我这个水平的理解)。
因为,派生类中比基类多了一些成员,对于派生类指针,其访问字节大小要比基类指针多,那么就存在一些非法空间可能会被访问。

所以,C++考虑安全起见,就不会允许可能存在越界的情况(拙见,大佬指正一下)。

而对于引用而言,和指针是一样的,因为,引用就是指针的封装,底层其实还是指针(汇编角度)。

好玩的地方

这样,基类的构造函数或者拷贝构造函数中的基类指针或引用也能接收派生类对象。这样,可以使用派生类对象去初始化一个基类对象,某种意义上,基类对象初始化为一个派生类对象,尽管只是初始化了派生类中基类的部分。

对于赋值,其实是调用了隐式赋值运算符重载

建议

对于C++继承而言,有三种继承方式,有三种访问限制。

根据上面的分析,尽量使用public继承, 对于基类中的成员变量使用protected限定。

使用private访问限定,导致派生类中根本不可见基类对象,无意义。使用protected或private访问控制,会导致,积累词语无法再类外访问或不可见,对于派生类继承后没什么用,所以不推荐。

继承中的作用域

类是存在作用域的,基类于派生类是存在继承关系,但是,这依就是两个独立的作用域

基类与派生类中一样标识符的成员

一样标识符的成员,包括成员变量成员函数
这是允许定义一样的标识符的成员。

class Player
{
protected:
	string _name;//名字
	bool hasTable;//是否有比赛场地
public:
	Player(string name="Tom",bool has=true)
		:_name(name)
		,hasTable(has)
	{}
	void Print(void)
	{
		cout << "Player:"<<_name << endl;
	}
};
class Member_Player:public Player
{

public:
	Member_Player(Player aim,vector<int> Score, double rate)
		:Player(aim),History_Score(Score),Rating(rate)
	{}
	void Print(void)
	{
		cout << "Member_Player:"<<Player::_name << endl;
	}
protected:
	vector<int> History_Score;//历史得分
	double Rating;//获胜比
};

int main(void)
{
	vector<int> v = { 1,2,3,4,5 };
	Player Tom("Tom",false);
	Member_Player Jack({ "Jack",true }, v, 0.4);
	Player op (Jack);
	Jack.Print();
	Jack.Player::Print();
	return 0;
}


如果是直接调用,只能调用派生类中的Print函数,
如果想调用基类中的Print函数,必须要声明类域。
还有,如果想在派生类中使用基类中的成员变量(前提是不能为private),直接调用即可,如果派生类中没有和基类中一样的成员变量

class Player
{
public:
	string _name;//名字
	bool hasTable;//是否有比赛场地
public:
	Player(string name="Tom",bool has=true)
		:_name(name)
		,hasTable(has)
	{}
	void Print(void)
	{
		cout << "Player:"<<_name << endl;
	}
};
class Member_Player:public Player
{

public:
	Member_Player(Player aim,vector<int> Score, double rate)
		:Player(aim),History_Score(Score),Rating(rate)
	{}
	void Print(void)
	{
		_name = "hello";
		cout << "Member_Player:"<<_name << endl;
	}
protected:
	vector<int> History_Score;//历史得分
	double Rating;//获胜比
	string _name;
};

int main(void)
{
	vector<int> v = { 1,2,3,4,5 };
	Player Tom("Tom",false);
	Member_Player Jack({ "Jack",true }, v, 0.4);
	Player* op = &Jack;
	op->_name = "tom";
	Jack.Print();
	Jack.Player::Print();
	return 0;
}

我把派生类改了一下。

在派生类中增加了一个与基类一样的成员变量。
调用后

看看代码执行了上面,输出结果是什么。
我调用了基类指针去修改派生类中的基类部分的成员变量。
说明,当调用派生类中的Print函数时,使用的成员变量默认是派生类中的成员变量。只有显示调用基类中的Print函数时,其中成员变量的使用是其基类自己的。

也就是说,在不同的类域中,遵循“就近原则”,智慧线在自动的类域中先找,如果有就直接使用,如果找不到,就回去更大的域中去寻找。
无论是成员函数还是成员变量。

其实,在基类与派生类中,有一样的成员是,这两个一样的成员构成隐藏关系,而不是重载关系因为,这是在两个独立的域中,重载是要求在一个作用域中。

当存在上面的例子的情况时,派生类的成员会隐藏基类的成员,除非显式指明作用域
例:

注意

最好不要在派生类中定义与基类同名的成员。这个虽然可以通过生你们作用域来指定调用,但是,人是最大的bug,一旦出错,还不好查出来问题在哪。

所以,强烈建议不要使用同名的成员

派生类

is-a关系

这个关系代表:派生类对象也是一个基类对象,可以对基类对象执行任何操作,也可以对派生类对象执行。
也代表包含的关系,
例如,一个fruit类,保存水果的重量和热量,而一个banana类代表一个香蕉的特性,因为香蕉是一个水果中的一种,所以可以从FRUIT类中派生出BANANA类,还可以为BANANA类增加新的特性。这是一种完全包含的关系。
如:

水果可以是早餐,但早餐不一定是水果,不一定是香蕉,早餐和水果就无法构成is-a的关系

派生的构造函数

当创建一个派生类的对象时,并不是直接就根据派生类去创建对象。
首先,会创建基类对象。也就是说,在派生类对象在入栈帧时,基类对象已经被创建好了(理解成变量入栈)。

比如有,创建派生类对象时,首先会调用,基类的构造函数,然后才会调用派生类的构造函数。

单步调试可以完整的看到这个过程。

介绍一下,创建派生类对象的整个流程。

  1. 要看在派生类的构造函数里是否有显式调用基类的构造函数,如果没有就会调用基类中的构造函数。
  2. 把基类对象创建好后,就会去调用派生类的构造函数,创建派生类对象。

派生的拷贝(复制)构造函数

对于派生类的基类而言,这都不是什么问题,赋值兼容规则,可以让派生类切割去构造一个基类,然后再回调用函数里的定义,创建相应的派生类对象。

还有一点,派生类切割创建基类对象时,调用的是基类的构造函数,这个函数也要处理深拷贝的问题

其中涉及了深浅拷贝的问题,,这里需要自己去重新编写函数定义,默认的拷贝构造函数并不适合深拷贝。

在下列情况,会调用拷贝构造函数

  1. 将新对象初始化为一个同类对象
  2. 按值将对象传递给函数
  3. 函数返回对象的值,而不是引用
  4. 编辑器生成的临时对象

派生类的赋值运算符重载

赋值运算符通常用于同类对象之间的赋值,

注:不要将赋值与初始化搞混了

如果语句中创建了新的对象,这是初始化;
如果是语句修改已有对象的值,这是赋值。

基类

Player& operator=(const Player& tmp)
	{
		_name = tmp._name;
		hasTable = tmp.hasTable;
		return *this;
	}

派生类

	Member_Player& operator=(const Member_Player& tmp)
	{
		History_Score = tmp.History_Score;
		Rating = tmp.Rating;
		_name = tmp._name;
		hasTable = tmp.hasTable;
		return *this;
	}

可能有些书上,回提供转移构造函数和转移赋值函数,其实,以我目前的水平看来,都是要先进行类型转换的(构造了一个临时对象),都是要转换成同样的类型,再处理。

析构函数

用于处理回收类创建对象而申请的资源。
这其实根本就不需要我们去显示调用,不然,我们使用类干什么?
看看编译器是怎么处理这些类的

因为,这些都是属于栈这个内存空间中,而栈的特性是后入先出,在这里也是一样的。对象创建,就会进入栈,为其开辟栈帧。
还有一点,我们先前提到的,使用派生类创建一个对象,要先创建一个基类对象,然后才会创建派生类添加的成员,一起构成一个对象。

当然,可能有人想在派生类的析构中先先清理掉其基类对象,调用基类的析构,如:

	~Member_Player()
	{
		Player::~Player();
		cout << "~Member_Player()" << endl;
	}

这样的话,

会调用两次析构,这就非常不符合,我们的设计原理,如果存在new分配空间的对象,在析构中delete两次,程序就会直接报错,所以这是不正确的也就是说,我们不应该在派生类的析构中去显式调用基类的析构。

其正确的结构应该是这样的,对象都是在栈中进行处理。

当,程序结束,系统要开始回收资源,对于创建的对象也要开始调用析构函数,回收资源,对象就要调用析构函数,出栈。这也是要符合栈顶特性的。



就这样依次打印析构函数里的信息,就会出现最后的效果。

类设计

构造函数不能被继承

构造函数不同于其他的类方法,因为这个方法,它是负责创建新的对象的,而其他的方法只是被现有的对象调用,这是构造函数不被继承的原因之一。

派生类对象继承基类的方法,就意味着,这个对象已经被创建好了,而对象没有创建好,派生类对象也就没法使用

继承意味着派生类对象可以使用基类对象的方法,然而,在构造函数完成工作前,基类对象是不存在的。

析构函数

一定要定义显式析构函数来释放所有的自定义类型(如:new分配内存创建的),并完成类对象所需要的特殊的清理工作。对于基类而言,即使不需要,编译器会自动处理那些成员,也最好提供一个虚析构函数。

析构函数也是不能被继承的,在程序结束时,编译器会先去调用派生类的析构函数,然后再去调用派生类的析构函数。

友元函数

友元函数并不是类成员,因此不能被继承。
友元只是类外可以突破类的访问权限的普通函数。

静态成员

静态成员得治并不在栈帧里,其位置是在静态区中,无论以什么样的继承方式,静态区中只有那为一一个独一无二的静态变量。

有关使用基类方法的说明

  • 派生类自动使用继承而来的基类方法,如果派生类没有重新定义该方法或者定义了一个函数名一摸一样的函数。
  • 派生类的构造函数自动调用基类的构造函数,如果没有在初始化成员列表中显示调用(赋值)基类构造函数。
  • 派生类的友元函数可以通过强制类型转换,将派生类的引用或者指针转换为基类引用或者指针,然后使用该指针或引用来调用基类的友元函数。

菱形继承

顾名思义,就是继承方式特别像菱形
大概长这样

Jack这个人,即是贵宾用户,又是工作人员。

比如:你在银行有5个亿的存款,银行会把你认证为VIP用户,然后你感到整天挥霍,很无聊,想找些事做,然后银行经理就会给你安排一个保安(bushi)、某个部门的小负责人。这样你也就是银行的员工了。
你在银行就有两个身份了,既是VIP用户,又是银行员工。

但是,这也引发了一个问题,Worker继承了Player这个类,而Member_Player也继承了Player,而Player这个类中的成员有_name,个人的名字,那Worker和Member_Player又被Person给继承了,那么其继承的两个类中有一样的成员,那么当访问该成员时,访问到到底是哪个成员呢?

访问不明确,这就造成了二义性的问题,
而且,有大量一样的成员,这又会造成数据冗余的问题。

当然,对于二义性的问题,可以通过指定作用域来解决。

比如:
你在银行的部门里,是副部长的职位,别人都叫你王副部长。
然而,当银行经理在办理业务时,称呼你,是叫你王总,也就是说你会有不同的称呼

这样就能正常编译,还解决了二义性的问题。

但是,C++作为一个高效的语言怎么能容忍数据冗余和二义性的问题?

虚继承

可以在Person所继承的两个基类(Worker和Member_Player)对他们的基类进行虚拟继承,因为,二义性和数据冗余的问题出在这上面。

使用了虚继承的代码

这两个一样的成员_name也能一起变化了,实际上这就是一个成员。

我给你们看看在内存中怎么处理的

看到了没?这个_name的地址都是一样的地址,即使是显式调用了不同类中的_name,但当复制到时候,其变化的位置是没有改变的,但是值发生了改变,就说明这两个变量实际上是一个变量。

这样就将虚继承中一样的成员化为一份

这样,就解决了数据冗余和二义性的问题。

感谢大佬观看,感谢感谢!

以上是关于C++认知继承的主要内容,如果未能解决你的问题,请参考以下文章

这些 C++ 代码片段有啥作用?

有趣的 C++ 代码片段,有啥解释吗? [复制]

以下代码片段 C++ 的说明

C++ 代码片段执行

c++类后面带一个:什么意思 class CAboutDlg : public CDialog//什么意思? public: CAboutDlg(); 求解释

此 Canon SDK C++ 代码片段的等效 C# 代码是啥?