C++ 继承和多态

前言

C++有三大特性:封装,继承和多态。类和对象将封装体现的淋漓尽致,封装将数据和方法结合到一起,和C语言相比,封装为C++带来了极大的便利,使很多学了C++的人都不想再写C语言(这里指的是C语言需要重复造轮子的方面使得C++使用者不想使用C语言)。

比如stack,vector,lits容器,有了封装,就有了STL标准库,要用容器直接用STL中的,不用自己写。并且当自己实现stack这样的容器时,比起C语言的数据方法分离,还是C++的封装实现得舒服。

再就是迭代器的设计,迭代器用来遍历容器,所有的迭代器使用方法都一样,但它们的底层却各不相同,正是有了封装。复杂的底层实现和简单的底层实现被封装统一,对外提供的接口使用都是一样的,这样的做法同时也节省了使用者的学习成本,而如果没有封装,使用者就需要对容器的底层有详细的了解,这样才能在使用容器时得心应手,但这无疑增加了使用成本。

最后一个就是类的复用,stack,queue,priority_queue的实现都是复用其他容器,复用性高符合了代码的高内聚原则。

继承

与封装并称的继承和多态又是什么?从某种程度上,继承的出现也是因为代码的复用性,比如有个图书管理系统,使用者有老师,学生,老师和学生有一个共同的特性,就是人的特性,姓名,性别,年龄等等,如果两个类都有这些信息,为什么不去复用呢?所以继承出现了,我们可以实现一个人的类Person,保存人的基本信息,老师和学生就继承Person,Person被称为基类,老师和学生为子类。

子类的格式:class 类名 : 继承方式 父类类名,比如class Student : public Person,Student是Person的公有继承的子类。在这里插入图片描述
访问限定符和继承方式的关键字都是相同的,因此在这样的组合下有9种情况,这9种情况是关于子类访问基类成员的权限。但只要记住两点:1.private成员无论怎么继承,子类都无法访问。2.剩下的访问权限就是访问限定符和继承方式中较小的那个,比如protected修饰的基类对象,用public继承,子类的访问权限为protected,protected和private的区别是private修饰的成员被继承后,在类中和类外都不能访问,而被protected修饰的成员被继承后能在类中访问,不能在类外访问

在实际运用中很少使用protected和private继承,大多使用public继承

切片

何为切片?将子类对象赋值给父类对象/父类指针/父类引用的过程被称为切片,因为赋值过程中需要将子类对象自己的成员切除,保留父类成员,再赋值给父类,这个过程也是向上转换,但语法规定,父类对象不能赋值给子类对象。还有就是用基类类型引用子类对象,以及基类指针指向子类对象,这两个过程中不会产生临时变量,引用是引用子类对象的父类部分,指针同理,指向的是子类中的父类部分,不会重新构造一个父类对象再用指针指向,或者去引用。在这里插入图片描述
(两者的地址也相同)

继承中的作用域

父类与子类构成两个独立的作用域,也就是说如果父类成员与子类成员同名,那么这两个成员不构成重载(如果两个成员是函数的话),而构成隐藏(也叫重定义,无论函数还是对象,只要同名就构成隐藏),即父类成员被子类屏蔽,不加父类作用域限定符访问的都是子类成员。所以尽量不要写出同名的成员

(重载的前提是两个函数的处于同一作用域,并且参数不同

class A
{
public:
	void fun()
	{
		cout << "class A" << endl;
	}
	int _num = 20;
};

class B : public A
{
public:
	void fun()
	{
		cout << "class B" << endl;
	}
	int _num = 10;
};

void test11()
{
	B b;
	b.fun();
	cout << b._num << endl;
	// 要访问父类成员就要加上访问限定符
	b.A::fun();
	cout << b.A::_num << endl;
}

在这里插入图片描述

子类的默认成员函数

可以将子类从父类继承的成员这部分看成一个自定义成员,构造子类对象时,会调用父类的默认构造函数初始化子类中从父类继承的成员(这个过程在初始化列表完成,并且在初始化子类成员之前),之后再初始化自己,规则参考普通类。子类的析构,拷贝,赋值重载也同理。

class Person
{
public:
	Person(string name)
	{
		_name = name;
	}
protected:
	string _name;
};

class Student : public Person
{
public:
	Student(string name = "", int num = 0)
		:Person(name) // 显示调用父类构造函数,因为父类没有默认构造函数
		,_num(num)
	{}
protected:
	int _num;
};
构造函数

在这里插入图片描述
不能在子类构造函数的初始化列表初始化父类成员的原因:子类构造函数的初始化列表会自动调用父类的默认构造函数(如果父类没有默认构造函数,需要在子类构造函数的初始化列表显式调用父类的构造函数),编译器认为此时的子类对象还没有父类成员(在初始化列表后,父类成员才被定义完成),所以报错,在初始化列表之后对父类成员进行赋值,这种行为是可以的,因为编译器认为子类对象中的父类成员已经创建了。
在这里插入图片描述
创建子类对象时,如果父类Person没有默认构造函数,但子类构造函数的初始化列表会调用父类的默认构造函数,而父类没有默认构造函数,程序报错在这里插入图片描述
解决方法是:在初始化列表调用父类的构造函数在这里插入图片描述
在这里插入图片描述
初始化列表的初始化顺序和声明顺序相同,不取决于你的代码先后顺序,可以理解为父类先于子类声明,所以Person(name)一定比_num(num)先执行

拷贝构造

在这里插入图片描述
这个和构造函数同理
在这里插入图片描述
如果拷贝构造什么都不写,程序先拷贝构造父类成员,走父类的默认拷贝构造函数,然后结束(因为什么都没有写),但编译器自动生成的拷贝构造会拷贝父类成员(自定义类型调用自定义类型的默认构造函数,内置类型完成浅拷贝)
在这里插入图片描述
编译器生成的构造函数就像这样,Person(s)这里有一个切片,因为父类拷贝构造的参数肯定是父类对象,用子类对象赋值给父类对象,这是一个切片。

赋值重载

在这里插入图片描述
如果不写赋值重载,编译器自动生成的就像这样,但要注意:调用父类的赋值重载时,由于父类和子类的赋值重载名字相同,构成隐藏,所以要指定类域进行调用,否则会出现栈溢出

析构

结论:父子类的析构函数构成隐藏,因为析构函数名会被处理成destruct。所以不能像之前一样,先调用父类的析构再调用子类的析构,编译器在这里做了处理,为了保证析构顺序:先子后父(为了满足栈的性质),在子类的析构函数结束前,会自动调用父类的析构函数,所以不需要我们显式调用父类析构,只需要释放子类的资源。

友元和继承,静态成员和继承

一句话很简单:友元不能被继承。静态成员被继承后是创建一个新的静态对象,还是共享同一个?答案是共享同一个

菱形继承

在这里插入图片描述
有这样一个经典的问题,菱形继承,如图,B,C继承了A,D又继承了B,C。那么D对象中存储两份A类型数据,造成了数据冗余以及二义性在这里插入图片描述
d中的B对象和C对象中都有一个a对象,造成了数据冗余以及二义性

可以使用virtual关键字对基类进行虚继承,解决数据冗余和二义性的问题
在这里插入图片描述
在这里插入图片描述
观察对象d的数据模型,发现d中除了存储基类B和基类C,还有4字节的空间存储了其他数据,这个数据其实是一个地址,指向虚基表的地址(这个数据叫做虚基表指针),虚基表又是什么?虚基表中存储了偏移量,根据这个偏移量能找到_a存储的地址。比如d.B::_a = 1这行代码其实是先找到这个虚基表指针,通过这个指针找到虚基表得到_a距离当前位置的偏移量,根据偏移量找到_a在d对象中存储的位置,再修改_a。

组合和继承

继承关系像是一种is-a的关系,子类和父类有相同的特点,比如学生是人。而组合关系是一种has-a的关系,就是类里面有另一个类的成员。之前模拟实现适配器容器时,stack复用了deque,这个复用不是继承而是组合,即将deque作为stack的一个成员。组合属于黑箱复用,外部不知道被组合对象的内部细节,只知道其对外开放的接口,所以外部只能访问其公有成员,以“黑箱”的方式进行复用,使两者之间的依赖少,耦合度低。而继承的白箱复用,子类可见父类的内部细节,一定程度上破坏了封装性,父类的改变对子类的影响大,两者之间的依赖程度高,耦合度高。

所以在逻辑合理的情况下,多使用组合降低代码的耦合度。

多态

多态是指不同继承关系的类对象调用同一个函数时产生不同的行为,如支付宝红包的金额。

先理解虚函数,用virtual修饰的函数称为虚函数,如果父类有虚函数,子类的一个虚函数和父类的相同(返回值类型,名字,参数),即这两个函数构成重写,也称覆盖。
在这里插入图片描述
BuyTicket函数构成重写
在这里插入图片描述
构成多态的两个条件:1.子类虚函数重写父类虚函数。2.用父类指针或引用调用虚函数。

(当父类函数被virtual修饰,子类重写该函数时,可以不用加virtual,因为子类继承了父类该函数的虚属性,但这样写很不规范,不推荐)

多态的两个例外

协变,重写虚函数时,允许返回类型不同但必须具有继承关系。即父类虚函数返回父类类型的指针或引用,子类虚函数返回子类类型的指针或引用

class A{};
class B : public A{};
class Person
{
public:
	virtual A* BuyTicket()
	{
		cout << "全价票" << endl;
		return nullptr;
	}
};

class Student : public Person
{
public:
	virtual B* BuyTicket()
	{
		cout << "半价票" << endl;
		return nullptr;
	}
};

在这里插入图片描述
析构函数,父类的析构函数加了virtual无论子类是否加virtual,两析构都构成重写,因为析构的函数名会被替换成destructor。
在这里插入图片描述
在这里插入图片描述

笔试题

class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};

