《深度探索C++对象模型》学习笔记

1 关于对象

1.1 C++对象模式

C++对象模型

非静态数据成员放置于每一个类对象中,静态数据成员则被存放在类对象之外。静态与非静态成员函数也被放在类对象之外。虚函数则以两个步骤支持:

  1. 每一个类产生出一堆指向虚函数的指针,放在表格之中,此表称为虚表 (virtual table, vtbl)。
  2. 在每一个类对象中安插一个指针,指向相关的虚表。通常这个指针被称为 vptr 。它的设定与重置由每一个类的构造函数、析构函数、拷贝赋值运算符自动完成。每一个类所关联的 type_info 对象(用以支持runtime type identification, RTTI)也经由虚表指出,通常放在表格的第一个slot(位置)。

3 Data语义学

3.0 综述

  • 一个虚基类子对象只会在派生类中存在一份实例,不管它在继承体系中出现了多少次。
  • C++标准并不强制规定如“基类子对象的排列顺序“或”不同存取层级的数据成员的排列顺序“这种琐碎细节。它也不规定虚函数或虚基类的实现细节。C++标准说:那些细节由各家厂商自定。
  • C++对象模型把非静态数据成员直接存放在每一个类对象中,对于继承来的非静态数据成员(无论是虚继承还是非虚继承)也是如此。不过并没有强制定义其间的排列顺序。至于静态数据成员则被放置在程序的一个全局数据段(global data segment)中,不影响类对象的大小。

3.1 Data Member 的绑定

  1. 有关数据成员的绑定问题,现在的C++已经解决了。

  2. 若一个 inline 函数在 class 声明之后立刻被定义,则还是对其评估求值。即对成员函数本体的分析会直到整个class的声明都出现了开始。因此在一个inline成员函数体之内的一个数据成员绑定操作,会在整个class声明完成之后才发生。(member scope resolution rules)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    extern int x;

    class Point3d {
    public:
    // 对于函数本体的分析将延迟直到class声明的右大括号出现才开始
    float X() const { return x; }
    private:
    float x;
    };

    // 分析在这里进行
  3. 然而这对于成员函数的参数列表并不为真。参数列表中的名称还是会在它们第一次遇到时被适当地决议(resolved)完成。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    typedef int length; // global typedef

    class Point3d {
    public:
    void mumble(length val); // length: int
    length mumble() { return _val; } // length: int
    private:
    typedef float length; // nested typedef
    length _val; // length: float
    }

    对于这种情况,请总是把“nested type声明”放在class的起始处。

3.2 Data Member 的布局

3.6 指向 Data Members 的指针

取一个非静态数据成员的地址将会得到它在类中的偏移量。

下面代码中数据成员指针的类型为:float Point3d::*。有些编译器返回的偏移量总是多1,因为考虑到不指向任何成员的指针应为0。如果不加1,有可能导致第一个数据成员成员的指针和不指向任何成员的指针相等,都为0。(此时说它是偏移量就有些不合适)

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
// G++ 8.1.0, 64-bit
#include <iostream>
using namespace std;

class Point3d {
public:
virtual ~Point3d() {}
static Point3d origin;
float x, y, z;
};

struct Base1 {
int val1;
};
struct Base2 {
int val2;
};
struct Derived : Base1, Base2 {
int val3;
};

Point3d Point3d::origin;

#define show(ptrToDataMember) printf(#ptrToDataMember " = %d\n", ptrToDataMember)

int main() {
// 注意不能用 cout << &Point3d::x,会匹配到 operator<<(bool)
// 为了简便,这里定义宏(与书上不同)
show(&Point3d::x); // 8
show(&Point3d::y); // 12
show(&Point3d::z); // 16

show(&Base1::val1); // 0
show(&Base2::val2); // 0
show(&Derived::val1); // 0
show(&Derived::val2); // 0(比较奇怪)
show(&Derived::val3); // 8
return 0;
}

个人感觉,成员指针并不能拿来输出。所以输出什么值也只能作为参考,便于理解这个概念。事实上,我觉得cout给出了正确的行为。它将一个成员指针视为bool,以表示其是否真正有效。即

