1. 程式人生 > >C學習之介面和實現

C學習之介面和實現

前  言
如今的程式設計師忙於應付大量關於API(Application Programming Interface)的資訊。但是,大多數程式設計師都會在其所寫的幾乎每一個應用程式中使用API並實現API的庫,只有少數程式設計師會建立或釋出新的能廣泛應用的API。事實上,程式設計師似乎更喜歡使用自己搞的東西,而不願意查詢能滿足他們要求的程式庫,這或許是因為寫特定應用程式的程式碼要比設計可廣泛使用的API容易。 
不好意思,我也未能免俗:lcc(我和Chris Fraser為ANSI/ISO C編寫的編譯器)就是從頭開始編寫的API。(在A Retargetable C Compiler: Design and Implementation一書中有關於lcc的介紹。)編譯器是這樣一類應用程式:可以使用標準介面,並且能夠建立在其他地方也可以使用的介面。這類程式還有記憶體管理、字串和符號表以及連結串列操作等。但是lcc僅使用了很少的標準C庫函式的例程,並且它的程式碼幾乎都無法直接應用到其他應用程式中。 
本書提倡的是一種基於介面及其實現的設計方法,並且通過對24個介面及其實現的描述詳細演示了該方法。這些介面涉及很多計算機領域的知識,包括資料結構、演算法、字串處理和併發程式。這些實現並不是簡單的玩具,而是為在產品級程式碼中使用而設計的。實現的程式碼是可免費提供的。 
C程式語言基本不支援基於介面的設計方法,而C++和Modula-3這樣的面向物件的語言則鼓勵將介面與實現分離。基於介面的設計跟具體的語言無關,但是它要求程式設計師對像C一樣的語言有更強的駕馭能力和更高的警惕性,因為這類語言很容易破壞帶有隱含實現資訊的介面,反之亦然。 
然而,一旦掌握了基於介面的設計方法,就能夠在服務於眾多應用程式的通用介面基礎上建立應用程式,從而加快開發速度。在一些C++環境中的基礎類庫就體現了這種效果。增加對現有軟體(介面實現庫)的重用,能夠降低初始開發成本,同時還能降低維護成本,因為應用程式的更多部分都建立在通用介面的實現之上,而這些實現無不經過了良好的測試。 
本書中的24個介面引自幾本參考書,並且針對本書特別做了修正。一些資料結構(抽象資料型別)中的介面源於lcc程式碼和20世紀70年代末到80年代初所做的Icon程式語言的實現程式碼(參見R. E. Griswold和M. T. Griswold所著的The Icon Programming Language)。其他的介面來自另外一些程式設計師的著作,我們將會在每一章的“擴充套件閱讀”部分給出詳細資訊。 
書中提供的一些介面是針對資料結構的,但本書不是介紹資料結構的,本書的側重點在演算法工程(包裝資料結構以供應用程式使用),而不在資料結構演算法本身。然而,介面設計的好壞總是取決於資料結構和演算法是否合適,因此,本書可算是傳統資料結構和演算法教材(如Robert Sedgewick所著的Algorithms in C)的有益補充。
大多數章節會只介紹一個介面及其實現,少數章節還會描述與其相關的介面。每一章的“介面”部分將會單獨給出一個明確而詳細的介面描述。對於興趣僅在於介面的程式設計師來說,這些內容就相當於一本參考手冊。少數章節還會包含“例子”部分,會說明在一個簡單的應用程式中介面的用法。 
每章的“實現”部分將會詳細地介紹本章介面的實現程式碼。有些例子會給出一個介面的多種實現方法,以展示基於介面設計的優點。這些內容對於修改或擴充套件一個介面或是設計一個相關的介面將大有裨益。許多練習題會進一步探究一些其他可行的設計與實現的方法。如果僅是為了理解如何使用介面,可以不用閱讀“實現”一節。 
介面、示例和實現都以文學(literate)程式的方式給出,換句話說,原始碼及其解釋是按照最適合理解程式碼的順序交織出現的。程式碼可以自動地從本書的文字檔案中抽取,並按C語言所規定的順序組合起來。其他也用文學程式講解C語言的圖書有A Retargetable C Compiler和D.E.Knuth寫的The Stanford GraphBase: A Platform for Combinatorial Computing。 
本書架構
本書材料可分成下面的幾大類: 
基礎 1. 引言 
                2. 介面與實現 
                4. 異常與斷言 
                5. 記憶體管理 
                6. 再談記憶體管理 
資料結構 7. 連結串列 
                8. 表 
                9. 集合 
                10. 動態陣列 
                11. 序列 
                12. 環 
                13. 位向量 
字串 3. 原子 
                14. 格式化 
                15. 低階字串 
                16. 高階字串 
演算法 17. 擴充套件精度算術 
                18. 任意精度算術 
                19. 多精度算術
執行緒 20. 執行緒 
建議大多數讀者通讀第1章至第4章的內容,因為這幾章形成了本書其餘部分的框架。對於第5章至第20章,雖然某些章會參考其前面的內容,但影響不大,讀者可以按任何順序閱讀。 
第1章介紹了文學程式設計和程式設計風格與效率。第2章提出並描述了基於介面的設計方法,定義了相關的術語,並演示了兩個簡單的介面及其實現。第3章描述了Atom介面的實現原型,這是本書中最簡單的具有產品質量的介面。第4章介紹了在每一個介面中都會用到的異常與斷言。第5章和第6章描述了幾乎所有的實現都會用到的記憶體管理介面。其餘各章都分別描述了一個介面及其實現。 
教學使用建議
我們假設本書的讀者已經在大學介紹性的程式設計課程中瞭解了C語言,並且都實際瞭解了類似《C演算法》一書中給出的基本資料結構。在普林斯頓,本書是大學二年級學生到研究生一年級的系統程式設計課程的教材。許多介面使用的都是高階C語言程式設計技巧,比如說不透明的指標和指向指標的指標等,因此這些介面都是學習這些內容非常好的例項,對於系統程式設計和資料結構課程非常有用。 
這本書可以以多種方式在課堂上使用,最簡單的就是用在面向專案的課程中。例如,在編譯原理課程中,學生通常需要為一個玩具語言編寫一個編譯器。在圖形學課程中同樣也經常有一些實際的專案。本書中許多介面消除了新建專案所需要的一些令人厭煩的程式設計工作,從而簡化了這類課程中的專案。這種用法可以幫助學生認識到在專案中重用程式碼可以節省大量勞動,並且引導學生在其專案中對自己所做的部分嘗試使用基於介面的設計。後者在團隊專案中特別有用,因為“現實世界”中的專案通常都是團隊專案。 
普林斯頓大學二年級系統程式設計課程的主要內容是介面與實現,其課外作業要求學生成為介面的使用者、實現者和設計者。例如其中的一個作業是這樣的,我給出了8.1節中描述的Table介面、它的實現的目的碼以及8.2節中描述的單詞頻率程式wf的說明,讓學生只使用我們為Table設計的目的碼來實現wf。在下一個作業中,wf的目的碼就有了,他們必須實現Table。有時我會顛倒這些作業的順序,但是這兩種順序對大部分學生來說都是很新穎的。他們不習慣在大部分程式中只使用目的碼,並且這些作業通常都是他們第一次接觸到在介面和程式說明中使用半正式表示法。 
最初佈置的作業也介紹了作為介面說明必要組成部分的可檢查的執行時錯誤和斷言。同樣,只有做過幾次這樣的作業之後,學生們才開始理解這些概念的意義。我禁止了突發性崩潰,即不是由斷言錯誤的診斷所宣佈的崩潰。執行崩潰的程式將被判為零分,這樣做似乎過於苛刻,但是它能夠引起學生們的注意,而且也能夠讓學生理解安全語言的好處,例如ML和Modula-3,在這些語言中,不會出現突發性崩潰。(這種評分方法實際上沒有那麼苛刻,因為在分成多個部分的作業中,只有產生衝突的那部分作業才會判為錯誤,而且不同的作業權重也不同。我給過許多0分,但是從來沒有因此導致任何一個學生的課程總成績降低達1分。)
一旦學生們有了自己的幾個介面後,接下來就讓他們設計新的介面並沿用以前的設計選擇。例如,Andrew Appel最喜歡的一個作業是一個原始的測試程式。學生們以組為單位設計一個作業需要的任意算術精度的介面,作業的結果類似於第17章到第19章中描述的介面。不同的組設計的介面不同,完成後對這些介面進行比較,一個組對另一個組設計的介面進行評價,這樣做很有啟迪作用。Kai Li的那個需要一個學期來完成的專案也達到了同樣的學習實踐效果,該專案使用Tcl/Tk系統(參見J. K. Ousterhout所著的Tcl and the Tk Toolkit)以及學生們設計和實現的編輯程式專用的介面,構建了一個基於X的編輯程式。Tk本身就是一個很好的基於介面的設計。
在高階課程中,我通常把作業打包成介面,學生可以自行修改和改進,甚至改變作業的目標。給學生設定一個起點可以減少他們完成作業所需的時間,允許他們做一些實質性的修改鼓勵了有創造性的學生去探索新的解決辦法。通常,那些不成功的方法比成功的方法更讓學生記憶深刻。學生不可避免地會走錯路,為此也付出了更多的開發時間。但只有當他們事後再回過頭來看,才會瞭解所犯的錯誤,也才會知道設計一個好的介面雖然很困難,但是值得付出努力,而且到最後,他們幾乎都會轉到基於介面的設計上來。 

