虚函数调用的感性理解#

静态类型和动态类型#

静态类型#

通过分析表达式本身即可确定的类型称为静态类型 (static type), 它跟程序的执行无关.

1int value;             // value 的类型是 int
2int array[5];          // array 的类型是 int[5]
3int* pointer = array;  // pointer 的类型是 int*
4*pointer     = 0;      // *pointer 的类型是 int&

动态类型#

引用、指针实际引用的类型称为动态类型 (dynamic type).

当不涉及继承和类层次时, 动态类型通常与静态类型相同.

1int value;
2int& reference = value;   // reference 的静态、动态类型均为 int&
3int* pointer   = &value;  // pointer 的类型是 int*
4*pointer       = 0;       // *pointer 的静态、动态类型均为 int&

但类层次的基类引用和指针可以引用派生类对象, 则动态类型会根据程序执行而变化.

 1class A {};
 2class B : public A {};
 3
 4A a;
 5B b;
 6
 7A& reference = b;  // reference 的静态类型是 A&, 动态类型是 B&
 8
 9A* pointer = &a;   // pointer 的类型是 A*
10                   // *pointer 的静态类型是 A&, 动态类型是 A&
11
12pointer = &b;      // pointer 的类型是 A*
13                   // *pointer 的静态类型是 A&, 动态类型是 B&

成员函数的调用#

成员函数的隐藏参数#

一个类可以创建出多个对象, 但成员函数只有一个. 那么当对象调用成员函数时, 成员函数是如何确定对应于哪个对象的?

例如下面的代码中, Widget::print() 如何知道 value_ 是对应于 a 对象而非 b 对象?

 1class Widget {
 2 public:
 3  void set_value(int value) {
 4    value_ = value;
 5  }
 6  void print() const {
 7    std::cout << value_;
 8  }
 9
10 private:
11  int value_;
12};
13
14int main() {
15  Widget a;
16  Widget b;
17  a.set_value(1);  // 设置 a.value_ 而不是 b.value_
18  a.print();       // 输出 a.value_ 而不是 b.value_
19}

事实上成员函数将对象作为隐藏参数传入, 而这个参数就是课上提及的 this 指针.

 1class Widget {
 2 public:
 3  void set_value(Widget* this, int value) {
 4    (*this).value_ = value;
 5  }
 6  void print(Widget const* this) {  // const 成员函数对应于 const 传参
 7    std::cout << (*this).value_;
 8  }
 9
10 private:
11  int value_;
12};

为了便于分析理解, 我之后将采用 C++23 的显式对象参数 (explicit object parameter) 语法, 写明对象是如何传入成员函数中的.

 1class Widget {
 2 public:
 3  void set_value(this Widget& self, int value) {
 4    self.value_ = value;
 5  }
 6  void print(this Widget const& self) {
 7    std::cout << self.value_;
 8  }
 9
10 private:
11  int value_;
12};

可供调用的成员函数#

当对象进行函数调用时, 可见 (visible) 的成员函数 由静态类型确定.

 1class A {
 2 public:
 3  void f(this A&, int value);
 4  void f(this A&);
 5  virtual void g(this A&);
 6};
 7
 8class B : public A {
 9 private:
10  void f(this B&);
11  void g(this B&) override;
12};
13
14class C : public B {
15 public:
16  void f(this C&);
17};

对于上面的代码, 我们可以画出各个类的可见信息图, 继承在图中表现为嵌套关系:

1class A {
2  public: void f(this A&, int value);
3  public: void f(this A&);
4  public: virtual void g(this A&);
5}

应该注意到, 基类中所有内容对于派生类都是可见的, publicprivate 等称为访问控制, 并不影响可见性.

当进行函数调用时, 对象通过可见函数找到最合适的函数, 之后再确认函数是否可达 (accessible).

非虚成员函数的调用#

对于非虚成员函数, 对象根据 静态类型 的可见信息图找到 最合适的函数确定可达性:

静态类型 A 调用非虚成员函数 f()#
 1class A {
 2  public: void f(this A&, int value);
 3  public: void f(this A&);
 4  public: virtual void g(this A&);
 5}
 6
 7C c;
 8A& a = c;
 9a.f();    // 静态信息为 A&, 找到 A::f