1
2
3
4
5
6
7
8
9
10
11
12
struct Base1 {
int val1;
};

int main() {
int Base1::* p = 0;
cout << boolalpha << p << endl; // false
p = &Base1::val1;
printf("%d\n", p); // 0
cout << boolalpha << p << endl; // true
return 0;
}

C++引用的实现

当我学习C++引用时,听到的第一句话是“引用是变量的别名,不像指针一样需要占用内存空间”。然而学到深处,发现此话并不完全正确。

本文主要介绍我如何通过实验来了解到C++引用的实现,其实引用的内部就是指针。当然这也于编译器有关,所以这里需要提及一下测试所用的编译器及环境。

测试环境是MinGW的g++ 8.1.0,64位编译器,64位的机子。所以指针的大小是8个字节,即64个bit。(注:因为目的是测试,所以测试时并没有处理对new操作符所产生对象的回收)

首先我写出了如下代码,试图通过指针偏移来获取有关引用的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <string>
using namespace std;

int main() {
int64_t x;
string& str = *new string();
int64_t y;

cin >> str; // 对引用做一次操作,避免编译器把变量优化掉

cout << &x << endl;
cout << &y << endl;
cout << str << endl;

return 0;
}

然而,这个程序的输出如下(str的输出忽略):

1
2
0x61fe00
0x61fdf8

难道引用真的不占内存?编译器真的很聪明,可能优化掉了吧;经过一系列尝试,我写出了另外一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <string>
using namespace std;

void foo(int64_t q, string& s, int64_t r) {
cout << "&q: " << &q << endl;
cout << "&r: " << &r << endl;

cout << "*(string**)(&q + 1): " << *(string**)(&q + 1) << endl;
}

int main()
{
string& str = *new string();
cout << "main(): " << &str << endl;
foo(0, str, 0);
return 0;
}

这段代码的输出是:

1
2
3
4
main(): 0x1e1bd0
&q: 0x61fde0
&r: 0x61fdf0
*(string**)(&q + 1): 0x1e1bd0

可见,q的地址是0x61fde0,r的地址是0x61fdf0。两个地址间相差16个字节!这里引用占用的内存出来了。显然引用对应的指针存储在q的8个字节之后。我们可以将q的地址加1,也就是加上8个字节,这里存储的就是引用的信息。假设它就是指针,那么考虑:(&q + 1)本身是一个指向string*的指针,也就是string**。所以若要获取指针的值,需要对这个值解一次引用,输出出来。(当然如果你想简单一点,可以直接把它转成int64_t然后用16进制输出亦可)

至此真相大白,程序输出的最后一行0x1e1bd0与主函数中new出来的对象的地址(见输出第一行)一致。所以得出结论:引用是用指针实现的。用户对引用的访问操作都内含一次解引用,而这对用户来说是透明的

不过需要提及的是,回想本文的第一个测试,发现引用的指针空间被优化掉了。所以引用有时也不一定会在栈上真正以指针体现出来。

Golang中的匿名函数、闭包、defer、panic、recover

匿名函数

1
2
3
4
5
6
7
8
9
10
package main

import "fmt"

func main() {
f := func() {
fmt.Println("test")
}
f()
}

闭包

下面代码中,输出的三个x的地址一定是一样的,它们引用同一个变量。

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
package main

import "fmt"

func main() {
f := closure(10)
fmt.Println(f(1))
fmt.Println(f(2))
}

func closure(x int) func(int) int {
fmt.Println(&x)
return func(y int) int {
fmt.Println(&x)
return x + y
}
}

/* possible output:
0xc000060068
0xc000060068
11
0xc000060068
12
*/

defer

  • 执行方式类似其它语言中的析构函数,在函数体执行结束后按照调用顺序的相反顺序逐个执行
  • 即使函数发生严重错误也会执行
  • 支持匿名函数的调用
  • 常用于资源清理、文件关闭、解锁以及记录时间等操作
  • 通过与匿名函数配合可在return之后修改函数计算结果
  • 如果函数体内某个变量作为defer时匿名函数的参数,则在定义defer时即已经获得了拷贝,否则则是引用某个变量的地址

