C++——【继承】的底层逻辑与【多态】实现机制

🌈个人主页@ꪔ小林Y

个人专栏《C++小白闯关日记》《C语言小白闯关日记》,《数据结构入门------从原理到实战》,《拾光Linux》《网络安全》

🍀代码信条 :每一行代码都是成长的脚印👣,每一次调试成功都是对坚持的回应

目录

【继承】

1.继承(inheritance)的定义

1.1概念

继承 机制是面向对象程序设计使代码可以复用 的最重要的手段,它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),这样产生新的类,称派生类。继承是类设计层次的复用

1.2格式

cpp 复制代码
#include<iostream>
using namespace std;
class Student
{
public:
	// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
	void identity()
	{
	}
	// 学习
	void study()
	{
	}
protected:
	string _name = "peter"; // 姓名
	string _address; // 地址
	string _tel; // 电话
	int _age = 18; // 年龄
	int _stuid; // 学号
};
class Person
{
public:
	// 进⼊校园/图书馆/实验室刷⼆维码等⾝份认证
	void identity()
	{
		cout << "void identity()" << _name << endl;
	}
protected:
	string _name = "张三"; // 姓名
	string _address; // 地址
	string _tel; // 电话
	int _age = 18; // 年龄
};
class Student : public Person
{
public:
	// 学习
	void study()
	{
	}
protected:
	int _stuid; // 学号
};
int main()
{
	Student s;
	s.identity();
	return 0;
}

我们看到的Person是基类,也称作父类。Student是派生类,也称作子类。

1.3继承父类成员访问方式的变化

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见
  1. 基类private成员在派生类中无论以什么方式继承都是不可见的
  2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
  3. 基类的私有成员在派生类都是不可以直接使用的,但是可以间接使用 。基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式)。例:

  4. 使用关键字class 时默认的继承方式是private ,使用struct 时默认的继承方式是public,不过最好显示的写出继承方式。

1.4继承类模板

cpp 复制代码
namespace Lin
{
	template<class T>
	class stack : public vector<T>
	{
	public:
		void push(const T& x)
		{
			// 但是模版是按需实例化,调用了哪个成员函数,就实例化哪个
			// push_back等成员函数未实例化,所以找不到
			vector<T>::push_back(x);
		}

		void pop()
		{
			vector<T>::pop_back();
		}

		const T& top()
		{
			return vector<T>::back();
		}

		bool empty()
		{
			return vector<T>::empty();
		}
	};
}

int main()
{
	Lin::stack<int> st;
	st.push(1);
	st.push(2);
	st.push(3);
	while (!st.empty())
	{
		cout << st.top() << " ";
		st.pop();
	}
	// 构造/析构/push_back, 其他成员函数就不会实例化
	//vector<int> v;
	//v.push_back(1);
	return 0;
}

2.父类和子类间的转换

  • 通常情况下我们把⼀个类型的对象赋值给另⼀个类型的指针或者引用时,存在类型转换,中间会产生临时对象,所以需要加const,如: int a = 1; const double& d = a; public继承中,就是⼀个特殊处理的例外,子类对象可以赋值给父类的指针 / 引用 ,而不需要加const,这里的指针和引用绑定是子类对象中的父类部分。也就意味着**⼀个父类的指针或者引用,可能指向父类对象,也可能指向子类对象。**
  • 子类对象赋值给父类对象是通过父类的拷贝构造函数或者赋值重载函数完成的

3.继承中的作用域

3.1隐藏

  • 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏。参数是否相同不重要
    (在子类成员函数中,可以使用父类::父类成员 显示访问)
  • 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。

例:1.A和B类中的两个func构成什么关系(B)

A. 重载(函数重载必须在同一个作用域内) B. 隐藏 C.没关系

2.下面程序的编译运行结果是什么(A)

A. 编译报错 B. 运行报错 C. 正常运行

