作为一个没有正经用 C++ 写过项目的码农,在面试到 C++ 候选人时,尽管心虚,但我也只能鼓起勇气,假装自己很熟 C++ 。
不过每当在简历上看到说自己 “精通” C++ 的时候,还是免不了心里呵呵一下,然后想起早年百度 C++ 规范的开头那句话:
不要使用 C++ 。
毕竟有个我觉得挺基础的 C++ 问题,能正经答出来的人,真的不多。
= 题面 =
题目从一个简单的 struct 开始:
struct X {
int a;
char b;
};
问:可以编译、运行如下代码么?
struct X *p = NULL;
p++;
思考一下?
= 回答 =
在犹豫一会以后,绝大部分人的回答是:
“可以编译,但不能正常运行”。
进一步询问为什么不能正常运行时,给出的理由大概意思是,空指针是无效的(或者说,没有指向可以修改的内存空间),所以不能修改。
如果你也是这么觉得的,可以再思考一下。
= 解析 =
题面确实有一些令人迷惑,所以很多人思考后仍然给出了上述回答。
他们的问题是,混淆了【指针的值】和【指针指向的对象】。
p 的值是 NULL ,确实指向了一个无效的对象,但是在 "p++" 这个操作中,我们修改的并不是其指向的对象,而是 p 指针本身。
本质上讲,(在常见的CPU架构,如 x86/x86-64/arm 等)指针等价于一个 long 类型,对指针的值进行操作,只是修改了指针指向的对象。
= 再问 =
按照一贯的套路,针对这个问题我还准备了追问:
“执行完后,p 的值预计是多少?”
不过鉴于大部分人都认为前述代码无法正确执行,所以往往只能这么问:
“用 sizeof(struct X) 求 X 的大小,预计可以得到多少?”
因为在 C/C++ 里,对一个指针的自增操作(即++操作符),其含义是将指针指向内存中连续的下一个对象。
这其实是个很开放的问题,除了少部分奇怪的回答之外,大部分人的回答都可以是正确的。
但重要的是为什么是正确的。
比如有些人的回答是 5,因为 int 占用 4 个字节, char 占用 1 个字节。
这当然可以没错 —— 只不过默认情况下,大部分编译器的行为并非如此。
= 再解析 =
例如下图,用 gcc -S 处理左边的 C 代码,生成对应的汇编:
可以看出,生成的机器码,是给 p 加上 8,而不是 5。
注:给不熟悉 gcc 汇编的同学解释一下
rbp 是栈寄存器,%rbp 是栈顶地址
-16(%rbp) 表示 p 存在栈顶 -16 个字节的位置
rcx 是 64 bit 寄存器(相对于 x86 的 cx 寄存器,32位)
$8 是常量 8
右边红框的意思是,将 p 的值从“栈顶-16”内存中拷贝到 rcx 寄存器中,+8,然后再写回内存。
前面说“可以没错”,是因为我们可以用 pragma 预处理指令改变编译器的行为:
可以看到,生成的汇编代码里, $8 变成了 $5 。
实际上这里还可能出现一些其他的答案,比如在 16 bit CPU 下,int 占用2个字节,或者你也可以用 pragma pack(2) 试试看。
所以重要的是为什么。
= 还问 =
编译器默认 8 字节的行为被称为“alignment”(内存对齐),很多人在给出这个答案的同时,也会顺口说到这个概念。
于是我可以继续问,内存对齐的意义是什么?
但是显然大多数人都还给老师了……
简而言之,这又是一个空间换时间的 case —— 在常见的 x86 架构下,从奇数地址读取一个 int 数据,会需要读取两次内存,并且将两次读取的数据拼起来,这显然会降低执行效率。
也许有的同学会觉得,即使知道这个概念,好像也没什么用,就像屠龙之术。
其实这就像《踩坑记:go服务内存暴涨》里面提到的内存管理知识一样,看起来好像大部分情况用不到,但真的遇到相关问题时,就会束手无策。
比如像 arm、ppc 这类 CPU 架构,其部署环境往往内存较少(有时会需要用 KB 作为单位),程序员会希望通过减少内存对齐来降低内存消耗;但需要注意的是,其不支持直接读写奇数地址,即使强制生成完全不对齐代码,执行往往会出错。
再比如,通过合理安排 struct 中成员的位置,也可以显著降低对内存的消耗。
例如 X、Y 这样两个 struct,包含相同的成员变量,但只是简单兑换一下 b、c 的位置,就可以节省 25% 的内存占用:
注:Y、Z 分别需要占用 16、12 字节
想想看,为什么 Z 不是只占用 10 个字节?
= 不问了不问了 =
最后得承认,这篇有点标题党了,实际上这是一个 C 问题,并不是 C++ 问题。
不过对于熟悉 C++ 的同学,没能正确回答这个问题实在让我有点意外,可能是我又陷入了知识的诅咒了(知道一个知识以后、就很难再想象不知道它的情况)。
你们怎么看这个题目呢?欢迎留言讨论。
推荐阅读: