深入理解Cpp对象模型


概述

C++对象模型,启动!

对象模型

  • 简单对象模型(A Simple Object Model)

对象保存成员的指针,成员实际存放在其他区域.

这种模型保证了成员中每个元素需要的内存空间都是一样的, 不论元素是何种类型, 访问时只需要确定成员的index和对象首地址即可. 坏处是访问成员时需要多进行一次内存访问, 并且占用了更多的内存空间.

  • 表格驱动对象模型(A Table-driven Object Model)

将对象中的数据成员和函数成员分别组织, 放在不同的内存区域, 对象中保存了指向这两个内存区域的指针.

这种模型保证了不同类型的对象具有相同的内存布局,

C++对象模型

每个有虚函数的类产生一个virtual table, 保存所有虚函数的指针, 对象在所在内存开始位置保存指向 vtable 的指针 vptr. 在调用类的虚函数时, 会沿着虚函数表搜索, 第一个满足条件的就是要调用的函数. 在调用类的构造函数, 复制构造函数等时这些函数会自动处理类的vptr. 比如下面的代码中, 假设类Base 是Derived的父类, 二者都定义了函数f(), 那么自然就有vptr. 在第二行中, 调用了Base的复制构造函数, 对象d会被截断, 并且b的vptr会在复制构造函数中设置, 因此第三行中b调用的是Base中的f(). 另外一个值得注意的是, 运行时多态只能通过指针和引用来实现.

Derived d;
Base b = b;
b.f();

单继承且无继承时, 每个对象只有一个vptr, 而当存在虚继承时, 虚继承的直接子类还会产生一个附加的 vptr, 指向自身的virtual table. 当存在多继承时, 会为每个父类产生一个vptr. 下面针对这些情况 详细举例说明.

  • 单继承, 无虚继承时的对象模型

这是最简单的情况, 在对象的开始处保存一个vptr指针, 指向一个虚函数指针数组, 非静态数据成员按继承, 声明的顺序排列.

  • 单继承, 有虚继承时的对象模型

采用虚继承的类会在产生多个vptr, 对象开始处是父类的vptr, 父类成员之后, 子类成员之前保存子类的vptr. 如下:

class BB
{
    int64_t m_bb;
};
class B1 : public virtual BB
{
    int64_t m_b1;
};
class DD : public B1
{
    int64_t m_dd;
};
class D1 : public DD
{
    int64_t m_d1;
};
// D1对象的结构, gcc 8.2.0, GNU gdb 8.1.1
// 注意, 这是在gdb中查看的结果, 并不代表真正的对象内存布局. 比如多继承, 有虚继承的情况.
{
    <DD> =
    {
        <B1> =
        {
            <BB> =
            {
                _vptr.BB = <vtable for D1+112>, // 父类的vptr
                m_bb
            },
            _vptr.B1 = <vtable for D1+24>, // 虚继承子类B1的vptr
            m_b1
        },
        m_dd
    },
    m_d1 // 没有采用虚继承, 因此与基类BB共用vptr.
}
  • 继承, 无虚继承时的对象模型

保留多个父类的vptr.

class BB
{
    int64_t m_bb;
};
class B1 : public BB
{
    int64_t m_b1;
};
class B2 : public BB
{
    int64_t m_b2;
};
class DD : public B1, B2
{
    int64_t m_dd;
};
// DD对象的结构, gcc 8.2.0, GNU gdb 8.1.1
{
    <B1> =
    {
        <BB> =
        {
            _vptr.BB = <vtable for DD+16>, // B1的vptr
            m_bb
        },
        m_b1
    },
    <B2> =
    {
        <BB> =
        {
            _vptr.BB = <vtable for DD+80>, // B2的vptr
            m_bb
        },
        m_b2
    },
    m_dd // 与基类B1共用vptr.
}

BB *bp = new DD; // 错误, 有歧义
BB *bp1 = dynamic_cast<B1*>(new DD); // 正确, bp1指向DD中的B1部分.
BB *bp2 = dynamic_cast<B2*>(new DD); // 正确, bp2指向DD中的B2部分.

很自然地, 当用BB类型的指针/引用保存DD对象时, 就会出现歧义, 编译器无法确定采用B1中的BB还是B2中BB. 可以使用 dynamic_cast 进行干预, 以达到预期目的.

  • 多继承, 有虚继承时的对象模型
    有了上面的结论, 就不难推测这种情况下的对象模型了.
class BB
{
    int64_t m_bb;
};
class B1 : virtual public BB
{
    int64_t m_b1;
};
class B2 : virtual public BB
{
    int64_t m_b2;
};
class DD : public B1, B2
{
    int64_t m_dd;
};
// DD对象的结构, gcc 8.2.0, GNU gdb 8.1.1
{
    <B1> =
    {
        <BB> =
        {
            _vptr.BB = <vtable for DD+160>,
            m_bb = 1
        },
        _vptr.B1 = <vtable for DD+24>,
        m_b1
    },
    <B2> =
    {
        _vptr.B2 = <vtable for DD+88>,
        m_b2
    },
    m_dd
}
// 实际内存布局可能是:
{
    <B1> =
    {
        vptr.B1,
        m_b1
    },
    <B2> =
    {
        vptr.B2,
        m_b2
    }
    m_dd,
    <BB> =
    {
        vptr.BB,
        m_bb
    }
}