目  錄
 
第1章 引言 1
1.1 文學程式 2
1.2 程式設計風格 6
1.3 效率 8
1.4 擴充套件閱讀 9
1.5 習題 9
第2章 介面與實現 11
2.1 介面 11
2.2 實現 13
2.3 抽象資料型別 15
2.4 客戶程式的職責 17
2.5 效率 21
2.6 擴充套件閱讀 22
2.7 習題 22
第3章 原子 24
3.1 介面 24
3.2 實現 25
3.3 擴充套件閱讀 30
3.4 習題 31
第4章 異常與斷言 33
4.1 介面 35
4.2 實現 38
4.3 斷言 44
4.4 擴充套件閱讀 46
4.5 習題 47
第5章 記憶體管理 49
5.1 介面 50
5.2 產品實現 54
5.3 稽核實現 55
5.4 擴充套件閱讀 62
5.5 習題 63
第6章 再談記憶體管理 65
6.1 介面 65
6.2 實現 67
6.3 擴充套件閱讀 72
6.4 習題 73
第7章 連結串列 75
7.1 介面 75
7.2 實現 79
7.3 擴充套件閱讀 83
7.4 習題 83
第8章 表 84
8.1 介面 84
8.2 例子:詞頻 87
8.3 實現 91
8.4 擴充套件閱讀 97
8.5 習題 97
第9章 集合 99
9.1 介面 99
9.2 例子:交叉引用列表 101
9.3 實現 107
9.3.1 成員操作 109
9.3.2 集合操作 111
9.4 擴充套件閱讀 114
9.5 習題 115
第10章 動態陣列 116
10.1 介面 116
10.2 實現 119
10.3 擴充套件閱讀 122
10.4 習題 122
第11章 序列 123
11.1 介面 123
11.2 實現 125
11.3 擴充套件閱讀 129
11.4 習題 129
第12章 環 131
12.1 介面 131
12.2 實現 134
12.3 擴充套件閱讀 141
12.4 習題 141
第13章 位向量 142
13.1 介面 142
13.2 實現 144
13.2.1 成員操作 146
13.2.2 比較 150
13.2.3 集合操作 151
13.3 擴充套件閱讀 152
13.4 習題 153
第14章 格式化 154
14.1 介面 154
14.1.1 格式化函式 155
14.1.2 轉換函式 157
14.2 實現 160
14.2.1 格式化函式 161
14.2.2 轉換函式 166
14.3 擴充套件閱讀 170
14.4 習題 171
第15章 低階字串 172
15.1 介面 173
15.2 例子:輸出識別符號 178
15.3 實現 179
15.3.1 字串操作 180
15.3.2 分析字串 184
15.3.3 轉換函式 188
15.4 擴充套件閱讀 189
15.5 習題 189
第16章 高階字串 192
16.1 介面 192
16.2 實現 197
16.2.1 字串操作 200
16.2.2 記憶體管理 204
16.2.3 分析字串 205
16.2.4 轉換函式 209
16.3 擴充套件閱讀 210
16.4 習題 210
第17章 擴充套件精度算術 212
17.1 介面 212
17.2 實現 217
17.2.1 加減法 218
17.2.2 乘法 220
17.2.3 除法和比較 221
17.2.4 移位 226
17.2.5 字串轉換 228
17.3 擴充套件閱讀 230
17.4 習題 230
第18章 任意精度算術 232
18.1 介面 232
18.2 例子:計算器 235
18.3 實現 240
18.3.1 取反和乘法 242
18.3.2 加減法 243
18.3.3 除法 246
18.3.4 取冪 247
18.3.5 比較 249
18.3.6 便捷函式 250
18.3.7 移位 251
18.3.8 與字串和整數的轉換 252
18.4 擴充套件閱讀 254
18.5 習題 255
第19章 多精度算術 257
19.1 介面 257
19.2 例子:另一個計算器 263
19.3 實現 269
19.3.1 轉換 272
19.3.2 無符號算術 275
19.3.3 有符號算術 277
19.3.4 便捷函式 280
19.3.5 比較和邏輯操作 285
19.3.6 字串轉換 288
19.4 擴充套件閱讀 290
19.5 習題 291
第20章 執行緒 292
20.1 介面 294
20.1.1 執行緒 294
20.1.2 一般訊號量 298
20.1.3 同步通訊通道 301
20.2 例子 301
20.2.1 併發排序 302
20.2.2 臨界區 305
20.2.3 生成素數 307
20.3 實現 311
20.3.1 同步通訊通道 311
20.3.2 執行緒 313
20.3.3 執行緒建立和上下文切換 322
20.3.4 搶佔 328
20.3.5 一般訊號量 330
20.3.6 MIPS和ALPHA上的上下文
切換 332
20.4 擴充套件閱讀 335
20.5 習題 336
附錄A 介面摘要 339
參考書目 363
 
引  言

