发布于: 2023-10-9最后更新: 2023-12-21字数 00 分钟

析构函数

virtual 析构函数

在C++中,如果基类的析构函数不是虚函数,那么在删除派生类对象的时候,只会调用基类的析构函数,而不会调用派生类的析构函数,可能会导致资源泄漏。因此,如果一个类被设计为基类,并且可能被其他类继承,那么它的析构函数通常应该被声明为虚函数。
当我们删除一个指向派生类对象的基类指针时,如果基类的析构函数是虚函数,那么会先调用派生类的析构函数,然后再调用基类的析构函数。这样就可以确保派生类的资源被正确释放。
析构函数可以是纯虚函数,这样的类是抽象类,不能创建该类的对象。但是,即使析构函数是纯虚函数,我们仍然需要为它提供一个定义(实现),因为当派生类的对象被删除时,派生类的析构函数会调用它。如果没有提供定义,就会导致链接错误。
示例:
在MSVC中,以下写法也可以(标准C++不行):

基类的析构函数调用虚函数

在C++中,基类的析构函数可以调用虚函数,但在实际执行析构过程中,虚函数调用不会派发到派生类的覆盖版本。也就是说,如果在基类的析构函数中调用一个虚函数,那么将会调用基类的版本,而不是派生类的版本。这是因为在执行析构函数时,对象的派生部分已经被析构,派生类的数据成员可能已经不存在了,所以C++规定在析构函数中,虚函数调用不会下沉到派生类。
以下是一个例子来展示这个行为:
在这个例子中,在析构Base对象时,Base的析构函数会调用print函数。但是,尽管Derived类覆盖了print函数,Base的析构函数中的print调用仍然会调用Base类的版本,而不是Derived类的版本。这是因为在Base的析构函数开始执行时,Derived的析构函数已经完成,Derived的部分已经不存在了。如果print函数依赖于Derived的任何数据成员,那么在此时调用Derived的版本可能会导致未定义的行为。
因此,一般来说,应该避免在析构函数中调用虚函数,因为这可能导致意外的行为。

虚函数实现

在 C++ 中,虚函数是实现多态的关键机制。当我们声明或定义一个虚函数,C++ 编译器会在内部为每个包含虚函数的类或者结构体生成一个虚函数表(也被称为 vtable)。每个对象实例会包含一个指向这个虚函数表的指针,通过这个指针可以找到所有的虚函数。这种机制允许我们在运行时动态决定调用哪个函数,实现多态。
虚函数表的查找过程可以分为以下步骤:
  1. 查找对象的虚函数表指针:首先,从对象实例中获取虚函数表的指针。这个指针通常被存储在对象内存的开始位置。(基类类型的指针没有子类对象的任何信息,不了解其内存布局,所以默认存在的虚表指针必然就放在对象的首部位置,使其可以直接获取到子类的虚表地址)
  1. 查找虚函数在虚函数表中的索引:然后,根据虚函数的声明顺序,确定函数在虚函数表中的索引。比如,一个类中第一个声明的虚函数在虚函数表中的索引就是0,第二个就是1,以此类推。
  1. 使用索引查找虚函数的地址:最后,使用从上一步获取的索引,在虚函数表中查找对应虚函数的地址。
以下是一个简单的例子:
在调用 b->func1() 时,虚函数查找的过程是这样的:
  1. 查找 b 的虚函数表指针。因为 b 实际上是 Derived 类型的,所以它的虚函数表指针指向 Derived 类的虚函数表。
  1. Derived 类的虚函数表中,根据 func1 的声明顺序确定索引,func1 是第一个声明的虚函数,所以索引是 0。
  1. 在虚函数表中使用索引 0 查找 func1 的地址,找到的是 Derived::func1() 的地址。
  1. 调用找到的 Derived::func1() 函数。
类似的,对于 b->func2() 的调用,虚函数查找的过程与 func1 类似,只是在虚函数表中的索引是 1。
在C++中,虚函数表和对象实例在内存中的布局可能如下所示:
在这个示意图中,每个对象实例都含有一个名为 vptr 的指针,这个指针指向该对象类型的虚函数表。虚函数表是一个函数指针数组,每个元素都是一个指向虚函数代码的指针。当我们调用一个对象的虚函数时,编译器会根据函数在虚函数表中的索引找到对应的函数指针,然后通过这个指针调用函数。
这只是一种可能的实现方式,不同的C++编译器可能会有自己的实现细节。比如,某些编译器可能会将虚函数表放在对象的末尾,而不是开头。此外,虚函数表中的函数指针的顺序可能也会因编译器而异。

类型转换

