用异或来交换两个变量是错误的

用异或来交换变量是错误的

 

陈硕 (giantchen_AT_gmail)

Blog.csdn.net/Solstice

 

翻转一个字符串,例如把 "12345" 变成 "54321",这是一个最简单的不过的编码任务,即便是 C 语言初学者的也能毫不费力地写出类似如下的代码:

 

// 版本一,用中间变量交换两个数,好代码

void reverse_by_swap(char* str, int n)

{

  char* begin = str;

  char* end = str + n - 1;

  while (begin < end) {

    char tmp = *begin;

    *begin = *end;

    *end = tmp;

    ++begin;

    --end;

  }

}

 

这个代码清晰,直白,没有任何高深的技巧。

 

不知从什么时候开始,有人发明了不使用临时变量交换两个数的办法,用“不用临时变量 交换 两个数”在 google 上能搜到很多文章。下面是一个典型的实现:

 

// 版本二,用异或运算交换两个数,烂代码

void reverse_by_xor(char* str, int n)

{

  // WARNING: BAD code

  char* begin = str;

  char* end = str + n - 1;

  while (begin < end) {

    *begin ^= *end;

    *end ^= *begin;

    *begin ^= *end;

    ++begin;

    --end;

  }

}

 

受一些过时的教科书的误导,有人认为程序里少用一个变量,节省一个字节的空间,会让程序运行更快。这是不对的,至少在这里不成立:

 

1.       这个所谓的“技巧”在现代的机器上只会更慢(我甚至怀疑它从来就不可能比原始办法快)。原始办法是两次内存读和写,这个"技巧"是六读三写加三次异或(或许编译器可以优化成两读三写加三次异或)。

2.       同样也不能节省内存,因为中间变量 tmp 通常会是寄存器(稍后有汇编代码供分析)。就算它在函数的局部堆栈(stack)上,反正栈已经开在那儿了,也没有进一步的函数调用,根本节约不了一丁点内存。

3.       相反,由于计算步骤较多,会使用更多的指令,编译后的机器码长度会增加。(这不是什么大问题,短的代码不一定快,后面有另外一个例子。)

 

这个技巧的意义完全在于应付变态的面试,所以知道就行,但绝对不能放在产品代码中。我也想不出问这样的面试题意义何在。

 

更有甚者,把其中三句:

    *begin ^= *end;

    *end ^= *begin;

    *begin ^= *end;

写成一句:

    *begin ^= *end ^= *begin ^= *end; // WRONG

这更是大有问题,会导致未定义的行为(undefined behavior)C 语言的一条语句中,一个变量的值只允许改变一次,像 x = x++ 这种代码都是未定义行为。在C语言里没有哪条规则保证这两种写法是等价的。
(致语言律师:我知道,黑话叫序列点,一个语句可能不止一个序列点,请允许我在这里使用不精确的表述。)

 

这不是一个值得炫耀的技巧,只会丑化劣化代码。

 

翻转字符串这个问题在 C++ 有更简单的解法——调用标准库里的 std::reverse。有人担心调用函数会有开销,这种担心是多余的,现在的编译器会把std::reverse() 这种简单函数自动内联展开,生成出来的优化汇编代码和“版本一”一样快。

 

// 版本三,用 std::reverse 颠倒一个区间,优质代码

void reverse_by_std(char* str, int n)

{

  std::reverse(str, str + n);

}

 

 

======== 第二部分,编译器会分别生成什么代码 ========

 

注意:查看编译器生成的汇编代码固然是了解程序行为的一个重要手段,但是千万不要认为看到的东西是永恒真理,它只是一时一地的真相。将来换了硬件平台或编译器,情况可能会变化。重要的不是为什么版本一比版本二快,而是如何发现这个事实。不要“猜 guess”,要“测 benchmark”。

 

g++ 版本 4.4.1,编译参数-O2 -march=core2x86 Linux 系统。

版本一编译的汇编代码是:

 

.L3:

        movzbl  (%edx), %ecx

        movzbl  (%eax), %ebx

        movb    %bl, (%edx)

        movb    %cl, (%eax)

        incl    %edx

        decl    %eax

        cmpl    %eax, %edx

        jb      .L3

 

我用 C 语言翻译一下:

register char bl, cl;

register char* eax;

register char* edx;

 

L3:

cl = *edx; //

bl = *eax; //

*edx = bl; //

*eax = cl; //

++edx;

--eax;

if (edx < eax) goto L3;

 

一共两读两写,临时变量没有使用内存,都在寄存器里完成。考虑指令级并行和cache的话,中间六条语句估计能在3、4个周期执行完。

 

版本二

 

.L9:

        movzbl  (%edx), %ecx

        xorb    (%eax), %cl

        movb    %cl, (%eax)

        xorb    (%edx), %cl

        movb    %cl, (%edx)

        decl    %edx

        xorb    %cl, (%eax)

        incl    %eax

        cmpl    %edx, %eax

        jb      .L9

 

C 语言翻译:

// 声明与前面一样

cl = *edx;   //

cl ^= *eax;  // 读,异或

*eax = cl;   //

cl ^= *edx;  // 读,异或

*edx = cl;   //

--edx;

*eax ^= cl;  // 读、写,异或

++eax;

if (eax < edx) goto L9;

 