关键字class和struct的区别

二者在绝大多数情况下是完全相同的, 可以互换, 只有几点不同.

class可以用于模板声明, struct不可以. C++引入class关键字, 保留struct的一个原因是为了体现OO, 并且兼容C, 而C中不需要模板, 也就不需要保证struct可以用于模板.

另外, 当用于声明类类型时二者略有差别:

  • 用class声明的类的成员的默认访问级别是private, 用struct声明的类的成员的默认访问级别是public.
  • 有继承时, 用class声明的类的默认继承方式是private, 用struct声明的类的默认继承方式是public. 这里的class, struct是指用于子类, 父类的声明方式不影响默认方式. 如下代码:
class BB {};
class D1 : BB {}; // private继承
struct D2 : BB {}; // public继承 

运行时多态必须通过public继承实现

这个设计是符合逻辑的. 可以设想, 如果使用其他继承方式, 那么从逻辑上说, 在类外不应该能访问父类成 员. 但是要实现运行时多态, 正常做法是将子类指针/引用赋值给一个父类类型的指针/引用(设为bp), 一旦复制成功, 我们就可以通过bp访问父类的public成员, 这显然与前面的逻辑要求矛盾. 所以, 在C++中, 前面说得”赋值”是违法的. 而没有这个”赋值”操作, 也就无法实现运行时多态, 因此必须通过public继承实现运行时多态.

Default Constructor的构造操作

默认构造函数(default constructor)的定义:

一个可以以空参数列表调用的构造函数称为默认构造函数, 这有两种情形, 一种是构造函数参数列表为空, 另一种是每个参数都在声明中给出了默认值.

默认构造函数可以是自己定义的, 也可以由编译器自动生成. 当用户没有定义任何构造函数时, 编译器就会为用户生成一个参数列表为空的默认构造函数.

trivial default constructor(无用默认构造函数)

满足下面所有的条件时, 一个默认构造函数是trivial的: - 不是由用户提供的, 即是由编译器生成的或者声明为default. - 类没有虚成员函数 - 类没有虚基类 - 类没有默认初始化的非静态成员 - 直接基类有trivial default constructor - 非静态类成员有trivial default constructor
显然, trivial default constructor不进行任何操作. 所有与C语言兼容的数据类型(POD类型)都具有trivial default constructor.

带有default constructor的member class object

编译器会为没有定义构造函数的类合成默认构造函数, 但是这个合成操作只有在构造函数真正需要被调用时才会发生.

那么在C++不同编译模块中, 编译器怎么避免生成多个默认构造函数呢? 解决方法是把合成的默认构造函数, 复制构造函数, 析构函数, 赋值运算符都作为inline, 而inline函数是静态链接(static linkage)的, 不会被编译模块(即文件)以外的看到. 如果函数太复杂, 作为inline不合适, 就会合成一个显式non-inline静态(explicit non-inline static)实例.

我们知道, 类对象是必须要初始化的, 当一个类的成员有其他类对象时, 就必须在构造函数中对类成员进行初始化. 如果是编译器合成的默认构造函数, 就在合成的默认构造函数中按类成员声明顺序调用它们的默认构造函数(当然, 如果没有就会引起错误). 注意一点, 对于显式定义的构造函数函数, 如果没有对部分类成员对象的初始化, 编译器会自动插入一些代码, 使得用户代码被执行之前, 先调用必要的默认构造函数, 调用顺序与它们的声明相同. 但是如果有的对象显式调用了构造函数, 有的没有, 顺序是如何确定的呢? 仍然按照它们的声明顺序调用.

“带有default constructor”的Base Class

如果一个子类的基类带有默认构造函数, 那么在合成子类的构造函数时, 会在其中插入对基类的默认构造函 数会的调用代码, 这个代码在成员的默认构造函数调用代码之前. 即先初始化基类, 再按声明顺序初始化子 类成员.

“带有一个Virtual Function”的Class

对于带有虚函数的类, 不论是直接声明的还是直接/间接继承而来的, 都有虚函数表, 对应对象有虚函数表指 针(vptr)作为数据成员. 那么vptr是如何确定的呢? 显然, 虚函数表是在编译阶段就可以确定的, 因此由 编译器合成. 但是vptr的确定就要分情况讨论了:

  • 对于静态初始化的对象, vptr由编译器初始化.
  • 对于动态初始化的对象, vptr由构造函数初始化. 因此编译器会在所有的构造函数中插入一些代码来完成这个任务.

“带有一个Virtual Base Class”的Class

