The Difference between Division and Arithmetic Right Shifting in C
你是否有听说过有符号数不能使用右移操作(>>
)来代替除法? 这篇短文会向你证明它,并尝试向你解释为什么。当然,如果你没有听说过,那么从现在开始,记住它!
Foundation: Logical Shift .vs. Arithmetic Shift
若你现在有二进制数x=1110B
,对其施加右移操作,请问高位填0还是填1?
逻辑移位不管造成的影响,总是用0来填充移位操作产生的空缺。但是这样简单的想法在一些情况总会出错。例如若上述x是有符号数,那么简单的填0就会造成错误,起码正负号出错了。
算数移位支持有符号数的移位操作,在移位后使用符号位进行填充,结合补码的表示方法,就能实现正确的负数移位操作。
总结来说:在有符号的场景下,使用算数位移;如果你能保证移位操作是无符号的,那么用逻辑位移也无妨.
x86汇编代码中,shr
代表逻辑右移指令,sar
代表算数右移指令,我们可以通过以下C代码及其反汇编的结果来更好的理解逻辑移位和算数移位:
#include <stdlib.h>
#include <stdio.h>
signed int x = -3;
unsigned int y = 3;
int main()
{
x >>= 1;
y >>= 1;
return 0;
}
x:
.long -3
y:
.long 3
main:
push rbp
mov rbp, rsp
mov eax, DWORD PTR x[rip]
sar eax
mov DWORD PTR x[rip], eax
mov eax, DWORD PTR y[rip]
shr eax
mov DWORD PTR y[rip], eax
mov eax, 0
pop rbp
ret
https://godbolt.org/z/K4M4Ko4c7
Demo to Prove the Subject
在我作为一个初级程序员的认知中,/2
和>>1
是等价的,甚至一起还听说过后者能够优化代码的效率。但是今天我要告诉你, Definitely wrong!
或许在遥远的古代,我们使用位移操作真的能够对代码进行加速,但是当下编译器已经足够聪明,如果你真的动手反汇编"/2
"的代码,那么你就会知道编译器已经替你优化为了位移操作。
更糟糕的是,我们要避免使用移位操作来实现除法或者乘法,不仅仅是因为这两者等价,实际上,他们并不是等价的!并且会造成错误!
Let me show you a little demo.
考虑如下的C语言代码:
#include <stdlib.h>
#include <stdio.h>
signed int x = -3;
signed int y = -3;
int main()
{
x >>= 1;
y /= 2;
return 0;
}
他们的汇编代码是相同的吗?这里还是拿X86汇编举例:
; Following is ‘x >>= 1’
mov eax, #-3 ;x
sar eax
mov x, eax
; Following is ‘y/= 2’
mov eax, #-3 ;y
mov edx, eax
shr edx, 31
add eax, edx
sar eax
mov y, eax
注意:以上的汇编代码省去了一些我认为无关紧要的操作,并不是完全正确的,但是足够表达他们的差别了。
可以看出,除法比移位多了一步shr edx, 31
过程,下面会探讨这个。
还有一件使你震惊的事件,x
, y
的值最终是不同的!是的,正是因为那条看似“多余”的shr
指令。
Why does This happen?
首先,我们可以确定的一件事是:编译器真的帮我们将除法操作优化为移位。所以,再也不要说你的代码中使用>>
来替代除法是为了增加执行效率了。
让我们来解释下为什么两者的结果是不同的。
首先,sar
指令在x86指令集中表示算数右移,这个是我们熟悉的,那么-3
进行算数右移后的结果就是-2
. 意味着>>
是向负无穷舍入的.
那么除法操作又是在干什么呢? 它是将原值加上其符号位.Demo中使用的数据类型是32位int
.
shr edx, 31
add eax, edx
这样做必然改变了原值啊,动手算一下就会知道,-3/2
的结果为-1
. 并且只有负奇数会受影响,对于正数,其符号为0;对于负偶数,其补码的最低位必为0,刚加上的1会被下一步的算数右移丢弃,不对高位产生影响。
Aha, 差别就是向负无穷舍弃还是向0舍弃,一时间竟然不知道哪个是正确的了。
What Should We Do?
根据最新的[C语言标准草案](ISO/IEC 9899:201x (open-std.org)) 6.5.7章节,负数的右移操作是implementation-defined,即取决于具体的实现:
The result of E1 >> E2 is E1 right-shifted E2 bit positions. If E1 has an unsigned type or if E1 has a signed type and a nonnegative value, the value of the result is the integral part of the quotient of E1 / 2E2. If E1 has a signed type and a negative value, the resulting value is implementation-defined.
因此,理论上它依赖于实现。所以我们在实际应用中为了程序的可移植性,应当避免对有符号数使用移位操作。除非你能确定它的值一定是非负数,在此情况下,请将它用无符号类型来声明。
对于除法操作,标准中的6.5.5章节规定了,除法操作总是向0舍入. 非常好!
When integers are divided, the result of the / operator is the algebraic quotient with any fractional part discarded.
检查你的代码,恢复所有的“优化”乘除法的行为吧!