一共六读三写三次异或,多了两条指令。指令多不一定就慢,但是这里异或版实测比临时变量版要慢许多,因为它每条指令都用到了前面一条指令的计算结果,没法并行执行。

 

版本三,生成的代码与版本一一样快。

 

.L21:

        movzbl  (%eax), %ecx

        movzbl  (%edx), %ebx

        movb    %bl, (%eax)

        movb    %cl, (%edx)

        incl    %eax

.L23:

        decl    %edx

        cmpl    %edx, %eax

        jb      .L21

 

 

这告诉我们,不要想当然地优化,也不要低估编译器的能力。关于现在的编译器有多聪明,这里有一个不错的介绍 http://www.linux-kongress.org/2009/slides/compiler_survey_felix_von_leitner.pdf

 

 

Bjarne Stroustrup 说过, I like my code to be elegant and efficient. The logic should be straightforward to make it hard for bugs to hide, the dependencies minimal to ease maintenance, error handling complete according to an articulated strategy, and performance close to optimal so as not to tempt people to make the code messy with unprincipled optimizations. Clean code does one thing well. 中文据韩磊的翻译《代码整洁之道》 http://www.china-pub.com/196266 (陈硕对文字有修改,出错责任在我):我喜欢优雅和高效的代码。代码逻辑应当直截了当,叫缺陷难以隐藏;尽量减少依赖关系,使之便于维护;以某种全局策略一以贯之地处理全部出错情况;性能调校至接近最优,省得引诱别人实施无原则的优化(unprincipled optimizations),搞出一团乱麻。整洁的代码只做好一件事。

 

这恐怕就是Bjarne提及的没有原则的优化,甚至根本连优化都不是。代码的清晰性是首要的。

 

 

======== 第三部分,为什么短的代码不一定快 ========

 

我前两天的一篇博客谈到负整数的除法运算 http://blog.csdn.net/Solstice/archive/2010/01/06/5139302.aspx ,其中引用了一段把整数转为字符串的代码。函数反复计算一个整数除以10的商和余数。我原以为编译器会用一条DIV除法指令来算,实际生成的代码让我大吃一惊:

 

.L2:

        movl    $1717986919, %eax

        imull   %ebx

        movl    %ebx, %eax

        sarl    $31, %eax

        sarl    $2, %edx

        subl    %eax, %edx

        movl    %edx, %eax

        leal    (%edx,%edx,4), %edx

        addl    %edx, %edx

        subl    %edx, %ebx

        movl    %ebx, %edx

        movl    %eax, %ebx

        movzbl  (%edi,%edx), %eax

        movb    %al, (%esi)

        addl    $1, %esi

        testl   %ebx, %ebx

        jne     .L2

 

一条 DIV 指令被替换成了十来条指令,编译器不是傻子,必然有原因。这里我不详细解释到底是怎么算的,基本思路是把除法转换为乘法,用倒数来算。其中出现了一个魔数 1717986919,转换成16进制是 0x66666667,等于 (2**33+3)/5

 

现代处理器上乘法运算和加减法一样快,比除法快一个数量级左右,编译器生成这样的代码是有理由的。10多年前出版的神作《程序设计实践》上介绍过如何做 micro benchmarking,方法和结果都值得一读,当然里边的数据恐怕有点过时了。

 

有本奇书《Hacker's Delight》,国内译作《高效程序的奥秘》 http://www.china-pub.com/18801 ,展示了大量这种速算技巧,第10章专门讲整数常量的除法。我不会把书中如天书般的技巧应用到产品代码中,但是我相信现代编译器的作者是知道这些技巧的,他们会合理地使用这些技巧来提高生成代码的质量。现在已经不是那个懂点汇编就能打败编译器的时代了。有一篇文章《The “C is Efficient” Language Fallacy》http://scienceblogs.com/goodmath/2006/11/the_c_is_efficient_language_fa.php 的观点我非常赞同:

Making real applications run really fast is something that's done with the help of a compiler. Modern architectures have reached the point where people can't code effectively in assembler anymore - switching the order of two independent instructions can have a dramatic impact on performance in a modern machine, and the constraints that you need to optimize for are just more complicated than people can generally deal with.

So for modern systems, writing an efficient program is sort of a partnership. The human needs to careful choose algorithms - the machine can't possibly do that. And the machine needs to carefully compute instruction ordering, pipeline constraints, memory fetch delays, etc. The two together can build really fast systems. But the two parts aren't independent: the human needs to express the algorithm in a way that allows the compiler to understand it well enough to be able to really optimize it.

 

最后,说几句C++模板。假如要编写一个任意进制的转换程序。C 语言的函数声明是:

bool convert(char* buf, size_t bufsize, int value, int radix);

 

既然进制是编译期常量,C++ 可以用带非类型模板参数的函数模板来实现,函数里边的代码与 C 相同。

template<int radix>

bool convert(char* buf, size_t bufsize, int value);

 

模板确实会使代码膨胀,但是这样的膨胀有时候是好事情,编译器能针对不同的常数生成快速算法。滥用 C++ 模板当然是错的,适当使用不会有问题。

 

======== 全文完,欢迎转载,请保留署名和链接 ========

 

你可能感兴趣的:(汇编,constraints,performance,dependencies,编译器,benchmarking)