当存在虚基类时, 通过虚基类指针/引用访问其非虚函数, 数据成员时, 应该是不属于多态的, 但是仍然在 运行时才能决定. 指针所指对象的实际类型很多时候是未知的, 在不同类型中, 由于采用了虚继承, 同一变 量偏移可能不一样(这是由实现决定的), 简而言之就是编译器不知道成员在指针所指对象的什么位置. 因此, 存在虚基类时, 就需要提供某种方法, 使我们能够通过虚基类指针访问虚基类的非虚函数和数据成员. 一种 方法是在子类中插入一个指向虚基类的指针, 将原始的通过虚基类指针访问那些成员的代码替换为先访问这个 指针, 再访问成员的代码. 如下所示:

virtualBasePointer->virtualBaseData; // 原始代码
virtualBasePointer->virtualBaseVptr->virtualBaseData; // 编译器替换后的代码

而这个虚基类指针的初始化就是由构造函数完成的.

注意

  1. 类的默认构造函数只有真正需要时才会被合成, 而不是没有定义构造函数时就会合成.
  2. 对于一个类的所有类成员对象, 如果没有显式初始化, 编译器会对其进行默认初始化. 但是对于内置类型, 例如int, 指针类型等, 不会进行初始化, 这是程序员的工作.

Copy Constructor的构造操作

3种情况下会调用复制构造函数:

  1. 用一个对象作为参数初始化另一个对象时.
  2. 对象作为函数参数时, 会用参数对象在函数作用域构造一个新的对象.
  3. 对象作为返回值时, 会用函数内部的对象在返回值所在作用域构造一个新的对象.

注意, 2, 3不一定会发生, 因为可能会存在右值参数, 返回值优化等, 具体情况不做详述.

如果不显式定义复制构造函数, 编译器有两种复制对象的方法: bitwise copy和default memberwise copy, 区别如下:

  • bitwise copy并不调用复制构造函数, 可能的实现方式如利用memcpy等, 因此效率更高, 复制出的对象和原对象完全相同.
  • default memberwise copy就如同对每个成员分别赋值一样, 对于内置类型, 直接初始化, 对于类类型, 递归调用其默认复制构造函数来初始化. 默认构造函数是由编译器合成的, 或者被声明为default. 其产生的新对象的用户定义的数据成员与原对象是一样的, 但是隐式的成员(如vptr), 内存布局(子类初始化父类)等不一定相同.

注意:
bitwise copy和浅复制(shallow copy)是不同的, 浅复制更侧重于当在类内部保存指针成员, 用指针指向实际数据的时候, 复制时仅仅复制指针的值. 这种情况包含在bitwise copy中.

那么在没有定义复制构造函数的时候, 编译器在什么情况下采用bitwise copy, 在什么情况下合成默认复制构造函数(即采用default memberwise copy)? 下面四种情况, 会采用后者, 其他情况采用前者.

  1. 当类含有类对象成员, 且这个成员含有复制构造函数时(不论是编译器合成的还是显式定义的).
  2. 当类继承自一个基类, 并且基类含有复制构造函数时(不论是编译器合成的还是显式定义的).
  3. 当类含有虚函数时.
  4. 当类有虚基类时.

上面的情况很容易理解. 对于1和2, 由于复制对象时, 要复制数据成员和基类, 既然它们提供了复制构造函数, 就可以认为需要在它们的复制构造函数中进行某些bitwise copy无法实现的操作, 因此不能采用bitwise copy. 对于3, 由于含有虚函数, 所以需要初始化对象的vtpr, 而vptr的值显然不一定等于参数对象的值, 例如用子类对象初始化父类对象时. 所以bitwise不能满足需求. 对于4, 由于含有虚基类, 父子基类的内存布局可能存在区别, 更不能采用bitwise copy.

当合成/用户定义的复制构造函数的语意和bitwise copy相同时, 是否应该用bitwise copy替换复制构造函数?

程序转化语意学(Program Transformation Semantics)

尽管在程序中可以使用不同的形式来初始化一个类对象, 但在编译阶段都会被转化成相同的形式. 例如:

class X;
X x0(paras);
X x1 = X(paras);
X x2(x0);
X x3 = x0;
X x4 = X(x0);

会被转化为:

X x0; // 声明但不初始化
X x1; // 声明但不初始化
X x2; // 声明但不初始化
X x3; // 声明但不初始化
X x4; // 声明但不初始化

// 调用构造函数初始化对象
x0.X::X(paras)
x1.X::X(paras)

// 调用复制构造函数初始化对象
x2.X::X(x0)
x3.X::X(x0)
x4.X::X(x0)

参数复制优化和返回值优化(都是指省略不必要的复制构造函数的调用, 后面统称为复制优化或copy elision)

从C++17开始, 标准规定了必须进行copy elision的情况:

  • 类似下面的情形:
T t = T(T(T())); // 只会调用一次默认构造函数, 要求类型相同(不考虑cv).
  • 在返回类对象时, 如果直接在return语句中创建对象, 并且该对象与函数返回值类型一致(不考虑cv)时, 一般称这个优化为RVO(return value optimization)(注意, RVO在C++17之前都不是强制的, 从C++17开始才规定为mandatory的.), 如下例子:
T f()
{
    ......
    return T();
}

T t = f(); // 只会调用一次默认构造函数.

