0%

vptr

这个小节没有提到 vptr 的处理。

  1. 对于复制构造函数,其本质还是构造,所以和一般的构造函数流程相似,会正确设置好 vptr。
  2. 对于赋值运算符,原有 vptr 和新的 vptr 是一样的,不需要修改。

怎么写复制构造/赋值?

写复制构造函数和复制赋值操作符的时候要记得把基类和成员都正确初始化(用默认的最好)。复制构造函数也算是构造函数,所以要使用成员初始化列表来正确初始化。

复制赋值操作符对基类赋值需要先将 *this 转换成基类的引用,然后用赋值表达式调用基类的赋值操作符。毕竟赋值操作符也不是构造函数,构造过程需要手写的部分有很多还容易写错!这也是为什么复制构造临时对象 + swap 的方法很受欢迎。

#include <cstdio>

struct A {
    int a;
    A() = default;
    A(A const &) { puts("A(const A &)"); }
    A &operator=(A const &) {
        puts("A &operator=(A const &)");
        return *this;
    }
};

struct B {
    int b;
    B() = default;
    B(B const &) { puts("B(const B &)"); }
    B &operator=(B const &) {
        puts("B &operator=(B const &)");
        return *this;
    }
};

struct D {
    int d;
    D() = default;
    D(D const &) { puts("D(const D &)"); }
    D &operator=(D const &) {
        puts("D &operator=(D const &)");
        return *this;
    }
};

struct C : A, B {
    int c;
    D d;
    C() = default;

    // 错误写法:(没有通过初始化列表对基类和成员起到复制作用,它们被默认构造)
    // C(const C &) {
    // }

    // 等效于默认的复制构造函数
    C(const C &other) : A(other), B(other), d{other.d} {}

    // 等效于默认的赋值操作符
    // 很麻烦,不如先复制构造再swap
    C &operator=(C const &other) {
        if (this == &other) {
            return *this;
        }
        // 右边的 other 会隐式转换成对应类型
        static_cast<A &>(*this) = other;
        static_cast<B &>(*this) = other;
        d = other.d;

        return *this;
    }
};

int main() {
    C c;
    puts("copy construction");
    C c1 = c;
    puts("copy assignment");
    c1 = c;
}

初始化会按照变量声明的顺序进行。因此虽然下面的代码想要用 j 的新值初始化 i,但实际上是 i(j) 先被执行,然后才是 j(val)

不过,构造函数代码块中的初始化过程始终发生于成员初始化列表之后。

静态成员函数出现之前

很久以前没有静态成员函数(直到 Cfront 2.0 才加入),那个时候静态方法可以用这种方式调用:

该方法没有通过 this 访存,因而不会出现段错误。尽管这种方法还在某些编译器上能够使用,但这种写法在现在是未定义行为

在表达式上调用静态成员函数

可以在一个表达式上调用静态成员函数。表达式的返回值被忽略,但是有副作用;另外表达式的返回值类型帮助确定了静态成员函数应该在哪里寻找。

尽管现在的编译器能够正确理解数据成员(含有隐式的 this 指针)的使用,并在看到整个类定义之后再查找名字,类型名的查找则发生的很早,导致使用时可能看不到、或者使用了错误的类型声明。

所以,应该尽可能在类定义的开头写好类型别名。

#include <iostream>
#include <type_traits>

typedef int length;

class Point3d {
public:
  **void mumble(length val) { _val = val; }
  length mumble() { return _val; }

  static_assert(std::is_same_v<length, int>);

private:
  typedef float length;
  length _val;
};

int main() {}

构造函数分为完整构造函数(Complete Object Constructor)和基类构造函数(Base Object Constructor)。

初始化顺序

显式构造一个对象的时候调用的是完整构造函数。该函数执行的初始化流程如下:

  1. 初始化所有基类子对象。基类子对象包括有直接关系的具体继承基类,也包括有直接或间接关系的虚基类。其中虚基类先于具体基类被初始化,尽管虚基类子对象被放在对象的最后面。
  2. 初始化自己的 vptr。
  3. 按声明顺序初始化成员。若在初始化列表中提供了初始化规则,则使用;否则,如果在声明时给出了初始化表达式,则使用;否则,采用默认初始化。
  4. 执行用户提供的代码块中的代码。