class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};

int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

问程序输出什么?首先两个类的func函数构成重写,B类继承A类的test接口。用p指针调用B类的test函数,B的test函数是从A继承下来的,这个继承属于实现继承,调用函数的地方在A不是B,而类的函数都隐藏了一个this指针,所以test函数的this指针类型为A,用p调用test函数,传入的this形参类型为B,这里发生一个切片,this指针虽然是A类型,但指向的是B类型对象。所以调用func函数是一个多态(func完成了虚函数的重写,并且,用父类指针调用函数),这里的func调用的是B的func,这个func是一个重写,属于接口继承,重写的是func的实现,所以func的接口与A相同,函数参数的缺省值与A相同,因此打印的是B->1。

析构函数也要重写

使用多态时,如果析构函数不重写,通过指针或者引用调用的析构函数,是指针或者引用对应的类型,所以使用多态时(用父类指针指向子类),析构只会析构父类的资源,如果子类资源没释放,有可能造成内存泄漏,所以在玩多态时析构函数记得重写(delete释放完子类的资源,由于继承的特性:子类析构函数结束前会去调用父类的析构,父类的资源也会被释放)。

final和override

在虚函数的最后加上final,表示该虚函数无法被重写
在这里插入图片描述
final还能加在类名后面,表示这个类是一个最终类,不能被继承。
在这里插入图片描述
override写在子类的函数后,检查该函数是否重写父类的虚函数,如果没有重写就报错。

重载、重写和重定义的比较

重载:在同一作用域中,两函数名相同,参数个数、类型或者顺序不同构成重载
重写(覆盖):在不同作用域(通常是父类和子类两个域),两虚函数名字相同,参数类型和返回值也相同构成重写
重定义(隐藏):在父类和子类两个域中,两个函数名字相同构成隐藏,所以两个父类和子类的同名函数不构成隐藏就构成重写

抽象类

虚函数后加上= 0,这个函数被称为纯虚函数。包含纯虚函数的类叫做抽象类,抽象类无法实例化对象,派生类继承抽象类后也无法实例化对象,除非重写抽象类中的纯虚函数。也就是说,抽象类的设定是为了派生类必须重写纯虚函数,这个设定和override差不多,都是检查必须重写虚函数。
在这里插入图片描述

接口继承和实现继承

普通函数的继承都是实现继承,继承的是函数的实现,派生类可以使用这个函数。而接口继承继承的是函数名,参数,返回类型,属性,之前说过的基类的函数加了virtual,派生类的重写函数不加virtual是可以的,所以基类函数加上virtual是用来给派生类进行重写达成多态的。

多态的原理

class Father
{
	virtual void func1()
	{
		cout << "Father func1()" << endl;
	}

	virtual void func2()
	{
		cout << "Father func2()" << endl;
	}
	int _f;
};

class Son :public Father
{
	virtual void func1()
	{
		cout << "Son func1()" << endl;
	}

	void func2()
	{
		cout << "Son func2()" << endl;
	}
	int _s;
};

int main()
{
	Father f;
	cout << sizeof(f) << endl;
	return 0;
}

在这里插入图片描述
Father对象f的大小为8字节,这是因为除了int类型成员_f还有一个指针(64位平台为8字节),_vfptr(虚函数表指针)虚表指针,指向存储虚函数地址的指针。f有两个虚函数,所以指针指向的虚表中有两个指针。
在这里插入图片描述
而Son类继承Father类,若Son类只重写了func1函数,创建Son对象,从下图观察它的结构在这里插入图片描述
可以看到s重写了func1,虚表和f一样,只是func1的地址不同,如果f不重写func1那么这两个对象的虚表都是相同的。
在这里插入图片描述
这就能很好的解释为什么重写也叫覆盖了,派生类重写基类的虚函数,派生类对象的虚表中,覆盖了原理基类的指针,写入了重写后函数的指针。重写:语言层面的概念,派生类对基类的函数实现进行重写。覆盖:原理层面的概念,派生类虚表拷贝基类虚表并覆盖重写的虚函数指针。

所以多态的原理就能很好的解释:切片后,虽然指针/引用不知道对象类型是基类还是派生类,但调用虚函数时根据函数名,只管去虚表中找这个函数地址,然后调用,由于派生类的虚表完成了覆盖,所以这个调用实现了多态。

