C++继承和多态核心重点知识刨析,一文必拿下

2022/3/2 22:45:25

本文主要是介绍C++继承和多态核心重点知识刨析,一文必拿下,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

一. 继承

  • 继承的本质是为了复用, 复用基类的数据成员和方法. 
  • 封装的本质是为了对外仅仅暴露必要的使用接口, 内部的具体实现细节和部分的核心接口对外是不可见的,   隐藏细节, 仅对外开放必要功能性接口....
  • 正是由于封装隐藏所需, 所以产生了公有属性 和  私有属性...  公有对类外类内均可见,可用, 私有的仅仅暴露在我们程序开发设计人员面前,, 用户是不可见的,也就是类外不可见 

1.1. 三种继承方式图解

  • 如果是学校的教学, 一般来说开局就是记住下面这张图, 但是其实理解之后便可了

  •  注意点1:  基类的私有数据成员在派生类中是绝对不可见的,  试想可以理解, 你爸的私密事情不可能告诉你吧, 所以类的私有成员仅仅在本类中可见, 派生类中都是不可见的..
  •  注意点2: 保护属性的产生是为了什么?? 是为了在派生类中可见, 在类外不可见。试想一下如果你需要部分数据成员对于类外用户是不可访问的, 但是对于自己的派生类中是可见的, 这个时候private 属性肯定是无法达到的, 于是protected 应运而生....   所以需要继承的类我们常不用private 而是用protected 定义成员对象... 
  •  继承方式   :   public  >   protected  >   private    继承后的成员属性,  public继承属性不变, protected  和  private  继承将对应的限制小的属性升级为限制更大的属性
  • 默认继承方式  :  class 是private继承    struct  是public继承
  • 使用继承, 我们的继承方式一般都是public继承, 因为其他继承方式, 没法复用代码呀

1.2. 继承的内存分布图, 父类私有成员子类不可见, 但是否继承下来??

  • 结论: 子类会将父类的所有的成员全部继承下来, 不可见只是语法上的限制, 内存上其实是有这个变量的, 只是语法上限制访问
class Base {
public:
	int a;
private:
	int b;
};

class Derived : public Base {
};
int main() {
	cout << sizeof(Base) << endl;   //??
	cout << sizeof(Derived) << endl;//??
	return 0;
}

  •  结果证明, 是全部继承下来的, 继续看看内存
class Base {
public:
	Base(int a, int b) 
		: _a(a)
		, _b(b) 
	{}
	int _a;
private:
	int _b;
};
class Derived : public Base {
public:
	Derived(int a = 0, int b = 0, int c = 0) 
		: Base(a, b)
		, _c(c) 
	{}
private:
	int _c;
};
int main() {
	Derived d(1, 2, 3);
	return 0;
}

可以看见的是私有成员虽然在派生类中不可见, 但是本质上也是存在的, 仅仅只是语法上限制了访问...    

1.3. 父子类对象之间的赋值

切割 :  

切片  :   切片, 指的是权限限制只能访问切片的基类部分的数据方法, 和切割还是不一样的,    不会重新拷贝一份出来, 而是直接的指向对应的基类, 然后限制只能访问数据基类的成员即可了, 这个就是切片引用的本质

  •  用好编译器的各种监视窗口 反汇编窗口, 对于我们学习分析特别有好

1.4.继承与静态成员 

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一 个static成员实例 。

static 静态成员是专属于一个类的, 无论派生出去多少个子类,   均只是一个static 成员实例对象, 不会一个派生类中产生一份, 而是全局独独一份:

class Base {
public:
	Base(int a, int b) 
		: _a(a)
		, _b(b) 
	{}
	int _a;
private:
	int _b;
	static int count;
};
int Base::count = 0;
class Derived1 : public Base {
public:
	Derived1(int a = 0, int b = 0, int c = 0) 
		: Base(a, b)
		, _c(c) 
	{}
private:
	int _c;
};
class Derived2 : public Base {
public:
	Derived2(int a = 0, int b = 0, int c = 0)
		: Base(a, b)
		, _c(c)
	{}
private:
	int _c;
};
int main() {
	Derived1 d1(1, 2, 3);
	Derived2 d2(1, 2, 3);
	return 0;
}

Base 类的进程数据成员都不会存在在派生类对象中, 是属于整个基类的,是在进入主函数之前就定义好了的, 全局独一份的, 存储在静态数据段中的

1.5.多继承引发的菱形继承(数据的冗余, 数据访问的二义性)

  •  如上这种继承方式就叫做菱形继承......