一個大程式由許多小的模組組成。這些模組提供了程式中使用的函式、過程和資料結構。理想情況下,這些模組中大部分都是現成的並且來自於庫,只有那些特定於現有應用程式的模組需要從頭開始編寫。假定庫程式碼已經全面測試過,而只有應用程式相關的程式碼會包含bug,那麼除錯就可以僅限於這部分程式碼。
遺憾的是,這種理論上的理想情況實際上很少出現。大多數程式都是從頭開始編寫,它們只對最低層次的功能使用庫,如I/O和記憶體管理。即使對於此類底層元件,程式設計師也經常編寫特定於應用程式的程式碼。例如,將C庫函式malloc和free替換為定製的記憶體管理函式的應用程式也是很常見的。
造成這種情況的原因無疑有諸多方面。其中之一就是,很少有哪個普遍可用的庫包含了健壯、設計良好的模組。一些可用的庫相對平庸,缺少標準。雖然C庫自1989年已經標準化,但直至現在才出現在大多數平臺上。
另一個原因是規模問題:一些庫規模太大,從而導致對庫本身功能的掌握變成了一項沉重的任務。哪怕這項工作的工作量似乎稍遜於編寫應用程式所需的工作量,程式設計師可能都會重新實現庫中他們所需的部分功能。最近出現頗多的使用者介面庫,通常會有這種問題。
庫的設計和實現是困難的。在通用性、簡單性和效率這三個約束之間,設計者必須如履薄冰,審慎前行。如果庫中的例程和資料結構過於通用,那麼庫本身可能難以使用,或因效率較低而無法達到預定目標。如果庫的例程和資料結構過於簡單,又可能無法滿足應用程式的需求。如果庫太難於理解,程式設計師乾脆就不會使用它們。C庫本身就提供了一些這樣的例子,例如其中的realloc函式,其語義混亂到令人驚訝的地步。
庫的實現者面臨類似的障礙。即使設計做得很好,糟糕的實現同樣會嚇跑使用者。如果某個實現太慢或太龐大,或只是感覺上如此,程式設計師都將自行設計替代品。最糟的是,如果實現有bug,它將使上述的理想狀況徹底破滅,從而使庫也變得無用。
本書描述了一個庫的設計和實現,它適應以C語言編寫的各種應用程式的需求。該庫匯出了一組模組,這些模組提供了用於小規模程式設計(programming-in-the-small)的函式和資料結構。在幾千行長的應用程式或應用程式元件中,這些模組適於用作零部件。
在後續各章中描述的大部分程式設計工具,都涵蓋在大學本科資料結構和演算法課程中。但在本書中,我們更關注將這些工具打包的方式,以及如何使之健壯無錯。各個模組都以一個介面及其實現的方式給出。這種設計方法學在第2章中進行了解釋,它將模組規格說明與其實現相分離,以提高規格說明的清晰度和精確性,而這有助於提供健壯的實現。
1.1 文學程式
本書並不是以“處方”的形式來描述各個模組,而是通過例子描述。各章完整描述了一兩個介面及其實現。這些描述以文學程式(literate program)的形式給出。介面及其實現的程式碼與對其進行解釋的正文交織在一起。更重要的是,各章本身就是其描述的介面和實現的原始碼。程式碼可以從本書的原始檔文字中自動提取出來,所見即所得。
文學程式由英文正文和帶標籤的程式程式碼塊組成。例如,
〈compute x • y〉≡ 
        sum = 0; 
        for (i = 0; i < n; i++) 
                        sum += x[i]*y[i]; 