下面这个代码中,defer时i就传值进去了。所以输出210

1
2
3
4
5
6
7
8
9
package main

import "fmt"

func main() {
for i := 0; i < 3; i++ {
defer fmt.Print(i)
}
}

下面这个代码中,由于闭包中的i是对main函数中局部变量i的引用。defermain函数结束后执行,而main函数结束时i的值已经为3。所以输出3次3

1
2
3
4
5
6
7
8
9
10
11
package main

import "fmt"

func main() {
for i := 0; i < 3; i++ {
defer func() {
fmt.Println(i)
}()
}
}

下面这个代码中,闭包里每一次传递的string事实上都不是同一个。所以输出的是ans2 ans1 ans0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"strconv"
)

func main() {
for i := 0; i < 3; i++ {
var t string = "ans" + strconv.Itoa(i)
defer func() {
fmt.Print(t + " ")
}()
}
}

defer配合panic与recover

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
package main

import "fmt"

func main() {
f1()
f2()
f3()
}

func f1() {
fmt.Println("func f1")
}

func f2() {
// defer 一定要在 panic 之前, 因为 panic 触发时
// panic 所在函数就会立刻终止并倒序调用所有已经存在的defer
// 若 defer 在 panic 之后, 程序根本不会知道后面有 defer
defer func() {
if err := recover(); err != nil {
fmt.Println("err =", err)
fmt.Println("recover in f2 (first)")
}
}()
defer func() {
if err := recover(); err != nil {
fmt.Println("err =", err)
fmt.Println("recover in f2 (second)")
}
}()
panic("panic in f2")
}

func f3() {
fmt.Println("func f3")
}
/* output:
func f1
err = panic in f2
recover in f2 (second)
func f3
*/

C++中的虚函数、重写与多态

在C++中顺利使用虚函数需知道的细节

  • 如函数在派生类中的定义有别于基类中的定义,而且你希望它成为虚函数,就要为基类的函数声明添加保留字virtual。在派生类的函数声明中,则可以不添加virtual。函数在基类中virtual,在派生类中自动virtual(但为了澄清,最好派生类中也将函数声明标记为virtual,尽管这非必须)。
  • 保留字virtual在函数声明中添加,不要再函数定义中添加。
  • 除非使用保留字virtual,否则不能获得虚函数,也不能获得虚函数的任何好处。
  • 既然虚函数如此好用,为何不将所有成员函数都设为virtual?这似乎只有一个理由——效率。编译器和“运行时”环境要为虚函数做多得多的工作。所以,无谓地将成员函数为virtual会影响程序执行效率。

重写

虚函数定义在派生类中发生改变时我们说函数定义被重写。一些C++书籍区分了重定义(redefine)和重写(override)。两者都是在派生类更改函数定义。函数是虚函数,就称为重写。如果不是,就称为重定义。对于我们程序员而言,这种区分似乎有点无聊,因为程序员在两种情况下做的事情是一样的。不过,编译器对于这两种情况确定是区别对待的。

多态

多态性是指借助晚期绑定技术,为一个函数名关联多种含义的能力。因此,多态性、晚期绑定和虚函数其实是同一个主题。

虚函数和扩展类型兼容性、切割问题

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
#include <iostream>
#include <string>
using std::cout;
using std::endl;
using std::string;

class Pet
{
public:
virtual void print();
string name;
};

class Dog : public Pet
{
public:
virtual void print();
string breed; // 品种
};

void Pet::print()
{
cout << "Pet name: " << name << endl;
}

void Dog::print()
{
cout << "Dog name: " << name << ", breed: " << breed << endl;
}

int main()
{
Pet vPet;
Dog vDog;
vDog.name = "Tiny";
vDog.breed = "Great Dane";
vPet = vDog;
// cout << vPet.breed;
return 0;
}