同样也规定了可以实施copy elision, 但不强制的情况, 比如NRVO(named return value optimization), 是指函数返回一个具名对象, 该对象是函数体内部定义的自动存储期变量, 并且是non-volatile的, 与函数返回值具有相同类型(不考虑cv). 具体可以参考copy elision

注意

  1. 只有当存在复制构造函数(不论是显式定义的还是编译器生成的)时, 编译器才有可能实施复制优化.
  2. 谨慎对待copy elision, 因为类设计者可能需要在复制/移动构造函数中进行某些特殊操作, 省略了之后可能带来难以调试的错误.

成员初始化列表(Member Initialization List)

应该用成员初始化列表来初始化变量的情况:

  1. 初始化一个引用时.
  2. 初始化一个常量成员时.
  3. 调用基类的构造函数, 并且这个构造函数有一组参数时.
  4. 调用类成员的构造函数, 并且这个构造函数有一组参数时.

类成员的初始化顺序与初始化列表的顺序无关, 而是与成员在类声明中的顺序一致. 所以, 尽量使初始化列表的顺序与声明顺序一致, 最好不要用一个成员来初始化另一个成员. 在编译阶段, 会将初始化列表转化为成员的初始化代码, 并置于构造函数体内的代码之前.

注意一点, 用成员函数的返回值来作为初始化列表的参数语法上是没有问题的, 但是需要保证这个成员函数不依赖于成员的数据对象, 因为很可能这个在调用此函数时还没有初始化其依赖的数据成员, 这就会引起难以发现的错误. 另外, 最好不要将其用于初始化基类成员, 详情见后面的讨论.

不含数据成员的类对象

对于不存在继承和虚函数的类, 没有数据成员时, 其大小至少是1 byte, 以保证变量有唯一的地址. 当加上虚函数后, 由于有虚函数指针, 对象大小等于一个指针的大小, 32位系统中是4 bytes, 64位系统中是8 bytes. 看下面的代码:

struct Empty {};
struct VirtualEmpty
{
    virtual void f() {}
};

Empty a;
Empty b;

cout<<sizeof(Empty)<<endl; // 输出为1
cout<<sizeof(VirtualEmpty)<<endl; // 输出为8

cout<<&a<<' '<<&b<<endl; // 在输出中可以看到b的地址比a的地址大一.

但是, 当其作为基类时, 在某些情况下则不必遵循上面的要求, 可以在子类中将其优化掉, 节省所占空间. 例如下面的情况:

struct Base {};
struct Derived : Base
{
    int64_t i;
};

cout<<sizeof(Base)<<endl; // 输出为1
cout<<sizeof(Derived)<<endl // 输出为8

显然这里没有必要保留额外空间来表示基类对象. 上面说过, 为空对象保留空间的原因是保证其有唯一地址, 避免出现不同对象的地址相同的情形. 但是在这里, 子类地址就可以作为父类地址, 不会出现不同对象地址相同的情形. 但是即使是继承, 也有不能进行优化的情况:

  • 子类的第一个非静态数据成员的类型和空基类相同.
  • 子类的第一个非静态数据成员的基类类型和空基类相同.

不难看出, 这两种情况下, 会有两个空基类对象(父类对象和子类数据成员对象)连续出现, 如果优化掉, 将不能区别二者. 示例如下:

struct Base {};

struct Derived1 : Base // 情况一
{
    Base b;
    int64_t i;
}d1;

struct Derived2
{
    Base b;
};
struct Derived3 : Base
{
    Derived2 d2;
    int64_t i;
}d3;

cout<<sizeof(Derived1)<<endl; // 输出为16, 基类对象和成员b各占1 byte, 由于内存对齐补齐8 bytes
cout<<sizeof(Derived2)<<endl; // 输出为1
cout<<sizeof(Derived3)<<endl; // 输出为16, 基类对象和成员d2各占1 byte, 由于内存对齐补齐8 bytes

cout<<&d1<<' '<<&d1.b<<endl; // 前者(基类对象地址)比后者小1
cout<<&d3<<' '<<&d3.d2.b<<endl; // 前者(基类对象地址)比后者小1

对于空类作为虚基类的情况, 同样可以进行优化. 例如下面的代码:

struct Base {};
struct Derived1 : virtual Base {};
struct Derived2 : virtual Base {};
struct Derived3 : Derived1, Derived1 {};
struct Derived4 : Derived1, Derived1
{
    Base b;
}d4;

cout<<sizeof(Derived3)<<endl; // 输出为16
cout<<sizeof(Derived4)<<endl; // 输出为24

cout<<&d4<<endl; // 输出为0x55c6986ffe70
cout<<dynamic_cast<Base*>(&d4)<<endl; // 输出为0x55c6986ffe70
cout<<&(d4->b)<<endl; // 输出为0x55c6986ffe80

为了实现虚继承, 类Derived1和Derived2包含一个指针. 而虚基类Base被优化掉了, 因此Derived3大小为16 bytes. 而Derived4中由于包含类型是Base的非静态成员, 需要占据8 bytes, 即Derived4大小为24 bytes. 注意这里基类被优化了, 子类数据成员没有被优化. 测试显示, 即使这个成员不是第一个或最后一个, 编译器仍然不会优化.

虽然标准没有规定非静态数据成员在内存中的排列顺序, 但是一般实现都是按照声明顺序排列. 而由于内存对齐的要求, 仅仅改变成员的声明顺序可能产生不同大小的对象, 例如下面的声明:

struct Test1 // 大小为16 bytes
{
    int64_t i1;
    char c1; // c1 和 c2 被放置在一个字(16 bytes)中
    char c2;
};
struct Test2 // 大小为24 bytes
{
    char c1;
    int64_t i1;
    char c2;
};
struct Test3 // 大小为16 bytes
{
    int64_t i1;
    int32_t i2; // i2,c1,c2 被放置在一个字(16 bytes)中
    char c1;
    char c2;
};

由于计算机是以字(32位机为4 bytes, 64位机为8 bytes)为单位来读写, 因此内存对齐可以加快存取操作. 否则当一个变量跨字时, 读取这个变量就需要两次内存读. 但是这可能会增加需要的内存空间, 这就需要程序员仔细安排变量顺序, 以保证获得最佳的空间利用率.

而对于普通类的静态数据成员, 则具有独立于对象的静态生存期, 保存在全局数据段中. 模板类的静态数据成员如果没有被显式特化或实例化, 则在使用时会被隐式特化, 只有当特化/实例化后才是有效定义的. 有下面几种情况, 而这几种都可以归到C++14引入的 variable template(变量模板), 参考cppreference.

struct Test1
{
    template<typename T> static T val; // 非模板类的模板静态成员.
};
template<typename T> T Test1::val = 0;

template<typename T>
struct Test2
{
    static T val; // 模板类的非模板静态成员.
};
template<typename T> T Test2<T>::val = 0;

template<typename T1>
struct Test3
{
    template<typename T2> static std::pair<T1, T2> val; // 模板类的模板静态成员.
};
template<typename T1>
template<typename T2>
std::pair<T1, T2> Test2<T1>::val = std::make_pair(T1(1), T2(2));

auto var = Test3<int>::val<float>; // 即pair<int, float>(1, 2)

数据成员的存取

静态数据成员

对静态成员, 通过对象或对象指针访问和通过类名访问没有区别, 编译器一般会将二者统一为相同形式. 类成员指针不能指向静态成员, 因为对静态成员取地址得到的是一个该成员的指针. 如:

class A
{
public:
    static int x;
};
&A::x; // 其类型是 int*

因为类静态成员都是保存在全局数据段中, 如果不同类具有相同名字的静态成员, 就需要保证不会发生名称冲突. 编译器的解决方法是对每个静态数据成员编码(这种操作称为name-mangling), 以得到一个独一无二的名称.

非静态数据成员

不存在虚基类时, 通过对象名或对象指针访问非静态数据成员没有区别. 存在虚基类时, 通过对象指针访问非静态数据成员需要在运行时才能确定, 因为无法确定指针所指对象的实际类型, 也就不能判断对象的内存布局, 也就不知道对象中该数据成员的偏移. 普通继承和虚继承的这个区别的原因在于, 普通继承的类对象的内存布局在编译时就可以决定, 而存在虚继承时则需要在运行时决定, 详情见下文虚继承对内存布局的影响的讨论.

继承对对象布局的影响

单继承

最简单的一种情况, 单继承不会修改父类的内存布局, 例如父类由于内存对齐产生的额外空间在子类中不会被消除, 而是保持原样. 所以下面的代码中, 子类大小是24 bytes, 而不是16 bytes.

struct Base // 16 bytes
{
    int64_t i1;
    char c1;
};
struct Derived : Base // 24 bytes
{
    char c2;
};

其原因是如果消除了这些额外空间, 将子类对象赋值给父类对象时就可能会在父类对象的额外空间位置赋值, 这改变了程序的语义, 显然是不合适的.

加上多态

为了支持动态绑定, 编译器需要在对象中添加虚表指针(vptr), 指向虚表. 虚表中包含类的类型信息和虚函数指针, 值得注意的是, vptr并不是指向虚表的起始地址, 很多时候该地址之前会保存着对象的类型信息, 程序通过此类型信息实现RTTI. 而vptr初值的设置和其所占空间的回收, 则分别由构造函数和析构函数负责, 编译器自动在其中插入相应代码. 这是多态带来的空间负担和时间负担.

那么vptr放在什么位置呢? 这是由编译器决定的, gcc将其放在对象头部, 这导致对象不能兼容C语言中的struct, 但是在多重继承中, 通过类成员指针访问虚函数会更容易实现. 如果放在对象末尾则可以保证兼容性, 但是就需要在执行期间获得各个vptr在对象中的偏移, 在多重继承中尤其会增加额外负担.

多重继承

标准并没有规定不同基类在布局中的顺序, 但是大多数实现按照继承声明顺序安排. 多重继承给程序带来了这些负担:

  • 将子类地址赋值给基类指针变量时, 如果是声明中的第一个基类, 二者地址相等, 可以直接赋值. 否则, 需要加上一个偏移量, 已获得对应对象的地址.
  • 上面的直接加偏移并不能保证正确性, 设想子类指针值为0, 直接加上偏移后指向的是一个内容未知的地址. 正确做法应该是将0值赋给基类指针变量. 因此, 需要先判断基类指针是否为0, 再做处理. 而对于引用, 虽然其底层是指针, 但是不需要检查是否为0, 因为引用必须要绑定到一个有效地址, 不可能为0.

