多态性

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

多态性

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

#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;
}

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

再谈联编与疑惑的解决

\(\text{upd:2023.11.22}\)

我们把上面代码改成 B* p = &b; 就会发生函数名隐藏了。

这里发生了很复杂的逻辑,让我们慢慢来把它捋清楚。

  • 首先要弄明白为什么一开始的代码可以运行:

    1. 编译器在编译的时候,会注意到 p -> f(6); 一句。这时候它会去检查 p 和 f 函数,即 p 这个对象有没有调用 f 函数的权限。
    2. 如果 p 有这个权限,并且 f 函数不是虚函数,那么编译器会采用「静态联编」的方式,直接把 f 函数的地址做在这个地方;如果 f 是虚函数,就会「动态联编」,也就是我们说的「动态绑定」。因为 f 函数可能在子类中被重写过,所以编译器不会把任何一个函数的地址做在这里,而是等到运行的时候去查 vtable 再来确定对应函数的地址,然后调用。
    3. B 类没有重写 f(int) 函数,所以它会直接继承 A::f(int),它的 vtable 里存的函数地址也是 A::f(int)
    4. 调用的时候,查到了 B 类的 vtable,然后调用了继承的 A::f(int)

那么既然 B 类的 vtable 里有 A::f(int) 函数,为什么改了之后就无法调用了呢?

这是因为 C++ 要发生函数名隐藏。即使 A::f(int) 已经被继承到 B 类里,但是仍然需要发生函数名隐藏,即 B 类自己的 f 函数把 A 类的两个重载的函数隐藏掉。

这样 B 类的指针就无权访问 A 类的两个重载 f 函数,尽管它们其中之一已经在 B 类的 vtable 里了。

值得一提的是,函数调用权限是编译器检查的,当它编译的时候认为 B* 类型的指针无法调用 A::f(int) 的时候,就会直接报错了,所以这样是通不过编译的。

posted @ 2023-11-20 15:24  ZTer  阅读(84)  评论(0编辑  收藏  举报