如何組織構(gòu)建多文件C語言程序(一)
準(zhǔn)備好你喜歡的飲料、編輯器和編譯器,放一些音樂,然后開始構(gòu)建一個由多個文件組成的 C 語言程序。
大家常說計算機(jī)編程的藝術(shù)部分是處理復(fù)雜性,部分是命名某些事物。此外,我認(rèn)為“有時需要添加繪圖”是在很大程度上是正確的。
在這篇文章里,我會編寫一個小型 C 程序,命名一些東西,同時處理一些復(fù)雜性。該程序的結(jié)構(gòu)大致基于我在 《如何寫一個好的 C 語言 main 函數(shù)》 文中討論的。但是,這次做一些不同的事。準(zhǔn)備好你喜歡的飲料、編輯器和編譯器,放一些音樂,讓我們一起編寫一個有趣的 C 語言程序。
優(yōu)秀 Unix 程序哲學(xué)
首先,你要知道這個 C 程序是一個 Unix 命令行工具。這意味著它運行在(或者可被移植到)那些提供 Unix C 運行環(huán)境的操作系統(tǒng)中。當(dāng)貝爾實驗室發(fā)明 Unix 后,它從一開始便充滿了設(shè)計哲學(xué)。用我自己的話來說就是:程序只做一件事,并做好它,并且對文件進(jìn)行一些操作。雖然“只做一件事,并做好它”是有意義的,但是“對文件進(jìn)行一些操作”的部分似乎有點兒不合適。
事實證明,Unix 中抽象的 “文件” 非常強大。一個 Unix 文件是以文件結(jié)束符(EOF)標(biāo)志為結(jié)尾的字節(jié)流。僅此而已。文件中任何其它結(jié)構(gòu)均由應(yīng)用程序所施加而非操作系統(tǒng)。操作系統(tǒng)提供了系統(tǒng)調(diào)用,使得程序能夠?qū)ξ募?zhí)行一套標(biāo)準(zhǔn)的操作:打開、讀取、寫入、尋址和關(guān)閉(還有其他,但說起來那就復(fù)雜了)。對于文件的標(biāo)準(zhǔn)化訪問使得不同的程序共用相同的抽象,而且可以一同工作,即使它們是不同的人用不同語言編寫的程序。
具有共享的文件接口使得構(gòu)建可組合的的程序成為可能。一個程序的輸出可以作為另一個程序的輸入。Unix 家族的操作系統(tǒng)默認(rèn)在執(zhí)行程序時提供了三個文件:標(biāo)準(zhǔn)輸入(stdin
)、標(biāo)準(zhǔn)輸出(stdout
)和標(biāo)準(zhǔn)錯誤(stderr
)。其中兩個文件是只寫的:stdout
和 stderr
。而 stdin
是只讀的。當(dāng)我們在常見的 Shell 比如 Bash 中使用文件重定向時,可以看到其效果。
$ ls | grep foo | sed -e 's/bar/baz/g' > ack
這條指令可以被簡要地描述為:ls
的結(jié)果被寫入標(biāo)準(zhǔn)輸出,它重定向到 grep
的標(biāo)準(zhǔn)輸入,grep
的標(biāo)準(zhǔn)輸出重定向到 sed
的標(biāo)準(zhǔn)輸入,sed
的標(biāo)準(zhǔn)輸出重定向到當(dāng)前目錄下文件名為 ack
的文件中。
我們希望我們的程序在這個靈活又出色的生態(tài)系統(tǒng)中運作良好,因此讓我們編寫一個可以讀寫文件的程序。
喵嗚喵嗚:流編碼器/解碼器概念
當(dāng)我還是一個露著豁牙的孩子懵懵懂懂地學(xué)習(xí)計算機(jī)科學(xué)時,學(xué)過很多編碼方案。它們中的有些用于壓縮文件,有些用于打包文件,另一些毫無用處因此顯得十分愚蠢。列舉最后這種情況的一個例子:哞哞編碼方案。
為了讓我們的程序有個用途,我為它更新了一個 21 世紀(jì) 的概念,并且實現(xiàn)了一個名為“喵嗚喵嗚” 的編碼方案的概念(畢竟網(wǎng)上大家都喜歡貓)。這里的基本的思路是獲取文件并且使用文本 “meow” 對每個半字節(jié)(半個字節(jié))進(jìn)行編碼。小寫字母代表 0,大寫字母代表 1。因為它會將 4 個比特替換為 32 個比特,因此會擴(kuò)大文件的大小。沒錯,這毫無意義。但是想象一下人們看到經(jīng)過這樣編碼后的驚訝表情。
$ cat /home/your_sibling/.super_secret_journal_of_my_innermost_thoughts
MeOWmeOWmeowMEoW...
這非常棒。
最終的實現(xiàn)
完整的源代碼可以在 GitHub 上面找到,但是我會寫下我在編寫程序時的思考。目的是說明如何組織構(gòu)建多文件 C 語言程序。
既然已經(jīng)確定了要編寫一個編碼和解碼“喵嗚喵嗚”格式的文件的程序時,我在 Shell 中執(zhí)行了以下的命令 :
$ mkdir meowmeow
$ cd meowmeow
$ git init
$ touch Makefile # 編譯程序的方法
$ touch main.c # 處理命令行選項
$ touch main.h # “全局”常量和定義
$ touch mmencode.c # 實現(xiàn)對喵嗚喵嗚文件的編碼
$ touch mmencode.h # 描述編碼 API
$ touch mmdecode.c # 實現(xiàn)對喵嗚喵嗚文件的解碼
$ touch mmdecode.h # 描述解碼 API
$ touch table.h # 定義編碼查找表
$ touch .gitignore # 這個文件中的文件名會被 git 忽略
$ git add .
$ git commit -m "initial commit of empty files"
簡單的說,我創(chuàng)建了一個目錄,里面全是空文件,并且提交到 git。
即使這些文件中沒有內(nèi)容,你依舊可以從它的文件名推斷每個文件的用途。為了避免萬一你無法理解,我在每條 touch
命令后面進(jìn)行了簡單描述。
通常,程序從一個簡單 main.c
文件開始,只有兩三個解決問題的函數(shù)。然后程序員輕率地向自己的朋友或者老板展示了該程序,然后為了支持所有新的“功能”和“需求”,文件中的函數(shù)數(shù)量就迅速爆開了。“程序俱樂部”的第一條規(guī)則便是不要談?wù)?ldquo;程序俱樂部”,第二條規(guī)則是盡量減少單個文件中的函數(shù)。
老實說,C 編譯器并不關(guān)心程序中的所有函數(shù)是否都在一個文件中。但是我們并不是為計算機(jī)或編譯器寫程序,我們是為其他人(有時也包括我們)去寫程序的。我知道這可能有些奇怪,但這就是事實。程序體現(xiàn)了計算機(jī)解決問題所采用的一組算法,當(dāng)問題的參數(shù)發(fā)生了意料之外的變化時,保證人們可以理解它們是非常重要的。當(dāng)在人們修改程序時,發(fā)現(xiàn)一個文件中有 2049 函數(shù)時他們會詛咒你的。
因此,優(yōu)秀的程序員會將函數(shù)分隔開,將相似的函數(shù)分組到不同的文件中。這里我用了三個文件 main.c
、mmencode.c
和 mmdecode.c
。對于這樣小的程序,也許看起來有些過頭了。但是小的程序很難保證一直小下去,因此哥忒拓展做好計劃是一個“好主意”。
但是那些 .h
文件呢?我會在后面解釋一般的術(shù)語,簡單地說,它們被稱為頭文件,同時它們可以包含 C 語言類型定義和 C 預(yù)處理指令。頭文件中不應(yīng)該包含任何函數(shù)。你可以認(rèn)為頭文件是提供了應(yīng)用程序接口(API)的定義的一種 .c
文件,可以供其它 .c
文件使用。
但是 Makefile 是什么呢?
我知道下一個轟動一時的應(yīng)用都是你們這些好孩子們用 “終極代碼粉碎者 3000” 集成開發(fā)環(huán)境來編寫的,而構(gòu)建項目是用 Ctrl-Meta-Shift-Alt-Super-B 等一系列復(fù)雜的按鍵混搭出來的。但是如今(也就是今天),使用 Makefile
文件可以在構(gòu)建 C 程序時幫助做很多有用的工作。Makefile
是一個包含如何處理文件的方式的文本文件,程序員可以使用其自動地從源代碼構(gòu)建二進(jìn)制程序(以及其它東西!)
以下面這個小東西為例:
00 # Makefile
01 TARGET= my_sweet_program
02 $(TARGET): main.c
03 cc -o my_sweet_program main.c
#
符號后面的文本是注釋,例如 00 行。
01 行是一個變量賦值,將 TARGET
變量賦值為字符串 my_sweet_program
。按照慣例,也是我的習(xí)慣,所有 Makefile
變量均使用大寫字母并用下劃線分隔單詞。
02 行包含該步驟要創(chuàng)建的文件名和其依賴的文件。在本例中,構(gòu)建目標(biāo)是 my_sweet_program
,其依賴是 main.c
。
最后的 03 行使用了一個制表符號(tab
)而不是四個空格。這是將要執(zhí)行創(chuàng)建目標(biāo)的命令。在本例中,我們使用 C 編譯器前端 cc
以編譯鏈接為 my_sweet_program
。
使用 Makefile
是非常簡單的。
$ make
cc -o my_sweet_program main.c
$ ls
Makefile main.c my_sweet_program
構(gòu)建我們喵嗚喵嗚編碼器/解碼器的 Makefile 比上面的例子要復(fù)雜,但其基本結(jié)構(gòu)是相同的。我將在另一篇文章中將其分解為 Barney 風(fēng)格。
形式伴隨著功能
我的想法是程序從一個文件中讀取、轉(zhuǎn)換它,并將轉(zhuǎn)換后的結(jié)果存儲到另一個文件中。以下是我想象使用程序命令行交互時的情況:
$ meow < clear.txt > clear.meow
$ unmeow < clear.meow > meow.tx
$ diff clear.txt meow.tx
$
我們需要編寫代碼以進(jìn)行命令行解析和處理輸入/輸出流。我們需要一個函數(shù)對流進(jìn)行編碼并將結(jié)果寫到另一個流中。最后,我們需要一個函數(shù)對流進(jìn)行解碼并將結(jié)果寫到另一個流中。等一下,我們在討論如何寫一個程序,但是在上面的例子中,我調(diào)用了兩個指令:meow
和 unmeow
?我知道你可能會認(rèn)為這會導(dǎo)致越變越復(fù)雜。
次要內(nèi)容:argv[0] 和 ln 指令
回想一下,C 語言 main 函數(shù)的結(jié)構(gòu)如下:
int main(int argc, char *argv[])
其中 argc
是命令行參數(shù)的數(shù)量,argv
是字符指針(字符串)的列表。argv[0]
是包含正在執(zhí)行的程序的文件路徑。在 Unix 系統(tǒng)中許多互補功能的程序(比如:壓縮和解壓縮)看起來像兩個命令,但事實上,它們是在文件系統(tǒng)中擁有兩個名稱的一個程序。這個技巧是通過使用 ln
命令創(chuàng)建文件系統(tǒng)鏈接來實現(xiàn)兩個名稱的。
在我筆記本電腦中 /usr/bin
的一個例子如下:
$ ls -li /usr/bin/git*
3376 -rwxr-xr-x. 113 root root 1.5M Aug 30 2018 /usr/bin/git
3376 -rwxr-xr-x. 113 root root 1.5M Aug 30 2018 /usr/bin/git-receive-pack
...
這里 git
和 git-receive-pack
是同一個文件但是擁有不同的名字。我們說它們是相同的文件因為它們具有相同的 inode 值(第一列)。inode 是 Unix 文件系統(tǒng)的一個特點,對它的介紹超越了本文的內(nèi)容范疇。
優(yōu)秀或懶惰的程序可以通過 Unix 文件系統(tǒng)的這個特點達(dá)到寫更少的代碼但是交付雙倍的程序。首先,我們編寫一個基于其 argv[0]
的值而作出相應(yīng)改變的程序,然后我們確保為導(dǎo)致該行為的名稱創(chuàng)建鏈接。
在我們的 Makefile
中,unmeow
鏈接通過以下的方式來創(chuàng)建:
# Makefile
...
$(DECODER): $(ENCODER)
$(LN) -f $< $@
...
我傾向于在 Makefile
中將所有內(nèi)容參數(shù)化,很少使用 “裸” 字符串。我將所有的定義都放置在 Makefile
文件頂部,以便可以簡單地找到并改變它們。當(dāng)你嘗試將程序移植到新的平臺上時,需要將 cc
改變?yōu)槟硞€ cc
時,這會很方便。
除了兩個內(nèi)置變量 $@
和 $<
之外,該步驟看起來相對簡單。第一個便是該步驟的目標(biāo)的快捷方式,在本例中是 $(DECODER)
(我能記得這個是因為 @
符號看起來像是一個目標(biāo))。第二個,$<
是規(guī)則依賴項,在本例中,它解析為 $(ENCODER)
。
事情肯定會變得復(fù)雜,但它還在管理之中。