虚拟继承

主要问题是如何实现只有一个虚拟基类. 主流方案是将虚拟基类作为共享部分, 其他类通过指针等方式指向虚拟基类, 访问时需要通过指针或其他方式获得虚拟基类的地址. gcc的做法是将虚基类放在对象末尾, 在虚表中添加一项, 记录基类对象在对象中的偏移, 从而获得其地址. 我们可以通过gdb调试来看看具体情况.

struct B
{
    int64_t i1 = 1;
    virtual void f()
    {
        cout<<"B::f() called\n";
    }
};
struct D1 : virtual B
{
    int64_t i2 = 2;
};
struct D2 : virtual B
{
    int64_t i3 = 3;
};

struct D3 : D1, D2
{
    int64_t i4 = 4;
}d3;

for(int i = 0 ; i < sizeof(d3)/8; ++i)
    cout<<"d3["<<i<<"] = 0x"<<std::hex<<*((int64_t*)&d3 + i)<<endl;

首先用g++编译, 载入gdb中

# g++ main.cc -g
# gdb a.out

之后, 设置断点, 运行程序, 再通过下面的命令查看对象d3的虚表.

(gdb) p d3
$2 = { = { = {_vptr.B = 0x555555557c58 , i1 = 1}, _vptr.D1 = 0x555555557c28 , i2 = 2},  = { _vptr.D2 = 0x555555557c40 , i3 = 3}, i4 = 4}
(gdb) p /a *((void**)0x555555557c28-3)@10
$4 = {0x28,
      0x0,
      0x555555557d20 <_zti2d3>,
      0x18,
      0xfffffffffffffff0,
      0x555555557d20 <_zti2d3>,
      0x0,
      0xffffffffffffffd8,
      0x555555557d20 <_zti2d3>,
      0x555555555446 }

可以发现, _vptr.D1等于(int64_t *)&d3, _vptr.D2等于((int64_t )&d3 + 2), _vptr.B等于((int64_t *)&d3 + 5). 显然分别是各个对象的vptr的值. gdb的第二个命令是打印部分虚表内容, -3指定起始位置, 10指定长度. 可见_vptr.D1指向输出的第四个, _vptr.D2指向输出的第七个, 二者指向位置的地址减3即为对应对象和基类对象的偏移. 同样可以看到前一个是当前对象的类型信息. 如果在C++中直接访问虚表, 可以用下面的代码, 这和上面用gdb打印虚表等效:

int64_t *vptr = (int64_t *)*(int64_t *)&d3; // D1的虚表地址.
for(int i = -3; i < 7; ++i)
    cout<<"_vptr.D1["<<i<<"] = 0x"<<std::hex<<*(vptr+i)<<endl;

成员函数的调用

普通非静态成员函数

C++的设计准则之一就是: nonstatic member function至少必须和一般的nonmember funciton有相同的效率.

为了保证类成员函数的效率, 编译器将对普通非静态成员函数的调用转换为对普通函数的调用. 步骤如下:

  1. 修改函数签名, 添加一个额外的参数(作为第一个参数), 称为this指针. 由此将函数和对象关联起来.
  2. 将函数中对非静态成员的访问改为经过this指针访问.
  3. 将成员函数重写为一个外部函数, 生成一个独一无二的名字(name mangling).

虚成员函数

编译器将对虚成员函数的调用转化为通过vptr调用函数. 在虚继承体系下, 任何含有某一虚函数的类, 该函数在虚表中的偏移都是固定的, 因此编译器可以根据函数名在编译期确定函数指针在虚表中的下标. 所以, 虚函数带来的额外负担就是增加一个内存访问.

p->func(param); // 设其在虚表中的下标为index.

// 上面的语句将被转化为
(*(p->vptr)[index])(p, param) // 这里p等于this指针, 所以将其作为第一个参数.

静态成员函数

对静态成员函数的访问将被转化为对普通函数的访问, 由于静态成员不能访问非静态数据成员, 因此不需要添加this指针. 静态函数有下面几个特点:

  • 不能直接访问类对象的非静态成员.
  • 不能被声明为const, volatile, virtual.
  • 可以通过类对象和类名来调用.

注意一点, 当通过类对象来调用静态成员函数, 并且这个对象是由一个表达式得到时, 虽然不需要执行表达式就能直接调用函数, 但是表达式仍然会被执行(evaluate), 因为此表达式可能会有副作用, 不能被忽略. 例如:

Object func();

func().static_func() // func()仍然会被先执行, func()中可能会有某些不可省略的操作.

虚成员函数的实现

单继承