10a.f(10);  // 静态信息为 A&, 找到 A::f

如果存在名字一样的函数, 只会查找最内层的函数:

静态类型 C 调用非虚成员函数 f()#
 1class A {
 2  // public: void f(this A&, int value);
 3  // public: void f(this A&);
 4  // public: virtual void g(this A&);
 5
 6  public: class B {
 7    // private: void f(this B&);
 8    private: void g(this B&) override;
 9
10    public: class C {
11      public: void f(this C&);
12    }
13  }
14}
15
16C c;
17c.f();    // 静态信息为 C&, 找到 C::f
18c.f(10);  // 静态信息为 C&, 错误: 未找到对应的函数

需要注意的是, 是找到最合适的函数后, 再确认函数是否可访问. 如果不可访问则调用直接失败, 而不会继续查找:

静态类型 B 调用非虚成员函数 f()#
 1class A {
 2  // public: void f(this A&, int value);
 3  // public: void f(this A&);
 4  // public: virtual void g(this A&);
 5
 6  public: class B {
 7    private: void f(this B&);
 8    private: void g(this B&) override;
 9  }
10}
11
12B b;
13b.f();  // 找到 B:f,
14        // 它处于 private 中, 不可访问, 故调用失败

虚成员函数的调用#

对于虚成员函数, 对象同样根据 静态类型 的可见信息图找到 最合适的函数确定可达性:

1class A {
2  public: void f(this A&, int value);
3  public: void f(this A&);
4  public: virtual void g(this A&);
5}
6
7A a;
8a.g();  // 找到 A::g

区别在于当虚成员函数发生调用时, 将会根据动态类型的可见信息图找到对应的重写函数, 对它进行实际调用:

(静态类型 A, 动态类型 B) 调用虚成员函数 g()#
 1class A {
 2  public: void f(this A&, int value);
 3  public: void f(this A&);
 4  public: virtual void g(this A&);
 5}
 6
 7class A {
 8  // public: void f(this A&, int value);
 9  // public: void f(this A&);
10  // public: virtual void g(this A&);
11
12  public: class B {
13    private: void f(this B&);
14    private: void g(this B&) override;
15  }
16}
17
18B b;
19A& a = b;
20a.g();  // 根据静态类型 A 找到虚成员函数 A::g, 它是可以访问的, 故调用成功
21        // 根据动态类型 B 找到最内层的重写函数 B::g, 发生实际调用

注意对象在成员函数内的静态类型:

 1class Base {
 2 public:
 3  void f(this Base& self) {
 4    std::cout << "Base::f()\n";
 5  }
 6  virtual void g(this Base& self) {
 7    std::cout << "Base::g()\n";
 8    self.f();
 9  }
10};
11
12class Derived : public Base {
13 public:
14  void f(this Derived& self) {
15    std::cout << "Derived::f()\n";
16  }
17  void g(this Derived& self) override {
18    std::cout << "Derived::g()\n";
19    self.f();  // 注意看显式对象参数 self, 此时静态类型是 Derived&!
20  }
21};
22
23Derived derived;
24Base& reference = derived;
25reference.g();  // 调用 Derived::g

习题#

这是本问题提问人 实际询问的问题, 此处作为习题提供来验证理解.

 1#include <iostream>
 2
 3class A {
 4 public:
 5  virtual void fun1() {
 6    std::cout << "A::fun1 " << '\n';
 7    fun2();
 8  }
 9  void fun2() {
10    std::cout << "A::fun2 " << '\n';
11    fun3();
12  }
13  virtual void fun3() {
14    std::cout << "A::fun3 " << '\n';
15    fun4();
16  }
17  void fun4() {
18    std::cout << "A::fun4 " << '\n';
19  }
20};
21
22class B : public A {
23 public:
24  void fun3() override {
25    std::cout << "B::fun3 " << '\n';
26    fun4();
27  }
28  void fun4() {
29    std::cout << "B::fun4 " << '\n';
30  }
31};
32
33int main() {
34  A a;
35  B b;
36  a.fun1();
37  b.fun1();
38  return 0;
39}

强制使用成员函数#

