C語言常規優化策略——引數傳遞、巨集定義、全域性變數與彙編
C語言常規優化策略
4 引數傳遞、巨集定義、全域性變數與彙編
按照結構化程式設計的原則,一種語言,如果具有賦值、選擇與迴圈三種結構,並嚴格按照這三種結構
來組織程式,避免使用象goto語句這類使程式控制發生跳轉的語言成分,在每一個程式塊(如選擇塊、循
環塊)中保持單向的輸入流和輸出流,寫出的程式就算是結構化的程式,因此,前面三節有關賦值語句、
條件語句和迴圈語句的優化策略對於採用其它結構程式語言,如PASCAL,進行程式設計的程式設計師來說,
同樣具有指導價值。
本節所討論的話題比較雜亂,不過基本思想卻是想將C語言中特有的,而在上面三節中沒有介紹過的一些
程式優化的思想在這裡集中討論一下。這些話題包括引數傳遞的有效方式,巨集定義與全域性變數的使用,
以及使用匯編語言來優化程式碼等方面。其中,掌握C語言的引數傳遞的原理並加以正確使用,是每一個老
練的C程式設計師必須掌握的,其它三個話題多少都帶有一定的爭議性,因為這些程式設計技巧與軟體工程的
某些原則是相違背的,但其中的奧秒卻是:儘管違背原則,可總是有些程式設計師在使用。原因在於有些技
巧,如將簡短的函式呼叫改為巨集擴充套件往往可以大幅提高程式效率。正如結構化程式設計運動並沒有從根
本上驅除goto語句一樣,這些有爭議的程式設計技巧可能還會長時間地在程式設計師中間流傳下去。審慎的
做法是:繼續使用它們,但不要過量。
4.1 C語言中函式的引數傳遞
C語言中函式的引數傳遞方式只有一種規則:傳值規則。所謂傳值,就是形參與實參之間只發生值的傳遞
。例如我們有一個計算絕對值的程式:
int MyAbs(int x)
{
if (x<0)
x=-x;
return x;
}
在函式的引數說明中出現的就是形參,如果我們要實際計算一些整數的絕對值時,就將這些整數(實參)
值代入函式的形參中,從而可以得到相應的返回絕對值,如MgAbs(-3)和MgAbs(0)等。
C編譯器在處理函式呼叫時,通常是這樣完成的:與函式的實際程式碼相關的有一片堆疊區域用來儲存引數
值。如MyAbs(x)函式的程式碼必有一個整型棧單元用於儲存整型變數x的值,當函式呼叫發生時,如呼叫
MyAbs(-3),將實參按次序和型別放入這一片堆疊單元,啟用函式,函式執行過程中就會在取相應形參的
值時,從堆疊中指定的地方去取值,而取得的值就是傳入的實參值。在這一過程中,實參本身將值傳入
後不會受影響。例如,在下面的程式碼中:
y=-3;
z=MyAbs(-y);
y的值在MyAbs函式呼叫結束後仍為-3。
C語言的這種單一傳值規則非常簡單,相對於PASCAL語言中傳值、傳名等多種引數傳遞規則而言更統一,
更易理解。但這也在一些C的初學者中造成誤會。例如,他們常問的一個問題是:如果函式要返回兩個值
時怎麼辦呢?例如,若要求設計一個函式求一個數組中元素的最小值和最大值時怎麼辦呢?在前面已經給
出了這一問題的正確方法,為了給初學者一個好的答覆,我們這裡再羅嗦幾句。
因為函式的引數只能傳值,而函式返回值只有一個,這時為了返回兩個值,可以設計一種結構,這個結
構中包含有要返回的兩個值。例如求一個數組的最小最大值的程式設計問題,我在剛開始學習C語言時就
採用過這種方法:
typedef struct tagTwoInt
{
int minV,maxV;
} TwoInt;
//下面是主程式
intt a[100], n=100;
TwoInt M;
int minV, maxV;
......
M=MinMax(a, n);
minV=M.minV;
maxV=M.maxV;
......
// 下面是函式
TwoInt MinMax(int *a, int n)
{
int minV, maxV;
TwoInt M;
// 下面是計算最小值minV和最大值maxV的程式
......
M.minV=minV;
M.maxV=maxV;
return M;
}
這段程式非常笨拙,且有著明顯生造的痕跡,但的確是我曾經“構思”出的有關多個返回值問題的一種
解決方案。而且雖然笨,其思想卻可適用於多個返回值的情況。
從今天的角度來看,這當然是一個錯誤的故事(It's a wrong story! 可譯成“不那麼回事”)。如果C語
言是這樣的,十年前我就不會再使用它了。正確的方法是怎樣的呢?我們在前面已經給出過了,即使用指
針,具體地說,使用指向最小最大兩個整型量的指標作為引數傳遞,在用指標作為引數傳遞時,儘管指
針本身的值是不會改變的,但指標所指向的具體地址單元中的值卻可以改變,這樣,我們不僅僅可以返
回兩個值,返回更多個值都有了一個好辦法。例如,為了利用函式FindMinMaxElems求陣列x[100]的最小
最大值,在主程式中可以使用下面的呼叫:
int x[100];
int nMin,nMax;
……
FindMinMaxElems(&nMin, &nMax, a, 100);
其中&nMin, &nMax為取變數nMin, nMax的地址,結果實際上是得到了指向這兩個變數的指標。
如果理解了C函式引數傳遞的方式,那麼也就不難理解為什麼高水平的C程式設計師只是以值的方式來傳遞標
準的C變數型別(如整數、位元組、浮點數等),而複雜的結構均以指標的方式來傳遞了。因為以整個結構作
為形參,會導致在函式呼叫點上大量的複製工作,而以結構的指標作為引數傳遞,結構中的所有內容均
可以引用到,而需要複製的僅僅是一個指標(一般是一個長整數)。
理解了C語言傳值規則,也就不難理解為什麼有些程式中需要使用指標的指標型別了,例如,我們要刪去
一個表List的頭元素,下面程式將是無能為力的:
void RmvHead(List *p, List *head)
{
head=p;
p=p->next;
head->next=null;
}
假設我們已構造出一個如圖所示的由3個元素構成的表,要求呼叫上述函式後結果如圖所示
p-->1-->2-->3-->null
head-->1-->null
p-->2-->3-->null
而呼叫上述函式RmvHead(p, head)後的實際結果怎麼樣呢?實際結果卻是
p-->1-->null
2-->3-->null
head指標原來是什麼值,現在還是什麼值,為什麼會這樣呢?我們分析一下函式的呼叫過程就會明白。
RmvHead(p,head)的完成步驟如下:
(1) 將p的值拷入函式入口引數的堆疊中,設相應的單元為p’
p’--+
|
V
p-->1-->2-->3-->null
(2) 將head的值拷貝到函式入口引數的堆疊中,設相應的單元為h’,這時函式中的p實際上指p’,head
實際上是指h’
(3) 執行函式的結果,各指標的情況如圖所示:
h’--+
|
V
p-->1-->null
p’-->2-->3-->null
可以看到p和h的值根本沒發生變化,因此,上面的程式完全是錯誤的,正確的做法為:
void RmvHead(List **pp, List **phead)
{
List *p, *head;
p=*pp;
head=p;
p=p->next;
head->next=null;
*pp=p;
*phead=head;
}
這樣,在呼叫時就可以得到所要求的結果了。
List *head ,*p;
……
RmvHead(&p, &head);
所以,無論是考慮到引數傳遞的效率還是程式的正確性,理解C的引數傳值規則都是最重要的。
4.2 全域性變數
由於函式呼叫中引數傳遞需要花費時間,即使將複雜結構引數的傳遞改為結構指標的傳遞,指標的拷貝
仍需耗費一定的代價,因此,有人建議採用一種“根本性”的解決辦法:對程式中需要傳遞引數的函式
重新改寫,將引數作為全域性變數申明在外,在所有的函式中都可以自由的使用,從而不需要引數的說明
。很遺憾這是一種不合軟體工程的做法。因為按軟體工程規範,恰恰應當儘可能避免使用全域性變數,因
為在一個大的軟體工程專案中,多個人同時從事軟體開發工作,如果全域性變數滿天飛,甲、乙兩人就不
可避免因為某一個全域性變數的定義或使用而發生衝突,而且,即使有辦法暫時消解衝突,大量引用全域性
變數也會導致系統資源的緊缺,導致程式的難以理解。有多少C程式設計師為了找到有關全域性變數的定義所在
,並把握這些變數的語義,而耗費了大量寶貴的時間。我曾經從事過一件讓人沮喪的程式移植工作,任
務是將一個石油方面的Dos應用程式移到Windows 3.1上。因為不涉及領域模型的修改,介面的移植工作
應該是比較容易完成的。可是問題不是這樣,移植後的程式永遠發生資源緊缺的錯誤。究其原因,乃是
在Dos版本中所有的程式變數幾乎全部是全域性變數,除了沒有任何涵義的i,j,k之外,而且其中還包括一
系列大大小小的陣列。為了徹底根除這一情況,我們課題組不得不對該應用的各種計算模型所使用的變
量進行仔細地推敲,從而差不多是以重寫所有程式碼的方式完成了這項移植工作,因為幾乎每個函式原型
都發生了改動,而湊合改動一下又非常危險;誰知哪天又會發生資源緊缺的錯誤呢?
這裡,我們並不想對全域性變數的使用進行指責,我們真正想表達的意思是:要剋制住使用全域性變數的衝
動,只有當真正需要時才使用它,比喻說你的函式中有90%需要同一引數。由於全域性變數真正能改程序序
的效率,而且也真正能給我們造成大大小小、明顯或隱蔽的麻煩,因此一定要慎重。
順使需要指出的是,為什麼Dos上能跑得很好的應用程式移到Windows 3.1上就發生麻煩了呢?因為
Windows 3.1上所使用的資源都基於一個64K的堆疊,而Windows的介面程式中所用的各種資源佔去其中相
當一部分,因此,老的Dos程式就遇到了困難。要突破64K的限制也有許多巧妙的方法,但考慮到Windows
95全32位程式設計已差不多取代了Windows 3.1的16程式設計,因此多說無益,我們還是就此打住,請大家直接在
32位程式設計中一試身手。
4.3 巨集定義
巨集定義除了一些大家所熟知的好處外,如可以提高程式的清晰性、可讀性,使於修改移植等,還有一個
很妙的地方:利用巨集定義來代替函式可以提高程式設計的效率。
這種效率的提升,其涵義是多方面的,一方面可以節省程式的空間上的篇幅,如關於求兩個數的最大值
的巨集
#define max ((x),(y)) ((x)<(y) ? (y) : (x))
既可用於兩個整型變數,也可用於兩個浮點型變數,節省語句不說,還節省了函式量。另一方面,恰當
地使用巨集定義可提高程式的時間效率。因為巨集定義僅僅是一種予編譯技術,意思是說,在形成真正的代
碼進行編譯之前,程式中所有出現巨集的地方都必須由予編譯器用巨集定義加以展開,這樣,如果我們將函
數改成巨集定義的話,根本就不存在引數的傳遞問題,甚至函式呼叫本身都不存在了,當然程式時間效率
會提升。可以說,巨集定義比全域性變數的使用更徹底。
使用巨集定義同使用全域性變數一樣需要剋制,需要慎重對待,否則就會象吸毒一樣容易上癮(原諒我使用這
麼惡毒的比喻。)巨集定義的使用實際上也存在副作用,大量的使用會破壞程式的可讀性,並給程式的除錯
帶來麻煩,一般來說,如果一個函式非常大,一般不宜採用巨集定義來進行改造,僅僅是那些小的函式,
而且非常影響效率的函式才值得這樣去做。
4.4 彙編
彙編是提高程式效率的又一個神話,曾經有一位學者對我說:”真正的程式設計師應該用匯編寫一切需要的
應用程式碼”,如果您是一個程式設計天才,我不反對您這樣去做,但也決不鼓勵。對於普通的程式設計師,
除非不得已(如寫單板機控制程式),否則想都不要去想。我是83年學習的VAX 11組合語言,14年了,從
來沒有真正需要寫過一個實際的彙編應用程式。
現在的C語言中一般都增加了內嵌彙編的成分,這為喜歡彙編的程式設計師大開了方便之門,他們可以隨其所
好在任何需要的時間、地點開始這一個工作,的確非常方便。可是說到底,彙編只是一種手工優化的方
法,在IBM的“深藍”已經戰勝人類棋王卡斯帕洛夫的今天,你真的相信你手工優化的程式碼一定比機器編
譯優化的程式碼效率要高嗎?所以,對於彙編只略知皮毛的程式設計師不要去想彙編這件事,只需要知道這是奶
奶的奶奶們常使用的紡車就可以了。
有專家對彙編和C語言完成同一任務的程式作過有趣的對比實驗,開始,他採用極樸素的彙編寫出的程式碼
,其效率比C程式碼低,他運用自己所瞭解的各種方法對彙編程式碼進行優化,程式碼越來越長,終於,在彙編
程式碼數均為C程式碼數10-20倍時,其效率開始佔上風。到此為止吧!這樣下去真的值得嗎?何況,現在有幾
個人能理解一個2萬行的彙編程式呢?且不說編寫和除錯了。
5 程式碼優化中的注意事項
程式碼優化不能僅僅停留在區域性、細節上來考慮,而是應該將其視為整個軟體工程的一個階段,從整個工
程的全域性高度來考慮。這個工程除了要求保證效率外,更重要的是保證其安全可靠,可以為以後的工程
提供借鑑,即軟體的可重用性等方面。這樣說,似乎是否定了本書的意義,其實不然。因為優化畢竟是
任何軟體工程必不可少的一個步驟,我們要說的只是不要把區域性的工作誇張到極至,從而看不到其它工
作的存在。
以下是程式碼優化需要注意的一些事項:
(1) 程式的優化以不破壞程式的可讀性(可理解性)為原則。
軟體技術的發展對軟體開發的工程化要求日益提高。以現在的標準來衡量,一個好的程式決不僅僅是執
行效率高的程式,象計算菲波那契數時採用計算的方法來交換兩個變數值的方法在50、60年代也許稱得
上是一種好的技巧,但在今天,程式的可讀性和可維護性要比這類“雕蟲小技”更加重要。
(2) 如果將程式的執行效率納入軟體的整個生命週期來考慮,為提高單個程式的效率而花費大量的開發
時間往往得不償失,只有在下列情況下,程式的優化才是有意義的:
(i) 首先保證程式的正確性和強壯性,然後才考慮優化;
(ii) 嚴重影響效率的程式才值得優化。例如系統反覆呼叫的核心函式。程式中各函式的執行時間可以利
用編譯器中相應的工具來統計,從中可以找出函式優化的線索,無關大局的函式沒有優化的價值。
時刻需要記住的是:程式設計師必須考慮程式的全域性效率而非區域性效率。以下是需要注意的一些方面:
(1) 如果程式的使用次數不多,那麼程式的編寫時間和除錯時間可能是主要的時間耗費。為了追求問題
完美的解,往往需要付出很多的時間和精力來編寫精巧的程式,因而機器的實際執行時間對於總的時間
耗費影響不大。這種情況下,應當選用最易於正確實現的演算法。一般來說,最簡單的方法往往是最有效
的方法。一個程式,若其控制與邏輯都非常複雜,難以掌握,則我們寧可選用簡單的方法,這樣可以保
證程式的安全性。
(2) 程式的執行效率受到機器時間和空間兩方面的限制,好的程式應當平衡兩者之間的關係。有些程式
看起來雖然有效,但由於需要佔用過多儲存空間,可能並不實用,甚至難於實現,例如,若使用的空間
大到超過機器記憶體的限制,而需要使用硬碟等外儲存裝置時,程式的效率將大打折扣。
在一個應用程式中,需要優化的程式碼往往只是有限的核心模組,對系統進行全盤優化往往是不切實際的
。但作為一種技術的儲備,程式碼優化是任何一個程式設計師都必須具備的基本功,在關鍵的時刻,程式碼優化
的思想將有助於你對嚴重影響系統效率的程式碼加以改造。這也正是本書對程式碼優化所持的基本觀點,同
時,也是本文意義所在。