前文提到的虚成员函数实现是单继承下的模型, 下面具体说明其实现(注意下面提到的函数都指的是虚函数). 首先, 我们知道每个类都只有一个虚表(多继承和虚继承的类对象有多个vtpr, 指向不同的虚表, 但是实际上这些虚表是一个, vptr只是指向虚表的不同偏移位置), 也就是说相同类型的对象的vptr值是相同的. 当单继承发生时, 子类不仅继承了父类的数据成员, 还继承了函数成员, 前者体现在类对象布局上, 而后者体现在虚表上. 虚表继承的步骤可能包含下面几步:

  1. 将父类虚表中的虚函数指针拷贝到子类虚表的相同下标位置.
  2. 如果子类重写了父类的虚函数, 就将被重写的虚函数的指针修改为对应函数的地址.
  3. 如果子类加入新的虚函数, 就增加虚表容量, 在后面添加新的函数指针.

从上面可以看到, 单继承下的虚函数效率高, 实现简单, 但是多继承和虚拟继承则要复杂很多.

多继承

多继承的复杂性在于下面几个问题:

  • 通过第2,3,…个父类的指针访问子类的虚函数.
  • 通过子类指针访问第2,3,…个父类的虚函数.
  • 重写的虚函数的返回类型可能和父类的被重写函数的返回类型不一样, 这是标准允许的.

在讨论上面的问题之前, 先复习一下C++中虚函数相关的知识.

首先, 明确虚函数重写的概念. 父类声明了一个虚函数, 如果其(直接或间接)子类定义了函数, 与父类虚函数具有相同的:

  • 名字
  • 参数类型列表(不包含返回值)
  • const/volatile类型, 参考 [1]
  • 引用类型(三种: 无引用符号, &, &&), 参考 [1]

则子类函数为虚函数(无论是否声明为virtual), 并且重写了父类的虚函数.

第二点, 多继承时, 我们通过子类指针可以访问所有父类的函数, 这一点很明确. 但是不能通过一个父类的指针访问其他父类的函数. 看下面的例子:

struct B1
{
    virtual void f1() {}
};
struct B2
{
    virtual void f2() {}
};
struct D
{
    virtual void f1() {}
    virtual void f2() {}
    virtual void fd() {}
};

B1 *p1 = new D;
p1->f2(); // illegal

B2 *p2 = new D;
p2->f1(); // illegal

也就是说, 通过一个类对象指针调用函数时, 这个函数必须要在这个类或其父类中声明过.

下面举例说明上面问题的复杂性.(调用虚函数时一定是通过指针或引用, 由于引用本质上是指针, 下面只讨论指针.)

对于第一个问题, 通过父类指针直接调用子类定义的函数时有两种情况:

  • 通过第一个基类指针访问时, 直接将指针值作为this指针值传给函数.
  • 通过第2,3,…个基类指针访问时, 需要调整指针值, 加上/减去一个偏移, 再作为this指针传给函数.

显然第二种情况下需要在运行时调整this指针的值, 因为编译时无法确定指针所指对象的实际类型.

除此之外, 再考虑一种特殊情况(间接调用子类虚函数):

  • 对一个父类指针调用delete.

如果析构函数被声明为virtual, 那么程序将根据指针所指对象的实际类型决定调用哪个析构函数. 这就需要在运行时需要调整指针的值, 以保证能够访问正确的vptr, 从而获得对应的析构函数.

上面两个例子说明第一个问题的复杂性在于需要在运行时根据指针所指对象的实际类型来调整指针的值, 使之指向子类对象. 其他两个问题复杂性的根源也来自于此, 不(会)做详述.

问题明确了, 解决办法呢? 老实说没怎么看懂, 就不瞎说了, 等以后看明白了再补.

虚继承

其复杂性同样在于指针值的运行时修改, 书中建议不要在虚基类中声明非静态的函数.

成员函数指针

成员函数指针只能指向类的非静态成员函数, 使用方法如下:

struct C
{
    void f(int i) {}
};

void (C::* p)(int) = &C::f; // pointer to member function
C c, *cp = &c;
(c.*p)(1); // 通过对象调用函数f
(cp->*p)(2); // 通过对象指针调用函数f

父类成员函数指针可以直接赋值给子类成员函数指针, 如下面的例子:

struct B
{
    virtual void f() {}
};

struct D : B
{
    virtual void f() {}
};

void (B::* bf)() = &B::f;
void (D::* df)() = bf;

B bp = new D;
(bp.*bf)(); // 调用D::f()
(bp.*df)(); // 调用D::f()

而子类的成员函数指针可以通过static_cast或C风格的类型转换将其转换为父类的成员函数指针.

void (D::* df)() = &D::f;
void (B::* bf1)() = static_cast<void (B::*)()>(df);
void (B::* bf2)() = (void (B::*)())df;

从上面的例子中可以看到, 成员函数指针仍然支持虚函数机制. 下面看看编译器是如何支持各种虚拟机制的.

虚函数

成员函数指针可以指向一个普通函数, 此时她可以是函数地址. 如果指向一个虚函数, 她可以是该函数在虚表中的偏移. 这两种值可以保存在相同类型的变量中, 但是如何区分她们呢? 早期C++限制最多有128个虚函数(应该是限制虚表长度为128吧), 所以偏移值最大为127. 而程序空间起始地址必定大于127, 因此可以通过将指针值和127做”&”(按位与)运算来判断是偏移还是函数地址.

