• 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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29

    在这里插入图片描述

    子类的默认成员函数

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

    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;
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21

    构造函数

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

    拷贝构造

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

    赋值重载

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

    析构

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

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

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

    菱形继承

    在这里插入图片描述
    有这样一个经典的问题,菱形继承,如图,B,C继承了A,D又继承了B,C。那么D对象中存储两份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;
    	}
    };
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    在这里插入图片描述
    析构函数,父类的析构函数加了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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    问程序输出什么?首先两个类的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。

    析构函数也要重写

    使用多态时,如果析构函数不重写,类对象析构时只会析构父类不会析构子类,有可能造成内存泄漏的问题,所以在玩多态时析构函数记得重写。

    final和override

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

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

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

    抽象类

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

    接口继承和实现继承

    普通函数的继承都是实现继承,继承的是函数的实现,派生类可以使用这个函数。而接口继承继承的是函数名,参数,返回类型,属性,之前说过的基类的函数加了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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    在这里插入图片描述
    Father对象f的大小为8,这是因为除了int类型成员_f还有一个指针(64位平台为16),_vfptr(虚函数表指针)虚表指针,指向存储虚函数地址的指针。f有两个虚函数,所以指针指向的虚表中有两个指针。
    在这里插入图片描述
    而Son类继承Father类,并重写了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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44

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

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

    如果可以用父类对象实现多态,那么切片就要拷贝虚表,假设现在有一个父类对象f,用子类对象s对其赋值,这时f的虚表是子类f的虚表。假设有一个父类指针p指向f,用p调用f实现的却是子类的函数,因为f里的虚表为子类的虚表。所以如果对象切片拷贝了虚表会造成混乱,只有指针或者引用,不用拷贝虚表就不会有这么多的问题。

    派生类的虚表

    
    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;
    };
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47

    派生类的虚表生成过程:拷贝基类的虚表,如果有虚函数重写了基类的虚函数,将该虚函数的地址覆盖原来的地址。现在的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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

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

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

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

    总结

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

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

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

  • 相关阅读:
    代码随想录算法训练营第51天 | 714.买卖股票的最佳时机含手续费 309.最佳买卖股票时机含冷冻期 300.最长递增子序列
    同事写了个http接口,我通过springCloud-feign调了一晚上一直熔断,让我开始怀疑我是不是在下一批的裁员名单中
    Web微服务
    条件随机场CRF(持续更新ing...)
    Spring Boot 项目的 pom.xml 中,groupId、artifactId 等信息要如何定义?——定义规则及案例
    计算机网络入门
    Alibaba官方上线,SpringBoot+SpringCloud全彩指南(第五版)
    PHP基础语法(下)
    基于大数据的Pagerank实验设计
    一、nacos安装与高可用部署
  • 原文地址:https://blog.csdn.net/weixin_61432764/article/details/126286421