有时我们需要在派生类中明确调用基类或派生类的函数, 则应该对查找范围进行限定:

 1class Base {
 2 public:
 3  void f(this Base& self);
 4  virtual void g(this Base& self);
 5};
 6
 7class Derived : public Base {
 8 public:
 9  void f(this Derived& self);
10  void g(this Derived& self) override {
11    self.Base::f();
12  }
13};

有时我们想让派生类沿用基类的函数, 同时又定义新的同名函数, 则可以使用 using 声明引入基类函数:

 1class Base {
 2 public:
 3  void f(this Base& self, int value);
 4};
 5
 6class Derived : public Base {
 7 public:
 8  using Base::f;  // 引入 Base::f
 9  void f(this Derived& self);
10};

类层次与虚析构函数#

类层次的一种用法是, 通过 new 获取堆内存 资源 并构造一个对象, 当使用完毕后, 通过 delete 调用析构函数并释放堆内存资源.

1int* owner = new int;
2delete owner;

如果将基类的析构函数作为公用非虚函数, 将会发生什么?

 1class Base {
 2 public:
 3  ~Base() {}
 4};
 5class Derived : public Base {
 6 public:
 7  ~Derived() {}
 8};
 9
10Base* base = new Derived();
11delete base;  // 释放资源, 将会调用 Base::~Base();

上面的代码中 base* 指向了一个派生类 Derived 对象, 并具有该对象的所有权, 这个对象存在以下信息:

1class Base {
2  /* Base 的信息 */
3  class Derived {
4    /* Derived 的信息 */
5  }
6}

但释放时基于静态类型调用析构函数 Base::~Base(), 仅仅对 Base 部分进行析构:

1class Base {
2  /* Base 的信息已经被析构了 */
3  class Derived {
4    /* Derived 的信息还在 */
5  }
6}

我们最终泄露了 Derived 部分的内存. 更可怕的是这实际上是未定义行为, 程序可以做任何事, 甚至炸掉你的电脑!

将析构函数作为公用虚函数#

我们可以将析构函数定义为公用虚函数, 使基类指针 delete 正确调用 Derived::~Derived().

 1class Base {
 2 public:
 3  virtual ~Base() {}
 4};
 5class Derived : public Base {
 6 public:
 7  ~Derived() override {}
 8};
 9
10Base* base = new Derived();
11delete base;  // 释放资源, 将会调用 Derived::~Derived();

当然由于基类已经是虚函数, 派生类中默认生成的析构函数就会是对它的重写.

1class Base {
2 public:
3  virtual ~Base() {}
4};
5class Derived : public Base {};
6
7Base* base = new Derived();
8delete base;  // 释放资源, 将会调用 Derived::~Derived();

将析构函数作为保护用非虚函数#

我们可以将析构函数定义为保护用非虚函数, 由于析构函数被设置为保护, 用户将无法访问 Base 的析构函数.

 1class Base {
 2 protected:
 3  ~Base() {}
 4};
 5class Derived : public Base {};
 6
 7Derived* derived = new Derived();
 8Base* base       = derived;
 9delete base;     // 错误: 不能调用保护用的析构函数
10delete derived;  // 正确

类层次与构造函数、析构函数#

对象在构造函数中时, 本层的信息正在被构造, 只有上一层才具有完整的类型信息.

 1class Base {
 2 public:
 3  Base() {}
 4
 5  virtual void f() {
 6    std::cout << "Base::f()\n";
 7  }
 8};
 9class Derived : public Base {
10 public:
11  Derived() : Base()  // 调用 Base 的构造函数, 此后 Base 的信息构造完全
12  {
13    /* 构造函数内 Derived 的信息正在被构造 */
14    f();  // 将会调用 Base::f();
15  }
16
17  void f() override {
18    std::cout << "Derived::f()\n";
19  }
20};

对象在析构函数中时, 本层的信息正在被析构, 只有上一层才具有完整的类型信息.

 1class Base {
 2 public:
 3  ~Base();
 4
 5  virtual void f() {
 6    std::cout << "Base::f()\n";
 7  }
 8};
 9class Derived : public Base {
10 public:
11  ~Derived() {
12    /* 析构函数内 Derived 的信息正在被析构 */
13    f();  // 将会调用 Base::f();
14  }  // 结束后继续调用 Base 的析构函数
15
16  void f() override {
17    std::cout << "Derived::f()\n";
18  }
19};

需要注意的是, 此处都是指构造函数、析构函数内部的虚函数调用:

  • 虚函数调用需要完整的类型信息.

  • 构造函数、析构函数是对类型信息进行构造和析构.

基于这两点, 其内部所执行的虚函数调用自然会产生这样的行为, 与构造函数、析构函数本身如何被调用无关.

对于构造函数、析构函数本身是否可以是虚函数, 我们也可以根据类型信息的构造和析构来推理:

  • 构造函数不能被设置为虚函数, 毕竟构造函数之前不存在任何类型信息.

  • 析构函数可以是虚函数, 毕竟构造函数已经完成了, 而析构函数执行前类型信息是完整的, 只是开始执行后逐渐对信息进行析构.

类层次与拷贝构造函数、拷贝赋值函数#

拷贝函数的问题#

默认的拷贝行为也是基于静态类型进行的. 通过 Derived 拷贝构造 Base 时, 将只会拷贝 DerivedBase 部分, 这称为对象切片 (object slicing).

 1#include <iostream>
 2
 3class Base {
 4 public:
 5  virtual ~Base() {}
 6
 7  int a;
 8};
 9class Derived : public Base {
10 public:
11  int b;
12};
13
14void function(Base base) {
15  std::cout << base.a;
16}
17
18int main() {
19  Derived derived;
20  derived.a = 1;
21  derived.b = 2;
22
23  function(derived);
24}

提示

你也可以通过断点调试查看该参数的数据, 自己验证一下结果如何. 断点调试非常有用, 请学习 断点调试的使用 并完成习题.

../../_images/object_slicing.png

断点调试查看拷贝结果#

引用或指针只是引用对象, 自然不存在拷贝的问题.

 1#include <iostream>
 2
 3class Base {
 4 public:
 5  virtual ~Base() {}
 6
 7  int a;
 8};
 9class Derived : public Base {
10 public:
11  int b;
12};
13
14void function(Base& base) {
15  std::cout << base.a;
16}
17
18int main() {
19  Derived derived;
20  derived.a = 1;
21  derived.b = 2;
22
23  function(derived);
24}

提示

你也可以通过断点调试查看该参数的数据, 自己验证一下结果如何. 断点调试非常有用, 请学习 断点调试的使用 并完成习题.

../../_images/referencing.png

断点调试查看引用结果#

好吧, 我们需要利用动态类型, 换句话说, 我们需要使用虚函数. 但问题是在 类层次与构造函数、析构函数 中我们已经分析过, 构造函数不能设置为虚函数, 所以拷贝构造函数不能设置为虚函数. 难道我们抛开拷贝构造函数不管, 将拷贝赋值函数定义为虚函数吗?

不, 我们应该保持拷贝构造函数和拷贝赋值函数的行为一致. 想象一下这样一种 int, 当发生拷贝构造时它创建一个新的 int, 当发生拷贝赋值时它指向同一个 int:

发生拷贝构造时为值语义, 发生拷贝赋值时为引用语义#
1int value1 = 0;
2int value2 = value1;  // 拷贝构造, value2 是新的 int
3int value3;
4value3 = value1;      // 拷贝赋值, value1 和 value3 指向同一个 int
5
6value3 = 5;
7std::cout << value1 << '\n';  // 输出 5
8std::cout << value2 << '\n';  // 输出 0
9std::cout << value3 << '\n';  // 输出 5

这太可怕了!

既然拷贝构造函数不能是虚函数, 那么拷贝赋值函数为了一致性, 也应该是非虚函数.

参见

但我想通过基类引用拷贝派生类!#

危险

这是比较进阶的内容, 添加进来只是为了提示有可解决方案.

那应该如何拷贝类层次呢? 有一种已有的设计模式可以解决这个问题, 它称为原型 (prototype) 设计模式.

 1class Base {
 2 public:
 3  Base() {}
 4  virtual ~Base() {}
 5  virtual Base* clone() const = 0;
 6};
 7
 8class Derived : public Base {
 9 public:
10  Derived* clone() const override {
11    return new Derived(*this);  // Dervied 自己知道如何拷贝自己
12  }
13};