class A {
public:
	A(int a = 0)
		: _a(a) {
	}
	int _a;
};

class B : public A {
public:
	B(int a = 0, int  b = 0) 
		: A(a)
		, _b(b) {
	}
	int _b;
};

class C : public A {
public:
	C(int a = 0, int c = 0) 
		: A(a)
		, _c(c) {
	}
	int _c;
};

class D : public B, public C {
public:
	D(int a = 0, int b = 0, int c = 0, int d = 0) 
		: B(a, b)
		, C(a, c)
		, _d(d) {
	}
	int _d;
};

 直接访问上述的_a 成员出现了问题, 因为不知道应该访问哪一个_a, 先看一看内存窗口:

 提出问题一: 存在两份_a数据成员, 造成两个问题, 1. 数据冗余,(两份_a)         2. 二义性问题(访问的之后究竟访问的是那一份_a)    

解决方式1:仅仅可以解决的就是说二义性的问题, 可以通过指定访问的方式, 通过:: 访问

 上述方式虽然是解决了数据访问的二义性问题, 但是还是没有解决的是数据冗余的问题, 数据冗余问题其实还是蛮大的, 现在A类的大小不大问题还显示不出来, 要是A类特别庞大, 就有一份重复的A类的各种数据成员被继承下来, 这个数据冗余带来的存储代价还是蛮大的

  • 解决方式2: 采取虚继承的方式来进行处理上述问题....   使得A类的数据成员仅仅只有一份
  • 但是如果这个A类的成员仅仅只是存在一份 当我们创建D的对象的时候这个属于祖宗类的成员是放到  B 类中还是C类中????

  •  加上virtual 关键字修饰进行虚继承
class A {
public:
	A(int a = 0)
		: _a(a) {
	}
	int _a;
};

class B : virtual public A {
public:
	B(int a = 0, int  b = 0) 
		: A(a)
		, _b(b) {
	}
	int _b;
};

class C : virtual public A {
public:
	C(int a = 0, int c = 0) 
		: A(a)
		, _c(c) {
	}
	int _c;
};

class D : public B, public C{
public:
	D(int a = 0, int b = 0, int c = 0, int d = 0) 
		: B(a, b)
		, C(a, c)
		, _d(d) {
	}
	int _d;
};

 首先咱先观察这个普通的监视窗口, 给我们的感觉好像就是存在两份的_a在其中? 是吗? 答案肯定不是呀, 那要这个虚继承有个屁用, 还不是没有解决数据冗余  (两份祖先数据成员的问题吗)  

答案是因为监视器是为了让我们看着简单, 不想那么多, 而经过了处理的, 其中存在细节处理, 我们可以真正深入内存窗口去查看.,  将列设置为4列, 单位就是一个int 了4字节的看,    

 存储的是地址差距的地址,  通过这个地址找到距离属于A的数据成员的位置进行访问

总结感慨:  多继承真他妈坑爹,  它搞出来的菱形继承的坑, 只能搞出来一个虚继承来填补: 数据冗余  +  数据访问的二义性,  但是来了菱形继承, 搞得这个继承下来的孙子派生类的内存模型复杂了简直不要太多,  内存分布模型再也不想切割和切片那样的明白简单了, 而是复杂了简直不要太多, 需要存储和A类数据成员的地址偏差,  为了保护这个偏差, 还没有直接存储, 而是通过存储这个差值的地址值来实现的,  解决了问题, but  令人的学习成本简直翻倍不要太多.

所以其实真正用的时候, 基本不是必须都是使用单继承, 而不是多继承

1.6.  小结继承, 是用组合还是用继承?

  • 继承与组合的选择? 分别: 
  • 继承更加透明, 继承对于派生类中使用基类的成员更加透明, 不只是public属性成员 还有protected 属性成员都是可见的, 而且一般继承都用的是protected 属性, 所以继承的复用在派生类中相对透明
  • 组合, 是一种黑箱接口复用, 和类外效果是完全一样的, 只能看到设计人员想要你看见的部分, 也就是只能使用其中的public 接口函数(一般来说) , 这样有些时候是非常的不方便的在有的时候, 很多数据成员无法直接访问, 使用它进一步设计接口不是很方便
  •   究竟是使用继承还是使用组合, 这个的选择没有固定的一个说法,  有的推荐为了更好的封装性,   低耦合性 , 能用组合的尽量用组合, 但是我觉得这个没有绝对的标准, 一般那种具有基本同层次的关系    比如  人 :  黑人 白人  黄种人这种  is 的关系就使用继承,  (两个类具有衍生关系, 就使用继承)  of 关系 两个类管理起来可以组合成一个整体就可以使用组合  ( 比如年月日类组合成日期类这种)

