虚函数调用的感性理解#
静态类型和动态类型#
静态类型#
通过分析表达式本身即可确定的类型称为静态类型 (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}
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}
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}
应该注意到, 基类中所有内容对于派生类都是可见的, public
和 private
等称为访问控制, 并不影响可见性.
当进行函数调用时, 对象通过可见函数找到最合适的函数, 之后再确认函数是否可达 (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
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.g(); // 找到 B::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}
点此查看答案
由于 A a
动态类型已经是基类 A
, 所有函数都调用 A
中的.
而 B b
经历如下调用,
- 37 行
b.fun1()
静态类型为
B&
, 查找到虚函数A::fun1()
, 基于动态类型B&
调用A::fun1()
.- 7 行
fun2()
静态类型为
A&
, 查找到非虚函数A::fun2()
, 直接调用A::fun2()
.- 11 行
fun3()
静态类型为
A&
, 查找到虚函数A::fun3()
, 基于动态类型B&
调用B::fun3()
.- 26 行
fun4()
静态类型为
B&
, 查找到非虚函数B::fun4()
, 直接调用B::fun4()
.
1A::fun1
2A::fun2
3A::fun3
4A::fun4
5A::fun1
6A::fun2
7B::fun3
8B::fun4
强制使用成员函数#
有时我们需要在派生类中明确调用基类或派生类的函数, 则应该对查找范围进行限定:
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
时, 将只会拷贝 Derived
的 Base
部分, 这称为对象切片 (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}
引用或指针只是引用对象, 自然不存在拷贝的问题.
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}
好吧, 我们需要利用动态类型, 换句话说, 我们需要使用虚函数. 但问题是在 类层次与构造函数、析构函数 中我们已经分析过, 构造函数不能设置为虚函数, 所以拷贝构造函数不能设置为虚函数. 难道我们抛开拷贝构造函数不管, 将拷贝赋值函数定义为虚函数吗?
不, 我们应该保持拷贝构造函数和拷贝赋值函数的行为一致. 想象一下这样一种 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
这太可怕了!
既然拷贝构造函数不能是虚函数, 那么拷贝赋值函数为了一致性, 也应该是非虚函数.
参见
rule of 3/5/0: 要么不定义任何特殊函数, 要么定义它们全部 中解释了拷贝构造函数、拷贝赋值函数、析构函数的定义原则.
copy-and-swap: 拷贝赋值函数的简单实现 中介绍了一种利用拷贝构造函数和析构函数直接定义拷贝赋值函数的惯用法.
但我想通过基类引用拷贝派生类!#
危险
这是比较进阶的内容, 添加进来只是为了提示有可解决方案.
那应该如何拷贝类层次呢? 有一种已有的设计模式可以解决这个问题, 它称为原型 (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};