0%

那些面试的翻车题(一)

胡言乱语的开头

事情还得从两天前的面试说起。没有自我介绍,面试官问我的第一个问题就是:C++中定义一个空的类,那么它的 sizeof 是多少?我内心:woc我怎么知道。面试官继续发问:如果给这个类中加一个空的虚析构函数,那么它的 sizeof 是多少?连蒙带猜给了个答案。面试官再次发问:如果给这个类中加一个静态函数,那么它的 sizeof 是多少?我:QAQ。

虽然从我个人角度去看,这几个问题似乎没什么意义 给自己菜找借口,但是不让我运行一下我还真的不敢确定地说出答案是多少。因此这也暴漏了我在相关方面的短板。所以呢,还是要好好研究一下,顺便复习一下编译链接过程的相关知识。

sizeof

先简单回顾一下 sizeof。我们很早就知道 sizeof 是个运算符,用来查询一个对象或者一个类型的大小,单位是 byte。对于基本的内置类型,sizeof 的使用不存在什么问题。下面列出一些可能令人困惑的地方:

  • sizeof 是全能的吗?显然不是。我们不可以对 functionincomplete type,以及bit field使用 sizeofincomplete type 是指缺乏决定其大小的信息的类型(这不是循环解释233),比如 void,比如 extern char a[]。前两者不能用是因为编译器不知道它们的大小是多少。是的,完成 sizeof 功能的是编译器,我们在后面的汇编代码中也可以看到这一点。显然对于前二者而言,不在链接后或运行时是无法知道其大小的。对于最后一条,纯粹是因为sizeof 的单位没法表示。
  • sizeof(pointer) 是指针本身的大小;sizeof(reference) 是被引对象的大小;sizeof(array) 是数组的大小;sizeof(class) 是对应的对象数组中单个元素的大小,这其中包括对象的内容和可能的填充。

空对象的大小

按照我的歪理常理推断,一个空的对象中什么都没有,那大小自然是 0 了。可是,当我运行下面的代码后

1
2
3
4
5
6
7
8
9
10
#include <iostream>

class A{};

int main() {
A a;
std::cout << sizeof(a) << std::endl;
std::cout << sizeof(A) << std::endl;
return 0;
}

得到的运行结果是两个 1。看着结果我不禁陷入懵逼。这个结果可以得到两个结论:sizeof 对类和对象不做区分,认为二者都是 class type;空对象的大小为 1。前者没什么好说的,但是空对象里面这 1 byte的空间到底放了什么东西呢?尤其是当我们对比运行以下代码后:

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>

class A{
char ch;
};

int main() {
A a;
std::cout << sizeof(a) << std::endl;
return 0;
}

会得到与空对象相同的结果。进行分析:后者的 1 byte 必然存放的是 ch ,而前者的 1 byte 并没有存放任何有意义的东西(不然后者的大小就不会是 1 byte 了)。那么空对象的 1 byte 的作用就显而易见了:占位

为什么要占位呢?

  1. 编译器靠什么来区分不同的对象(变量)呢?不是变量名,而是与之绑定的存储对象(变量)的地址。因此,地址就是一个对象的 unique identifier,对于空对象也是如此。要想区分不同的空对象,就要对每个空对象分配不同的地址。因此空对象的大小自然不能为 0
  2. 让我们设想一个空对象数组。如果空对象的大小为 0,那么这个数组中指针的加法就会失效,这显然是不可以接受的。

P.S. :滑稽.jpg

加个虚函数

面试时候回答空对象的大小加一个指向虚表的指针的大小。而根据上述实验的结论,大小应该是 8(64位机器和编译器)。使用如下代码进行实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
// test1.cpp
#include <iostream>

class AAA{
public:
virtual ~AAA(){}
};

int main() {
AAA a;
std::cout << sizeof(a) << std::endl;
return 0;
}

结果是 8。里面应该只有一个指向虚表的指针。但是为了验证这个猜想,我们需要把类的内存布局打印出来。怎么打印呢?我在 gcc 的 documentation 里找到了这样一个编译选项(gcc版本 9.3.0,编译选项在 3.17 GCC Developer Options 中):

1
2
3
-fdump-lang-class

Dump class hierarchy information. Virtual table information is emitted unless ’‘slim’’ is specified. This option is applicable to C++ only.

注:之前在 SO 中看到有人说 -fdump-class-hierarchy 这个编译选项,不过使用时发现编译器报错。后经查找,gcc 在 8.0 后就废除了这个编译选项(链接)。

编译后得到文件 test1.cpp.001l.class,查找到 AAA 相关内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
Vtable for AAA
AAA::_ZTV3AAA: 4 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI3AAA)
16 (int (*)(...))AAA::~AAA
24 (int (*)(...))AAA::~AAA