cpp 复制代码
class A
{
public:
 void fun()
 {
 cout << "func()" << endl;
 }
};
class B : public A
{
public:
 void fun(int i)
 {
 cout << "func(int i)" <<i<<endl;
 }
};
int main()
{
 B b;
 b.fun(10);
 b.fun();
 //正确写法:
 //b.A::fun();
 return 0;
};

4.子类的默认成员函数

4.1六个默认成员函数

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
  3. 派生类的operator=必须要调用基类的operator=完成基类的复制。需要注意的是派生类的operator=隐藏了基类的operator=,所以显示调用基类的operator=,需要指定基类作用域。
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调派生类构造
  6. 派生类对象析构清理先调用派生类析构再调基类的析构。
  7. 因为多态中⼀些场景析构函数需要构成重写,重写的条件之⼀是函数名相同。那么编译器会对析构函数名进行特殊处理,处理成destructor(),所以基类析构函数不加virtual的情况下,派生类析构函数和基类析构函数构成隐藏关系。
cpp 复制代码
class Person
{
public :
 Person(const char* name = "peter")
 : _name(name )
 {
 cout<<"Person()" <<endl;
 }
 
 Person(const Person& p)
 : _name(p._name)
 {
 cout<<"Person(const Person& p)" <<endl;
 }
 
 Person& operator=(const Person& p )
 {
 cout<<"Person operator=(const Person& p)"<< endl;
 if (this != &p)
 _name = p ._name;
 return *this ;
 }
 ~Person()
 {
 cout<<"~Person()" <<endl;
 }
protected :
 string _name ; // 姓名
};
class Student : public Person
{
public :
 Student(const char* name, int num)
 : Person(name)
 , _num(num )
 {
 cout<<"Student()" <<endl;
 }
 Student(const Student& s)
 : Person(s)
 , _num(s ._num)
 {
 cout<<"Student(const Student& s)" <<endl ;
 }
 
 Student& operator = (const Student& s )
 {
 cout<<"Student& operator= (const Student& s)"<< endl;
 if (this != &s)
 {
 // 构成隐藏,所以需要显⽰调⽤
 Person::operator =(s);
 _num = s ._num;
 }
 return *this ;
 } 
 
 ~Student()
 {
 cout<<"~Student()" <<endl;
 }
protected :
 int _num ; //学号
};
int main()
{
 Student s1 ("jack", 18);
 Student s2 (s1);
 Student s3 ("rose", 17);
 s1 = s3 ;
 return 0;
}

4.2实现一个不能被继承的类

方法1:基类的构造函数私有,派生类的构成必须调用基类的构造函数,但是基类的构成函数私有化以后,派生类看不见就不能调用了,那么派生类就无法实例化出对象。

方法2:C++11新增了⼀个final 关键字,final修改基类,派生类就不能继承了。

5.继承与友元

友元关系不能继承,也就是说基类友元不能访问派生类私有和保护成员

6.继承与静态成员

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

7.多继承及其菱形继承问题

7.1单继承

⼀个派生类只有⼀个直接基类时称这个继承关系为单继承

7.2多继承

⼀个派生类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派面类成员在放到最后面。

7.2.1多继承中的偏移问题
cpp 复制代码
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main()
{
 Derive d;
 Base1* p1 = &d;
 Base2* p2 = &d;
 Derive* p3 = &d;
 
 return 0;
}

根据以上代码,下面说法正确的是(C )

A:p1 == p2 == p3

B:p1<p2<p3

C:p1==p3!=p2

D:p1!=p2!=p3

7.3菱形继承

菱形继承是多继承的⼀种特殊情况。菱形继承的问题,从下面的对象成员模型构造,可以看出菱形继承有数据冗余和⼆义性 的问题,在Assistant的对象中Person成员会有两份。实践中不建议 设计这种菱形继承。

7.4虚继承

虚继承虽然解决了菱形继承的二义性,但是底层实现就很复杂,性能也会有⼀些损失,所以最好不要设计出菱形继承。

