交換兩個變量效率問題

首先聲明,在面向對象盛行的時代裏,我改用對象這兩個詞來指代最廣泛的變量。 現在的變量就不一定只是一個整型或浮點型,甚至不是一個基本數據類型。我們 將在更廣泛的意義上討論對象交換的問題。

在前一篇文章 “ 關於兩個對象交換的問題”(注意,名稱已改)中,我們討論了交換兩個變量 的幾種方法,並給出了形式化的公式。而在這一篇文章中,我們將討論的是效率 與可行性的問題。(注:這個主題的想法,主要是受farproc朋友對上一篇文章的留言引發 的。)

中間變量方式

首先,我們來看採用最簡單直接的交換方式的代碼:

{
int tmp;
tmp = a;
a = b;
b = tmp;
}

按語言本身的特性來想,這些代碼做以下這些工作:

  1. 在棧上分配爲整型變量tmp分配空間;
  2. 將a的值放入tmp中;
  3. 將b的值放入a中;
  4. 將tmp的值放入b中;
  5. 釋放爲tmp分配的棧空間。

而實際上呢?我們來看看生成的彙編代碼:

        movl        b, %eax    ;將b從內存載入到寄存器eax
movl a, %edx ;將a從內存載入到寄存器edx
movl %eax, a ;將eax的內容存入到內存a中
xorl %eax, %eax ;將eax清零
movl %edx, b ;將edx的內容存入到內存b中

看起來,彙編指令並不象我們想象的那樣複雜。因爲變量要參與運算首先要從內 存載入到寄存器中,所以要將兩個變量交換隻需按相反的順序再存入到內存中就 可以了。只是四個內存與寄存器之間交換數據的指令,看起來好像沒有交換操作 似的。而此處爲什麼要將eax清零呢?因爲eax寄存器是專門用來放函數返回值 的,而我們的測試函數很簡單,除了執行上面的操作外,剩下的就是return 0;了,因此它與變量交換根本沒有關係。從上面可以看到,編譯器爲我們做的工 作遠比我們想像的要多。

異或方式

接下來,我們來看基於異或方式交換的代碼:

{
a ^= b;
b ^= a;
a ^= b;
}

這一代碼看起來很純粹,沒有一句是浪費的(是指全部操作都與交換有關,沒有 像上例中的分配臨時變量空間的操作),而且代碼直接對應操作:三次異或。憑 着直覺,我們覺得它應該是效率最高的。但是它帶來的副作用是代碼的可讀性大 大降低(注意,可讀性很重要),而一些人認爲這是值得的,因爲它帶來的效率。 我們接下來看看究竟是不是值得的。

下面是上面代碼對應的彙編代碼:

        movl        b, %eax       ;將b從內存載入寄存器eax
movl a, %ecx ;將a從內存載入寄存器ecx
movl %eax, %edx ;將eax的值保存到edx中
xorl %ecx, %edx ;ecx與edx異或
xorl %edx, %eax ;edx與eax異或
xorl %eax, %edx ;eax與edx異或
movl %eax, b ;將eax的值存入到內存b中
xorl %eax, %eax ;將eax置0:設置返回值,與上例中一樣
movl %edx, a ;將edx的值存入到內存a中

哦,好像有點暈了。
它總共用了四次內存與寄存器之間的數據移動操作,一次寄存器之間的賦值,以 及三次異或運算。
我很詫異編譯器會產生這樣的彙編代碼,我懷疑是編譯選項出了問題(這是在-O2下 的結果),於是試了-O3的結果,居然也是完全一樣,更令人意想不到的 是,在-O1下產生的結果居然是最簡潔的。不過我們先來看上面這些代碼都做了些 什麼操作,是否都是必要的操作。

“意外”現象分析

首先我們將上面的C代碼改寫一下(現在想來才覺得C代碼其實也是一樣的迷惑 人,我並不清楚它到底經過了哪些步驟,而只知道它能交換兩個整型變量的值而 已):

{
int tmp;

tmp = a ^ b; //得到異或的中間結果,即任何a、b中與它
//異或,都會得到另外一個的值(對比參考
//第一篇文章中關於加和乘情況的討論)
b = tmp ^ b; //b的最終結果:b=(a^b)^b=a^(b^b)=a
a = tmp ^ a; //a的最終結果:a=(a^b)^a=b^(a^a)=b
}

現在,我們來將彙編代碼逐行翻譯爲C代碼來看看(忽略內存與寄存器之間的數據 交換):

        int tmp;        //寄存器edx對應變量tmp

tmp = b;
tmp = a ^ tmp; //對應於tmp = a ^ b;

b = tmp ^ b;

tmp = b ^ tmp;
a = tmp; //對應於a = tmp ^ b;

