## 什么是宏
書名是『宏』,它被作者展開為這本書的全部內容。藥瓶上的標簽是『宏』,將藥片從瓶中傾倒出來,就是這個宏的展開結果。被用的最多的『宏』,應該是 Internet 的超級鏈接。每當你點擊一個超級鏈接,就相當于將這個宏展開為網頁中的內容。生活中,類似的例子還有很多,只要你給某種具體的事物貼上了一個標簽,那么這個標簽就相當于宏。
人類非常喜歡給事物貼標簽,盡管無論他們貼與不貼,那些事物本身依然是存在的。在編程中,如果你想給一段代碼貼標簽,最簡單最直接的辦法就是使用宏。那些還在用匯編語言編程的人,他們是離不開宏的,因為匯編語言本身就是將一大堆標簽貼在了更大的一堆機器代碼上。如果所用的編程語言不提供宏功能,可以用這種編程語言為一段代碼制作一個標簽——函數,不過這種標簽就不是宏了,而且要付出一些性能上的代價,因為標簽的展開過程被推遲到程序的運行過程。
C 語言自誕生后,只用了 5 年就讓匯編語言歸隱山林了,這可能要歸功于 Unix 的成功以及 Dennis Ritchie 的忽悠。Steve Johnson——yacc, lint, spell 以及 PCC(Portable C Compiler)的作者說:『Dennis Ritchie 告訴所有人,C 函數的調用開銷真的很小很小。于是人人都開始編寫小函數,搞模塊化。然而幾年后,我們發現在 PDF-11 中函數的調用開銷依然非常大,而 VAX 機器上的代碼往往在 CALL 指令上花費掉 50% 的運行時間。Dennis 對我們撒了謊!但為時已晚,我們已經欲罷不能……』
現代的編程語言,幾乎都贊同用函數來取代宏。擁護者們往往會給出一些冠冕堂皇的理由是,諸如不必額外實現一個宏處理器,函數比宏更安全并且更容易調試。事實上,他們的理由僅僅是迎合現實而已。如果將這些人扔進時空裂縫讓他們穿越到 Ken Thompson 編寫 Unix 系統的時代,讓他們也在一臺廢棄的 PDP-7 型號的計算機上寫程序。在這種內存只有 8KB 的計算機上,那些冠冕堂皇的理由近乎與科幻小說等價。函數之所以能夠取代宏,僅僅是因為 CPU 的計算速度比過去更快了,內存比以前更大了,犧牲一些程序性能,讓編程工作更容易一些,這樣比較合算而已。編程語言的性能與機器的性能似乎總是成反比的。
宏被很多人主觀的棄用了,得益于現代編程語言的表達能力,他們似乎幾乎不需要用宏,于是他們作出結論:宏過時了。事實上,宏會永遠居于眾編程語言之上的,因為前者總是能夠生成后者。編程專家總是會告訴我們,要慎用宏。膽子小的程序猿看到宏就躲得遠遠的,以至于他們總覺得那些使用宏的代碼是糟糕的,是不安全的。事實上,在編程中,若能恰如其分的使用宏,可以讓代碼更加簡潔易讀,特別是對 C 語言這種表現力不足的語言。
例如下面 C 代碼中的宏:
~~~
#define DEF_PAIR_OF(dtype) \
typedef struct pair_of_##dtype { \
dtype first; \
dtype second; \
} pair_of_##dtype##_t
DEF_PAIR_OF(int);
DEF_PAIR_OF(double);
DEF_PAIR_OF(MyStruct);
~~~
是不是有點 C++ 模板的意味?像 C 標準庫提供的?`qsort`?函數所接受的回調函數,也可以用類似的方法半自動生成。有關 C 語言宏的基本規則與技巧,可參考『[宏定義的黑魔法 - 宏菜鳥起飛手冊](http://onevcat.com/2014/01/black-magic-in-macro/)』。即使是表達能力很強的現代編程語言,在處理復雜問題上,也無法避免代碼自身的頻繁重復,妥善的使用宏總是可以消除這種重復,甚至可以創造一些 DSL(領域專用語言)。
在代碼中適當的運用宏,創造優雅易讀的代碼,這樣或許更能體現編程是一種藝術。雖然有些編程語言未提供宏功能,但是我們總是會有 GNU m4 這種通用的宏處理器可用。
## GNU m4 簡介
m4 是一種宏處理器,它掃描用戶輸入的文本并將其輸出,期間如果遇到宏就將其展開后輸出。宏有兩種,一種是內建的,另一種是用戶定義的,它們能接受任意數量的參數。除了做展開宏的工作之外,m4 內建的宏能夠加載文件,執行 Shell 命令,做整數運算,操縱文本,形成遞歸等等。m4 可用作編譯器的前端,或者單純作為宏處理器來用。
所有的 Unix 系統都會提供 m4 宏處理器,因為它是 POSIX 標準的一部分。通常只有很少一部分人知道它的存在,這些發現了 m4 的人往往會在某個方面成為專家。這不是我說的,這是 m4 手冊說的。
有些人對 m4 非常著迷,他們先是用 m4 解決一些簡單的問題,然后解決了一個比一個更大的問題,直至掌握如何編寫一個復雜的 m4 宏集。若癡迷于此,往往會對一些簡單的問題寫出復雜的 m4 腳本,然后耗費很多時間去調試,反而不如直接手動解決問題更有效。所以,對于程序猿中的強迫癥患者,要對 m4 有所警惕,它可能會危及你的健康。這也不是我說的,是 m4 手冊說的。
## m4 基本工作過程
上文提到『m4 是一種宏處理器,它掃描用戶輸入的文本并將其輸出,期間如果遇到宏就將其展開后輸出』,其實更正式的說,應該是:m4 從文本輸入流中獲取文本并將其發送到文本輸出流,期間如果遇到宏就將其展開后發送到文本輸出流。
在 Brian Kernighan 與 Dennis Ritchie 合著的《C Programming Language》中將流(Stream)定義為『與磁盤或其它外圍設備關聯的數據的源或目的地』。基于這個定義,m4 的輸入流就是與磁盤或其它外圍設備關聯的數據的源,其輸出流就是與磁盤或其它外圍設備關聯的數據的源或目的地,只不過 m4 希望它的輸入流與輸出流的內容是文本。如果你不那么較真,可以將流理解為文件,對于 m4 而言,就是文本文件,但是下文會堅持使用流的概念。
> m4 使用流的概念并非巧合,如果說巧合,也只是因為它的作者恰好也是 Brian Kernighan 與 Dennis Ritchie。
m4 是如何從輸入流中獲取文本并將其發送到輸出流的?肯定不是簡單的讀取文本就了事,因為 m4 有一個任務是『遇到宏就將其展開』。這意味著 m4 在從輸入流中讀取文本的過程中至少需要檢測所讀取的某段文本是不是宏。也就是說,從 m4 的角度來看,它首先要將輸入流所提供的文本分為兩類:宏與非宏。如果 m4 讀取的是一段文本是非宏,它基本上會將它們直接發送到輸出流。之所以說是『基本上』,是因為非宏的文本會被進一步分類處理,其中細節后文會講。如果 m4 讀取的文本片段是宏,m4 就會將它展開,然后將展開結果發送到輸出流。
m4 的工作過程具有一定程度的即時性,它不需要將輸入流中全部信息都讀取出來,然后再進行處理,而是扮演了一種過濾器的角色。從用戶的角度來看,文本流入 m4,然后又流出。
從圖靈的角度來看 m4,輸入流與輸出流可以銜接起來構成一條無限延伸的紙帶,m4 是這條紙帶的讀寫頭,所以 m4 是一種圖靈機。事實上,m4 的確是一種圖靈機。因此 m4 的計算能力與任何一種編程語言等同,區別只體現在編程效率以及所編寫的程序的運行效率方面。感覺基于 m4 來講解計算機原理還是挺不錯的。
## m4 的工作空間
m4 既然是圖靈機,它至少需要有一個『狀態寄存器』,否則它無法判斷當前從輸入流中讀取的文本是宏還是非宏。為了提高文本處理效率,還應該有一個緩存空間,使得 m4 在這一空間中高效工作。現代的 CPU,沒有緩存的應該很罕見。
m4 緩存的容量為 512KB。當它滿了的時候,m4 會將自動將其中的內容妥善的保存到一份臨時文件中備用。所以,只要你的磁盤或其它外圍設備的容量足夠,就不要擔心 m4 無法處理大文件。
> 注意,m4 緩存,這個概念是我瞎杜撰的。GNU m4 官方文檔沒這個概念,官方的概念是轉移(Diversion)。
類似 CPU 的多級緩存,m4 的緩存空間也是劃分了級別的。符合 POSIX 標準的 m4,可將緩存空間劃分為 10 種級別,編號依次為?`0, 1, 2, ..., 9`。GNU m4 對緩存空間的級別數量不作限制。
m4 默認在 0 號緩存中工作,它在這個緩存對文本進行處理,然后將其發送到輸出流。使用 m4 內建的宏?`divert`,可以從當前緩存切換到其他緩存。例如:
~~~
divert(3)
~~~
就從當前的緩存切換到 3 號緩存了,然后 m4 就在 3 號緩存中對輸入流中的文本進行處理。如果不繼續使用?`divert`?進行緩存切換,m4 會一直在 3 號緩存中工作,直到輸入流終結。最后,m4 會將各個緩存中的文本匯總到 0 號緩存中。
緩存的匯總過程是按照緩存級別進行的。m4 會根據緩存級別的編號的增序進行匯總。例如,它總是先將 1 號緩存的內容匯總到 0 號緩存中,然后將 2 號緩存的內容匯總到 0 號緩存中,以此類推,最后將 0 號緩存中的內容依序發送到輸出流中。
劃分了級別的緩存,像是一道一道分水嶺,使得文本流像河流一樣擁有支流,不同的支流最終又匯集到一起,奔流到海……是不是有些氣勢恢宏的感覺,然而你也應該考慮到這樣的現實:百川東到海,何時復西歸?也就是說,文本流經 m4 的過程也像河流入海一樣的不可逆。這是宏最大的弱點。在程序中濫用宏,形同過度開采水資源。
軟件領域有一門學科,叫逆向工程,研究如何借助反匯編技術重現某個程序的原有邏輯。具體技術我不是很了解,但是幸好有這門學科,否則我的顯卡很難在新版本的 Linux 內核上工作。因為 Nvidia 官方的 Linux 驅動自某個版本之后就宣布不再支持我這種型號的顯卡了,而 Nvidia 官方驅動已經被大神實施逆向工程產生了 Nouveau 驅動,而后者又被集成到了 Linux 內核中。
似乎跑題了,我想表達的是,逆向工程固然能夠在一定程度上復原某個程序的源碼,但它卻永遠無法基于宏的展開結果重現宏的定義,只有宏的作者才知道當初究竟發生了什么。
這時,你應該有一個問題。如果你真的想學習 m4,那就必須要有這個問題——m4 為什么要對緩存劃分級別?回顧一下上文,各個緩存的匯總過程是遵循特定次序的。有了這種分級的緩存匯總機制,你就有能力借助緩存來控制文本的支流,決定哪條支流先匯入 0 號緩存。你可以說這樣你有機會扮演大禹,但是我覺得這更像鐵路調度員所做的事。對于鐵路調度員而言,文本流是他要調度的一組列車。
## 暗黑緩存
更有趣的是,m4 也提供了暗黑緩存,它的編號是?`-1`。GNU m4 對暗黑緩存也不限制數量,只要它們的編號是負數就可以。
暗黑緩存,似乎有點恐怖,實際上你可以將它們理解為地下河。也就是流過暗黑緩存的文本,m4 會將它們匯總到 0 號緩存,匯總過程按照暗黑緩存編號的遞減次序進行的,但是 m4 不會將暗黑緩存匯總的內容發送到輸出流。這沒什么不好理解的,現實中沒有什么東西是負數的。
在 m4 的應用中,暗黑緩存的主要作用就是作為宏定義的空間。如果在 0 號緩存定義一個宏,例如:
~~~
divert(0)
define(say_hello_world, Hello World!)
~~~
定義了一個名為?`say_hello_world`?的 m4 宏。宏定義語句『展開』為一個長度為 0 的字符串,然后發送到輸出流。長度為 0 的字符串,就是空文本,即使它被發送到輸出流,對輸出流不會產生任何影響,但是?`say_hello_world`?宏之前,也就是`divert(0)`?之后存在一個換行符,m4 會將這個換行符發送到輸出流。除非你原本就希望輸出流中需要這個換行符,否則你就在輸出流中引入了一個額外的換行符,通常情況下,它不是你想要的結果。為了更好的說明這一點,可以看下面的示例:
~~~
divert(0)
define(say_hello_world, Hello World!)
say_hello_world
~~~
這個示例就是在上述代碼中又增加了一行文本,它表示調用了上一行所定義的?`say_hello_world`?宏。假設示例代碼保存在 hello.m4 文件中,然后執行以下命令:
~~~
$ m4 hello.m4
~~~
此時,hello.m4 就是 m4 的輸入流。m4 從輸入流中讀取文本,處理文本,然后將處理結果發送到輸出流。此時,輸出流是系統的標準輸出設備(stdout),也就是當前的終端屏幕。
執行上述命令后,我們期望的結果通常是:
~~~
$ m4 hello.m4
say_hello_world
~~~
然而,m4 輸出的卻是:
~~~
$ m4 hello.m4
Hello World!
~~~
`Hello World!`?前面出現了兩處空行,一處是?`divert`?語句后面的換行符導致的,另處是?`say_hello_world`?宏定義語句后面的換行符導致的。
如果將?`say_hello_world`?宏定義語句放在暗黑緩存中,可以解決一半問題。例如:
~~~
divert(-1)
define(say_hello_world, Hello World!)
divert(0)
say_hello_world
~~~
再次執行 m4 命令,可得:
~~~
$ m4 hello.m4
Hello World!
~~~
現在?`Hello World!`?前面只有 1 處空行了,它是?`divert(0)`?后面的換行符導致的。要消除它,有兩種方法。第一種方法就是?`divert(0)`?后面不換行,例如:
~~~
divert(-1)
define(say_hello_world, Hello World!)
divert(0)say_hello_world
~~~
另一種方法是使用 m4 內建的?`dnl`?宏,它會從將它被調用的位置到后面的第一個換行符之間的文本(包括換行符本身)一并刪除,例如:
~~~
divert(-1)
define(say_hello_world, Hello World!)
divert(0)dnl
say_hello_world
~~~
這兩種方法輸出的結果是相同的。為了讓文本具有更好的可讀性,通常用?`dnl`?來做這樣的事。
## 挑戰
(1) 對于以下 m4 代碼
~~~
divert(-1)
define(say, )
define(hello, HELLO)
define(world, WORLD!)
divert(0)dnl
say hello world
~~~
推測一下 m4 的處理結果,然后執行 m4 命令檢驗所做的推測是否正確。
(2) 對于以下 m4 代碼
~~~
divert(2)
define(say, )
define(hello, HELLO)
divert(1)
define(world, WORLD!)
divert(0)dnl
say hello world
~~~
推測一下 m4 的處理結果,然后執行 m4 命令檢驗所做的推測是否正確。