C++虚函数机制学习笔记

MirrorYuChen
MirrorYuChen
发布于 2025-01-17 / 27 阅读
0
0

C++虚函数机制学习笔记

C++虚函数机制学习笔记

1.C++函数调用绑定

​ C++中,函数调用绑定是指将函数调用和函数定义进行关联的过程,主要分为静态绑定和动态绑定。

1.1 静态绑定

​ 静态绑定是指在编译时就确定函数调用与函数定义之间关联。编译器根据函数调用处上下文信息,如函数名、参数类型等,来确定调用哪个函数。主要适用于如下情况:

  • (1) 非虚函数调用:对于类普通成员函数、静态成员函数和全局函数等非虚函数,编译器在编译时就能确定应该调用哪个函数。
/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
#include <iostream>

void Sum(int a, int b) {
  std::cout << "The sum is: " << a + b << std::endl;
}

void Sum(float a, float b) {
  std::cout << "The sum is: " << a + b << std::endl;
}

class A {
public:
  void Print() {
    std::cout << "Hello, World." << std::endl;
  }
  static void Call() {
    std::cout << "A Call..." << std::endl;
  }
};

int main(int argc, char *argv[]) {
  {
    int a = 10;
    int b = 20;
    Sum(a, b);         // calls the first version of Sum()
  }
  {
    float a = 10.5;
    float b = 20.5;
    Sum(a, b);         // calls the second version of Sum()
  }

  {
    A a;
    a.Print();
    a.Call();
    A::Call();
  }
  return 0;
}

​ 在 main中,对于 Sum函数的调用,在编译期间就能通过传入参数类型确定具体调用接口。

  • (2) 模板函数实例化:模板函数在编译期根据模板参数进行实例化,生成具体函数定义,这也是静态绑定的一种体现。
/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
#include <iostream>

template <typename T, typename U>
auto Sum(T a, U b) -> decltype(a + b) {
  return a + b;
}

int main(int argc, char *argv[]) {
  {
    int a = 10;
    int b = 20;
    auto c = Sum(a, b);
    std::cout << "Sum of " << a << " and " << b << " is " << c << std::endl;
  }
  {
    float a = 10.5;
    double b = 20.5;
    auto c = Sum(a, b);
    std::cout << "Sum of " << a << " and " << b << " is " << c << std::endl;
  }
  return 0;
}

1.2动态绑定

​ 动态绑定是指在运行时才确定函数调用和函数定义之间的关联。它利用虚函数机制,通过虚表和虚指针实现,主要用于实现多态。编译器在编译时不会确定调用哪个虚函数,而是在运行时通过对象的虚指针找到对应虚表,再根据虚表中的函数指针调用对应虚函数。

/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
#include <iostream>

class Base {
public:
  void func1() {  // 非虚函数,静态绑定
    std::cout << "Base func1" << std::endl;
  }
  virtual void func2() {  // 虚函数,动态绑定
    std::cout << "Base func2" << std::endl;
  }
};

class Derived : public Base {
public:
  void func1() {         // 非虚函数,隐藏基类函数
    std::cout << "Derived func1" << std::endl;
  }
  void func2() override {  // 虚函数,覆盖基类虚函数
    std::cout << "Derived func2" << std::endl;
  }
};

int main(int argc, char *argv[]) {
  Base b;
  Derived d;
  Base *pb = &d;

  b.func1();  // 输出:Base func1,静态绑定,调用Base类的func1
  b.func2();  // 输出:Base func2,静态绑定,调用Base类的func2

  d.func1();  // 输出:Derived func1,静态绑定,调用Derived类的func1
  d.func2();  // 输出:Derived func2,静态绑定,调用Derived类的func2

  pb->func1();  // 输出:Base func1,静态绑定,调用Base类的func1,因为func1是非虚函数
  pb->func2();  // 输出:Derived func2,动态绑定,调用Derived类的func2,因为func2是虚函数
  return 0;
}

​ func1是非虚函数,采用静态绑定,编译器在编译时就确定了调用哪个函数。而 func2是虚函数,采用动态绑定,通过基类指针 pb调用 func2时,会根据 pb指向的对象的实际类型 Derived,调用派生类中的 func2函数。

2.C++虚函数机制

2.1 内存布局查看工具使用

​ 我们先使用工具查看一下上面这些类的内存布局,不同工具的命令用法:

编译器 用法
g++ (Ubuntu 9.4.0-1ubuntu1~20.04.2) 9.4.0 g++ -fdump-lang-class [filename]
clang version 10.0.0-4ubuntu1 clang -Xclang -fdump-vtable-layouts -c <filename>
msvc 2019 cl /d1 reportSingleClassLayout<classname> <filename>

2.2 虚函数表和虚函数指针分析

​ 为了方便分析,将一些无关代码注释掉:

/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
// #include <iostream>

class Base {
public:
  void func1() {  // 非虚函数,静态绑定
    // std::cout << "Base func1" << std::endl;
  }
  virtual void func2() {  // 虚函数,动态绑定
    // std::cout << "Base func2" << std::endl;
  }
};

class Derived : public Base {
public:
  void func1() {         // 非虚函数,隐藏基类函数
    // std::cout << "Derived func1" << std::endl;
  }
  void func2() override {  // 虚函数,覆盖基类虚函数
    // std::cout << "Derived func2" << std::endl;
  }
};

int main(int argc, char *argv[]) {
  Base b;
  Derived d;
  Base *pb = &d;

  b.func1();  // 输出:Base func1,静态绑定,调用Base类的func1
  b.func2();  // 输出:Base func2,静态绑定,调用Base类的func2

  d.func1();  // 输出:Derived func1,静态绑定,调用Derived类的func1
  d.func2();  // 输出:Derived func2,静态绑定,调用Derived类的func2

  pb->func1();  // 输出:Base func1,静态绑定,调用Base类的func1,因为func1是非虚函数
  pb->func2();  // 输出:Derived func2,动态绑定,调用Derived类的func2,因为func2是虚函数
  return 0;
}

​ clang下使用如下指令,生成对应虚函数表及虚指针信息:

>> clang -Xclang -fdump-vtable-layouts -c main.cc
Vtable for 'Base' (3 entries).
   0 | offset_to_top (0)
   1 | Base RTTI
       -- (Base, 0) vtable address --
   2 | void Base::func2()

VTable indices for 'Base' (1 entries).
   0 | void Base::func2()

Vtable for 'Derived' (3 entries).
   0 | offset_to_top (0)
   1 | Derived RTTI
       -- (Base, 0) vtable address --
       -- (Derived, 0) vtable address --
   2 | void Derived::func2()

VTable indices for 'Derived' (1 entries).
   0 | void Derived::func2()

​ msvc下使用如下指令,生成对应虚函数表及虚指针信息:

>> cl /d1 reportSingleClassLayoutBase main.cc
用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.29.30157 版
版权所有(C) Microsoft Corporation。保留所有权利。

main.cc

class _s__RTTIBaseClassDescriptor       size(36):
        +---
 0      | pTypeDescriptor
 8      | numContainedBases
12      | _PMD where
24      | attributes
28      | pClassDescriptor
        +---

class _s__RTTIBaseClassArray    size(1):
        +---
 0      | arrayOfBaseClassDescriptors
        +---

class Base      size(8):
        +---
 0      | {vfptr}
        +---

Base::$vftable@:
        | &Base_meta
        |  0
 0      | &Base::func2

Base::func2 this adjustor: 0
Microsoft (R) Incremental Linker Version 14.29.30157.0
Copyright (C) Microsoft Corporation.  All rights reserved.
>> cl /d1 reportSingleClassLayoutDerived main.cc
用于 x64 的 Microsoft (R) C/C++ 优化编译器 19.29.30157 版
版权所有(C) Microsoft Corporation。保留所有权利。

main.cc

class Derived   size(8):
        +---
 0      | +--- (base class Base)
 0      | | {vfptr}
        | +---
        +---

Derived::$vftable@:
        | &Derived_meta
        |  0
 0      | &Derived::func2

Derived::func2 this adjustor: 0
Microsoft (R) Incremental Linker Version 14.29.30157.0
Copyright (C) Microsoft Corporation.  All rights reserved.

​ gcc下使用如下指令,生成对应虚函数表及虚指针信息:

>> g++ -fdump-lang-class main.cc

​ 这时,会生成一个 .class的文件:

Vtable for Base
Base::_ZTV4Base: 3 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI4Base)
16    (int (*)(...))Base::func2

Class Base
   size=8 align=8
   base size=8 base align=8
Base (0x0x7fa391f4f420) 0 nearly-empty
    vptr=((& Base::_ZTV4Base) + 16)

Vtable for Derived
Derived::_ZTV7Derived: 3 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Derived::func2