與我們轉換後的代碼相比,對這段代碼編譯器好像有點犯迷糊了。我們明明沒有 用中間變量的代碼,它居然不僅用了中間變量,而且還多用了兩個賦值操作。
接下來我們再看在-O1下產生的結果:

        movl        b, %eax       ;將b載入到寄存器eax
movl %eax, %edx ;將eax的值保存到edx
xorl a, %edx ;內存a與edx異或,結果保存到edx,得到中間結果
xorl %edx, %eax ;edx與eax異或,結果到eax,得到b的最終值,即a
movl %eax, b ;保存到內存b
xorl %eax, %edx ;edx與eax異或,結果到edx,得到a的最終值,即b
movl %edx, a ;保存到內存a
movl $0, %eax ;設置返回值

這一結果與我們手工轉換的代碼是類似的。但它不僅進行了四次內存與寄存器之 間的數據移動操作(對應於中間變量交換的情況),而且還進行了一次寄存器之 間的賦值,兩次寄存器之間的異或運算,以及一次寄存器與內存之間的異或運算 (應該包含一次內存與隱含寄存器之間的數據移動,以及一次異或運算)。由此 看來,-O1產生的代碼確實不如-O2產生的代碼效率高,編譯器並沒有犯迷糊。

結論

很明顯可以看出,異或方式的效率比預期的要壞得多,而且要比採用中間變量的 方式更壞。現在看來,如果我們一開始就從彙編及CPU的執行流程上來考慮的話, 就可以很容易的得出這一結論。在機器的角度來考慮交換兩個整型變量(即相對 應的內存)的值,只需要將兩個變量的值載入到寄存器中,然後按相反的對應關 系使用,或是按相反的對應關係保存到內存中即可,完全不需要經過中間計算。 而用異或方式,除了上述內存與寄存器之間的數據移動操作外,還需要進行三次 的異或操作(以及可能由此帶來的移動操作)。這個結論是顯而易見的。
採用異或的方式,我們不僅犧牲了可讀性,而且還犧牲了效率,所以並不可取。
其它的方式,如加、乘等,用腳趾頭想想也知道結果了,所以就不再討論了。

說明

以上的結果,只是根據由C代碼生成的彙編代碼的行數,及其內存與寄存器之間數 據移動的次數等方面比較它們的效率;C代碼也是很簡單而純粹的整型變量交換, 與實際情況差別較大;而且最重要的是沒有來實際測量它們的運行時間,因此得出 的結論並不一定正確。

本次只討論的是對整型變量交換的情況,而實際中要交換的對象是多種多樣的。 比如在C++中,最常見的應該就是類對象的交換,甚至是兩個不知道何種類型的對 象的交換(考慮模板類的情形)。

並不是所有對象都支持異或、加、乘的運算,所以這些方法就基本捨棄了,但仍要 重視它們所帶來的思想上的東西(這種情況下仍然有可以用它們,但是很危險, 參見注1)。而基於中間變量的方式也要加以小心,一些對 象必須提供合適的拷貝構造函數和賦值運算符函數,才能保證交換操作在語義上 也是正確的,比如那些內部含有指針成員的類對象。

更廣泛的結論

總的來說,採用中間變量方式交換兩個對象的值,是最通用、可讀性最高、效 率比較高的一種方式。在此我建議大家在一般情況下,都採用這種方式。 (注2

[1] 我們可以將對象看成若干個字符類型變量的數組,從而可以使用異或等方式。 但是,這並不能保證它的語義是正確的,尤其是在C++中。可以這樣說,在實際情 況中,這樣的操作幾乎總是會帶來錯誤。

[2] 說到最後,還不如原來就不要知道這種方法呢:)

[n] 我的系統平臺是Debian 4.1.1、GCC 4.1.2,所有編譯選項默認均爲-O2,編譯爲 彙編代碼的選項爲-S。

[n+1] farproc的彙編結果是另一種情況。在進行交換之前數據已經載入到寄存器中,從而考慮的只有寄存器中的運算。下面是他的留言:

經過我的測試(vc2005 release),使用一個臨時變量的交換方式還是效率最高的。位異或的次之,相加或相乘的最慢。
其實看一下生成的彙編碼就很清楚了。
使用臨時變量版本:

     mov eax,edi
mov edi,esi
mov esi,eax

位異或版本:

     xor edi,esi
xor esi,edi
xor edi,esi

加減版本:

     add edi,esi
mov ecx,edi
sub ecx,esi
mov esi,ecx
sub edi,esi

[n+2] 思想在交流中迸發:kebing.zhgmailcom

發佈了0 篇原創文章 · 獲贊 4 · 訪問量 3萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章