首页 > 其他分享 >多态性

多态性

时间:2023-11-20 15:36:24浏览次数:31  
标签:vtable 函数 int 多态性 virtual 内存 子类

  • 多态性是指在父类中定义的属性和方法被子类继承后,可以具有不同的数据类型或表现出不同的行为,这使得同一个属性或者方法在父类及其各个子类中具有不同的含义。

多态性

我们先来看一段代码和它的运行结果:

#include <iostream>

using namespace std;

class A
{
public:
  A() : i(10) {}
  virtual ~A(){}
  virtual void f()
  {
    cout << "A::f()" << i << endl;
  }
private:
  int i;
};

class B : public A
{
public:
  B() : j(20) {}
  virtual void f()
  {
    cout << "B::f()" << j << endl;
  }
private:
  int j;
};

void f(A* p)
{
  p -> f();
}

int main()
{
  A a;
  B b;
  f(&a);
  f(&b);

  return 0;
}

我们向函数里传入了不同的参数,在不使用分支的情况下实现了调用不同的函数,这就是多态性。

可能有人会说:「因为 p 带入父类的指针就调用父类的 f 函数,带入子类的指针的时候因为函数屏蔽原则就会调用子类的 f 函数。」

但是请注意一点,我们对 b 进行了「向上造型」,&b 是一个 B* 类型的变量,但是被向上造型为 A* 了(这句话本身并不对,这里只是为了证明问题不在函数名屏蔽上)。

而向上造型之后是没法调用子类中的函数的,这说明其中发生的故事并没有那么简单。

vitural 关键字

vitural 关键字加在函数类型名之前,表示这个函数是一个虚函数,虚函数作用于该类和该类所有子类及其子类的任意代子类的同名同参数表的函数。

  • vitural 关键字告诉编译器,如果 virtual 类型的函数被通过指针或者引用调用的话,要根据调用对象的 vtable 来调用对应的函数,我们把这个过程称为 「动态绑定」

这句话不太好理解,但是可以看一下上面的全局 f 函数中 p -> f(); 一句。

A::f 被声明是一个 virtual 函数,因此调用它的时候会去检查它的 vtable,经过某些逻辑后,它选择调用了 B::f() 而不是 A::f()

至于这个起到关键作用的 vtable 是什么,让我们在下一节中再做详细讨论。

由于这个性质,让全局函数 f 成为了一个通用的万能函数,假使将来 A 类再派生出其他子类来,这些子类中又有属于它们自己的 f 函数,我们依然可以通过全局函数 f 来调用这些子类中的 f 函数。

需要注意的是,一旦我们在父类中声明了 virtual 函数,它的所有子类以及子类的 n 代子类中所有同名同参数表的函数都默认加了 virtual 。但是我们依然建议在这些子类中的同名同参数表函数之前加上 virtual 关键字,因为这样更方便阅读代码。

幕后的虚函数

引例

C++怪谈:

  • 一个类只要拥有 virtual 关键字修饰的函数,它占用的内存就会变大一点。

我们在上面那段代码中使用 sizeof 函数检查一下 a 所占空间,是 16 个字节。

但诡异的是,我们只声明了一个 int 类型的变量,这说明其中仍然暗藏玄机。

我们采用向上造型一节中的乱搞方法,强行把 a 里的内存一个一个拿出来看一下。

int main()
{
  A a;
  int* p = (int*)&a;
  cout << sizeof(a) << endl;
  cout << *p << endl;
  return 0;
}

结果输出了一个奇奇怪怪的负数,并不是我们所期待的 10,我们把 p++ 再看看。

结果还是一个奇怪的数,这说明前 8 字节都是一个不正常的被藏起来的东西,再次 p++ 。

这次终于输出了变量 i 的值。

要解释上面的反常现象,需要了解以下三点:

  • 拥有 virtual 关键词修饰的函数的类,系统会在类的成员变量的内存的前面申请一个隐藏的指针 vptr 。
  • C++ 的内存对齐原则。
  • 指针所占内存大小。

64 位的程序内,指针变量占 8 字节的内存(32 位占 4 字节),所以 vptr 会占用 8 字节的内存;而根据 C++ 的内存对齐原则,最小的成员变量所占的空间会和最大的成员变量对齐。换句话说,int 类型变量会和 vptr 所占空间对齐,因此 i 变量实际上占了 8 个字节而不是 4 个。

合在一起看,对象 a 内包含了 vptr 和 i 两个成员各占 8 字节,一共占用 16 字节。

vptr

  • 当类内有 virtual 修饰的函数时,系统会创建一个隐藏指针 vptr,指向 vtable。

  • vtable 中有该类内所有 virtual 修饰的函数的地址,一个类的所有对象共用相同的 vtable。

当一个子类继承父类的时候,也会继承父类的 vtable,然后尝试用自己的 virtual 函数去替代父类的函数,让我们用下面这个例子来说明这一点。