Class Derived
   size=8 align=8
   base size=8 base align=8
Derived (0x0x7fa391dfb1a0) 0 nearly-empty
    vptr=((& Derived::_ZTV7Derived) + 16)
  Base (0x0x7fa391f4f540) 0 nearly-empty
      primary-for Derived (0x0x7fa391dfb1a0)

​ 可以看到,对于 Base类,它的虚指针 vptr指向了 Base虚表中存放的虚函数地址 Base::func2,对于 Derived类,虚指针指向了 Derived虚表中存放的虚函数地址 Derived::func2

​ 代码中 Base *pb = &d;,基类指针 pb的虚指针会指向派生类 Derived的虚函数表,因此调用过程中 pb->func2();会调用派生类的 func2实现。

2.3 虚函数表与 typeinfo信息

​ 现在我们看一下虚函数表内容:

Vtable for Base
Base::_ZTV4Base: 3 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI4Base)
16    (int (*)(...))Base::func2
  • (1) 0 (int (*)(...))0:通常是一个特殊的值,用于表示这是一个类型信息指针的占位符。在某些编译器实现中,这个位置可能用于存储一些特殊运行时信息,但具体用途可能因编译器而异。
  • (2) 8 (int (*)(...))(& _ZTI4Base)& _ZTI4Base是一个指向 Base类的 type_info对象的指针。type_info对象包含了 类的类型信息,用于支持 RTTI(运行时类型信息)。例如,dynamic_cast操作会使用这个指针来检查类型转换的合法性。
  • (3) 16 (int (*)(...))Base::func2Base::func2Base类中一个虚函数。这个条目存储了 func2函数的地址,使得通过基类指针或引用调用 func2时,能正确调用到 Base类实现。
#include <iostream>
#include <typeinfo>

class Base {
public:
    virtual void func2() {
        std::cout << "Base::func2" << std::endl;
    }
    virtual ~Base() {
        std::cout << "Base destructor" << std::endl;
    }
};

int main() {
    Base b;
    std::cout << typeid(b).name() << std::endl;  // 使用RTTI
    b.func2();
    return 0;
}

3.论基类虚析构函数的必要性

3.1 常规使用

​ 正常实例化一个基类对象和一个派生类对象:

/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
#include <iostream>

class Base {
public:
  Base() {
    std::cout << "Base constructor" << std::endl;
  }

  ~Base() {
    std::cout << "Base destructor" << std::endl;
  }
};

class Derived : public Base {
public:
  Derived() {
    std::cout << "Derived constructor" << std::endl;
  }

  ~Derived() {
    std::cout << "Derived destructor" << std::endl;
  }
};


int main(int argc, char *argv[]) {
	std::cout << "****************" << std::endl;
  {
    Base a;
  }
  std::cout << "****************" << std::endl;
  {
    Derived b;
  }
  std::cout << "****************" << std::endl;
  {
    Base *a = new Base();
    delete a;
  }
  std::cout << "****************" << std::endl;
  {
    Derived *b = new Derived();
    delete b;
  }
  std::cout << "****************" << std::endl;
  return 0;
}

​ 输出结果为:

****************
Base constructor
Base destructor
****************
Base constructor
Derived constructor
Derived destructor
Base destructor
****************
Base constructor
Base destructor
****************
Base constructor
Derived constructor
Derived destructor
Base destructor
****************

​ 可以看到,没有任何问题,实例化一个派生类,会先调用基类构造函数,然后调用派生类构造函数,b生命周期结束后,会先调用派生类析构函数,然后调用基类析构函数。

3.2 向上转换

​ 所谓向上转换就是将一个基类指针指向派生类对象:

/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
#include <iostream>

class Base {
public:
  Base() {
    std::cout << "Base constructor" << std::endl;
  }

  ~Base() {
    std::cout << "Base destructor" << std::endl;
  }

  void print() {
    std::cout << "Base print" << std::endl;
  }
};

class Derived : public Base {
public:
  Derived() {
    std::cout << "Derived constructor" << std::endl;
  }

  ~Derived() {
    std::cout << "Derived destructor" << std::endl;
  }

  void print() {
    std::cout << "Derived print" << std::endl;
  }
};

int main(int argc, char *argv[]) {
  Base *b = new Derived();
  b->print();
  delete b;
  return 0;
}

​ 运行结果:

Base constructor
Derived constructor
Base print
Base destructor

