【C++】继承和多态

news2024/11/16 21:34:49

继承和多态

  • 一、继承
    • 1. 继承概念
    • 2. 继承定义
      • (1)继承的格式定义
      • (2)继承父类成员访问方式的变化
    • 3. 父类和子类对象赋值转换
    • 4. 继承中的作用域
    • 5. 子类的默认成员函数
    • 6. 继承与友元
    • 7. 继承与静态成员
    • 8. 复杂的菱形继承及菱形虚拟继承
      • (1)继承类型
      • (2)虚拟继承解决数据冗余和二义性的原理
    • 9. 继承的总结
  • 二、多态
    • 1. 多态的概念
    • 2. 多态的定义及实现
      • (1)多态的构成条件
      • (2)虚函数
      • (3)虚函数的重写
      • (3)override 和 final
      • (4)重载、覆盖(重写)、隐藏(重定义)
    • 3. 抽象类
      • (1)概念
      • (2)接口继承和实现继承
    • 4. 多态的原理
      • (1)虚函数表
      • (2)多态的原理
      • (3)动态绑定与静态绑定
    • 5. 虚函数和虚表存在于哪里?
    • 6. 单继承中的虚函数表

一、继承

1. 继承概念

继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类/子类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。

我们先简单看一下继承的使用,如以下代码:

		class Person
		{
		public:
			void Print()
			{
				cout << "name:" << _name << endl;
				cout << "age:" << _age << endl;
			}
		
		protected:
			string _name = "Mike";
			int _age = 18;
		};
		
		
		// 继承 Person 父类
		class Student : public Person
		{
		protected:
			string s_id;
		};
		
		// 继承 Person 父类
		class Tercher : public Person
		{
		protected:
			string t_id;
		};

继承后,父类的 Person 的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了 StudentTeacher 复用了 Person 的成员。下面我们使用监视窗口查看 StudentTeacher 对象,可以看到变量的复用;调用 Print 可以看到成员函数的复用;如下代码:

			int main()
			{
				Person p;
				Student s;
				Tercher t;
				p.Print();
				s.Print();
				t.Print();
				return 0;
			}

我们先观察监视窗口:

在这里插入图片描述

我们可以看到,st 都继承了父类的成员变量;再尝试调用父类的成员函数:

在这里插入图片描述

2. 继承定义

(1)继承的格式定义

继承的定义格式如下,Person父类/基类Student子类/派生类public继承方式

在这里插入图片描述

(2)继承父类成员访问方式的变化

继承父类成员访问方式的变化如下表:

在这里插入图片描述

其中以 public 继承方式我们称父类和子类是一种 is-a 关系,也就是“我是一个你”。

总结:

  1. 父类 private 成员在子类中无论以什么方式继承都是不可见的。这里的不可见是指父类的私有成员还是被继承到了派生类对象中,但是语法上限制子类对象不管在类里面还是类外面都不能去访问它。
  2. 父类 private 成员在子类中是不能被访问,如果父类成员不想在类外直接被访问,但需要在子类中能访问,就定义为 protected。可以看出保护成员限定符是因继承才出现的。
  3. 父类的私有成员在子类都是不可见。父类的其他成员在子类的访问方式 == min(成员在基类的访问限定符,继承方式),其中比较规则:public > protected > private.
  4. 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。
  5. 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用 protetced/private 继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

下面演示 public 继承关系下父类成员的各类型成员访问关系的变化 :

		// Print() 函数在父类中是 pubilc 成员
		class Person
		{
		public:
			void Print()
			{
				cout << _name << endl;
			}
		protected:
			string _name = "Mike"; // 姓名
		};
		
		
		// public 继承父类
		class Student : public Person
		{
		public:
			Student()
				:_stuid(000)
			{
				Print();
			}
		protected:
			int _stuid; // 学号
		};
		
		int main()
		{
			Student s;
			s.Print();
			return 0;
		}

如上,Print() 函数在父类中是 pubilc 成员,子类继承方式也是 public,所以此时 Print() 在类内类外都可访问,如下:

在这里插入图片描述

当我们将 Print() 的访问限定符改为 protected 后,如下:

		// Print() 函数在父类中是 protected 成员
		class Person
		{
		protected:
			void Print()
			{
				cout << _name << endl;
			}
		protected:
			string _name = "Mike"; // 姓名
		};

那么此时 Print() 函数只能在子类中使用,在类外不可使用:

在这里插入图片描述

在这里插入图片描述

3. 父类和子类对象赋值转换

  1. 子类对象可以赋值给父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片或者切割。寓意把子类中父类那部分切来赋值过去,如下图所示:

在这里插入图片描述

  1. 父类对象不能赋值给子类对象,因为子类就是继承父类下来的,父类有的子类也有。

如下示例:

			class Person
			{
			protected:
				string _name;
				int _age;
			};
			
			class Student : public Person
			{
			public:
				string s_id;
			};
			
			int main()
			{
				Student s;
				
				// 1.子类对象可以赋值给父类对象/指针/引用
				Person p = s;
				Person* pp = &s;
				Person& rp = s;
			
				// 2.基类的指针可以通过强制类型转换赋值给派生类的指针
				pp = &s;
				Student* ps1 = (Student*)pp; // 这种情况转换时可以
				ps1->s_id = 7;
			
				pp = &p;
				Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问
				ps2->s_id = 3;
			
				return 0;
			}

4. 继承中的作用域

  1. 在继承体系中父类和子类都有独立的作用域。
  2. 父类和子类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 父类::基类成员 显示访问)
  3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
  4. 注意在实际中在继承体系里面最好不要定义同名的成员。

例如以下示例:

		class A
		{
		public:
			void fun()
			{
				cout << "func()" << endl;
			}
		};
		
		class B : public A
		{
		public:
			void fun(int i)
			{
				A::fun();  // 可显示调用 A 类的 fun 函数
				cout << "func(int i)->" << i << endl;
			}
		};
		
		int main()
		{
			B b;
			b.fun(10);  // 默认调的是 B 类的 fun 函数
			b.A::fun(); // 可显示调用 A 类的 fun 函数
			return 0;
		}

B 中的 funA 中的 fun 不是构成重载,因为不是在同一作用域,而是构成隐藏,成员函数满足函数名相同就构成隐藏。

5. 子类的默认成员函数

6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在子类中,这几个成员函数是如何生成的呢?

  1. 子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显示调用。

  2. 子类的拷贝构造函数必须调用父类的拷贝构造完成父类的拷贝初始化。

  3. 子类的 operator= 必须要调用父类的 operator= 完成基类的复制。

  4. 子类析构函数会在被调用完成后自动调用父类析构函数清理父类成员。因为这样才能保证子类对象先清理子类成员再清理父类成员的顺序。

  5. 子类对象初始化先调用父类构造再调子类构造。

  6. 子类对象析构清理先调用子类析构再调父类的析构。

  7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成 destrutor(),所以父类析构函数不加 virtual 的情况下,子类析构函数和父类析构函数构成隐藏关系。

下面演示继承中构造函数和析构函数的调用情况:

			class A
			{
			public:
				A() { cout << "A()" << endl; }
				~A() { cout << "~A()" << endl; }
			
			private:
				int _a;
				
			};
			
			
			class B : public A
			{
			public:
				B() { cout << "B()" << endl; }
				~B() { cout << "~B()" << endl; }
			};

上述代码中,我们实例化B对象,编译器会先调用父类的构造,即先调A的构造,然后再构造子类B,析构顺序按照先子后父,例如下图:

在这里插入图片描述

6. 继承与友元

友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员。简单一句话说就是,父类的友元并不是子类的友元,所以不能继承下来。

7. 继承与静态成员

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

8. 复杂的菱形继承及菱形虚拟继承

(1)继承类型

1. 单继承:一个子类只有一个直接父类时称这个继承关系为单继承。

在这里插入图片描述

2. 多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承。

在这里插入图片描述

3. 菱形继承:菱形继承是多继承的一种特殊情况。

在这里插入图片描述

菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余二义性的问题。以上图为例,在 D 的对象中 A 成员会有两份,如下图:

在这里插入图片描述

用代码来说明如下代码为四个类以以上的方式继承:

			class A
			{
			public:
				int _a;
				
			};
			
			class B : public A
			{
			public:
				int _b;
			};
			
			class C : public A
			{
			public:
				int _c;
			};
			
			class D : public B, public C
			{
			public:
				int _d;
			};

我们尝试实例化一个D类对象d,先尝试修改d对象中的 _b 值:

在这里插入图片描述

如上图是没有问题的,我们再尝试修改d对象中的 _a 值:

在这里插入图片描述