class A
{
public:
  A() : i(10) {}
  virtual ~A(){}
  virtual void g()
  {
    cout << "A::g()" << endl;
  }
  virtual void f()
  {
    cout << "A::f()" << i << endl;
  }
private:
  int i;
};

class B : public A
{
public:
  B() : j(20) {}
  virtual void f()
  {
    cout << "B::f()" << j << endl;
  }
private:
  int j;
};

在这个例子中,父类和子类的内存模型应该是这样的(按照变量地址从上到下排列)

A::
vtable -> A::f(), A::g(), A::~A()
i

B::
vtable -> A::g(), B::f(), B::~B()
i
j

特别的,子类的 vtable 不会继承父类 virtual 修饰的析构函数,而是它自己的析构函数(原因应该很好想吧)。

vtable

  • 当一个函数通过指针或者引用调用的时候,会优先调用这个指针所指的(或这个引用所引用的)对象中的 vtable 中的函数。

这句话定语很多,我们把它分开说;函数被调用时,会检查调用它的那个指针(或引用)指向的那一块内存,从这块内存中找 vtable,再从 vtable 中找对应的函数。

我们再来解释一下开头那个例子:

f(&a) 中,p 指向了 a 的地址,a 是一个 A 类,因此它的 vtable 是 A 类的 vtable,于是全局函数找到了 A::f()

同理,f(&b) 中,p 指向了 b 的地址,因此编译器从 B 类的 vtable 中找到了 B::f()

自然的,现在我们可以试着理解为什么必须通过指针或者引用才能做到动态绑定了——因为指针发生向上造型是无所谓的。

为什么?因为指针只是指向一块内存的 4 个字节(64位 8 字节)的变量而已,指针发生向上造型只是改了一个类型,它本身指向的内存位置没变;换句话说,那块内存里的数据的类型和值都不会变(就像我们用 int* 的指针可以拿出对象里的变量一个道理)。

假设我们把 f 函数改成 void f(A p) 把 b 传进去的时候,同样会发生向上造型。但是这个向上造型会产生一定的影响:当我们把 b 向上造型为 A 类的时候,它的 vtable 也变成 A 类的 vtable 了。这时候我们再去查 vtable,永远也查不到 B::f()

我们来做个实验验证这一点:

void f(A p)
{
  int* q = (int*) &p;
  cout << "   f::q = " << q << endl;
  p.f();
}

int main()
{
  A a;
  B b;

  int* Avptr = (int*) &a;
  cout << "A::vptr = " << Avptr << endl;
  int* Bvptr = (int*) &b;
  cout << "B::vptr = " << Bvptr << endl;

  int* p = (int*) &b;
  cout << "main::p = " << p << endl; 
  
  f(b);
  
  return 0;
}

我们直接检查这些 vptr 指向的内存里的东西。

显然 b 向上造型之后,它的 vptr 指向的内存变成了 A 类的对象的 vptr 指向的内存。也就是说,在向上造型中,vtable 是不会跟着一起走的。因为 f 函数查到了 A 类的 vtable,自然也就会调用 A::f() 了。


正经的部分结束了,现在来玩点花活:

int main()
{
  A a;
  B b;

  int* p = (int*) &b;
  int* q = (int*) &a;
  A* pa = &a;

  a = b;//向上造型
  *q = *p;//偷偷把向上造型之后的 b 的 vtable 改成它之前的 vtable

  pa -> f();

  return 0;
}

如果我们把 b 的 vtable 也挪过去,会发生什么呢?

可以看到,这时候调用了 B::f()。这进一步说明动态绑定动态绑定是依据 vtable 来实现的。

有意思的是:输出的 j 的值是 0,而不是初始化的 20。

让我们重新再看一下上面的内存模型:

A::
vtable -> A::f(), A::g(), A::~A()
i

B::
vtable -> A::g(), B::f(), B::~B()
i
j <- B::f() 访问的位置

//向上造型后:

a::
vtable -> A::f(), A::g(), A::~A()
i

//修改 vtable 后:

a::
vtable -> A::g(), B::f(), B::~B()
i
  <- B::f() 访问的位置

可以看到,这时候 B::f() 实际上访问了一个无效的内存,输出 0 也不奇怪了。

为什么析构函数要 virtual

这也很好解释,请看下面这段代码:

A* p = new B;//牛逼的一句
/*
do something
*/
delete p;

假设我们把一个 B 类型的对象交给了 A 的指针 p,然后要 delete 它。如果析构函数不是 virtual 的,那么 p 就会去调用 A 类的 析构函数去析构一个 B 类的对象,这显然是不合适的。

如果析构函数是 virtual 的,那么 delete 就会通过 vtable 找到 B 类的析构函数,这样就能正确的调用了。

  • 如果一个类中有任意一个 virtual 类型的构造函数,那么这个类的析构函数必须是 virtual 的,这保证在可能的向上造型的过程中可以正常析构该类的对象。

重载和重写

  • 通过多态性,子类中和父类相同名字相同参数表的函数的实现可以不一样,我们把这种关系称为:「重写」。

  • 如果父类中的某个 virtual 函数有重载函数,那么子类必须重写所有的重载函数,否则将发生函数名隐藏?