​ 可以看到构造过程中,new Derived()会调用 Derived类的构造函数,这时会先调用基类构造函数,然后调用派生类的构造内容,但是析构时,指针 b类型为 Basedelete时只会调用 Base类的析构函数,所以只是析构了基类对象资源,这样就会造成子类对象资源未释放,出现资源泄漏问题。

​ 为什么会这样呢?其实很好理解,因为 b的指针类型为 Base,我们在 delete b时,当然只会调用 Base的析构函数,同样的,print()方法调用也只会调用基类的。

3.3 基类虚析构函数

​ 这时,我们只需要将基类的析构函数及 print函数声明为虚函数,就实现我们预期的结果了:

/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
#include <iostream>

class Base {
public:
  Base() {
    std::cout << "Base constructor" << std::endl;
  }

  virtual ~Base() {
    std::cout << "Base destructor" << std::endl;
  }

  virtual void print() {
    std::cout << "Base print" << std::endl;
  }
};

class Derived : public Base {
public:
  Derived() {
    std::cout << "Derived constructor" << std::endl;
  }

  ~Derived() {
    std::cout << "Derived destructor" << std::endl;
  }

  void print() {
    std::cout << "Derived print" << std::endl;
  }
};

int main(int argc, char *argv[]) {
  Base *b = new Derived();
  b->print();
  delete b;
  return 0;
}

​ 输出结果:

Base constructor
Derived constructor
Derived print
Derived destructor
Base destructor

​ print结果符合预期的原理前面虚函数机制里面已经说明清楚了,那么为什么基类析构函数声明为虚函数,资源释放就能符合预期呢?还是和前面一样,我们先将一些无关代码注释掉:

/*
* @Description: main
* @Author: chenjingyu
* @Date: 2025-01-15 16:21:41
* @FilePath: main.cc
*/
// #include <iostream>

class Base {
public:
  Base() {
    // std::cout << "Base constructor" << std::endl;
  }

  virtual ~Base() {
    // std::cout << "Base destructor" << std::endl;
  }

  virtual void print() {
    // std::cout << "Base print" << std::endl;
  }
};

class Derived : public Base {
public:
  Derived() {
    // std::cout << "Derived constructor" << std::endl;
  }

  ~Derived() {
    // std::cout << "Derived destructor" << std::endl;
  }

  void print() {
    // std::cout << "Derived print" << std::endl;
  }
};

int main(int argc, char *argv[]) {
  Base *b = new Derived();
  b->print();
  delete b;
  return 0;
}

​ 然后使用命令来获取对象布局:

>> g++ -fdump-lang-class main.cc

​ 得到 .class的输出结果如下:

Vtable for Base
Base::_ZTV4Base: 5 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI4Base)
16    (int (*)(...))Base::~Base
24    (int (*)(...))Base::~Base
32    (int (*)(...))Base::print

Class Base
   size=8 align=8
   base size=8 base align=8
Base (0x0x7ff89ae07420) 0 nearly-empty
    vptr=((& Base::_ZTV4Base) + 16)

Vtable for Derived
Derived::_ZTV7Derived: 5 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI7Derived)
16    (int (*)(...))Derived::~Derived
24    (int (*)(...))Derived::~Derived
32    (int (*)(...))Derived::print

Class Derived
   size=8 align=8
   base size=8 base align=8
Derived (0x0x7ff89acb31a0) 0 nearly-empty
    vptr=((& Derived::_ZTV7Derived) + 16)
  Base (0x0x7ff89ae07840) 0 nearly-empty
      primary-for Derived (0x0x7ff89acb31a0)

​ 前面我们知道当 Base *b = new Derived();时,b是一个 Base类型的指针,但是它的虚指针会指向 Derived类的虚函数表,析构时,先调用虚指针指向的析构函数,然后再调用自身析构函数,这样就实现了正确的资源释放结果。

​ 这里解释一下虚函数表中,两个相同条目:

  • 16 (int (*)(...))Base::~Base:普通析构函数指针。当通过基类指针删除单个对象时,调用这个指针。
  • 24 (int (*)(...))Base::~Base:删除数组对象的析构函数指针。当通过基类指针删除数组对象时,调用这个指针。

​ 所以,如果基类指针或引用可能用于删除派生类对象,那么基类的析构函数必须声明为虚函数。这样可以确保通过基类指针或引用删除派生类对象时,能够正确调用派生类的析构函数,从而避免资源泄漏。

4.参考资料


评论