(((int)pmf) & ~127) ? (*pmf)(ptr) : (*ptr->vptr[(int)pmf])(ptr);

多继承和虚继承

支持这些机制的方法就更加复杂了. Stroustrup提出的一种方式是将成员函数指针定义为一个结构体, 包含this指针偏移, 虚基类指针偏移等等. 不过因为对不需要如此复杂机制的函数调用带来额外负担而受到批评. 有的实现对成员函数指针有多种实现方式, 以减少不必要的负担. 比如微软, 对单继承, 多继承, 虚继承就采用不同的方式来实现. 这个地方感觉还是不够具体, 坑先留着, 以后再填.

inline函数

在下面的情况下, 一个函数是inline函数:

  • 声明中包含inline关键字的函数
  • 当一个函数(成员函数或非成员友元函数)的定义在类内部时
  • 被声明为constexpr的函数(since C++11)

inline函数只是一种建议, 建议编译器将对inline函数的调用转换, 但是编译器并不一定会接受该建议, 而且非inline函数也有可能被转换, 这依赖于具体实现. 使用inline函数时要注意下面几点:

  • inline函数可能会增加生成的文件的大小.

  • inline函数尽可能简单. 减少不必要的局部变量, 否则可能会在结果中产生大量的局部变量.(现在的编译器应该可以优化这个了吧)

  • 赋值运算符.

  • 显式定义复制赋值运算符,

  • 拒绝赋值行为.

对于第三点, C++11之前需要将operator =声明为private, 并且不提供其定义. 而C++11之后, 可以用下面的语句实现:

ClassName& ClassName::operator =(const ClassName&) = delete;

另外C++11提供的一个语法是可以将其显式声明为default, 虽然用户显式声明之, 但是定义是由编译器隐式生成的.

ClassName& ClassName::operator =(const ClassName&) = default;

当不需要拒绝赋值时, 就需要考虑是不是显式提供一个operator =. 一个原则是:

只有在默认复制赋值运算符的行为不安全或不正确时, 才需要显式定义复制赋值运算符.

那么问题来了, 默认复制赋值运算符的行为是什么?

Trivial copy assignment operator

当复制赋值运算符满足下面的条件是, 她就是tirivial的:

  • 不是用户提供的(隐式定义的或声明为default).
  • 类没有虚函数.
  • 类没有虚基类.
  • 直接基类的复制赋值运算符都是trivial的.
  • 非静态成员的复制赋值运算符是tirvial的.

满足这个条件的对象的赋值行为是bitwise的, 就如同调用std::memmove一样. 所有与C语言兼容的数据类型都满足此条件. 不满足上面的的条件时, 就采用member-wise复制赋值行为. 以上的bitwise和member-wise就是默认复制赋值运算符的行为.

另一个问题是存在虚基类时复制赋值运算符可能会多次对基类子对象调用operator =, gcc-8就是如此. 一般含有虚基类的子类的复制赋值运算符定义如下:

struct A { /*...*/ };
struct B : virtual A { /*...*/ };
struct C : virtual A { /*...*/ };
struct D : B, C { /*...*/ };

A& A::operator =(const A& a)
{
    /*
    ... // member copy assignment
    */
}

B& B::operator =(const B& b)
{
    this->A::operator=(b); // 直接调用 A::operator =
    /*
    ... // member copy assignment
    */
}

C& C::operator =(const C& c)
{
    this->A::operator=(c); // 直接调用 A::operator =
    /*
    ... // member copy assignment
    */
}

D& D::operator =(const D& d)
{
    this->A::operator=(d); // 直接调用 A::operator =
    this->B::operator=(d); // 间接调用 A::operator =
    this->C::operator=(d); // 间接调用 A::operator =
    /*
    ... // member copy assignment
    */
}

C++并没有提供类似复制构造函数的语法来保证虚基类只会被复制一次. 所以, 书中建议将虚基类的复制赋值运算符声明为delete, 甚至不要再虚基类中声明数据成员.

对象析构

书中提到一个值得注意的问题, 并不是定义了构造函数就需要定义析构函数, 这种”对称”是无意义的. 只有当需要一个析构函数时, 我们才应该显式定义之. 那么什么时候需要呢? 首先要搞清楚析构函数的作用, 她是对象的生命周期的终结, 而函数体内执行的主要是是对对象持有的资源的释放, 例如在构造函数中动态申请的空间. 析构函数的操作与构造函数类似, 但是顺序相反.

Trivial destructor

类T的析构函数如果满足下面的条件, 就是trivial的:

  • 析构函数不是用户定义的.(隐式声明或声明为default)
  • 析构函数非虚.(这就要求父类的虚函数也非虚)
  • 直接父类的析构函数是trivial的.
  • 非静态数据成员(数组的数据成员)的析构函数是trivial的.

trivial析构函数不进行任何操作, 析构时只需要释放对象的空间即可. 所有与C语言兼容的数据类型都是trivial destructible的.


评论
  目录