C++ Prime 中提到,对于父类的 virtual 函数,子类没有重写就直接继承,但是没有提到重载函数的问题。

网课上睡了函数名隐藏的问题,但是实际上这好像并没有发生,下面这段代码是可以正常运行的:

class A
{
public:
  A() : i(10) {}
  virtual ~A(){}
  virtual void f()
  {
    cout << "A::f()" << endl;
  }
  virtual void f(int i)
  {
    cout << "A::f(int i)" << endl;
  }
private:
  int i;
};

class B : public A
{
public:
  B() : j(20) {}
  virtual void f()
  {
    cout << "B::f()" << endl;
  }
private:
  int j;
};

int main()
{
  A a;
  B b;
  A* p = &b;
  p -> f(6);
  //B 中并没有重写 f(int i) 但是这里可以调用继承来的父类的 f 函数

  return 0;
}

如果有懂的大佬欢迎评论区留言。

标签:vtable,函数,int,多态性,virtual,内存,子类
From: https://www.cnblogs.com/zaza-zt/p/17844054.html

相关文章

  • 多态和多态性
    什么是多态:一类事物的多种形态这是其中的体现比如:动物类:猪,狗,人多态基础classAni0mal:defspeak(self):passclassPig(Animal):defspeak(self):print('哼哼哼')classDog(Animal):defspeak(self):print('汪汪汪'......
  • Python 中多态性的示例和类的继承多态性
    单词"多态"意味着"多种形式",在编程中,它指的是具有相同名称的方法/函数/操作符,可以在许多不同的对象或类上执行。函数多态性一个示例是Python中的len()函数,它可以用于不同的对象。字符串对于字符串,len()返回字符的数量:示例x="HelloWorld!"print(len(x))元组......
  • Python 中多态性的示例和类的继承多态性
    单词"多态"意味着"多种形式",在编程中,它指的是具有相同名称的方法/函数/操作符,可以在许多不同的对象或类上执行。函数多态性一个示例是Python中的len()函数,它可以用于不同的对象。字符串对于字符串,len()返回字符的数量:示例x="HelloWorld!"print(len(x))元组对......
  • python面向对象的三大特性:封装性、继承性、多态性
    python面向对象的三大特性:封装性、继承性、多态性一、python中的封装在python代码中,封装具有两层含义:①在把现实世界中的实体中的属性和方法写到类的里面的操作即为封装。classPerson(object):#封装属性#封装方法②封装可以为属性和方法添加私有权限(属性和方......
  • 【Java 基础篇】Java 接口全面解析:简化多态性与代码组织
    接口(Interface)是Java面向对象编程中的一个重要概念。它允许定义一组抽象方法,这些方法可以被实现类(类)实现。接口提供了一种规范,规定了实现类必须提供哪些方法,但不关心具体的实现细节。本篇博客将深入探讨Java中接口的概念、语法和实际应用,适用于初学者,帮助你轻松理解和应用接口......
  • C#中扩展方法无法获得多态性的行为
    在C#中,扩展方法(ExtensionMethods)是一种用于给现有类型添加新方法的技术。但是,扩展方法无法实现多态性的行为,因为它们是静态方法,它们的行为是在编译时确定的,而不是在运行时。多态性是面向对象编程的一个重要概念,它允许不同的对象以不同的方式响应相同的方法调用。多态性的实现依......
  • 多态性 - C++中实现运行时多态的方式
    一、概述C++中的多态性是指同一个函数可以有多种不同的实现方式,并且在运行时根据实际情况进行选择执行。在C++中实现多态有两种方式:静态多态和动态多态。静态多态是指在编译时确定函数的实现,包括函数重载和模板函数;动态多态是指在运行时根据对象的实际类型来确定函数的实现,包括虚......
  • 进程,线程和协程;为什么有了GIL锁还要互斥锁;多态和多态性;鸭子类型
    进程,线程和协程;为什么有了GIL锁还要互斥锁;多态和多态性;鸭子类型为什么有了GIL锁还要互斥锁1.GIL本身就是一个大的互斥锁2.同一个进程下资源是共享的,也就是说多条线程可以操作同一个变量3.多个线程可以操作同一个变量就会出现数据安全问题4.临界区:指一段代码或一段程序片段,需......
  • 多态性
    多态性引入:传统的方法(形参为不同类,就要新建不同的方法)代码复用性不高,不利于代码维护多(多种)态(状态):方法或对象具有多种形态多态的具体体现:方法的多态方法的重载和重写都体现了多态对象的多态对象的多态一个对象的编译类型和运行类型可以不一致,编译类型在定义对象时就......
  • C++的多态性
    C++面向对象中的多态性是指同一种类型的对象在不同的情况下表现出不同的行为。所谓消息是指对类成员函数的调用,不同的行为是指不同的实现,也就是调用了不同的函数,从广义上说,多态性是指一段程序能够处理多种类型对象的能力。在C++中,虚函数是指在基类中声明的函数,在派生类中可以被重......