二. 多态

2.1. 多态是什么?

  • 简单的理解多态: 对于同一个方法, 传入不同的对象调用, 会产生不一样的结果.
  • 上述这个也叫做动态的多态, 先来一小段函数  + 结果说明:
class Father {
public:
	virtual void sleep() {
		cout << "我是大人睡觉不打铺盖" << endl;
	}
};
class Son : public Father {
public:
	virtual void sleep() {
		cout << "我是小孩睡觉打铺盖" << endl;
	}
};
void Sleep(Father& who) {
	who.sleep();
}
int main() {
	Father f;
	Son s;
	Sleep(s);//传入孩子是啥结果? 
	Sleep(f);//传入Fa是啥结果?
	return 0;
}

  •  还有一个叫做静态的多态, 在编译时候确定, 函数重载.  

2.2. 多态的必要条件?

  •  使用基类指针或者引用去调用虚函数  
  •  在派生类中对于调用的虚函数进行重写

2.3. 虚函数重写的必要条件, 重写的本质是一种覆盖

  • 要构成虚函数重写, 在派生类中需要有和基类中完全一样的虚函数, 这个虚函数不仅仅只是函数名相同, 还需要参数返回值也必须一致才能构成虚函数重写(覆盖),称子类重写了父类虚函数
  • 虚函数重写特殊情况  1. 协变   2, 虚析构函数重写 
  • 协变 :    构成虚函数重写, 还存在一种返回值不同的特殊情况, 叫做协变, 基类虚函数返回基类引用或者指针,派生类虚函数返回一个派生类对象的引用或者指针    
class Base {
};//写一个空基类做返回
class Derived : public Base {
};//写一个空派生类做返回

class Fa {
public:
	virtual Base* GetBase() {
		cout << "传入基类对象, 掉基类的虚函数" << endl;
		cout << "基类虚函数返回基类指针引用. 派生类虚函数返回派生类指针引用, 称为协变" << endl;
		return new Base;
	}
};
class Son : public Fa {
public:
	virtual Derived* GetDerive() {
		cout << "传入派生类对象, 掉派生类重写的虚函数" << endl;
		cout << "基类虚函数返回基类指针引用. 派生类虚函数返回派生类指针引用, 称为协变" << endl;
		return new Derived;
	}
};
void test(Fa& who) {
	delete who.GetBase();
}
int main() {
	Fa f;
	Son s;
	test(f);
	test(s);
	return 0;
}

特殊案例二:  其实也不算特殊, 因为底层编译器会进行处理:

尽量将析构函数定义为虚函数, 为啥??  构造函数都不能定义为虚函数, 为啥析构函数可以? 函数名都不满足相同, 如何构成虚函数重写的要求??? (尽量让析构函数定义为虚析构函数构成多态??)

  • 因为对于析构函数而言, 编译器底层做了诸多的处理, 底层编译器会将析构函数函数名全部处理成destructor 作为析构函数的函数名的...
  • 然后再说一下析构函数的调用问题,  构造是先调用父类构造, 然后是子类构造, 析构自然是先调用子类析构, 然后再调用父类析构了, 因为析构函数的调用我们没有办法显示的指定先调用子类析构再调用父类析构, 所以析构函数的调用是由编译器自动调用了
class Fa {
public:
	Fa() :_a(new int(0)) {
		cout << "constructor Fa" << endl;
	}
	~Fa() {
		delete _a;
		cout << "destructor Fa" << endl;
	}
private:
	int* _a;
};
class Son :public Fa {
public:
	Son() {
		cout << "constructor Son" << endl;
	}
	 ~Son() {
		cout << "destructor Son" << endl;
	}
};
int main() {
	Fa* f1 = new Fa;
	Fa* f2 = new Son;
	delete f1;
	delete f2;
	return 0;
}

  •  上述问题如何解决?   父类指针指向子类对象. 但是无法调用子类对象的析构函数释放资源
  • 解决办法 : 利用多态的特性, 将析构函数定义为虚函数,   ??  可是函数名不同? 如何满足虚函数重写条件?   编译器底层处理成destructor 统一函数名. 加上virtual 之后再看结果:

2.4. 抽象类 ------> 纯虚函数

  • 抽象类(接口类) 包含纯虚函数的类叫做抽象类 (别名接口类)
  • 含有纯虚函数的类是抽象类, 无法实例化对象, 如果需要实例化对象必须将所有纯虚函数进行重写
class Base {
public:
	virtual void Work() = 0; 
	virtual void Sleep() = 0;
};

class Derived : public Base {
public:
	virtual void Work() {
		cout << "完成上述的接口函数" << endl;
		cout << "我正在工作" << endl;
	}
	virtual void Sleep() {
		cout << "完成上述的接口函数" << endl;
		cout << "我正在睡觉" << endl;
	}
};

2.5. 虚函数表。。。(虚表指针, 吃透)

  • 一道经典笔试题目虚表指针

 上述问题答案是4?  为啥. 就算是存在占位也应该是1字节, 去掉virtual 瞅瞅?

  •  是1 都是不算特别奇怪,  这一个字节做占位
  • 4 个 字节哪里来的?

  •  上述指针就叫做虚表指针, 指向一张虚函数表.
  • 这张虚函数表中存储的全部是函数入口, (函数指针)本质函数指针数组
  • 这个虚表指针指向上述的虚函数表(简称虚表).  这个指针的本质是一个三级指针(指向一个函数指针数组)
typedef void (*PFunc)(); 
//PFunc 是一个类型, 函数指针类型
class Base {
public:
	virtual void func1() {
		cout << "Base virtual void func1()" << endl;
	}
	virtual void func2() {
		cout << "Base virtual void func2()" << endl;
	}
	virtual void func3() {
		cout << "Base virtual void func3()" << endl;
	}
};
class Derived : public Base {
public:
	virtual void func1() {
		cout << "Derived virtual void func1()" << endl;
	}
	virtual void func2() {
		cout << "Derived virtual void func2()" << endl;
	}
	virtual void func3() {
		cout << "Derived virtual void func3()" << endl;
	}
};
//打印虚表函数
void PrintVTable(PFunc** vptr) {
	cout << "虚表函数调用如下: " << endl;
	PFunc* VFunArr = (PFunc*)vptr;//强制转换为
	for (int i = 0; VFunArr[i] != NULL; ++i) {
		VFunArr[i]();	//调用函数
	}
	cout << endl;
}
int main() {
	Derived d;
	PrintVTable((PFunc**)*(int*)&d);
	return 0;
}

  •  通过上述方式可以进行虚表函数的调用 ,   VFunArr[i]() 可以调用虚函数, 更进一步证明了  VFuncArr[i] 是虚函数的入口, 函数指针, 也说明了虚表就是一个存储虚函数指针的数组... 函数指针() 可以实现函数调用 
  • 强调:            虚表中存储的不是虚函数, 而是虚函数的地址, 函数入口, 虚函数指针

再画一张图给大家加深一下印象:

 

  •  到了恶心的环节了, 当时我跟大家的想法是完全一致的(刚学习的时候). 为啥这个指针要转换过去转换过来的, 主要原因是, 指针的基类型级别不同, 指针 ++ 之后 跳转的地址值是不确定的

三. 总结

  • 本文从继承分析到多态:   很多小的细节没有介绍到, 主要是解释了一下其中我认为的重难点
  • 继承: 主要是多继承引起的菱形继承, 处理是虚继承, 全局一份最初基类数据, 所以这个数据放置的位置放置在B 和 C中都不合适, 然后不放置在B和C中如何可以访问这个数据呢? 为了访问这个数据, 采取的措施是记录到这个全局一份数据的偏移量, 为了保护这个偏移量, 在对象内存模型中存储的是这个偏移量的地址
  • final 关键字修饰类,该类不能继承   
  • 多态解释:  不同的对象调用同一个函数, 执行同样的行为会产生不同的结果
  • 多态必要条件,父类指针指向子类对象或者引用子类对象, 在派生类中重写基类虚函数, 使用父类的指针或者引用调用这个虚函数
  • 多态的特例: 协变 和  虚析构(底层处理 统一成destructor析构函数名)
  • 多态的底层原理 为啥虚函数重写的本质是覆盖, 愿意是虚函数的地址存储在虚表中, 重写虚函数其实是对于虚表中对应的虚函数地址中的虚函数进行的一个覆盖操作..
  • 如何找到虚表, 对象模型中会存储一个虚表指针, 帮我们找到这个虚函数


这篇关于C++继承和多态核心重点知识刨析,一文必拿下的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程