Class AAA
size=8 align=8
base size=8 base align=8
AAA (0x0x7f93df7a8d80) 0 nearly-empty
vptr=((& AAA::_ZTV3AAA) + 16)

猜想无误。

加个静态函数

这个问题其实是在问,(静态)成员函数放在哪里。其实如果对 sizeof 熟悉的话,是可以直接推测出来:sizeof(class) 没问题,sizeof(function) 不行,所以 function 不在 class 里面。当然这个推测有点扯淡,我们还是直接实验一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//test2.cpp
#include <iostream>

class AAA{
public:
static void funnnnnnn() {
for (int i = 0; i < 10; i++)
std::cout << i << std::endl;
}
};

int main() {
AAA a;
a.funnnnnnn();
return 0;
}

使用命令 gcc -fdump-lang-class -c test2.cpp 编译,得到文件 test2.cpp.001l.class的相关内容:

1
2
3
4
Class AAA
size=1 align=1
base size=0 base align=1
AAA (0x0x7f02ed6c8d80) 0 empty

和空类是一样的。那么静态函数放在哪里了呢?我们使用命令

1
2
$ gcc -c test2.cpp
$ objdump -s -d test2.o > out2

查看编译后的 elf 文件,找到相关信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Disassembly of section .text._ZN3AAA9funnnnnnnEv:

0000000000000000 <_ZN3AAA9funnnnnnnEv>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: 48 83 ec 10 sub $0x10,%rsp
c: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
13: 83 7d fc 09 cmpl $0x9,-0x4(%rbp)
17: 7f 2c jg 45 <_ZN3AAA9funnnnnnnEv+0x45>
19: 8b 45 fc mov -0x4(%rbp),%eax
1c: 89 c6 mov %eax,%esi
1e: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 25 <_ZN3AAA9funnnnnnnEv+0x25>
25: e8 00 00 00 00 callq 2a <_ZN3AAA9funnnnnnnEv+0x2a>
2a: 48 89 c2 mov %rax,%rdx
2d: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 34 <_ZN3AAA9funnnnnnnEv+0x34>
34: 48 89 c6 mov %rax,%rsi
37: 48 89 d7 mov %rdx,%rdi
3a: e8 00 00 00 00 callq 3f <_ZN3AAA9funnnnnnnEv+0x3f>
3f: 83 45 fc 01 addl $0x1,-0x4(%rbp)
43: eb ce jmp 13 <_ZN3AAA9funnnnnnnEv+0x13>
45: 90 nop
46: c9 leaveq
47: c3 retq

注意 C++ 代码中的名字(变量名、函数名、类名)都会被编译器修饰,可以通过 builtin 工具 c++filt进行解析。和本文无关,这里不再详细展开。

我们可以看到这个静态成员函数也是规规矩矩呆在.text段,只不过编译器进一步给它加了作用域限定 ._ZN3AAA9funnnnnnnEv。除此之外和正常的函数没什么不同。为了说明这一点,我们给源代码加了个同名函数

1
2
3
4
void funnnnnnn() {
for (int i = 0; i < 10; i++)
std::cout << i << std::endl;
}

再次进行编译。得到void funnnnnnn()的汇编内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0000000000000018 <_Z9funnnnnnnv>:
18: f3 0f 1e fa endbr64
1c: 55 push %rbp
1d: 48 89 e5 mov %rsp,%rbp
20: 48 83 ec 10 sub $0x10,%rsp
24: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp)
2b: 83 7d fc 09 cmpl $0x9,-0x4(%rbp)
2f: 7f 2c jg 5d <_Z9funnnnnnnv+0x45>
31: 8b 45 fc mov -0x4(%rbp),%eax
34: 89 c6 mov %eax,%esi
36: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 3d <_Z9funnnnnnnv+0x25>
3d: e8 00 00 00 00 callq 42 <_Z9funnnnnnnv+0x2a>
42: 48 89 c2 mov %rax,%rdx
45: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 4c <_Z9funnnnnnnv+0x34>
4c: 48 89 c6 mov %rax,%rsi
4f: 48 89 d7 mov %rdx,%rdi
52: e8 00 00 00 00 callq 57 <_Z9funnnnnnnv+0x3f>
57: 83 45 fc 01 addl $0x1,-0x4(%rbp)
5b: eb ce jmp 2b <_Z9funnnnnnnv+0x13>
5d: 90 nop
5e: c9 leaveq
5f: c3 retq

嘿!完全一样!

放在结尾

这一通实验搞下来,也恢复增加了不少关于汇编的知识,想想另开一篇吧。