0%

这里从编译器的角度, 着重讨论对象的内存布局, 分析单继承, 多继承, 虚继承对内存布局带来的影响.

对象模型

  • 简单对象模型(A Simple Object Model): 对象直接保存成员的值. C++中采用的就是这种方法.

  • 表格驱动对象模型(A Table-driven Object Model): 对象中的每个成员对应一个指针, 指针指向成员实际的值. C++虚表采用这种方法实现.

C++对象模型

编译器会为每个有虚函数的类会生成一个 virtual table, 在其中保存所有虚函数的指针, 并在类对象内存开始位置保存指向vtable的指针vptr. 通过对象的引用或指针调用虚函数时, 编译器可以就可以通过对象的vptr将调用定向到对应的函数, 实现动态绑定. 在调用类的构造函数, 复制构造函数等时这些函数会自动处理类的vptr. 比如下面的代码中, 假设类Base是Derived的父类, 二者都定义了虚函数f(). 在第二行中, 调用了Base的复制构造函数, 对象d会被截断, 并且b的vptr会在复制构造函数中设置, 因此第三行中b调用的是Base中的f(). 另外一个值得注意的是, 运行时多态只能通过指针和引用来实现, 而不能通过对象实现. 因为对象的类型在编译器键可以确定.

1
2
3
Derived d;
Base b = d;
b.f();

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

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

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    // 这里省略了类中虚函数的定义
    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+56>, // 父类的vptr
    m_bb
    },
    _vptr.B1 = <vtable for D1+24>, // 虚继承子类B1的vptr
    m_b1
    },
    m_dd
    },
    m_d1 // 没有采用虚继承, 因此与基类BB共用vptr.
    }

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

    保留多个父类的vptr.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    // 这里省略了类中虚函数的定义
    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, public 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+40>, // 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 进行干预, 以达到预期目的.

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

    有了上面的结论, 就不难推测这种情况下的对象模型了.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    // 这里省略了类中虚函数的定义
    class BB
    {
    int64_t m_bb;
    };
    class B1 : public virtual BB
    {
    int64_t m_b1;
    };
    class B2 : public virtual BB
    {
    int64_t m_b2;
    };
    class DD : public B1, public B2
    {
    int64_t m_dd;
    };
    // DD对象的结构, gcc 8.2.0, GNU gdb 8.1.1
    {
    <B1> =
    {
    <BB> =
    {
    _vptr.BB = <vtable for DD+88>,
    m_bb = 1
    },
    _vptr.B1 = <vtable for DD+24>,
    m_b1
    },
    <B2> =
    {
    _vptr.B2 = <vtable for DD+56>,
    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是指用于子类, 父类的声明方式不影响默认方式. 如下代码:

    1
    2
    3
    class BB {};
    class D1 : BB {}; // private继承
    struct D2 : BB {}; // public继承

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

这个设计是符合逻辑的. 可以设想, 如果使用其他继承方式, 那么从逻辑上说, 在类外不应该能访问父类成员. 但是要实现运行时多态, 正常做法是将子类指针/引用赋值给一个父类类型的指针/引用(设为bp), 之后通过bp访问父类的public成员. 也就是说, 对象中不可以在类外访问的成员, 通过将对象指针赋值给基类指针, 再通过基类指针就可以访问. 这违反了类中的访问权限控制. 所以, 在C++中, 前面的"赋值"是违法的, 无法通过编译. 而没有这个"赋值"操作, 也就无法实现运行时多态, 因此必须通过public继承实现运行时多态.

初始化的含义

所谓初始化,就是给一个变量一个初始值,在使用一个变量的值之前,需要对其进行初始化,否则,得到的就

是一个无意义的值。

初始化的方法

用圆括号:type var(expression-list)

圆括号中是我们提供给构造函数的参数,看下面的例子

1
2
3
4
5
6
7
8
9
10
int i(1) ;
string s("hello") ;
class MyClass
{
int i ;
string s ;
MyClass();
MyClass(int , const string&) ;
};
MyClass obj(1 , "hello") ;

这是一种显示初始化的方式,相当于主动调用对应类型的构造函数。

1
2
int i(1.1) ;
MyClass obj(1.1 , "hello") ;

注意,如果想采用默认初始化方式初始化一个变量,下面的做法是错误的:

1
2
3
int i() ;
string s() ;
MyClass obj() ;

因为编译器会将其看作是函数声明,而不是变量定义。正确的做法应该1是:

1
2
3
4
5
6
int i ;
int ii{} ;
string s ;
string ss{} ;
MyClass obj ;
MyClass objj{} ;

说到这里,就不得不提到 the Most Vexing Parse ,请看下面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Timer
{
public:
Timer();
};
class TimeKeeper
{
public:
TimeKeeper(const Timer& t);
int get_time();
};
int main()
{
TimeKeeper time_keeper(Timer()); // 1
return time_keeper.get_time(); // 2
}

代码来自 维基百科

上面代码的标注1的那行会被看作是一个名字为time_keep的函数, 返回值是 TimeKeeper 类型, 参数是一个返回值为Timer类型的函数指针, 而不是对象定义. 要实现后者, 可以这样:

1
TimeKeeper time_keeper((Timer()));

更好的做法是使用C++11定义的用{}来初始化对象,可以避免很多错误。

用等于号:type var = expression

对于内置类型来说, =初始化和()初始化几乎没有区别(我也不知道区别在哪里). 对于类类型, =初始化调用的是copy构造函数, 而赋值是调用重载的=操作符.

1
2
MyClass c1 = c0 ;   // call MyClass(const MyClass&)
c1 = c0 ; // call operator =(const MyClass&)

等于号经常和大括号{}一起用于初始化,

1
MyClass c = {1 , "123"} ;

当等号右边值的类型(源类型)与目标类型不同时, 会先寻找可用的类型转换方法将源类型转换为目标类型, 然后通过 direct-initilize (复制构造函数)来初始化目标变量. 最后进行优化, 直接在目标变量的内存上构造转换后对象, 从而减少了一次复制构造函数的调用. 注意, 在C++17之前, 即使这里没有使用到复制构造函数, 也要求其存在/可调用. 下面的代码可以验证. g++编译后会提示不能将 non-const lreference 绑定到 rvalue, 这个错误发生在将转换后对象传递给复制构造函数时. 转换后对象是一个右值, 这里故意将其参数定义为 non-const reference, 从而引发这个错误. 如果编译参数加上 --std=c++17 则不会出现此错误.

1
2
3
4
5
6
7
struct Test
{
Test(int i) {}
Test(Test &t) {}
};

Test t = 1; // illegal before C++17.

详情参考 copy initilization.

用大括号:type var{initializer-list} //C++11推荐

C++11为了解决C++98混乱的初始化方式而提出的一种初始化方式: uniform initialization 保证它可以用于所有的初始化. 实现方式就是采用大括号. 下面介绍一些{}与而其他初始化语法的不同之处:

  • 大括号初始化不允许对内置类型进行隐式的窄化类型转换(implicit narrowing conversion), 但是注意, 这个类型转换不仅仅依据类型, 还会依据值的大小.
1
2
3
4
char c1{10} ;   // 正确,10是int 类型,但是可以char大小可以保存
char c2{256} ; // 错误,256超过了char可保存的大小
int i1(11.11) ; // 正确,i1 == 11
int i2{11.11} ; // 错误,会出现精度损失
  • 想要在定义非静态类成员时为成员赋初值(称为 default member initialize), 只能使用=和{}来初始化, 不允许使用().
  • 初始化数组和标准库容器
1
2
int arr[10]{1,2,3} ;
std::vector<int> v{1,2,3} ;
  • 初始化不可复制的对象(uncopyable objects), 比如 std::atomic, 这里就只能用 {} 和 (), 而不能用 =.
1
2
3
std::atomic<int> ai_1{0} ;    //正确
std::atomic<int> ai_1(0) ; //正确
std::atomic<int> ai_1 = 0 ; //错误

什么也不用:type var

有时候我们可能直接定义一个对象, 没有添加=,{},()以显式地初始化, 比如:

1
2
3
int i ;
string s ;
MyClass c ;

这种情况下根据定义所在的位置和对象类型的不同,有不同的初始化行为。

  • 对于类类型,会自动调用类的默认构造函数来初始化对象。

  • 对于内置类型,当变量在全局作用域或者被定义为局部静态变量时, 会被默认初始化, 一般是零初始化.

1
2
3
4
5
6
7
8
9
int i ;   // i == 0
double d ;// d == 0
bool b ; // b == false
MyClass c ; // 调用MyClass()构造函数
void f()
{
static int i ; // i == 0
cout<<i<<endl ;
}
  • 而对于自动变量,则默认初始化得到的是一个不定值。绝大多数情况下使用这个变量都会是一个未定义的行为,例外请参考:cppreference