可以参考书中第 216 页。

如何初始化虚基类

对于每个直接或间接拥有虚基类的类,其构造函数都必须在成员初始化列表中调用虚基类的构造函数来初始化它。如果不显式调用虚基类构造函数,则视为使用其默认构造函数。下面的代码中取消注释 VirtualBase() = default; 并删掉 C(): A(), B(), VirtualBase("C") {} 中的 VirtualBase("C"),则代码仍能编译。

析构函数和构造函数一样,也分完整析构函数和基类析构函数两种。而且析构函数的工作流程和构造函数相反。

如果一个类的基类有未实现的析构函数(未提供定义或者是纯虚函数),会导致链接失败。这是因为子类的析构函数有调用基类析构函数的逻辑!

什么时候需要虚析构函数

https://stackoverflow.com/a/461237

Declare destructors virtual in polymorphic base classes. This is Item 7 in Scott Meyers’ Effective C++. Meyers goes on to summarize that if a class has any virtual function, it should have a virtual destructor, and that classes not designed to be base classes or not designed to be used polymorphically should not declare virtual destructors.

**自动生成的析构函数不是虚函数,但是可以显式声明其为 virtual 然后用 = default 引入。**从基类继承的析构函数后虚实性质保持不变。

基本概念

cfront 实现

#include <iostream>

struct X { public: int i; };       // size: 4
struct A: virtual X { int j; };    // size: 16
struct B: virtual X { double d; }; // size: 24
struct C: A, B { int k; };         // size: 40

int main() {
    C c;
    std::cout << c.i << '\n';
}

目的不同

虚拟继承用来动态访问数据(节约存储空间或保证数据地址唯一),虚函数用来动态访问方法。

虚表的访问方式

如果 A 类有虚函数,那么 A 类已经有 vptr、是多态类,通过 A 类指针/引用访问虚函数则需要查找虚表。

如果 A 类是普通类并且被虚拟继承,那么 vptr 并不会放到 A 类中,通过 A 类指针/引用访问虚函数也不会查表。

考虑 A *pa = new B;,其中 B 类虚拟继承于 A 类,通过 A 类指针 pa 访问到的成员就是真正要找的 A 中的成员,因为从 B 类指针转换到 A 类指针时编译器已经正确处理好了指针偏移问题,从而不需要担心没有 vptr 导致访问不到正确的成员。

考虑菱形继承的情况:

有人说从 Cfront 转向专门的 C++ 编译器的一大原因就是支持异常处理,因为异常处理在 C 语言中很难做。

一个函数的指令区域分成三种:

  1. try 以外,且没有活跃对象
  2. try 以外,有活跃对象,发生异常时需要析构
  3. try 以内

有一个活跃对象和两个活跃对象应该会有区别吧?这样制表/查表的压力很大。

参考 https://itanium-cxx-abi.github.io/cxx-abi/abi.html#member-pointers

向下转换的能力!

和类的指针/引用的转换不同,指向成员的指针既可以向下转,又可以向上转!只需要目的类型拥有对应的成员即可。

struct A    { long a; };
struct B: A { long b; };
int main() {
    // long (A::*p) = &B::b; // 错误,A 类型没有成员 B::b
        long (A::*q) = &B::a; // B::a 和 A::a 是相同的
    long (B::*r) = &A::a; // 向下转
}

指向数据成员

需要存储 this 偏置,按照 Itanium C++ ABI,存储的类型为 ptrdiff_t。一般数据成员指针就用其相对于开头的偏移作为指针的值,而空指针则用 -1 表示。

奇怪的现象:对指向数据成员指针的空初始化会将其置为空指针,也就是 -1虽然含义为空,但是其比特位并不是全零的。