定義了名為〈compute x • y〉的程式碼塊,其程式碼計算了陣列x和y的點積。在另一個程式碼塊中使用該程式碼塊時,直接引用即可:
〈function dotproduct〉≡ 
        int dotProduct(int x[], int y[], int n) { 
                int i, sum; 

        〈compute x • y〉 
                return sum; 

當〈function dotproduct〉程式碼塊從本章對應的原始檔中抽取出來時,將逐字複製其程式碼,用到程式碼塊的地方都將替換為對應的程式碼。抽取〈function dotproduct〉的結果是一個只包含下述程式碼的檔案: 
int dotProduct(int x[], int y[], int n) { 
                int i, sum; 

                sum = 0; 
                for (i = 0; i < n; i++) 
                                sum += x[i]*y[i]; 
                return sum; 

文學程式可以按各個小片段的形式給出,並附以完備的文件。英文正文包含了傳統的程式註釋,這些並不受程式設計語言的註釋規範的限制。
程式碼塊的這種特性將文學程式從程式語言強加的順序約束中解放出來。程式碼可以按最適於理解的順序給出,而不是按語言所硬性規定的順序(例如,程式實體必須在使用前被定義)。
本書中使用的文學程式設計系統還有另外一些特性,它們有助於逐點對程式進行描述。為說明這些特性並提供一個完整的C語言文學程式的例子,本節其餘部分將描述double程式,該程式檢測輸入中相鄰的相同單詞,如“the the”。
% double intro.txt inter.txt 
intro.txt:10: the 
inter.txt:110: interface 
inter.txt:410: type 
inter.txt:611: if 
上述UNIX命令結果說明,“the”在intro.txt檔案中出現了兩次,第二次出現在第10行;而在inter.txt檔案中,interface、type和if也分別在給出的行出現第二次。如果呼叫double時不指定引數,它將讀取標準輸入,並在輸出時略去檔名。例如:
% cat intro.txt inter.txt | double 
10: the 
143: interface 
343: type 
544: if 
在上述例子和其他例示中,由使用者鍵入的命令顯示為斜程式碼體,而輸出則顯示為通常的程式碼體。
我們先從定義根程式碼塊來實現double,該程式碼塊將使用對應於程式各個元件的其他程式碼塊:
〈double.c 3〉≡
 〈includes 4〉
 〈data 4〉
 〈prototypes 4〉
 〈functions 3〉 
按照慣例,根程式碼塊的標籤設定為程式的檔名,提取〈double.c 3〉程式碼塊,即可提取整個程式。其他程式碼塊的標籤設定為double的各個頂層元件名。這些元件按C語言規定的順序列出,但也可以按任意順序給出。
〈double.c 3〉中的3是頁碼,表示該程式碼塊的定義從書中哪一頁開始。〈double.c 3〉中使用的程式碼塊中的數字也是頁碼,表示該程式碼塊的定義從書中哪一頁開始。這些頁碼有助於讀者瀏覽程式碼時定位。
main函式處理double的引數。它會開啟各個檔案,並呼叫doubleword掃描檔案:
〈functions 3〉≡ 
  int main(int argc, char *argv[]) {
      int i; 

      for (i = 1; i < argc; i++) {
          FILE *fp = fopen(argv[i], "r");
          if (fp == NULL) { 
              fprintf(stderr, "%s: can't open '%s' (%s)\n",
                  argv[0], argv[i], strerror(errno));
              return EXIT_FAILURE; 
          } else {
                  doubleword(argv[i], fp);
                  fclose(fp); 
              } 
          }
          if (argc == 1) doubleword(NULL, stdin);
          return EXIT_SUCCESS; 
      } 

   〈includes 4〉≡ 
    #include <stdio.h>
    #include <stdlib.h>
    #include <errno.h> 
doubleword函式需要從檔案中讀取單詞。對於該程式來說,一個單詞由一個或多個非空格字元組成,不區分大小寫。getword從開啟的檔案讀取下一個單詞,複製到buf [0..size 1]中,並返回1;在到達檔案末尾時該函式返回0。
〈functions 3〉+≡ 
  int getword(FILE *fp, char *buf, int size) { 
      int c; 

      c = getc(fp); 
     〈scan forward to a nonspace character or EOF 5〉 
     〈copy the word into buf[0..size-1] 5〉 
      if (c != EOF) 
          ungetc(c, fp); 
      return〈found a word? 5〉; 
  } 

〈prototypes 4〉≡ 
  int getword(FILE *, char *, int); 
該程式碼塊說明了另一個文學程式設計特性:程式碼塊標籤〈functions 3〉後接的+≡表示將getword的程式碼附加到程式碼塊〈functions 3〉的程式碼的後面,因此該程式碼塊現在包含main和getword的程式碼。該特性允許分為多次定義一個程式碼塊中的程式碼,每次定義一部分。對於一個“接續”程式碼塊來說,其標籤中的頁碼指向該程式碼塊的第一次定義處,因此很容易找到程式碼塊定義的開始處。
因為getword在main之後定義,在main中呼叫getword時就需要一個原型,這就是〈prototypes 4〉程式碼塊的用處。該程式碼塊在一定程度上是對C語言“先聲明後使用”(declaration- before-use)規則的讓步,但如果該程式碼定義得一致並在根程式碼塊中出現在〈functions 3〉之前,那麼函式可以按任何順序給出。
getword除了從輸入獲取下一個單詞之外,每當遇到一個換行字元時都對linenum加1。doubleword輸出時將使用linenum。
〈data 4〉≡ 
  int linenum; 

〈scan forward to a nonspace character or EOF 5〉≡ 
  for ( ; c != EOF && isspace(c); c = getc(fp)) 
      if (c == '\n') 
          linenum++; 

〈includes 4〉+≡ 
  #include <ctype.h> 
linenum的定義,也例證了程式碼塊的順序不必與C語言的要求相同。linenum在其第一次使用時定義,而不是在檔案的頂部或getword定義之前,後兩種做法才是合乎C語言要求的。
size的值限制了getword所能儲存的單詞的長度,getword函式會丟棄過多的字元並將大寫字母轉換為小寫:
〈copy the word into buf[0..size-1] 5〉≡ 
  { 
      int i = 0; 
      for ( ; c != EOF && !isspace(c); c = getc(fp)) 
          if (i < size - 1) 
              buf[i++] = tolower(c); 
      if (i < size) 
          buf[i] = '\0'; 
  } 
索引i與size-1進行比較,以保證單詞末尾有空間儲存一個空字元。在size為0時,if語句保護了對快取的賦值操作。在double中不會出現這種情況,但這種防性程式設計(defensive programming)有助於捕獲“不可能發生的bug”。
剩下的程式碼邏輯是,如果buf中儲存了一個單詞則返回1,否則返回0:
〈found a word? 5〉≡ 
  buf[0] != '\0' 
該定義表明,程式碼塊不必對應於C語言中的語句或任何其他語法單位,程式碼塊只是文字而已。
doubleword讀取各個單詞,並將其與前一個單詞比較,發現重複時輸出。它只檢視以字母開頭的單詞:
〈functions 3〉+≡ 
  void doubleword(char *name, FILE *fp) { 
      char prev[128], word[128]; 

      linenum = 1; 
      prev[0] = '\0'; 
      while (getword(fp, word, sizeof(word)) { 
          if (isalpha(word[0]) && strcmp(prev, word)==0) 
             〈word is a duplicate 6〉 
          strcpy(prev, word); 
      } 
  } 
〈prototypes 4〉+≡ 

  void doubleword(char *, FILE *); 

〈includes 4〉+≡ 
  #include <string.h> 
輸出是很容易的,但僅當name不為NULL時才輸出檔名及後接的冒號:
〈word is a duplicate 6〉≡
  {
       if (name)
           printf("%s:", name);
       printf("%d: %s\n", linenum, word);
   } 
該程式碼塊被定義為一個複合語句,因而可以作為結果用在它所處的if語句中。
1.2 程式設計風格
double說明了本書中程式所使用的風格慣例。程式能否更容易被閱讀並理解,比使程式更容易被計算機編譯更為重要。編譯器並不在意變數的名稱、程式碼的佈局或程式的模組劃分方式。但這種細節對程式設計師閱讀以及理解程式的難易程度有很大影響。
本書程式碼遵循C程式的一些既定的風格慣例。它使用一致的慣例來命名變數、型別和例程,並在本書的排版約定下,採用一致的縮排風格。風格慣例並非是一種必須遵循的剛性規則,它們表示的是程式設計的一種哲學方法,力求最大限度地增加程式的可讀性和可理解性。因而,凡是改變慣例能有助於強調程式碼的重要方面或使複雜的程式碼更可讀時,你完全可以違反“規則”。
一般來說,較長且富於語義的名稱用於全域性變數和例程,而數學符號般的短名稱則用於區域性變數。程式碼塊〈compute x • y〉中的迴圈索引i屬於後一種慣例。對索引和變數使用較長的名稱通常會使程式碼更難閱讀,例如下述程式碼中
sum = 0; 
for (theindex = 0; theindex < numofElements; theindex++) 
    sum += x[theindex]*y[theindex]; 
長變數名反而使程式碼的語義含混不清。
變數的宣告應該靠近於其第一次使用的地方(可能在程式碼塊中)。linenum的宣告很靠近在getword中首次使用該變數的地方,這就是個例子。在可能的情況下,區域性變數的宣告在使用變數的複合語句的開始處。例如,程式碼塊〈copy the word into buf[0..size-1] 5〉中對i的宣告。
一般來說,過程和函式的名稱,應能反映過程完成的工作及函式的返回值。因而,getword應當返回輸入中的下一個單詞,而doubleword則找到並顯示出現兩次或更多次的單詞。大多數例程都比較簡單,不會超過一頁程式碼,程式碼塊更短,通常少於十二行。
程式碼中幾乎沒有註釋,因為圍繞對應程式碼塊的正文代替了註釋。有關注釋風格的建議幾乎會引發程式設計師間的戰爭。本書將效法C程式設計方面的典範,最低限度地使用註釋。如果程式碼很清晰,且使用了良好的命名和縮排慣例,則這樣的程式碼通常是自明的。僅當進行解釋時(例如,解釋資料結構的細節、演算法的特例以及異常情況)才需要註釋。編譯器無法檢查註釋是否與程式碼一致,誤導的註釋通常比沒有註釋更糟糕。最後,有些註釋只不過是種干擾,其中的噪音和過多的版式掩蓋了註釋內容,從而使這些註釋只會掩蓋程式碼本身的含義。
文學程式設計避免了註釋戰爭中的許多爭論,因為它不受程式設計語言註釋機制的約束。程式設計師可以使用最適合於表達其意圖的任何版式特性,如表、方程、圖片和引文。文學程式設計似乎提倡準確、精確和清晰。
本書中的程式碼以C語言編寫,它所使用的大多數慣用法通常已被有經驗的C程式設計師所接受並希望採用。其中一些慣用法可能使不熟悉C語言的程式設計師困惑,但為了能用C語言流利地程式設計,程式設計師必須掌握這些慣用法。涉及指標的慣用法通常是最令人困惑的,因為C語言為指標的操作提供了幾種獨特且富有表達力的運算子。庫函式strcpy將一個字串複製到另一個字串中並返回目標字串,對該函式的不同實現就說明了“地道的C語言”和新手C程式設計師編寫的程式碼之間的差別,後一種程式碼通常使用陣列:
char *strcpy(char dst[], const char src[]) {
    int i; 

    for (i = 0; src[i] != '\0'; i++) 
        dst[i] = src[i]; 
    dst[i] = '\0'; 
    return dst; 

“地道”的版本則使用指標:
char *strcpy(char *dst, const char *src) { 
    char *s = dst; 

    while (*dst++ = *src++)
        ; 
    return s; 

這兩個版本都是strcpy的合理實現。指標版本使用通常的慣用法將賦值、指標遞增和測試賦值操作的結果合併為單一的賦值表示式。它還修改了其引數dst和src,這在C語言中是可接受的,因為所有引數都是傳值的,實際上引數只不過是已初始化的區域性變數。
還可以舉出很好的例子,來表明使用陣列版本比指標版本更好。例如,所有程式設計師都更容易理解陣列版本,無論他們能否使用C語言流暢地程式設計。但指標版本是最有經驗的C程式設計師會編寫的那種程式碼,因而程式設計師閱讀現存程式碼時最有可能遇到它。本書可以幫助讀者學習這些慣用法、理解C語言的優點、並避免易犯的錯誤。
1.3 效率
程式設計師似乎被效率問題困擾著。他們可能花費數小時來微調程式碼,使之執行得更快。遺憾的是,大部分這種工作都是無用功。當猜測程式的執行時間花費在何處時,程式設計師的直覺非常糟糕。
微調程式是為了使之更快,但通常總是會使之更大、更難理解、更可能包含錯誤。除非對執行時間的測量表明程式太慢,否則這樣的微調沒有意義。程式只需要足夠快即可,不一定要儘可能快。
微調通常在“真空”中完成。如果一個程式太慢,找到其瓶頸的唯一途徑就是測量它。程式的瓶頸很少出現在預期位置或者因你所懷疑的原因導致,而且在錯誤位置上微調程式是沒有意義的。在找到正確的位置後,僅當該處花費的時間確實佔執行時間的很大比例時,才有必要進行微調。如果I/O佔了程式執行時間的60%,在搜尋例程中節省1%是無意義的。
微調通常會引入錯誤。最快崩潰的程式絕非勝者。可靠性比效率更重要;與交付足夠快的可靠軟體相比,交付快速但會崩潰的軟體,從長遠看來代價更高。
微調經常在錯誤的層次上進行。快速演算法的直接簡明的實現,比慢速演算法的手工微調實現要好得多。例如,減少線性查詢的內層迴圈的指令數,註定不如直接使用二分查詢。
微調無法修復低劣的設計。如果程式到處都慢,這種低效很可能是設計導致的。當基於編寫得很糟糕或不精確的問題說明給出設計時,或者根本就沒有總體設計時,就會發生這種令人遺憾的情況。
本書中大部分程式碼都使用了高效的演算法,具有良好的平均情況效能,其最壞情形效能也易於概括。對大多數應用程式來說,這些程式碼對典型輸入的執行時間總是足夠快速的。當某些程式的程式碼效能可能會導致問題時,書中自會明確註明。
一些C程式設計師在尋求提高效率的途徑時,大量使用巨集和條件編譯。只要有可能,本書將避免使用這兩種方法。使用巨集來避免函式呼叫基本上是不必要的。僅當客觀的測量結果表明有問題的呼叫的開銷大大超出其餘程式碼的執行時間時,使用巨集才有意義。操作I/O是較適宜採用巨集的少數情況之一。例如,標準的I/O函式getc、putc、getchar和putchar通常實現為巨集。
條件編譯通常用於配置特定平臺或環境的程式碼,或者用於程式碼除錯的啟用/禁用。這些問題是實際存在的,但條件編譯通常只是解決問題的較為容易的方法,而且總會使程式碼更難於閱讀。而重寫程式碼以便在執行期間選擇平臺依賴關係通常則更為有用。例如,一個編譯器可以在執行時選擇多種(比如說六種)體系結構中的一個來生成程式碼,這樣的一種交叉編譯器要比必須配置並搭建六個不同的編譯器更有用,而且可能更易於維護。
如果應用程式必須在編譯時配置,與C語言的條件編譯工具相比,版本控制工具更擅長完成該工作。這樣,程式碼中就不必充斥著前處理器指令,因為那會使程式碼難於閱讀,並模糊被編譯和未被編譯的程式碼之間的界限。使用版本控制工具,你看到的程式碼即為被執行的程式碼。對於跟蹤效能改進情況來說,這些工具也是理想的選擇。
1.4 擴充套件閱讀
對於標準C庫來說,ANSI標準 [ANSI 1990]和技術上等效的ISO標準 [ISO 1990]是權威的參考文獻,但 [Plauger,1992]一書給出了更詳細的描述和完整的實現。同樣,C語言相關問題的定論就在於這些標準,但[Kernighan and Ritchie,1988]一書卻可能是最廣為使用的參考。[Harbison and Steele,1995]一書的最新版本或許是C語言標準的最新的資料,它還描述瞭如何編寫“乾淨的C”,即可以用C++編譯器編譯的C程式碼。[Jaeschke,1991]一書將標準C語言的精華濃縮為緊湊的詞典格式,這份資料對C程式設計師來說也很有用。
[Kernighan and Plauger,1976]一書給出了文學程式的早期例子,當然作者對文學程式設計沒太多認識,只是使用了專門開發的工具將程式碼整合到書中。WEB是首批明確為文學程式設計設計的工具之一。[Knuth,1992]一書描述了WEB和它的一些變體及用法,[Sewell,1989]一書是WEB的入門介紹。更簡單的工具([Hanson,1987],[Ramsey,1994])發展了很長時間才提供WEB的大部分基本功能。本書使用notangle來提取程式碼塊,它是Ramsey的noweb系統中的程式之一。[Fraser and Hanson,1995]一書也使用了noweb,該書以文學程式的形式給出了一個完整的C語言編譯器。該編譯器也是一個交叉編譯器。
double取自 [Kernighan and Pike,1984],在該書中double是用AWK [Aho, Kernighan and Weinberger,1988]程式設計語言實現的。儘管年齡老邁,但[Kernighan and Pike,1984]仍然是UNIX程式設計哲學方面的最佳書籍之一。
學習良好的程式設計風格,最好的方法是閱讀風格良好的程式。本書將遵循 [Kernighan and Pike,1984]和 [Kernighan and Ritchie,1988]中的風格,這種風格經久而不衰。[Kernighan and Plauger,1978]一書是程式設計風格方面的經典著作,但該書並不包含C語言的例子。Ledgard的小書[Ledgard,1987]提供了類似的建議,而 [Maguire,1993]從PC程式設計的角度闡述了程式設計風格問題。[Koenig,1989]一書暴露的C語言的黑暗角落,強調了那些應該避免的東西。[McConnell,1993]一書在與程式構建相關的許多方面提供了明智的建議,並針對使用goto語句的利弊兩方面進行了不偏不倚的討論。
學習編寫高效的程式碼,最好的方法是在演算法方面有紮實的基礎,並閱讀其他高效的程式碼。[Sedgewick,1990]一書縱覽了大多數程式設計師都必須知道的所有重要演算法,而 [Knuth,1973a]一書對演算法基礎進行了至為詳細的討論。[Bentley,1982]一書有170頁,給出了編寫高效程式碼方面的一些有益的建議和常識。
1.5 習題
1.1 在一個單詞結束於換行符時,getword在〈scan forward to a nonspace or EOF 5〉程式碼塊中將linenum加1,而不是在〈copy the word into buf[0..size-1] 5〉程式碼塊之後。解釋這樣做的原因。如果在本例中,linenum的加1操作是在〈copy the word into buf[0..size-1] 5〉程式碼塊之後進行,會發生什麼情況?
1.2 當double在輸入中發現三個或更多相同單詞時會顯示什麼?修改double來改掉這個“特性”。
1.3 許多有經驗的C程式設計師會在strcpy的迴圈中加入一個顯式的比較操作:
char *strcpy(char *dst, const char *src) { 
    char *s = dst; 

    while ((*dst++ = *src++) != '\0')
        ; 
    return s; 

        顯式比較表明賦值操作並非筆誤。一些C編譯器和相關工具,如Gimpel Software的PC-Lint和LCLint[Evans,1996],在發現賦值操作的結果用作條件表示式時會發出警告,因為這種用法是一個常見的錯誤來源。如果讀者有PC-Lint或LCLint,可以在一些“測試”過的程式上進行試驗。

介面與實現

模組分為兩個部分,即模組的介面與實現。介面規定了模組做什麼。介面會宣告識別符號、型別和例程,提供給使用模組的程式碼。實現指明模組如何完成其介面規定的目標。對於給定的模組,通常只有一個介面,但可能有許多實現提供了介面規定的功能。每個實現可能使用不同的演算法和資料結構,但它們都必須合乎介面的規定。
客戶程式(client)是使用模組的一段程式碼。客戶程式匯入介面,實現則匯出介面。客戶程式只需要看到介面即可。實際上,它們可能只有實現的目標碼。多個客戶程式共享介面和實現,因而避免了不必要的程式碼重複。這種方法學也有助於避免bug,介面和實現編寫並除錯一次後,可以經常使用。
2.1 介面
介面僅規定客戶程式可能使用的那些識別符號,而儘可能隱藏不相關的表示細節和演算法。這有助於客戶程式避免依賴特定實現的具體細節。客戶程式和實現之間的這種依賴性稱之為耦合(coupling),在實現改變時耦合會導致bug,當依賴性被與實現相關的隱藏或隱含的假定掩蓋時,這種bug可能會特別難於改正。設計完善且陳述準確的介面可以減少耦合。
對於介面與實現相分離,C語言只提供了最低限度的支援,但通過一些簡單的約定,我們即可獲得介面/實現方法學的大多數好處。在C語言中,介面通過一個頭檔案指定,標頭檔案的副檔名通常為.h。這個標頭檔案會宣告客戶程式可能使用的巨集、型別、資料結構、變數和例程。客戶程式用C前處理器指令#include匯入介面。
以下例子說明了本書中的介面使用的約定。下述介面
〈arith.h〉≡ 
  extern int Arith_max(int x, int y); 
  extern int Arith_min(int x, int y); 
  extern int Arith_div(int x, int y); 
  extern int Arith_mod(int x, int y); 
  extern int Arith_ceiling(int x, int y); 
  extern int Arith_floor (int x, int y); 
聲明瞭六個整數算術運算函式。該介面的實現需要為上述每一個函式提供定義。
該介面命名為Arith,介面標頭檔案命名為arith.h。在介面中,介面名稱表現為每個識別符號的字首。這種約定並不優美,但C語言幾乎沒有提供其他備選方案。所有檔案作用域中的識別符號,包括變數、函式、型別定義和列舉常數,都共享同一個名稱空間。所有的全域性結構、聯合和列舉標記則共享另一個名稱空間。在一個大程式中,在本來無關的模組中,很容易使用同一名稱表示不同的目的。避免這種名稱碰撞(name collision)的一個方法是使用字首,如模組名。一個大程式很容易有數千全域性識別符號,但通常只有幾百個模組。模組名不僅提供了適當的字首,還有助於使客戶程式程式碼文件化。
Arith介面中的函式提供了標準C庫缺失的一些有用功能,並對除法和模運算提供了良定義的結果,而標準則將這些操作的行為規定為未定義(undefined)或由具體實現來定義(implementation-defined)。
Arith_min和Arith_max函式分別返回其整型引數的最小值和最大值。
Arith_div返回x除以y獲得的商,而Arith_mod則返回對應的餘數。當x和y都為正或都為負時,Arith_div(x,y)等於x/y,而Arith_mod(x,y)等於x%y。然而當兩個運算元符號不同時,由C語言內建運算子所得出的返回值取決於具體編譯器的實現。當y為零時,Arith_div和Arith_mod的行為與x/y和x%y相同。
C語言標準只是強調,如果x/y是可表示的,那麼(x/y)*y + x%y必須等於x。當一個運算元為負數時,這種語義使得整數除法可以向零舍入,也可以向負無窮大舍入。例如,如果13/5的結果定義為2,那麼標準指出,13%5必須等於13  (13/5)*5  13  (2)*5  3。但如果13/5定義為3,那麼13%5的值必須是13  (3)*5  2。
因而內建的運算子只對正的運算元有用。標準庫函式div和ldiv以兩個整數或長整數為輸入,並計算二者的商和餘數,在一個結構的quot和rem欄位中返回。這兩個函式的語義是良定義的:它們總是向零舍入,因此div(-13,5).quot總是等於2。Arith_div和Arith_mod同樣是良定義的。它們總是向數軸的左側舍入,當其運算元符號相同時向零舍入,當其符號不同時向負無窮大舍入,因此Arith_div(-13,5)返回3。
Arith_div和Arith_mod的定義可以用更精確的數學術語來表達。Arith_div(x,y)定義為不超過實數z的最大整數,而z*y=x。因而,對x=-13和y=5(或者x = 13和y = 5),z為2.6,因此Arith_div(-13,5)為3。Arith_mod(x,y)定義為等於x - y*Arith_div(x,y),因此Arith_mod(-13,5)為13 5*(3)  2。
Arith_ceiling和Arith_floor函式遵循類似的約定。Arith_ceiling(x,y)返回不小於x/y的實數商的最小整數,而Arith_floor(x,y)返回不大於x/y的實數商的最大整數。對所有運算元x和y來說,Arith_ceiling返回數軸在x/y對應點右側的整數,而Arith_floor返回x/y對應點左側的整數。例如:

Arith_ceiling( 13,5) = 13/5 = 2.6 = 3 
Arith_ceiling(-13,5) =-13/5 = -2.6 = -2 
Arith_floor ( 13,5) = 13/5 = 2.6 = 2 
Arith_floor (-13,5) =-13/5 = -2.6 = -3 
即便簡單如Arith這種程度的介面仍然需要這麼費勁的規格說明,但對大多數介面來說,Arith的例子很有代表性和必要性(很讓人遺憾)。大多數程式語言的語義中都包含漏洞,某些操作的精確含義定義得不明確或根本未定義。C語言的語義充滿了這種漏洞。設計完善的介面會塞住這些漏洞,將未定義之處定義完善,並對語言標準規定為未定義或由具體實現定義的行為給出明確的裁決。
Arith不僅是一個用來顯示C語言缺陷的人為範例。它也是有用的,例如對涉及模運算的演算法,就像是雜湊表中使用的那些演算法。假定i從零到N - 1,其中N大於1,並對i加1和i減1的結果模N。即,如果i為N-1,i+1為0,而如果i為0,i-1為N-1。下述表示式
i = Arith_mod(i + 1, N); 
i = Arith_mod(i - 1, N); 
正確地對i進行了加1模N和減1模N的操作。表示式i = (i+1) % N可以工作,但i = ( i-1) % N無法工作,因為當i為0時,(i-1) % N可能是1或N-1。程式設計師在(-1) % N返回N-1的計算機上可以使用(i-1) % N,但如果依賴這種由具體實現定義的行為,那麼在將程式碼移植到(-1) % N返回1的計算機上時,就可能遭遇到非常出人意料的行為。庫函式div(x,y)也無濟於事。它返回一個結構,其quot和rem欄位分別儲存x/y的商和餘數。在i為零時,div(i-1, N).rem總是1。使用i = (i-1+N) % N是可以的,但僅當i-1+N不造成溢位時才行。
2.2 實現
實現會匯出介面。它定義了必要的變數和函式,以提供介面規定的功能。實現具體解釋了介面的語義,並給出其表示細節和演算法,但在理想情況下,客戶程式從來都不需要看到這些細節。不同的客戶程式可以共享實現的目標碼,通常是從(動態)庫載入實現的目標碼。
一個介面可以有多個實現。只要實現遵循介面的規定,完全可以在不影響客戶程式的情況下改變實現。例如,不同的實現可能會提供更好的效能。設計完善的介面會避免對特定機器的依賴,但也可能強制實現依賴於機器,因此對用到介面的每種機器,可能都需要一個不同的實現(也可能是實現的一部分)來支援。
在C語言中,一個實現通過一個或多個.c檔案來提供。實現必須提供其匯出的介面規定的功能。實現會包含介面的.h檔案,以確保其定義與介面的宣告一致。但除此之外,C語言中沒有其他語言機制來檢查實現與介面是否符合。
如同本書中的介面,本書描述的實現也具有一種風格化的格式,如arith.c所示:
〈arith.c〉≡ 
  #include "arith.h" 
  〈arith.c functions 14〉 

〈arith.c functions 14〉≡ 
  int Arith_max(int x, int y) {
      return x > y ? x : y;
  } 

  int Arith_min(int x, int y) {
      return x > y ? y : x;
  } 
除了〈arith.c functions 14〉,更復雜的實現可能包含名為〈data〉、〈types〉、〈macros〉、〈prototypes〉等的程式碼塊。在不會造成混淆時,程式碼塊中的檔名(如arith.c)將略去。
在Arith_div的引數符號不同時,它必須處理除法的兩種可能行為。如果除法向零舍入,而y不能整除x,那麼Arith_div(x,y)的結果為x/y  1,否則,返回x/y即可:
〈arith.c functions 14〉+≡ 
int Arith_div(int x, int y) { 
    if (〈division truncates toward 0 14〉
    && 〈x and y have different signs 14〉 && x%y != 0) 
        return x/y - 1; 
    else 
        return x/y; 

前一節的例子,即將13除以5,可以測試除法所採用的舍入方式。首先判斷x和y是否小於0,然後比較兩個判斷結果是否相等,即可檢查符號問題:
〈division truncates toward 0 14〉≡ 
  -13/5 == -2 

〈x and y have different signs 14〉≡ 
  (x < 0) != (y < 0) 
Arith_mod可以按其定義實現:
int Arith_mod(int x, int y) { 
    return x - y*Arith_div(x, y); 

如果Arith_mod也像Arith_div那樣進行判斷,那麼也可以使用%運算子實現。在相應的條件為真時,
Arith_mod(x,y) = x - y*Arith_div(x, y)
               = x - y*(x/y - 1)
               = x - y*(x/y) + y 
加下劃線的子表示式是標準C對x%y的定義,因此Arith_mod可定義為:
〈arith.c functions 14〉+≡ 
  int Arith_mod(int x, int y) { 
      if (〈division truncates toward 0 14〉
      && 〈x and y have different signs 14〉 && x%y != 0) 
          return x%y + y; 
      else 
          return x%y; 

Arith_floor剛好等於Arith_div,而Arith_ceiling等於Arith_div加1,除非y能整除x:
〈arith.c functions 14〉+≡ 
  int Arith_floor(int x, int y) { 
      return Arith_div(x, y); 
  } 

  int Arith_ceiling(int x, int y) {
      return Arith_div(x, y) + (x%y != 0); 
  } 
2.3 抽象資料型別
一個抽象資料型別是一個介面,它定義了一個數據型別和對該型別的值所進行的操作。一個數據型別是一個值的集合。在C語言中,內建的資料型別包括字元、整數、浮點數等。而結構本身也能定義新的型別,因而可用於建立更高階型別,如列表、樹、查詢表等。
高階型別是抽象的,因為其介面隱藏了相關的表示細節,並只規定了對該型別值的合法操作。理想情況下,這些操作不會暴露型別的表示細節,因為那樣可能使客戶程式隱含地依賴於具體的表示。抽象資料型別或ADT的標準範例是棧。其介面定義了棧型別及其五個操作:
〈initial version of stack.h〉≡ 
  #ifndef STACK_INCLUDED 
  #define STACK_INCLUDED 

  typedef struct Stack_T *Stack_T; 

  extern Stack_T Stack_new (void); 
  extern int Stack_empty(Stack_T stk); 
  extern void Stack_push (Stack_T stk, void *x); 
  extern void *Stack_pop (Stack_T stk); 
  extern void Stack_free (Stack_T *stk); 

  #endif 
上述的typedef定義了Stack_T型別,這是一個指標,指向一個同名結構。該定義是合法的,因為結構、聯合和列舉的名稱(標記)佔用了一個名稱空間,該名稱空間不同於變數、函式和型別名所用的名稱空間。這種慣用法的使用遍及本書各處。型別名Stack_T,是這個介面中我們關注的名稱,只有對實現來說,結構名才比較重要。使用相同的名稱,可以避免用太多罕見的名稱汙染程式碼。
巨集STACK_INCLUDED也會汙染名稱空間,但_INCLUDED字尾有助於避免衝突。另一個常見的約定是為此類名稱加一個下劃線字首,如_STACK或_STACK_INCLUDED。但標準C將下劃線字首保留給實現者和未來的擴充套件使用,因此避免使用下劃線字首看起來是謹慎的做法。
該介面透露了棧是通過指向結構的指標表示的,但並沒有給出結構的任何資訊。因而Stack_T是一個不透明指標型別,客戶程式可以自由地操縱這種指標,但無法反引用不透明指標,即無法檢視指標所指向結構的內部資訊。只有介面的實現才有這種特權。
不透明指標隱藏了表示細節,有助於捕獲錯誤。只有Stack_T型別值可以傳遞給上述的函式,試圖傳遞另一種指標,如指向其他結構的指標,將產生編譯錯誤。唯一的例外是引數中的一個void指標,該該引數可以傳遞任何型別的指標。
條件編譯指令#ifdef和#endif以及定義STACK_INCLUDED的#define,使得stack.h可以被包含多次,在介面又匯入了其他介面時可能出現這種情況。如果沒有這種保護,第二次和後續的包含操作,將因為typedef中的Stack_T重定義而導致編譯錯誤。
在少數可用的備選方案中,這種約定似乎是最溫和的。禁止介面包含其他介面,可以完全避免重複包含,但這又強制介面用某種其他方法指定必須匯入的其他介面,如註釋,也強迫程式設計師來提供包含指令。將條件編譯指令放在客戶程式而不是介面中,可以避免編譯時不必要地讀取介面檔案,但代價是需要在許多地方衍生出很多亂七八糟的條件編譯指令,不像只放在介面中那樣清潔。上文說明的約定,需要編譯器來完成所謂的“髒活”。
按約定,定義ADT的介面X可以將ADT型別命名為X_T。本書中的介面在這個約定基礎上更進一步,在介面內部使用巨集將X_T縮寫為T。使用該約定時,stack.h如下:
〈stack.h〉≡ 
  #ifndef STACK_INCLUDED 
  #define STACK_INCLUDED 

  #define T Stack_T 
  typedef struct T *T; 

  extern T Stack_new (void); 
  extern int Stack_empty(T stk); 
  extern void Stack_push (T stk, void *x); 
  extern void *Stack_pop (T stk); 
  extern void Stack_free (T *stk); 

  #undef T 
  #endif 
該介面在語義上與前一個是等效的。縮寫只是語法糖(syntactic sugar),使得介面稍微容易閱讀一些。T指的總是介面中的主要型別。但客戶程式必須使用Stack_T,因為stack.h末尾的#undef指令刪除了上述的縮寫。
該介面提供了可用於任意指標的容量無限制的棧。Stack_new建立新的棧,它返回一個型別為T的值,可以作為引數傳遞給其他四個函式。Stack_push將一個指標推入棧頂,Stack_pop在棧頂刪除一個指標並返回該指標,如果棧為空,Stack_empty返回1,否則返回0。Stack_free以一個指向T的指標為引數,釋放該指標所指向的棧,並將型別為T的變數設定為NULL指標。這種設計有助於避免懸掛指標(dangling pointer),即指標指向已經被釋放的記憶體。例如,如果names通過下述程式碼定義並初始化:
#include "stack.h" 
Stack_T names = Stack_new(); 
下述語句
Stack_free(&names); 
將釋放names指向的棧,並將names設定為NULL指標。
當ADT通過不透明指標表示時,匯出的型別是一個指標型別,這也是Stack_T通過typedef定義為指向struct Stack_T的指標的原因。本書中大部分ADT都使用了類似的typedef。當ADT披露了其表示細節,並匯出可接受並返回相應結構值的函式時,介面會將該結構型別定義為匯出型別。第16章中的Text介面說明了這種約定,該介面將Text_T宣告為struct Text_T的一個typedef。無論如何,介面中的主要型別總是縮寫為T。
2.4 客戶程式的職責
介面是其實現和其客戶程式之間的一份契約。實現必須提供介面中規定的功能,而客戶程式必須根據介面中描述的隱式和顯式的規則來使用這些功能。程式設計語言提供了一些隱式規則,來支配介面中宣告的型別、函式和變數的使用。例如,C語言的型別檢查規則可以捕獲介面函式的引數的型別和數目方面的錯誤。
C語言的用法沒有規定的或編譯器無法檢查的規則,必須在介面中詳細說明。客戶程式必須遵循這些規則,實現必須執行這些規則。介面通常會規定未檢查的執行時錯誤(unchecked runtime error)、已檢查的執行時錯誤(checked runtime error)和異常(exception)。未檢查的和已檢查的執行時錯誤是非預期的使用者錯誤,如未能開啟一個檔案。執行時錯誤是對客戶程式和實現之間契約的破壞,是無法恢復的程式bug。異常是指一些可能的情形,但很少發生。程式也許能從異常恢復。記憶體耗盡就是一個例子。異常在第4章詳述。
未檢查的執行時錯誤是對客戶程式與實現之間契約的破壞,而實現並不保證能夠發現這樣的錯誤。如果發生未檢查的執行時錯誤,可能會繼續執行,但結果是不可預測的,甚至可能是不可重複的。好的介面會在可能的情況下避免未檢查的執行時錯誤,但必須規定可能發生的此類錯誤。例如,Arith必須指明除以零是一個未檢查的執行時錯誤。Arith雖然可以檢查除以零的情形,但卻不加處理使之成為未檢查的執行時錯誤,這樣介面中的函式就模擬了C語言內建的除法運算子的行為(即,除以零時其行為是未定義的)。使除以零成為一種已檢查的執行時錯誤,也是一種合理的方案。
已檢查的執行時錯誤是對客戶程式與實現之間契約的破壞,但實現保證會發現這種錯誤。這種錯誤表明,客戶程式未能遵守契約對它的約束,客戶程式有責任避免這類錯誤。Stack介面規定了三個已檢查的執行時錯誤:
(1) 向該介面中的任何例程傳遞空的Stack_T型別的指標;
(2) 傳遞給Stack_free的Stack_T指標為NULL指標;
(3) 傳遞給Stack_pop的棧為空。
介面可以規定異常及引發異常的條件。如第4章所述,客戶程式可以處理異常並採取校正措施。未處理的異常(unhandled exception)被當做是已檢查的執行時錯誤。介面通常會列出自身引發的異常及其匯入的介面引發的異常。例如,Stack介面匯入了Mem介面,它使用後者來分配記憶體空間,因此它規定Stack_new和Stack_push可能引發Mem_Failed異常。本書中大多數介面都規定了類似的已檢查的執行時錯誤和異常。
在向Stack介面新增這些之後,我們可以繼續進行其實現:
〈stack.c〉≡ 
  #include <stddef.h> 
  #include "assert.h" 
  #include "mem.h" 
  #include "stack.h" 

  #define T Stack_T 
 〈types 18〉 
 〈functions 18〉 
#define指令又將T定義為Stack_T的縮寫。該實現披露了Stack_T的內部結構,它是一個結構,一個欄位指向一個連結串列,連結串列包含了棧上的各個指標,另一個欄位統計了指標的數目。
〈types 18〉≡ 
  struct T {
      int count;
      struct elem {
          void *x;
          struct elem *link; 
     } *head;
}; 
Stack_new分配並初始化一個新的T:
〈functions 18〉≡ 
  T Stack_new(void) {
      T stk; 

      NEW(stk);
      stk->count = 0;
      stk->head = NULL;
      return stk; 

NEW是Mem介面中一個用於分配記憶體的巨集。NEW(p)為p指向的結構分配一個例項,因此Stack_ new中使用它來分配一個新的Stack_T結構例項。
如果count欄位為0,Stack_empty返回1,否則返回0:
〈functions 18〉+≡ 
  int Stack_empty(T stk) {
      assert(stk);
      return stk->count == 0; 
  } 
assert(stk)實現了已檢查的執行時錯誤,即禁止對Stack介面函式中的Stack_T型別引數傳遞NULL指標。assert(e)是一個斷言,聲稱對任何表示式e,e都應該是非零值。如果e非零,它什麼都不做,否則將中止程式執行。assert是標準庫的一部分,但第4章的Assert介面定義了自身的assert,其語義與標準庫類似,但提供了優雅的程式終止機制。assert用於所有已檢查的執行時錯誤。
Stack_push和Stack_pop分別在stk->head連結串列頭部新增和刪除元素:
〈functions 18〉+≡ 
  void Stack_push(T stk, void *x) {
      struct elem *t; 

      assert(stk);
      NEW(t);
      t->x = x;
      t->link = stk->head;
      stk->head = t;
      stk->count++; 
  } 

void *Stack_pop(T stk) {
      void *x;
      struct elem *t; 

      assert(stk);
      assert(stk->count > 0);
      t = stk->head;
      stk->head = t->link;
      stk->count--;
      x = t->x;
      FREE(t);
      return x; 
  } 
FREE是Mem用於釋放記憶體的巨集,它釋放其指標引數指向的記憶體空間,並將該引數設定為NULL指標,這與Stack_free的做法同理,都是為了避免懸掛指標。Stack_free也呼叫了FREE:
〈functions 18〉+≡ 
  void Stack_free(T *stk) {
      struct elem *t, *u; 

      assert(stk && *stk); 
      for (t = (*stk)->head; t; t = u) {
          u = t->link;
          FREE(t); 
      }
      FREE(*stk);
  } 
該實現披露了一個未檢查的執行時錯誤,本書中所有的ADT介面都會受到該錯誤的困擾,因而並沒有在介面中指明。我們無法保證傳遞到Stack_push、Stack_pop、Stack_empty的Stack_T值和傳遞到Stack_free的Stack_T*值都是Stack_new返回的有效的Stack_T值。習題2.3針對該問題進行了探討,給出一個部分解決方案。
還有兩個未檢查的執行時錯誤,其效應可能更為微妙。本書中許多ADT通過void指標通訊,即儲存並返回void指標。在任何此類ADT中,儲存函式指標(指向函式的指標)都是未檢查的執行時錯誤。void指標是一個類屬指標(generic pointer,通用指標),型別為void *的變數可以容納指向一個物件的任意指標,此類指標可以指向預定義型別、結構和指標。但函式指標不同。雖然許多C編譯器允許將函式指標賦值給void指標,但不能保證void指標可以容納函式指標 。
通過void指標傳遞任何物件指標都不會損失資訊。例如,在執行下列程式碼之後,
S *p, *q; 
void *t; 
... 
t = p; 
q = t; 
對任何非函式的型別S,p和q都將是相等的。但不能用void指標來破壞型別系統。例如,在執行下列程式碼之後,
S *p; 
D *q; 
void *t; 
... 
t = p; 
q = t; 
我們不能保證q與p是相等的,或者根據型別S和D的對齊約束,也不能保證q是一個指向型別D物件的有效指標。在標準C語言中,void指標和char指標具有相同的大小和表示。但其他指標可能小一些,或具有不同的表示。因而,如果S和D是不同的物件型別,那麼在ADT中儲存一個指向S的指標,將該指標返回到一個指向型別D的指標中,這是一個未檢查的執行時錯誤。
在ADT函式並不修改被指向的物件時,程式設計師可能很