如上图,可以看出编译不通过,理由就是对 _a 的访问不明确,就是因为存在二义性,在 d 中存有两份 _a,编译器不知道我们访问的是哪一个;

但是我们可以指定访问域,如下图:

在这里插入图片描述

因为在 B 中和 C 中各有一份 _a,所以我们可以指定 B 中的 _a,或者 C 中的 _a 进行指定操作,这样就解决二义性问题了;但是数据冗余还没有解决。

但是有一种方法可以既解决二义性,也解决数据冗余问题,就是虚拟继承虚拟继承可以解决菱形继承的二义性和数据冗余的问题。 如上面的继承关系,在 BC 继承 A 时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。

我们用代码演示证明一下,首先我们在 BC 继承 A 时加上 virtual 关键字,说明是虚拟继承:

			class A
			{
			public:
				int _a;
				
			};
			
			class B : virtual public A
			{
			public:
				int _b;
			};
			
			class C : virtual public A
			{
			public:
				int _c;
			};
			
			class D : public B, public C
			{
			public:
				int _d;
			};

接下来我们直接实例化 D 对象,并访问 _a,如下图:

在这里插入图片描述

如上二义性问题就解决了,那么我们看看 d 对象中 BC 中的 _a 的值 :

在这里插入图片描述

我们观察可知,d 对象中继承下来的类里面的 _a 都是 1,也就是说,这个 _a 在这个 d 对象中,只有一份!那就说明虚拟继承也解决了数据冗余问题了!

(2)虚拟继承解决数据冗余和二义性的原理

首先我们先通过调试观察一下普通菱形继承中,d 对象中的内存分布,如下图所示:

在这里插入图片描述

我们可以观察到 d 对象中有两份 _a ,明显的数据冗余。

接下来我们加上虚拟继承,继续观察 d 对象中的内存分布,如下图:

在这里插入图片描述

上图是菱形虚拟继承的内存对象成员模型:这里可以分析出 D 对象中将 A 放到的了对象组成的最下面,这个 A 同时属于 BC,那么 BC 如何去找到公共的 A 呢?这就和 BC 中多了两个地址有关系了,这两个地址是什么呢?我们可以取它们的地址到内存窗口去观察一下:

在这里插入图片描述

这里是通过了 BC 的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表虚基表中存的是偏移量,通过偏移量可以找到下面的 A。例如上图中 B 中的指针指向的虚基表中,它的偏移量是十六进制的 14 字节,化作十进制就是 20 字节,那么从虚基表指针开始,往下 5 个单位就是 A,每个单位 4 个字节,那么就刚好是 20 个字节就能找到 A.

9. 继承的总结

继承和组合

  • public 继承是一种 is-a 的关系。也就是说每个子类对象都是一个父类对象。

  • 组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
    什么是组合呢?如下代码就是组合,其中是 B 组合了 A

      			class A
      			{
      			public:
      				int _a;
      			};
      			
      			class B
      			{
      			public:
      				int _b;
      				A a;
      			};
    
  • 优先使用对象组合,而不是类继承 。

  • 继承允许我们根据父类的实现来定义子类的实现。这种通过生成子类的复用通常被称为白箱复用(white-box reuse)。术语 “白箱” 是相对可视性而言:在继承方式中,父类的内部细节对子类可见 。继承一定程度破坏了父类的封装,父类的改变,对子类有很大的影响。子类和父类间的依赖关系很强,耦合度高。

  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以 “黑箱” 的形式出现。组合类之间没有很强的依赖关系,耦合度低。 优先使用对象组合有助于我们保持每个类被封装。

  • 实际尽量多去用组合。组合的耦合度低,代码维护性好。 不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

二、多态

1. 多态的概念

多态的概念: 通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。

2. 多态的定义及实现

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如 Student 继承了 PersonPerson 对象买票全价,Student 对象买票半价。

(1)多态的构成条件

那么在继承中要构成多态还有两个条件:

  1. 必须通过父类的指针或者引用调用虚函数;
  2. 被调用的函数必须是虚函数,且子类必须对父类的虚函数进行重写

我们先简单看一下多态的使用,如以下代码:

			class Person
			{
			// 让 BuyTicket 成为虚函数 
			public:
				virtual void BuyTicket()
				{
					cout << "买票-全价" << endl;
				}
			};
			
			class Student : public Person
			{
			// 对 BuyTicket 进行重写
			public:
				virtual void BuyTicket()
				{
					cout << "买票-半价" << endl;
				}
			};
			
			// 父类的指针或者引用调用
			//void func(Person& p)
			void func(Person* pa)
			{
				pa->BuyTicket();
			}

