1. 程式人生 > >用異或來交換兩個變數是錯誤的

用異或來交換兩個變數是錯誤的

用異或來交換變數是錯誤的

翻轉一個字串,例如把 “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=core2,x86 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

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,imullmovlsarl31, %eax
sarl 2,sublmovlleal(addlsublmovlmovlmovzbl(movbaddl1, %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
bool convert(char* buf, size_t bufsize, int value);

模板確實會使程式碼膨脹,但是這樣的膨脹有時候是好事情,編譯器能針對不同的常數生成快速演算法。濫用 C++ 模板當然是錯的,適當使用不會有問題。

用異或交換兩個整數的陷阱
前面我們談到了,可用通過異或運算交換兩個數,而不需要任何的中間變數。 如下面:

void exchange(int &a, int &b)
{
    a ^= b;
    b ^= a;
    a ^= b;
}

然而,這裡面卻存在著一個非常隱蔽的陷阱。

通常我們在對陣列進行操作的時候,會交換陣列中的兩個元素,如exchang(&a[i], &b[j]), 這兒如果i==j了(這種情況是很可能發生的),得到的結果就並非我們所期望的。

void main() 
{
   int a[2] = {1, 2};
   exchange(a[0], a[1]); //交換a[0]和a[1]的值
   printf("1---a[0]=%d a[1]=%d\n", a[0], a[1]);
   exchange(a[0], a[0]); //將a[0]與自己進行交換
   printf("2---a[0]=%d a[1]=%d\n", a[0], a[1]);
}

上面那段測試程式碼的輸出是:
1—a[0]=2 a[1]=1
2—a[0]=0 a[1]=1
很意外吧,第一次的交換正確的執行了,但是第二次呼叫exchange的時候卻將a[0]置為了0. 仔細分析,不難發現,這正是我們在exchange裡面用異或實現交換所造成的。如果輸入a和b是同一個數,exchange裡面程式碼相當於:

a ^= a;
a ^= a;
a ^= a;
成了a做了3次於自己的異或,其結果當然是0了。

既然這樣,我們就不能夠在任何使用交換的地方採用異或了,即使要用,也一定要在交換之前判斷兩個數是否已經相等了,如下:

void exchange(int &a, int &b)
{
    if(a == b) return; //防止&a,&b指向同一個地址;那樣結果會錯誤。
    a ^= b;
    b ^= a;
    a ^= b;
}

今天做陣列操作時,做一些交換,呼叫的swap()函式,用的異或來些寫的,結果排查了很長時間才發現錯誤。。。