C++ 提供了四种类型转换运算符:static_castdynamic_castconst_castreinterpret_cast。以下是它们的基本区别:
  1. static_cast:这是最常见的类型转换运算符,可以用于大多数情况。它在编译时执行类型转换,因此不能进行运行时类型检查。例如,它可以用于基本数据类型之间的转换,将枚举转换为整数,将void指针转换为其他类型的指针等。
    1. dynamic_cast:这个类型转换运算符主要用于类层次结构中基类和派生类之间的转换。它在运行时执行类型检查,如果转换是安全的(即,目标类型是对象的实际类型或其基类型),那么转换就会进行,否则就会失败(对于指针类型,结果为 nullptr)。注意,为了使 dynamic_cast 正常工作,基类必须含有虚函数。
      1. const_cast:这个类型转换运算符用于修改类型的 constvolatile 属性。最常见的用途是在函数中删除参数的 const 性质,以便可以对其进行修改。
        1. reinterpret_cast:这是最不安全的类型转换运算符。它会生成一个新的值,这个值在位模式上与原始值相同,但类型可以完全不同。它通常用于进行低级操作,如操作特定硬件,或实现某些依赖于特定编译器或平台的功能。
          dynamic_cast 补充
          dynamic_cast的实现原理涉及到多种概念,包括虚函数、虚函数表(vtable)、运行时类型信息(RTTI,Runtime Type Information)和类型擦除。
          1. 虚函数和虚函数表(vtable):在C++中,通过将函数声明为虚函数,可以实现多态。每个含有虚函数的类或结构体都有一个与之关联的虚函数表。虚函数表是一个存储函数指针的数组,这些函数指针指向类的虚函数实现。每个类的实例都有一个指向其虚函数表的指针。
          1. 运行时类型识别(RTTI):RTTI是C++语言的一部分,允许在运行时确定对象的类型。dynamic_casttypeid运算符就依赖RTTI来工作。
          1. 类型擦除:在某些情况下,可能需要在运行时重新发现对象的类型,尤其是在处理基类指针或引用时,它可能实际上指向或引用一个派生类对象。
          现在,我们可以谈谈dynamic_cast的工作原理了。假设我们有一个基类Base和一个派生类Derived,并且我们有一个指向Base的指针,但实际上它指向一个Derived对象:
          我们可以尝试使用dynamic_cast将这个Base指针转换为Derived指针:
          在这种情况下,dynamic_cast的工作方式如下:
          1. 它首先检查 b 是否可以安全地转换为 Derived*。为了做这个检查,它使用 RTTI 获取 b 实际指向的对象的类型信息,然后看这个类型是否与 Derived 相兼容。
          1. 如果 b 实际上指向一个 Derived 对象(或派生自 Derived 的对象),那么转换就会成功,dynamic_cast 返回一个指向该对象的 Derived* 指针。
          1. 如果 b 不指向一个 Derived 对象,那么转换就会失败。对于指针类型,dynamic_cast 会返回 nullptr;对于引用类型,dynamic_cast 会抛出 std::bad_cast 异常。
          注意,dynamic_cast的运行时间可能比其他的cast运算符要长,因为它需要在运行时执行类型检查。此外,只有当基类至少有一个虚函数时,dynamic_cast和RTTI才能正常工作,这是因为只有在这种情况下,对象才会有类型信息。如果基类没有虚函数,那么在派生类对象上使用dynamic_cast可能会失败。
          reinterpret_cast 补充:
          reinterpret_cast 是 C++ 中最强大,也是最危险的类型转换运算符。它对位模式不做任何改变,只是告诉编译器将数据看作另一种类型。这意味着它根本不会进行任何类型的检查或转换。因此,它主要用于那些需要直接操作内存或其他资源的低级编程任务。
          以下是 reinterpret_cast 的一些常见用途:
          1. 转换指针类型:这包括函数指针和对象指针。例如,你可能需要将 void* 指针转换为具体的类型指针,或者在函数指针之间进行转换。
          1. 转换整数和指针:你可能需要将地址存储在整数变量中,或者需要直接设置指针的值。
          1. 操作硬件或实现特定的底层功能:例如,你可能需要以特定的方式解释设备发出的数据,或者你可能需要对位模式进行精确的控制。
          然而,由于 reinterpret_cast 不进行任何类型检查或转换,所以使用它有很大的风险。如果转换后的类型与原始数据不兼容,那么结果就是未定义的。例如,如果你将 int 指针转换为 double 指针,然后试图通过新指针读取数据,那么你可能会得到一个完全没有意义的值。
          因此,除非你完全确定你正在做什么,并且没有其他更安全的选项,否则你应该尽量避免使用 reinterpret_cast。在大多数情况下,其他的类型转换运算符(如 static_castdynamic_cast)或者模板元编程技术都是更好的选择。

          继承的区别

          C++中的类可以以三种不同的方式继承基类:publicprotectedprivate。这些访问修饰符决定了基类成员在派生类中的访问级别:
          1. Public 继承:基类中的公有成员在派生类中仍然是公有的,基类中的保护成员在派生类中仍然是保护的,基类中的私有成员在派生类中不可访问。
          1. Protected 继承:基类中的公有成员和保护成员在派生类中都变为保护的,基类中的私有成员在派生类中不可访问。
          1. Private 继承:基类中的公有成员和保护成员在派生类中都变为私有的,基类中的私有成员在派生类中不可访问。
          下面是一个示例来说明这些差异:
          注意,无论基类成员在派生类中的访问级别如何,都不会影响它们在基类中的访问级别。也就是说,基类中的私有成员总是只能在基类中访问,而不论继承方式如何。
          同时,这些访问修饰符(publicprotectedprivate)也决定了派生类的类型与基类类型的转换方式。对于public继承,任何地方都可以将派生类对象视为基类对象。对于protectedprivate继承,只有在派生类的成员函数中才可以将派生类对象视为基类对象。
          进一步解释上面最后一句话。
          在派生类的成员函数中,可以将派生类的对象转换为基类类型的指针或引用。这种转换是基于"是一个(is-a)"的关系,因为派生类的实例也是基类的实例。但是,这种转换的可见性受到继承方式的限制。具体来说,如果是public继承,那么在任何地方都可以把派生类的对象转换为基类类型的指针或引用;如果是protectedprivate继承,那么只有在派生类的成员函数中才可以进行这种转换。
          下面是一个例子:
          在这个例子中,PublicDerived是公开继承Base的,所以我们可以在test函数中访问pd.x,并且可以将PublicDerived对象的地址赋给Base指针。但是,PrivateDerived是私有继承Base的,所以我们不能在test函数中访问prd.x,也不能将PrivateDerived对象的地址赋给Base指针。然而,在DerivedFromPrivate的成员函数foo中,我们可以访问基类的x成员,并且可以将this指针赋给Base指针,因为这些操作都发生在派生类的成员函数中。

          运算符重载

          在C++中,你可以重载大多数的运算符,包括[](取数组)、%(取模)、&(位运算)。但不能重载&&(逻辑与),下面是具体的例子:
          []运算符的重载:
          这通常在实现自定义数组或集合类时使用。
          %运算符的重载:
          &运算符的重载:
          这通常在实现自定义的位操作时使用。
          &&运算符的重载:
          在C++中,你不能重载逻辑运算符&&。这是因为这些运算符涉及到短路求值(short-circuit evaluation),也就是说,如果左操作数已经足够确定整个表达式的值,那么右操作数就不会被求值。如果你尝试重载这些运算符,你就无法保证这种行为。因此,为了避免混淆,C++禁止重载这些运算符。
          进一步解释短路求值
          逻辑运算符 &&(逻辑与)和 ||(逻辑或)在 C++ 中是不能被重载的,主要原因是它们涉及到了“短路求值”(Short-circuit evaluation)的特性。
          “短路求值”是指在计算逻辑表达式时,一旦表达式的值可以确定,就不再计算后面的部分。这是因为在逻辑运算中,“与”运算 && 和 “或”运算 || 具有如下的特性:
          • 对于 && 运算,如果左边的表达式结果为 false,那么无论右边的表达式的值是 true 还是 false,整个表达式的结果都是 false。所以,如果左边的表达式值为 false,就没有必要再计算右边的表达式,可以直接确定整个表达式的值为 false
          • 对于 || 运算,如果左边的表达式结果为 true,那么无论右边的表达式的值是 true 还是 false,整个表达式的结果都是 true。所以,如果左边的表达式值为 true,就没有必要再计算右边的表达式,可以直接确定整个表达式的值为 true
          如果允许重载 &&|| 运算符,那么这两个运算符的短路求值特性就无法得到保证,因为运算符重载实际上是函数调用,所有的参数在调用前都需要被求值,无法做到“短路”。这可能会导致程序的行为与预期不符,因此 C++ 不允许重载这两个运算符。
          例如,假设我们有一个表达式 a && b,其中 ab 都是函数调用,如果 a 返回 false,那么根据短路求值,b 应该不会被调用。但是,如果我们重载了 && 运算符,那么 ab 都会被调用,这可能会改变程序的行为。
          以上是C++中关于运算符重载的一些基本规则和例子。在实际编程中,运算符重载应当谨慎使用,以避免混淆和误解。一般来说,只有当重载的运算符的行为与其原始的行为非常接近时,才应该使用运算符重载。

          🆕
          C++ NEW FEATURE
          💼
          STL
          🏐
          C++内存模型
          📸
          C++魔法汇总
          🕍
          C++ QA
          🎀
          C++上层应用(常见)

          ref:
          1. C++模板:Docs (feishu.cn)