上述代码vPet = vDog;的赋值是允许的,但赋给变量vPet的值会丢失其breed字段。这称为切割问题(slicing problem)。例如,cout << vPet.breed会报错。

切割问题:在将派生类对象赋给基类变量时,派生类对象有、基类没有的数据成员会在赋值过程中丢失,基类没有的成员函数也会丢失。在最终的基类对象中,将无法使用这些丢失的成员。

切割测试:

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
56
57
58
59
60
#include <iostream>
#include <string>

using std::cout;
using std::endl;
using std::string;

class Demo
{
public:
Demo(const string& s): str(s)
{
cout << "Demo constructor called (" + str + ").\n";
}
~Demo()
{
cout << "Demo destructor called (" + str + ").\n";
}
Demo(const Demo& other)
{
str = other.str;
cout << "Demo copy constructor called (" + str + ").\n";
}
Demo& operator=(const Demo& other)
{
str = other.str;
cout << "Demo operator= called (" + str + ").\n";
return *this;
}
private:
string str;
};

class Base
{
public:
Demo member1 = Demo("member1");
};

class Derived : public Base
{
public:
Demo member2 = Demo("member2");
};

int main()
{
Derived derived;
Base base;
base = derived;
}
/* Output
Demo constructor called (member1).
Demo constructor called (member2).
Demo constructor called (member1).
Demo operator= called (member1).
Demo destructor called (member1).
Demo destructor called (member2).
Demo destructor called (member1).
*/

幸好,C++提供了一种方式,允许在将一个Dog视为Pet的同时不丢失品种名称:

1
2
3
4
5
6
7
Pet *pPet;
Dog *pDog;
pDog = new Dog;
pDog->name = "Tiny";
pDog->breed = "Great Dane";
pPet = pDog;
pPet->print(); // prints "Dog name: Tiny, breed: Great Dane"

基类Petprint()声明为virtual。所以一旦编译器看到pPet->print();就会检查PetDogvirtual表,判断pPet指向的是Dog类型的对象。因此,它会使用Dog::print(),而不是Pet::print()

配合动态变量进行OOP是一种全然不同的编程方式。只要记住以下两条简单的规则,理解起来就容易得多。

  1. 如果指针pAncestor的域类型是指针pDescendant的域类型的基类,则以下指针赋值操作允许:pAncestor = pDescendant;。此外,pDescendant指向的动态变量的任何数据成员或成员函数都不会丢失。
  2. 虽然动态变量所有附加字段(成员)都没有丢,但要用virtual成员函数访问。

视图对虚成员函数定义不齐全的类进行编译

编译前,如果还有任何尚未实现的virtual成员函数,编译就会失败,并产生形如undefined reference to Class_Name virtual table的错误信息。即使没有派生类,只有一个virtual成员,并且没有调用该虚函数,只要函数没有定义,就会产生这种形式的消息。此外,可能还会产生进一步的错误消息,声称程序对默认构造函数进行了未定义的引用,即使确实已定义了这些构造函数。

始终/尽量使析构函数成为虚函数(主要讲述把析构函数声明为虚函数的优点)

这里主要阐述让析构函数称为虚函数的好处,但实际上也有坏处。在《Effective C++》条款07中有提到具体内容,见本文后记。

C++11 新用法

基于哈希的 map 和 set

简述

基于哈希的 mapset ,它们分别叫做 unordered_map, unordered_set 。数据分布越平均,性能相较 mapset 来说提升就更大。但由于它们基于哈希,所以并不像 mapset 一样能自动排序;它们都是无序的。

我做了一个测试:随机生成 $10^7$ 个 int 范围内的整数(平均分布),然后将其分别插入 mapunordered_map,再完整的做一次查询,查看时间和内存上的消耗。

测试结果

结构 总耗时 插入耗时 查询耗时 内存
map 18,041 MS 10,299 MS 7,742 MS 230.7 MB
unordered_map 7,138 MS 5,426 MS 1,712 MS 212.0 MB

当数据分布平均时,从时间上看,两者的性能差距约为 $7138 / 18041 \approx 40\%$

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×