链接:https://www.nowcoder.com/questionTerminal/c64aadd25ceb4789bfd404819704855d
来源:牛客网
题目的写法事实上是有严重安全隐患的
援引《C++ Primer(Fifth Edition)》4.1.3节:
Order of operand evaluation is independent of precedence and associativity. In an
expression such as f() + g() * h() + j():
• Precedence guarantees that the results of g() and h() are multiplied.
• Associativity guarantees that the result of f() is added to the product of g() and h() and that the result of that addition is added to the value of j().
• There are no guarantees as to the order in which these functions are called.If f, g, h, and j are independent functions that do not affect the state of the same
objects or perform IO, then the order in which the functions are called is irrelevant. If
any of these functions do affect the same object, then the expression is in error and
has undefined behavior.
大意如下:
操作数的求职顺序与运算符的优先级和结合律无关。在一个形如f() + g() * h() + j()的表达式中:
- 优先级保证g()的返回值与h()的返回值相乘
- 结合律保证f()的返回值与g() * h()的结果相加,并将结果与j()的返回值相加
- 但是没有任何保证可以确定这些函数调用的顺序
如果f,g,h,j既没有共同关联参数的(do affect the same object),也不是输入输出系统(IO)函数,那么函数调用的顺序彼此互不影响。如果其中的任何函数都引用了相同的对象,那么这个表达式就是错误的。它会产生未定义的行为。
我们看回题干:
1 |
|
这里面的突出问题在于
1. 表达式状态共享:
子表达式共享状态p变量
导致一方的求值运算会受另一方结果影响。
2. 运算对象求值顺序不明:
C++中只有'&&', '||', ',', '?:' 这四个运算符明确了其所属运算对象的求值顺序。
函数调用也是一种运算符
而实参压栈顺序完全依赖于编译器实现,三个*p++求值顺序不明。
那么结合第一个问题,假如从左向右压栈结果就是123
如果换个编译器可能顺序又不同了
所有选项可能都能有幸成为正确答案
所以,这种表达式是错误的,会产生未定义的行为。
这种题目真的不该再出。
----
2017.10.24 更
今天上来瞅一眼评论
看到GondorFu的回复
想起其实在自己其他回答中也有不少类似的评论
这些都表达了一个广泛存在的意识形态:
函数的参数从右向左压入调用栈
那么参数表达式的执行顺序自然是从右至左
这个说法由来已久
并且有种“情不知所起 一往而深”的味道
大家(包括我)都是从各种语言书籍中看到的这种说法
不得不说这种意识形态荼毒甚广
以至于我在几篇回答中引用了《C++ Primer(Fifth Edition)》
仍然有不少胖友将信将疑
所以 私以为需要在此开宗明义
彻彻底底地论述这个问题的实质
那么就从不同编译器的调用约定(calling convention)说起吧
让我们先厘清什么是调用约定
以下摘自Wikipedia
In computer science, a calling convention is an implementation-level (low-level) scheme for how subroutines receive parameters from their caller and how they return a result. Differences in various implementations include where parameters, return values, return addresses and scope links are placed, and how the tasks of preparing for a function call and restoring the environment afterward are divided between the caller and the callee.
简单翻译如下
在计算机科学领域,调用约定是一种编译器级别的方案。这个方案规定了函数如何从它的调用方获取实参以及如何返回函数结果。对于不同的编译器而言,他们的调用约定的不同之处包括参数、返回值、返回地址、域指针等的存储位置,如何发起函数调用和调用结束后如何恢复,以及调用方和被调方的任务如何划分等。
大家困惑的参数压栈顺序也是调用约定的范畴,即上文所言“参数、返回值、返回地址、域指针等的存储位置”与“如何发起函数调用和调用结束后如何恢复”
既然调用约定是编译器级别的方案,那么不同编译器应该就有不同的实现。
在此我把我研究过的常见的几种编译器的调用约定分别说一下
编译器一般都按照芯片的指令集进行划分
i386:
这种指令集对应的是大家以前常用的32位Intel芯片
i386出生的时候寄存器还很少,不够用
所以这厮在函数调用中的参数传递全靠压栈
我们以如下函数调用为例
1 |
|
这个函数有10个参数
i386会从右至左将实参逐个压入ESP
也就是它的栈帧
汇编表现为
1 2 3 4 5 6 |
|
大家看的所谓语言书籍的作者当年基本都是i386的使用者
这就是大家看到“压栈顺序从右至左”这一说法的原因
X86_64:
原来压栈方式的调用约定限制了函数调用的速度
因为压栈用的是内存
这个时候世界飞速发展 摩尔定律潜移默化
芯片很快进入了64位时代
寄存器的数量大大增加
X86_64开始动用部分寄存器来完成参数传递的工作
其中RDI, RSI, RDX, RCX, R8D, R9D寄存器分别用于
正序存储第1至第6个实参
剩下的更多参数就采用老办法
逆序压入栈帧
还是以上文的函数例子
X86_64的汇编表现为
1 2 3 4 5 6 7 8 9 10 11 |
|
由此可见
到了64位
函数调用就不再是i386那样式儿的一概从右至左压栈了
而是当参数少于6个时,直接从左至右使用寄存器
参数太多时才会动用堆栈
事实上这种取舍是非常合理的
因为编码规范一般都会要求大家设计函数调用不要超过4个形参
大家平时使用的普通函数大都没有或者只有一个形参
ARM:
ARM指令集的芯片大量用于移动终端
大家的手机芯片大部分都是ARM架构的
这里不带数字的ARM默认是32位的指令集
与Intel的区别在于ARM已经使用寄存器来完成参数传递了
策略是前4个参数使用R0, R1, R2, R3来传递
多出的参数用堆栈
ARM64:
ARM64和X86_64很像
它使用了X0-X5存储前6个参数,其他用堆栈
虽然不同指令集的编译器所使用的汇编语言和寄存器名称有些许出入
相信大家能够理解
以上讲解正是为了阐明一个道理
不同编译器的调用约定是不同的
一定要树立这个意识形态
尽信书不如无书
我以X86_64这个目前最流行的台式计算机芯片指令集来举例
看看题目中的语句会被编译器翻译成什么样的汇编代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
由汇编代码可知,我们在X86_64上根本不会用到调用栈
因为参数数量尚未超过6个
那就不会有所谓从右向左求值的说法
接下来我们要深入探讨调用约定的问题
考察如下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
|
大家不妨思考下10个fooX()函数最终的执行顺序如何?
以下是打印出来的实验结果
int foo0()
int foo1()
int foo2()
int foo3()
int foo4()
int foo5()
int foo6()
int foo7()
int foo8()
int foo9()
0123456789
也许这会出乎一些胖友的意料
毕竟上文描述X86_64先存寄存器再压栈
那么是否foo6 - foo9应该倒序执行?
让我们看看汇编代码
# 所有参数表达式中的函数调用已经提前完成 leaq L___PRETTY_FUNCTION__._Z4foo0v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo1v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo2v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo3v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo4v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo5v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo6v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo7v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo8v(%rip), %rdi callq _puts leaq L___PRETTY_FUNCTION__._Z4foo9v(%rip), %rdi callq _puts subq $8, %rsp # 编译器将参数表达式的返回值分别传入相应的寄存器或栈帧 leaq L_.str.1(%rip), %rdi movl $0, %esi movl $1, %edx movl $2, %ecx movl $3, %r8d movl $4, %r9d movl $0, %eax pushq $9 pushq $8 pushq $7 pushq $6 pushq $5 callq _printf addq $48, %rsp xorl %eax, %eax popq %rbp retq .section __TEXT,__cstring,cstring_literals L___PRETTY_FUNCTION__._Z4foo0v: ## @__PRETTY_FUNCTION__._Z4foo0v .asciz "int foo0()" L___PRETTY_FUNCTION__._Z4foo1v: ## @__PRETTY_FUNCTION__._Z4foo1v .asciz "int foo1()" L___PRETTY_FUNCTION__._Z4foo2v: ## @__PRETTY_FUNCTION__._Z4foo2v .asciz "int foo2()" L___PRETTY_FUNCTION__._Z4foo3v: ## @__PRETTY_FUNCTION__._Z4foo3v .asciz "int foo3()" L___PRETTY_FUNCTION__._Z4foo4v: ## @__PRETTY_FUNCTION__._Z4foo4v .asciz "int foo4()" L___PRETTY_FUNCTION__._Z4foo5v: ## @__PRETTY_FUNCTION__._Z4foo5v .asciz "int foo5()" L___PRETTY_FUNCTION__._Z4foo6v: ## @__PRETTY_FUNCTION__._Z4foo6v .asciz "int foo6()" L___PRETTY_FUNCTION__._Z4foo7v: ## @__PRETTY_FUNCTION__._Z4foo7v .asciz "int foo7()" L___PRETTY_FUNCTION__._Z4foo8v: ## @__PRETTY_FUNCTION__._Z4foo8v .asciz "int foo8()" L___PRETTY_FUNCTION__._Z4foo9v: ## @__PRETTY_FUNCTION__._Z4foo9v .asciz "int foo9()" L_.str.1: ## @.str.1 .asciz "%d%d%d%d%d%d%d%d%d%d\n"
代码虽长 但是逻辑还是比较清晰的
说明了一个道理
参数中的函数调用完全是提前完成的
编译器可以按照自有的顺序来执行
这里X86_64就是按照从左至右的顺序执行的
并没有受 “函数结果是存储于寄存器还是堆栈” 这个问题的影响
也不需要等到传入各自的寄存器或栈帧前 再忙不迭地执行参数表达式
综合上述
我以X86_64做了一些示例
这些例子给大家透露的提示就是
对于调用约定 每个编译器都可能有其内部实现
一些老旧书籍所言的函数调用参数传递执行顺序
很可能并未考虑所有编译器的情况
即使考虑了不同编译器的情况
然而却忽略了
参数中表达式的执行顺序其实与传参顺序无关 这个事实
愿诸君能够以严谨的态度 找规范做实验去探究和求证所遇到的问题