8.继承和组合

  • public继承是⼀种is-a的关系。也就是说每个派生类对象都是⼀个基类对象。
  • 组合 是⼀种has-a 的关系。假设B组合了A,每个B对象中都有⼀个A对象。
  • 继承 允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语"白箱"是相对可视性而言:在继承方式中,基类的内部细节对派生类可见 。继承⼀定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
  • 对象组合 是类继承之外的另⼀种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以"黑箱"的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
  • 优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承(is-a)也适合组合(has-a),就用组合。

【多态】

1.多态的概念

多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态 (静态多态)和运行时多态 (动态多态),编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态运行时归为动态

运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。例:

1.1实现多态的必要条件

  • 必须是基类的指针或者引用 调用虚函数
  • 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。

说明:要实现多态效果,第⼀必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第⼆派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。

2.虚函数

类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。

2.1虚函数的重写/覆盖

虚函数的重写/覆盖:派生类中有⼀个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。注意多态时这里重写的是基类的实现部分

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。

cpp 复制代码
class Person {
 public:
 virtual void BuyTicket() { cout << "买票-全价" << endl; }
 };
 class Student : public Person {
 public:
 virtual void BuyTicket() { cout << "买票-打折" << endl; }
};
 void Func(Person* ptr)
 {
 // 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket
 // 但是跟ptr没关系,⽽是由ptr指向的对象决定的。
 ptr->BuyTicket();
 }
 int main()
 {
 Person ps;
 Student st;
 Func(&ps);
 Func(&st);
 return 0;
 }

2.2多态场景面试题

以下程序输出结果是什么(B)

A: A->0 B: B->1 C: A->1 D: B->0 E: 编译出错 F: 以上都不正确

分析:

下面这个例子就不构成多态,就是一个普通调用:

cpp 复制代码
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;
}

2.3析构函数的重写

基类的析构函数为虚函数 ,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析函数构成重写,虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统⼀处理成destructor,所以基类的析构函数加了vialtual修饰,派生类的析构函数就构成重写。

注意这里是一个大坑:

2.4override 和 final关键字

C++11提供了override 关键字,可以帮助用户检测是否重写 。如果我们不想让派生类重写这个虚函数 ,那么可以用final去修饰。例:

cpp 复制代码
class Car {
 public:
 virtual void Dirve()
 {}
 };
 class Benz :public Car {
 public:
 virtual void Drive() override { cout << "Benz-舒适" << endl; }
 };
 int main()
 {
 return 0;
 }
 
 // error C3248: "Car::Drive": 声明为"final"的函数⽆法被"Benz::Drive"重写
 class Car
 {
 public:
 virtual void Drive() final {}
 };
 class Benz :public Car
{
 public:
 virtual void Drive() { cout << "Benz-舒适" << endl; }
 };
 int main()
 {
 return 0;
 }

2.5重载/重写/隐藏的对比

重载 重写/覆盖 隐藏
两个函数在同一作用域 两个函数分别在继承体系的父类和子类不同作用域 两个函数发别在继承体系的父类和子类不同作用域
函数名相同,参数不同,参数类型或者个数不同,返回值可同可不同 函数名,参数,返回值都必须相同,协变例外 函数名相同
两个函数都是虚函数 两个函数只要不构成重写,就是隐藏
父子类的成员变量相同也叫隐藏

3.纯虚函数和抽象类

虚函数的后面写上 =0 ,则这个函数为纯虚函数 ,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类 ,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

cpp 复制代码
class Car
 {
 public:
 virtual void Drive() = 0;
 };
class Benz :public Car
 {
 public:
 virtual void Drive()
 {
 cout << "Benz-舒适" << endl;
 }
 };
class BMW :public Car
 {
 public:
 virtual void Drive()
 {
 cout << "BMW-操控" << endl;
 }
 };
 int main()
 {
 // 编译报错:error C2259: "Car": ⽆法实例化抽象类
//Car car;
 Car* pBenz = new Benz;
 pBenz->Drive();
 Car* pBMW = new BMW;
 pBMW->Drive();
 return 0;
 }

4.多态的原理

4.3虚函数表指针

cpp 复制代码
 class Base
 {
 public:
 virtual void Func1()
 {
 cout << "Func1()" << endl;
 }
 protected:
 int _b = 1;
 char _ch = 'x';
 };
 int main()
 {
 Base b;
 cout << sizeof(b) << endl;
 return 0;
}

上面编译为32位程序的运行结果是什么(D)

A. 编译报错 B. 运行报错 C. 8 D. 12

分析:

上面题目运行结果12bytes,除了_b和_ch成员,还多⼀个__vfptr放在对象的前面,对象中的这个指针我们叫做虚函数表指针 (v代表virtual,f代表function)。⼀个含有虚函数的类中都至少都有⼀个虚函数表指针,因为⼀个类所有虚函数的地址要被放到这个类对象的虚函数表中,虚函数表也简称虚表

4.4多态的原理(重点面试考)

多态分为静态多态和动态多态。

静态多态就是编译时确定的多态,同一个函数名表现出的多种形态,传不同参数调用不同函数去了;动态多态是指用一个基类的指针指向不同对象(基类对象或派生类的),指向谁调用谁的虚函数,前提是这些虚函数得完成各自的重写,底层的原理是依靠对象当中存的一张虚函数表,不同虚函数表里存了不同的虚函数,运行时指向谁就到谁的虚函数表里面找到对应的虚函数进行调用。

满足多态条件后,底层不再是编译时通过调用对象确定函数的地址,而是运行时到指向的对象的虚表中确定对应的虚函数的地址 ,这样就实现了指针或引用指向基类就调用基类的虚函数,指向派生类就调用派生类对应的虚函数。

4.5动态绑定与静态绑定

  • 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
  • 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

4.6虚函数表

基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。

  • 派生类由两部分构成,继承下来的基类自己的成员 ,⼀般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
  • 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
  • 派生类的虚函数表中包含 :
    (1)基类的虚函数地址
    (2)派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址三个部分。
  • 虚函数表本质是一个存虚函数指针的指针数组,⼀般情况这个数组最后面放了⼀个0x00000000标记。(g++系列编译不会放)
  • 虚函数和普通函数⼀样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
    🎊到这儿,本期stack&queue的内容就结束了。如果文中有表述不准的地方,或是你有更清晰的理解思路,强烈欢迎在评论区留言交流------ 技术路上多碰撞,才能更快进步

觉得内容对你有帮助的话,别忘了点赞❤️➕收藏🌟,方便后续回顾复习;想跟着一起系统学习数据结构的朋友,也可以点击关注,下一期我们会聚焦更进一步的学习。不见不散✌️

相关推荐
汉克老师1 小时前
GESP2026年9月认证C++三级( 第一部分选择题(1~7题)精讲
c++·gesp·小学生·学c++编程
小小龙学IT2 小时前
Qt QPainter 2D 绘图系统深度解析:从画笔到渲染引擎适
c++·qt
wabs6662 小时前
关于二叉树【力扣100.相同的树的思考】
数据结构·c++·算法·leetcode·二叉树·递归法
小小龙学IT3 小时前
Boost.PFR 开源结构体字段反射库深度解析
c++·开源
程序猿编码3 小时前
零依赖纯手写:C++ 实现完整神经网络,张量反向传播全打通
c++·神经网络·transformer·大模型推理
码匠许师傅3 小时前
【C++三方组件】utfcpp:UTF-8 字符串处理避坑
c++
蒸蒸yyyyzwd3 小时前
八股学习 MVCC 慢查询优化和 read 过程
c++·八股
APEX真好玩3 小时前
把高频传感器(IMU)数据封装进 MP4:FFmpeg 私有数据轨实践
c++
晴天的雨.9923 小时前
[C++算法]盛最多水的容器(双指针算法)
开发语言·c++·算法