下面开始多态的调用,如下结果:

在这里插入图片描述

如上就是多态的简单使用,下面开始详细介绍多态的条件。

(2)虚函数

虚函数: 即被 virtual 修饰的类成员函数称为虚函数。

如下代码中的 BuyTicket 函数:

			class Person
			{
			public:
				virtual void BuyTicket()
				{
					cout << "买票-全价" << endl;
				}
			};

(3)虚函数的重写

虚函数的重写(覆盖):子类中有一个跟父类完全相同的虚函数(即子类虚函数与父类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了父类的虚函数。

如下段代码中,子类 Student 类对 BuyTicket 函数完成了重写:

			class Person
			{
			public:
				virtual void BuyTicket()
				{
					cout << "买票-全价" << endl;
				}
			};
			
			class Student : public Person
			{
			public:
				// void BuyTicket()  // 子类对 BuyTicket 进行重写时,也可以不加 virtual
				virtual void BuyTicket()
				{
					cout << "买票-半价" << endl;
				}
			};

在重写父类虚函数时,子类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议。这也和下面要说的析构函数的重写有一定的关联。

虚函数重写的两个例外:

  1. 协变(父类与子类虚函数返回值类型不同)

子类重写父类虚函数时,与父类虚函数返回值类型不同。即父类虚函数返回父类对象的指针或者引用,子类虚函数返回子类对象的指针或者引用时,称为协变。(了解即可)

如以下这段代码中,也构成多态:

			class Person
			{
			public:
				virtual Person* BuyTicket()
				{
					cout << "买票-全价" << endl;
					return nullptr;
				}
			};
			
			class Student : public Person
			{
			public:
				virtual Student* BuyTicket()
				{
					cout << "买票-半价" << endl;
					return nullptr;
				}
			};
  1. 析构函数的重写(父类与子类析构函数的名字不同)

如果父类的析构函数为虚函数,此时子类析构函数只要定义,无论是否加 virtual 关键字,都与父类的析构函数构成重写,虽然父类与子类析构函数名字不同。这样看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor .

如果在父类中没有加上 virtual 即析构函数不构成多态,当下面这种情景时,不能正确调用析构函数:

			// 析构函数不构成多态
			class Person
			{
			public:
				~Person()
				{
					cout << "~Person()" << endl;
				}
			};
			
			class Student : public Person
			{
			public:
				~Student()
				{
					cout << "~Student()" << endl;
				}
			};

在这里插入图片描述

如上图,当我们 new 的是一个子类对象,将它赋值兼容给父类,会发生切片操作,此时我们释放 p 指针,由于析构函数不构成多态,因此只会调用父类的析构函数,此时子类部分的空间就没有被释放,就会发生内存泄漏。

当我们在父类的析构函数加上 virtual,此时就构成多态了,子类的析构加不加 virtual 都无所谓,就是为了防止这种情况,我们在子类中忘记对析构函数进行重写,所以才会有上面的例外,在子类中进行重写时可以不加 virtual;所以我们在父类中加上 virtual 即可构成多态,如下段代码:

			class Person
			{
			public:
				virtual ~Person()
				{
					cout << "~Person()" << endl;
				}
			};
			
			class Student : public Person
			{
			public:
				~Student()
				{
					cout << "~Student()" << endl;
				}
			};

在这里插入图片描述

(3)override 和 final

从上面可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此:C++11提供了 override 和 final 两个关键字,可以帮助用户检测是否重写。

  1. final:修饰虚函数,表示该虚函数不能再被重写;

如下段代码:

在这里插入图片描述

  1. override:检查子类虚函数是否重写了父类某个虚函数,如果没有重写编译报错。

如下段代码:

在这里插入图片描述

此时将B类中的func函数的返回类型改为 void 即可通过编译。

(4)重载、覆盖(重写)、隐藏(重定义)

重载、覆盖(重写)、隐藏(重定义)的对比如下图所示:

在这里插入图片描述

3. 抽象类

(1)概念

在虚函数的后面写上 =0 ,则这个函数为纯虚函数包含纯虚函数的类叫做抽象类(也叫接口类)抽象类不能实例化出对象。子类继承后也不能实例化出对象,只有重写纯虚函数,子类才能实例化出对象。纯虚函数规范了子类必须重写,另外纯虚函数更体现出了接口继承。

			class A
			{
			public:
				virtual void func() = 0
				{
					cout << "A::func()" << endl;
				}
			};
			
			class B : public A
			{
			public:
				virtual void func()
				{
					cout << "B::func()" << endl;
				}
			};
			
			class C : public A
			{
			public:
				virtual void func()
				{
					cout << "C::func()" << endl;
				}
			};

如上段代码,A类是抽象类,不能实例化出对象,B类和C类继承了A类,并完成重写 func 函数,所以B类和C类可以实例化对象;下面我们简单使用一下:

在这里插入图片描述

(2)接口继承和实现继承

普通函数的继承是一种实现继承,子类继承了父类函数,可以使用函数,继承的是函数的实现。

虚函数的继承是一种接口继承,子类继承的是父类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

4. 多态的原理

(1)虚函数表

我们先看一下下面这个类的大小是多少?

			class A
			{
			public:
				virtual void Func()
				{
					cout << "Func()" << endl;
				}
			private:
				int _a = 3;
			};

根据我们以前学的知识,成员函数不占A类的空间,而是放在公共代码区,而成员变量则根据内存对齐计算大小,所以 sizeof(A) = 4,这样算对吗?我们看一下结果:

在这里插入图片描述

如上图,答案是 8,为什么会是 8 呢?我们实例化一个对象出来观察它里面到底有什么:

在这里插入图片描述

通过上图我们发现,除了 _a 成员,还多一个 __vfptr 放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么子类中这个表放了些什么呢?我们接着往下分析;

我们写一个 Base 类Derive 类继承BaseBase 类中Func1 和 Func2 是虚函数,Func3 不是虚函数; Derive 中重写 Func1函数,如下段代码:

			class Base
			{
			public:
				virtual void Func1()
				{
					cout << "Base::Func1()" << endl;
				}
				virtual void Func2()
				{
					cout << "Base::Func2()" << endl;
				}
				void Func3()
				{
					cout << "Base::Func3()" << endl;
				}
			private:
				int _b = 1;
			};
			
			
			class Derive : public Base
			{
			public:
				virtual void Func1()
				{
					cout << "Derive::Func1()" << endl;
				}
			private:
				int _d = 2;
			};

下面我们实例化 b 对象和 d 对象:

			int main()
			{
				Base b;
				Derive d;
				return 0;
			}

然后我们通过调试窗口观察对象中的内存:

在这里插入图片描述

我们看到,两个对象的虚函数指针是不一样;我们再进一步观察虚函数指针中虚表的内容:

在这里插入图片描述

通过观察和测试,我们发现了以下几点问题:

  1. 子类对象 d 中也有一个虚表指针,d 对象由两部分构成,一部分是父类继承下来的成员和虚表指针,另一部分是自己的成员。
  2. 父类 b 对象和子类 d 对象虚表是不一样的,这里我们发现 Func1完成了重写,所以 d 的虚表中存的是重写的 Derive::Func1 的地址,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数地址的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
  3. 另外 Func2 继承下来后是虚函数,所以放进了虚表,Func3 也继承下来了,但是不是虚函数,所以不会放进虚表。
  4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
  5. 总结一下子类的虚表生成:
    a.先将父类中的虚表内容拷贝一份到子类虚表中;
    b.如果子类重写了父类中某个虚函数,用子类自己的虚函数覆盖虚表中父类的虚函数 ;
    c.子类自己新增加的虚函数按其在子类中的声明次序增加到子类虚表的最后。

(2)多态的原理

有了上面的基础,我们就可以分析一下多态的原理了;

首先我们有以下两个类实现的多态:

			class Base
			{
			public:
				virtual void Func1()
				{
					cout << "Base::Func1()" << endl;
				}
			private:
				int _b = 1;
			};
			
			
			class Derive : public Base
			{
			public:
				virtual void Func1()
				{
					cout << "Derive::Func1()" << endl;
				}
			private:
				int _d = 2;
			};
			
			void Test(Base* pa)
			{
				pa->Func1();
			}

接下来我们分别使用Base和Derive实例化出对象 b 和 d,再调用 Test 函数:

			int main()
			{
				Base b;
				Test(&b);
			
				Derive d;
				Test(&d);
			
				return 0;
			}

下面我们从汇编的角度分析多态的原理,如下图:

在这里插入图片描述
从上图可以看出,多态调用是运行时,去虚表里面找到函数地址,确定地址再调用。

但如果是对象调用,如下代码,就不构成多态,我们同样在汇编角度分析:

			void Test(Base pa)
			{
				pa.Func1();
			}
			
			int main()
			{
				Base b;
				Test(b);
			
				Derive d;
				Test(d);
			
				return 0;
			}

在这里插入图片描述

从上图可以看出,普通调用是编译链接时确定地址。

所以,我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数;其中虚函数覆盖就不多说了;

父类的指针或引用接收对象的指针或引用调用虚函数,本质就是父类指针指向子类对象中切割出来父类的那一部分,然后再取虚函数指针找到虚函数的地址。

那么我们使用对象调用为什么不能实现多态呢?其实使用子类对象赋值给父类对象,会切割出子类对象中父类那一部分成员拷贝给父类,但是不会拷贝虚函数指针。为什么呢?我们假设:子类对象会拷贝虚函数表指针给父类对象,我们看一下以下代码:

			Person* p = new Person;
			
			Student s;
			*p = s;
			
			delete p;	

此时出现的问题就是,多态调用指向父类,调用的不一定是父类的虚函数了,因为我们中间将 s 赋给了 *p,现在 p 中的虚函数指针是 s 的;所以这种方法不可取。

(3)动态绑定与静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载;
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态

5. 虚函数和虚表存在于哪里?

虚函数和虚表存在于哪里?有人会说虚函数存在虚表,虚表存在对象中。但是上面的回答的错误的。

想要知道虚函数和虚表存在于哪里,我们可以打出各个区域的地址观察,观察虚函数和虚表离哪个区域比较近,下面我们开始验证,如下段代码:

			class Base
			{
			public:
				virtual void func1()
				{
					cout << "Base::func1" << endl;
				}
				virtual void func2()
				{
					cout << "Base::func2" << endl;
				}
			private:
				int a;
			};
			
			void func()
			{
				cout << "void func()" << endl;
			}
			
			int main()
			{
				Base b1;
				static int a = 0;
				int b = 0;
				int* p1 = new int;
				const char* p2 = "hello";
			
				printf("静态区:%p\n", &a);
				printf("栈:%p\n", &b);
				printf("堆:%p\n", p1);
				printf("代码段/常量区:%p\n", p2);
				printf("虚表:%p\n", *((int*)&b1));
				printf("虚函数地址:%p\n", &Base::func1);
				printf("普通函数地址:%p\n", func);
			
				return 0;
			}

运行结果如下:

在这里插入图片描述

我们可以看到,在 vs2019 中,虚表和虚函数的地址都是离代码段/常量区最近的,所以我们认为,在 vs2019 中,它们是在代码段中的。

6. 单继承中的虚函数表

需要注意的是在单继承和多继承关系中,下面我们去关注的是子类对象的虚表模型,因为父类的虚表模型前面我们已经看过了,没什么需要特别研究的。我们这里就只看单继承的中的虚函数表。

例如有以下代码:

			class Base 
			{
			public:
				virtual void func1() 
				{ 
					cout << "Base::func1" << endl; 
				}
				virtual void func2() 
				{ 
					cout << "Base::func2" << endl; 
				}
			private:
				int a;
			};
			
			class Derive :public Base 
			{
			public:
				virtual void func1() 
				{ 
					cout << "Derive::func1" << endl; 
				}
				virtual void func3() 
				{ 
					cout << "Derive::func3" << endl; 
				}
				virtual void func4() 
				{ 
					cout << "Derive::func4" << endl; 
				}
			private:
				int b;
			};
			
			
			int main()
			{
				Base b;
				Derive d;
			
				return 0;
			}

我们通过监视窗口观察子类对象中的虚表模型:

在这里插入图片描述

观察上图中的监视窗口中我们发现看不见 func3func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug。那么我们如何查看 d 的虚表呢?下面我们使用代码打印出虚表中的函数。

			class Base 
			{
			public:
				virtual void func1() 
				{ 
					cout << "Base::func1" << endl; 
				}
				virtual void func2() 
				{ 
					cout << "Base::func2" << endl; 
				}
			private:
				int a;
			};
			
			class Derive : public Base 
			{
			public:
				virtual void func1() 
				{ 
					cout << "Derive::func1" << endl; 
				}
				virtual void func3() 
				{ 
					cout << "Derive::func3" << endl; 
				}
				virtual void func4() 
				{ 
					cout << "Derive::func4" << endl; 
				}
			private:
				int b;
			};
			
			
			// typedef 函数指针类型
			typedef void (*VFUNC)();
			void PrintVFT(VFUNC* a)
			{
				for (size_t i = 0; a[i] != 0; i++)
				{
					printf("[%d]:%p->", i, a[i]);
					VFUNC f = a[i];
					f(); // 相当于调(*f)(); 调函数指针对应的函数
				}
			}
			
			int main()
			{
				Base b;
				PrintVFT((VFUNC*)(*((int*)&b)));
				cout << endl;
				Derive d;
				PrintVFT((VFUNC*)(*((int*)&d)));
			
				return 0;
			}

打印的结果如下:

在这里插入图片描述

此时我们就可以看到 d 对象中的虚函数了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1130900.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Zynq UltraScale+ XCZU9EG 纯VHDL解码 IMX214 MIPI 视频,2路视频拼接输出,提供vivado工程源码和技术支持

目录 1、前言免责声明 2、我这里已有的 MIPI 编解码方案3、本 MIPI CSI2 模块性能及其优越性4、详细设计方案设计原理框图IMX214 摄像头及其配置D-PHY 模块CSI-2-RX 模块Bayer转RGB模块伽马矫正模块VDMA图像缓存Video Scaler 图像缓存DP 输出 5、vivado工程详解PL端FPGA硬件设计…

linux安装nginx 1.25.2

1.下载nginx-1.25.2.tar.gz放在/opt下 wget http://nginx.org/download/nginx-1.25.2.tar.gz 2.解包 Nginx 软件包&#xff1a; tar -xvf nginx-1.25.2.tar.gz 3.安装 Nginx 依赖&#xff1a; 在安装 Nginx 之前&#xff0c;需要先安装一些依赖库&#xff1a;pcre、openss…

某大型车企:加强汽车应用安全防护,开创智能网联汽车新篇章

​某车企是安徽省最大的整车制造企业&#xff0c;致力于为全球消费者带来高品质汽车产品和服务体验&#xff0c;是国内最早突破百万销量的汽车自主品牌。该车企利用数字技术推动供应链网络的新型互动&#xff0c;加快数字化转型&#xff0c;持续进行场景创新、生态创新&#xf…

无线测温产品在菲律宾某工厂配电项目的应用

摘要&#xff1a;配电系统是由多种配电设备和配电设施所组成的变换电压和直接向终端用户分配电能的一个电力网络系统。由于配电系统作为电力系统的一个环节直接面向终端用户&#xff0c;它的完善与否直接关系着广大用户的用电可靠性和用电质量&#xff0c;因而在电力系统中具有…

总结使用React做过的一些优化

一、修改css模拟v-show {!flag && <MyComponent style{{display: none}} />} {flag && <MyComponent />}<MyComponent style{{ display: flag ? block : none }} />二、循环使用key const todosList todos.map(item > {<li key{it…

分享:身份证读卡器java工程乱码解决办法

身份证读卡器java工程乱码解决办法 1、窗口>首选项>常规>工作空间 文本文件编码选择&#xff1a;缺省值&#xff08;GBK&#xff09; 2、工程>属性>资源 文本文件编码选择&#xff1a;从容器继承&#xff08;GBK&#xff09; IDE for Eclipse Committers (in…

MES生产管理系统与供应链协同管理

MES生产管理系统在制造业中发挥着越来越重要的作用&#xff0c;它与供应链管理密切相关&#xff0c;对于提高供应链的协同和优化有着重要的意义。本文将探讨MES管理系统与供应链管理之间的关系&#xff0c;包括实时数据共享、生产计划协调和供应链效率提升等方面。 MES系统能够…

npm install 报node-sass command failed

一、前言 最近在前端项目Vue项目install时会出现node-sass command failed的错误&#xff0c;原因是NodeJS和node-sass的版本不对应导致的&#xff0c;本文将给出解决方案。 二、解决方案 以下是NodeJS和node-sass版本的对照关系&#xff1a;

彩虹知识商城7.0.3小森升级版新增供货商开心学习版

彩虹知识商城7.0.3小森升级版新增供货商开心学习版 1.新增邮件提醒功能&#xff0c;支持给用户发订单、结算等邮件通知 2.支持给管理员发送提现、域名审核等邮件通知 3.支持设置手续费最低扣除金额 4.修复了其他一些已知问题

QT 自定义窗体加载完成函数

使用信号和槽函数&#xff0c;具体如下&#xff1a; QT-如何在窗口/对话框显示后自动执行指定任务_qt 界面显示完在调用函数-CSDN博客文章目录QT-如何在窗口/对话框显示后自动执行指定任务一、如何实现在窗口展示出来后&#xff0c;执行某个函数二、如何成功实现判断条件后选择…

怒刷LeetCode的第28天(Java版)

目录 第一题 题目来源 题目内容 解决方法 方法一&#xff1a;动态规划 方法二&#xff1a;迭代 方法三&#xff1a;斐波那契数列公式 第二题 题目来源 题目内容 解决方法 方法一&#xff1a;栈 方法二&#xff1a;路径处理类 方法三&#xff1a;正则表达式 方法…

生活废品回收系统 JAVA语言设计和实现

目录 一、系统介绍 二、系统下载 三、系统截图 一、系统介绍 基于VueSpringBootMySQL的生活废品回收系统包含资源类型模块、资源品类模块、回收机构模块、回收机构模块、资源销售单模块、资源交易单模块、资源交易单模块&#xff0c;还包含系统自带的用户管理、部门管理、角…

QGIS003:【04地图导航工具栏】-地图显示、新建视图、时态控制、空间书签操作

摘要&#xff1a;QGIS地图导航工具栏包括平移地图、居中显示、放大、缩小、全图显示、缩放到选中要素、缩放到图层、缩放到原始分辨率、上一视图、下一视图、新建地图视图、新建3D地图视图、新建空间书签、打开空间书签、时态控制面板、刷新等选项&#xff0c;本文介绍各选项的…

Linux软件包和进程管理

一、RPM软件包管理 1、RPM管理工具 &#xff08;1&#xff09;RPM是红帽包管理(Redhat Package Manager)的缩写。 由Red Hat公司提出的一种软件包管理标准。 是Linux各发行版中应用最广泛的软件包格式之一&#xff08;还有debian的发行版deb安装包&#xff09;。 RPM功能通过…

借助文心大模型4.0轻松搞定统计报表

在10月17日的百度世界2023上&#xff0c;文心大模型4.0版本正式发布&#xff01;会上百度董事长李彦宏为我们展示了文心大模型4.0在多轮对话、搜索、地图、商业智能、智能会议、智能视频等方面的强悍。 对此我们保持疑问&#xff0c;那文心大模型4.0真有这么好&#xff1f;我们…

base_lcoal_planner的LocalPlannerUtil类中getLocalPlan函数详解

本文主要介绍base_lcoal_planner功能包中LocalPlannerUtil类的getLocalPlan函数&#xff0c;以及其调用的transformGlobalPlan函数、prunePlan函数的相关内容 一、getLocalPlan函数 getLocalPlan函数的源码如下&#xff1a; bool LocalPlannerUtil::getLocalPlan(const geomet…

网络协议--广播和多播

12.1 引言 在第1章中我们提到有三种IP地址&#xff1a;单播地址、广播地址和多播地址。本章将更详细地介绍广播和多播。 广播和多播仅应用于UDP&#xff0c;它们对需将报文同时传往多个接收者的应用来说十分重要。TCP是一个面向连接的协议&#xff0c;它意味着分别运行于两主…

用 Rust 和 cURL 库制作一个有趣的爬虫

目录 一、介绍 二、准备工作 三、代码实现 四、解析 HTML 并提取特定元素示例 总结 本文将介绍如何使用 Rust 编程语言和 cURL 库制作一个有趣的网络爬虫。我们将通过实例代码来展示如何抓取网页内容、处理数据和解析 HTML 结构。同时&#xff0c;还将探讨爬虫技术的原理、…

语义分割的常用方法和评价准则

常用方法 目前主流的语义分割网络一般是遵循下采样,上采样,特征融合,然后重复该过程,最后经过softmax像素分类。 评价准则 语义分割的评价准则为: 1.像素精度(pixel accuracy):每一类像素正确分类的个数/ 每一类像素的实际个数。

Linux C语言开发-D4数据类型

数据类型分类 bool类型&#xff1a;非零为真&#xff08;true&#xff09;&#xff0c;零为假&#xff08;false&#xff09;&#xff0c;其在<stdbool.h>头文件中 2552的8次方-1 1272的7次方-1 -128的补码是&#xff1a;10000000 一定要注意长度和范围&#xff0c;防…