编译时决议和运行时决议


class Father
{
public:
	virtual void func1()
	{
		cout << "Father::func1()" << endl;
	}

	virtual void func2()
	{
		cout << "Father::func2()" << endl;
	}

	void func3()
	{
		cout << "Father::func3()" << endl;
	}
	int _f;
};

class Son : public Father
{
public:
	virtual void func1()
	{
		cout << "Son::func1()" << endl;
	}

	void func3()
	{
		cout << "Son::func3()" << endl;
	}
	int _s;
};

int main()
{
	Son s;
	Father* ptr = &s;
	ptr->func1();
	ptr->func3();
	return 0;
}

在这里插入图片描述
从汇编的角度,ptr调用func属于运行时决议,程序跑起来后去虚表中找函数地址。而调用func3却是编译时决议,因为func不是虚函数没有完成重写,因此虚表中没有该函数地址。在这里插入图片描述
两个调用的汇编代码量就不一样,ptr只知道它指向的类型是Father,Son类对象在切片后被ptr指向,所以ptr指向的是一个基类Father模型,如果Father类对象没有func3函数,程序则会报错(结合多态的原理,调用func1时,程序去虚表中找以func1为函数名的函数,而虚表的func1被子类的func1函数地址覆盖,所以这个调用构成了多态,但func3没有在虚表中,程序会去虚表之外找这个函数,如果没有父类没有func3,但子类有,程序报错,因为ptr是一个父类指针,不关心子类是什么样的)

为什么多态必须用父类的指针/引用调用,不能用对象?

再次理解对象切片:子类对象中与父类对象相同的部分会被保留,子类对象独有的部分会被丢弃,所以切片后的对象只包含父类的成员变量与成员函数。对于子类的虚表,如果与父类的虚表不同,那么它将被替换为父类的虚表。所以用子类对象赋值给父类对象后,形成的对象的虚表是父类的虚表,调用虚函数只能调用到父类的虚函数,这样的话就无法形成多态

派生类的虚表


class Father
{
public:
	virtual void func1()
	{
		cout << "Father::func1()" << endl;
	}

	virtual void func2()
	{
		cout << "Father::func2()" << endl;
	}

	void func3()
	{
		cout << "Father::func3()" << endl;
	}
	int _f;
};


class Son : public Father
{
public:
	virtual void func1()
	{
		cout << "Son::func1()" << endl;
	}

	virtual void func2()
	{
		cout << "Son::func2()" << endl;
	}

	void func3()
	{
		cout << "Son::func3()" << endl;
	}

	virtual void func4()
	{
		cout << "Son::func4()" << endl;
	}
	int _s;
};

派生类的虚表生成过程:拷贝基类的虚表,如果有虚函数重写了基类的虚函数,将该虚函数的地址覆盖原来的地址。现在的Father有2个虚函数,虚表中存储了2个虚函数指针,而Son写了第三个虚函数,理论上只要是虚函数就要存储到虚表中,但通过监视窗口观察派生类对象s只有两个虚函数,由于监视窗口是经过优化的,为了验证派生类的虚表有三个虚函数,写一段程序验证。在这里插入图片描述

typedef void (*V_FUNC)();

void PrintTable(V_FUNC* arr)
{
	for (size_t i = 0; i < 3; i++)
	{
		printf("arr[%d]:[%p]\n", i, arr[i]);
		arr[i]();
	}
}

int main()
{
	Son s;
	Father* ptr = &s;

	PrintTable((V_FUNC*)*(int*)&s);
	return 0;
}

在这里插入图片描述
在vs下,虚表指针存储在整个对象模型最开始的位置,要得到这个虚表指针就要取出对象的前4个字节数据,先取对象的地址,将其强转成int*再解引用得到的就是对象的前4个字节数据,即虚表指针。

然后是打印的函数,函数参数是一个存储虚函数指针的数组,在知道数组有几个虚函数的前提下,遍历整个数组。所以将之前得到的对象前4字节数据转换成一个函数指针再调用。

从结果来看派生类自己的虚函数会接着存储在虚表后。

总结

先补充一个语法点:虚表的本质是存储函数指针的数组,一般情况下这个数组以空指针结束。

虚表的生成过程:先将基类的虚表拷贝(如果是派生类对象)到派生类的虚表中,如果派生类重写了基类的虚函数,将原虚函数地址覆盖,写入现在虚函数地址,最后将派生类自己的虚函数写到虚表后。

虚函数存储在哪?虚表呢?, 虚函数作为函数,存储到代码段中,它的地址存储到了虚表中。类外经过地址的验证,(vs下)虚表也是存储在代码段中的

  • 6
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值