面向指標程式設計

發表於2016-03-15

物件導向程式設計,面向設計模式程式設計(亦即設計模式),面向介面程式設計,面向模板程式設計(亦即泛型程式設計),面向函式程式設計(亦即函數語言程式設計),面向多核時代的並行程式設計,面向大資料的機器學習程式設計……這麼多年,大家要面向的東西已經夠多了,然而我看到的現象是,很多程式語言讓大家面向 xxx 的同時在竭力迴避指標。我可不想面向這麼多東西,所以我只好加入指標的黑暗勢力。我要不自量力的來寫一篇《面向指標程式設計》作為投名狀,藉以表示我與軟體世界的光明勢力的徹底決裂。

這個世界上,提供指標的程式語言很少,這樣的語言有組合語言、C/C++ 以及 Pascal 等。Pascal 我沒學過。組合語言過於黑暗,我現在功力還不足以駕馭它。C++,我覺得它簡直是黑暗勢力中的敗類——它試圖掙脫指標,走向光明,結果卻出了一堆么蛾子。所以我還是俗套的選 C 語言來闡述指標的黑暗力量。

閱讀本文之前,請讀三遍無名師說的話:當尊者 Ritchie 發明 C 時,他將程式設計師放到緩衝溢位、堆損壞和爛指標 bug 的地獄中懲罰。然後自我安慰一下,如果地獄未能使我屈服,那麼我會比地獄更黑暗更強大。

指標是什麼?

記憶體是以位元組為單位的一個很大但是又經常不夠用的空間。指標是記憶體中 x 個連續的位元組中儲存的資料——在 32 位的機器上,x 的值為 4;在 64 位機器上,x 值為 8。為了敘述的簡便,本文只在 64 位的機器上談論指標。

指標是一種資料,這沒什麼稀奇的。從機器的角度來看,程式的一切是存放在陣列中的資料。只有那些自作多情的程式猿才會像亞里士多德一樣自作多情的認為程式是由物件 + 方法或者許多函式複合而成的。事實上,從最遠離機器的 Lisp 語言的角度來看,程式的一切也都是資料,存放在表中的資料。如果忽視程式本身就是資料這個客觀事實,程式猿們很容易就走上了形而上學的道路,然後他們會度過漫長的、罪惡的、痛苦的中世紀,膜拜著一個又一個神棍,當然期間也出現了幾位聖·奧古斯丁。

那麼,指標中儲存著什麼資料?記憶體地址。

記憶體是以位元組為單位的空間,其中每個位元組都伴隨著一個地址,這個地址機器賦予的,並不是我們的程式編制的。你可以將整個記憶體空間想象成一棟大樓,將位元組想象為大樓中每個房間,將每個位元組的地址想象為房間的門牌號,於是指標中儲存的資料就類似於門牌號。

如果你從未學過 C 語言,讀到此處可能會問,我們為什麼要在記憶體中儲存記憶體地址?不知你是否住過賓館。在正規的賓館裡,每個房間的門後都會貼著逃生路線圖,圖中『儲存』了該賓館與你的房間同一樓層內的全部房間的門牌號以及它們的佈局。如果你住酒店時從來也不看逃生路線圖,那麼從現在開始,入住酒店後第一件事就是認真的看一下它,關鍵時刻它能救你一命。在記憶體中儲存記憶體地址,雖然不是救你性命的,但是可以藉此構造與賓館逃生路線圖相似的抽象事物——記憶體資料的抽象與複合。

記憶體空間的有名與無名

現在來看兩行 C 程式碼:

foo 是什麼?foo 表示一個記憶體地址。foo 前面的 int 是資料型別修飾,它表示 foo 是記憶體中 4 個連續位元組的首位元組地址( 64 位機器上,int 型別的資料長度為 4 個位元組)。C 編譯器總是會根據某個記憶體地址相應的型別來確定以該記憶體地址起始的一段連續位元組中所儲存的資料的邏輯意義。因此,當我們用 int 型別來修飾 foo,編譯器就會認為以 foo 開始的連續 4 個位元組中儲存的資料是一個整型資料。在上述程式碼中,這個整型資料是 10,我們通過賦值運算子 = 將這個整型數儲存到記憶體中以 foo 地址開始的連續 4 個位元組中。

從此刻開始,要記住一個事實,那就是 C 語言中所有的變數名,本質上都是記憶體地址。之所以不直接使用記憶體地址,而是使用一些有意義的名字,這就類似於沒人願意用你的身份證號來稱呼你,大家更願意用你的姓名來稱呼你。

由於 C 語言認為資料的長度是由其型別確定的。例如,int 型別的資料長度是 4 個位元組,char 型別的資料長度是是 1 個位元組,使用者自定義的 struct 型別的資料長度則是根據實際情況而待定。在這種情況下,所有表示記憶體地址的名字,它們實質上表示的是記憶體中各種型別資料儲存空間的起始地址——專業一點,就是基地址。凡是用名字來表示基地址的記憶體空間,我們就將其稱為有名的記憶體空間

再來看 bar 是什麼?bar 是記憶體地址的名字,由於 bar 前面有個 * 號,這表示我們打算在以 bar 為基地址的連續 8 個位元組中儲存一個記憶體地址(別忘了,我們是在 64 位機器上,指標資料的長度是 8 個位元組)——foo 所表示的那個地址,亦即 &foo。在這裡, & 是取值符,它會對 foo 說,你甭給我耍花樣了,老實交代你的身份證號!在* 之前還有 int,這意味著在以 bar 為基地址的連續 8 個位元組中儲存的那個記憶體地址是某個用於儲存整型資料的記憶體空間的基地址。

由於 bar 是某個記憶體空間的基地址,而這個記憶體空間中儲存的是一個記憶體地址,所以 bar 就是所謂的指標。在這裡,我們可以認為 bar 是對某塊以 foo 為基地址的記憶體空間的『引用』,也就是在一個房間號為 bar 的房間裡儲存了房間號 foo。按照 C 語言教材裡常用的說法,可將 int *bar = &foo 這件事描述為『指標 bar 指向了整型變數 foo』,然而事實上記憶體裡哪有什麼針,哪有什麼指向?一切都是記憶體空間的引用。在上面的例子裡,我們是用 foo 來直接引用某個記憶體空間,然後又使用 bar 來間接引用某個記憶體空間。

在上面的例子裡,bar 引用的是一個有名的記憶體空間。那麼有沒有無名的記憶體空間呢?看下面的程式碼:

malloc(sizeof(int)) 就是一個無名的記憶體空間,因為它是一個表示式,而這個表示式描述的是一系列行為,行為需要藉助動詞來描述,而無法用名詞來描述。比如『我在寫文章』,這種行為無法只使用名詞來描述,必須藉助動詞。任何會終止的行為都可表示為一系列的狀態的變化,也就是說任何會終止的行為都會產生一個結果,而這個結果可以用名詞來描述。例如 malloc(sizeof(int)) 這個行為就是可終止的,它的結果是它在記憶體所開闢 4 個位元組的空間的基地址,這個基地址是沒有名字的,所以它就是個無名的基地址,因此它對應的記憶體空間就是無名的記憶體空間。在上例中,我們將這個無名的記憶體空間的基地址儲存到了一個有名的記憶體空間——以 bar 為基地址的記憶體空間。

C 語言的創始人—— Dennis Ritchie 與 Brian Kernighan 將帶名字的儲存空間稱為物件(Object)——並非『物件導向程式設計』中的物件,然後將指代這個物件的表示式稱為左值(lvalue)。也就是說,在 C 語言中,上例中的 foobar 都是左值,因為它們總是能夠出現在賦值符號的左側。

看下面的程式碼:

第三行的 printf 語句中的 *bar 也是一個左值,因為它指代了一個有名字的儲存空間,這個儲存空間的名字就叫做 *bar。這個儲存空間其實就是以 foo 為基地址的儲存空間。在表示式 *bar 中, * 號的作用是解引用,就是將以 bar 為基地址的記憶體空間中儲存的記憶體地址取出來,然後去訪問這個記憶體地址對應的記憶體空間。由於 *bar 的型別是 int,所以程式自身就可以知道要訪問的是以 *bar 為基地址的 4 個位元組,因此它可以準確無誤的將整型資料 10 取出來並交給 printf 來顯示。

指標最黑暗之處在於,當你拿到了一塊記憶體空間的基地址之後,你可以藉助這個基地址隨意訪問記憶體中的任何區域!也就是說,你可以從通過指標獲得記憶體空間的入口,然後你可以讓你的程式在記憶體中隨便逛,隨便破壞,然後你的程式可能就崩潰了。你的程式如果隱含緩衝區溢位漏洞,它甚至可被其他程式控制著去執行一些對你的系統非常不利的程式碼,這就是所謂的緩衝區溢位攻擊。C 語言不提供任何緩衝區保護機制,能否有效保護緩衝區,主要取決於你的 C 程式設計技藝。

現在我們寫 C 程式時,基本上不需要擔心自己的程式會遭遇緩衝區溢位攻擊。因為只有那些被廣泛使用的 C 程式才有這種風險;如果很不幸,你寫的 C 程式真的被很多人使用了,那也不需要太擔心。《深入理解計算機系統》在 3.12 節『儲存器的越界引用和緩衝區溢位』中告訴我們,現代作業系統對程式執行時所需要的棧空間是隨機生成的,導致攻擊者很難獲得棧空間中的某個確定地址,至少在 Linux 系統中是這樣子。C 語言編譯器提供了棧破壞檢測——至少在 GCC 中是這樣,其原理就是程式的棧空間放置了一隻『金絲雀』,程式在執行中一旦發現有襲擊『金絲雀』的可恥程式碼,它就會異常終止。處理器層面也對可執行程式碼所在的記憶體區域進行了限定,這樣攻擊者很難再向程式的棧空間插入攻擊系統的可執行程式碼了。

棧與堆

如果我說 C 語言是一種部分支援垃圾記憶體回收的語言……你可能會認為我腦子壞掉了。事實上,C 語言中的所有的區域性變數包括指標超出作用域時,它們所佔據的儲存空間都會被『回收』。這算不算記憶體垃圾回收?

從 C 程式的角度來看,記憶體並非一個以位元組為單位的一個很大但是又經常不夠用的空間,不是一個,而是兩個。其中一個空間叫棧,另一個空間叫堆。可被 C 程式『回收』儲存空間是棧空間。也就是說,在一個函式中,所有的區域性變數所佔據的儲存空間屬於棧空間。可能再說的學術一點,就是絕大多數左值都在棧空間(有人在很遙遠的地方指出,全域性變數是左值,但它不在棧空間,它與程式同壽,與程式齊光)。

當一個函式執行結束,它所佔據的棧空間就不再屬於它了,而是將會被一個新的待執行的函式佔據。所以,從本質上說,C 程式對棧空間的回收都不屑一顧,因為它根本不回收,而是舊的資料會被新的資料覆蓋。

堆空間,我們在程式裡無法直接訪問,只能藉助指標。因為堆空間的記憶體地址可被指標引用。例如,當使用 malloc 分配空間時,所分配空間的基地址總是儲存在一個位於棧空間的指標中的。

棧空間通常遠遠小於堆空間,即便如此也幾乎不會出現某個函式會耗盡棧空間的現象。如果這種現象出現了,那隻能證明造出這種現象的程式猿應該繼續學習 C 語言了。棧空間被耗盡,往往是因為有些程式本來是寫成遞迴,但可能是程式碼寫錯了,導致遞而不歸;還有一種可能是遞迴層次太深,這時可以想辦法在堆空間中模擬一個棧來解決。還有一種情況就是在函式中定義了很大的陣列,導致棧空間放不下……這種情況總是可以靠分配堆空間來解決。

資料的抽象

當你具備了一些 C 程式設計基礎,並且能夠理解上文中的內容,那麼你就可以對各種型別的資料進行抽象了。

我們為什麼要對資料進行抽象?《計算機程式的構造和解釋》的第 2 章的導言部分給出了很好的答案,即:許多程式在設計時就是為了模擬複雜的現象,因為它們就常常需要構造出一些運算物件,為了能夠模擬真實世界中的現象的各個方面,需要將運算物件表示為一些元件的複合結構。

下面來對自行車鏈的任意一個鏈節進行模擬:

然後我們可以造出 3 個鏈節,然後可以造出世界上最短的車鏈:

如果再多造一些鏈節,就可以得到周長大一些的車鏈,也能夠製造出各種形狀的多邊形,但是最好是藉助無名的記憶體空間。下面的程式碼可以建立一條具有 1000 個鏈節的鏈條:

如果我們將前面那個示例中的 ab, c 視為三角形的三個頂點,那麼我們所創造的三個鏈節構成的鏈條就變成了一個三角形。同理,上述所建立的 1000 個鏈節的鏈條就變成了一個 1000 條邊首尾相接的多邊形。如果學過拓撲學,那麼自然可以發現任何與圓環同胚的結構都可以基於 struct chai_node 這種資料結構模擬出來,而我們所仰仗的東西僅僅是將三個指標封裝到一個結構體中。

事實上,struct chain_node 中的第三個指標 void *shape 還沒被用到。這是一個 void * 型別的指標,是喜歡用 C 程式碼玩各種抽象的程式猿的最愛,因為它能引用任何型別資料所在記憶體空間的基地址。這就意味著 struct chain_node 可以藉助 shape 指標獲得強大的擴充套件能力。

現在,我要製造一種很簡陋的鏈節,它的形狀僅僅是一個矩形的小鐵片,上面打了兩個小圓孔。我將它的資料結構設計為:

基於這些資料結構,我就可以寫出一個專門用來製造矩形小鐵片的函式:

然後再為 create_chain_node_shape 所接受的兩種引數寫出相應的建構函式:

為了讓 create_circle 更方便使用,最好再建立一個 struct point 的建構函式:

一切所需要的構件都已準備完畢,現在可以開始生產某種特定型號的鏈節了,即:

最後再將製造鏈條的程式碼略作修改:

現在我們所模擬的車鏈與現實中的車鏈已經有些形似了。上述程式碼雖然有些冗長,下文會對其進行重構,現在先來總結一下上述程式碼中指標的用法。

仔細觀察上述程式碼中我們所定義的結構體,它們的共同特徵是:所有非 C 內建的資料型別都是結構體型別,當它們作為某個結構體成員型別時均被宣告為指標型別。為什麼要這樣?如果你真的打算問這個問題,那麼就請你觀察一下上述的 5 個 create_xxx 函式,你會發現這些 create 函式的引數與返回值也都是結構體型別的指標。將這些現象綜合起來,可以得出以下結論:

  1. 將結構體指標作為函式的引數與返回值,可以避免函式呼叫時發生過多的記憶體複製。
  2. 當一個結構體型別作為其他結構體的成員型別時,將前者宣告為指標型別,可以在後者的 create 函式中避免繁瑣的解引用。
  3. void * 指標可以引用任意型別的資料儲存空間的基地址。例如在 create_chain_node 函式的定義中,我們將一個 struct chain_node_shape 型別的指標賦給了 void * 型別的指標 shape

這三條結論是指標在資料抽象中的慣用手法,它不僅關係到資料結構的設計,也關係到資料結構的構造與銷燬函式的設計。(上述程式碼為了省事,沒有定義資料結構的銷燬函式)

資料再抽象

上一節的程式碼有些冗長,我們可以嘗試對其進行精簡。首先看下面這三個結構體及其 create 函式:

顯然,這些程式碼長的太像了!那四個結構體都是儲存兩個成員的結構體,而相應的 create 函式也無非是將函式所接受的引數儲存到結構體成員中。有沒有辦法用很少的程式碼來表示它們?有!

既然每個結構體都儲存 2 個成員,那麼我們就先將上述程式碼刪掉,然後定義一個 pair 型別的結構體:

pair 結構體中,我們用了兩個 void * 指標,只有如此我們方能很自信的說 pair 可以儲存任意型別的兩個資料。接下來,只需修改 create_chain_node 函式的定義:

我勇敢的承認這個基於 struct paircreate_chain_node 函式太醜陋了,但是我們總算是消除了大量的結構體及其建構函式了,而且整體程式碼量減少了大約 1/6。

仔細觀察上述程式碼,顯然下面的三段程式碼存在著高度的重複:

這三段程式碼都在向 pair 結構體中存入兩個 double * 型別的資料。既然如此,我們可以專門寫一個函式,讓它生成面向 double *pair 結構體,即:

然後再次重構 create_chain_node 函式:

山重水複疑無路

經過再次重構後的 create_chain_node 看上去要好了一些,但是依然有兩段程式碼存在高度重複:

但是僅從 pair 結果體層面已經無法對這兩段程式碼進行簡化了,而且我又非常不想寫一個像下面這樣的輔助函式:

雖然 create_hole 能夠將上述兩段重複的程式碼簡化為:

但是與 pair_for_double_type 函式相比,create_hole 這個函式的應用範圍非常狹小。由於 pair_for_double_type 函式可以將兩個 double 型別的資料儲存到 pair 結構體中,在我們的例子中建立二維點與矩形可以用到它,在科學計算中建立極座標、複數以及所有的二次曲線方程式也都都能用到它,但是 create_hole 卻只能在建立車鏈這件事上有點用處。也就是說,正是因為 pair_for_double_type 函式所取得的成功,導致我們認為 create_hole 的品味太低。我們應該想一想還有沒有其他途徑可以消除上述程式碼的重複。

仔細分析 left_holeright_hole 的構造過程,不難發現 holecenterradius 這兩種資料的型別不一致是造成我們難以對上述重複的程式碼進行有效簡化的主要原因,create_hole 之所以能夠對上述重複的程式碼進行大幅簡化,是因為它根據我們的問題構造了一個特殊的 pair 結構體——姑且稱之為 X,這個結構體的特殊之處在於其 first 指標儲存的是一個面向 double * 的同構型別的 pair 結構體,其 second 指標則儲存了一個 double 型別資料的基地址。正是因為 X 的結構太特殊了,所以導致 create_hole 這種抽象的應用範圍過於狹隘,以至於現實中只有圓形比較符合這種結構體。

既然是異構的 pair,而我們已經實現了一個可以建立儲存 double 型別資料的 pair 的函式 pair_for_double_type,這個函式的結果是可以直接存入異構 pair 中的。現在我們缺少只是一個可以將 double 值轉化為可直接存入異構 pair 的函式,即:

有了這個函式,就可以對 create_chain_node 繼續進行簡化了:

而且,基於 malloc_double 函式,還能對 pair_for_double_type 函式進行簡化:

事實上,如果我們再有一個這樣的函式:

還能對 reate_chain_node 再做一步簡化:

看到了吧,只要略微換個角度,很多看似難以簡化的程式碼都能得以簡化。這個簡化的過程一直是在指標的幫助下進行的,但事實上,當你的注意力一直集中在怎麼對程式碼進行簡化時,指標的使用簡直就是本能一樣的存在,以至於你覺得你並沒有藉助指標的任何力量,完全是你自己的邏輯在指導著你的行為。在這個過程中,無論是物件導向還是面向模板,都很難將你從冗長的程式碼中拯救出來……

面向什麼,可能就會失去未面向的那些

在上文中模擬車鏈的程式中,我一開始是用物件導向的方式來寫的,所以我造出了 5 個結構體,分別描述了二維點、矩形、圓形、鏈節形狀以及鏈節等物件,結果卻出現了一大堆繁瑣的程式碼。雖然物件導向程式設計,在思維上是非常簡單的,那就是現實中有什麼,我們就模擬什麼。但是你認真思考一下,現實中其實很多東西都有共性,如果你傻乎乎的去逐個模擬,而忽略它們的共性,那麼你的程式碼絕對會非常臃腫。

當然,物件導向程式設計也提倡從所模擬的事物中提取共性,然後藉助繼承的方式來簡化程式碼。但是一旦信仰了類與繼承,你能做的最好的抽象就是對某一類事物進行抽象,比如你能夠對『車』類的事物進行抽象,但是你卻無法將對『飛機』和『車』這兩類中的事物進行抽象。顯然,飛機與車是有共性的,例如它們都能載客,都有儀表盤,都有窗戶,都有座位,都有服務員……

當我發現基於物件導向創造的那些結構體存在著一個共性——它們都包含著兩個成員,很自然的就會想到我應該製造一個包含著兩個任意型別的結構體 pair,然後用 pair 來容納我需要的資料。當物件導向程式設計正規化在你的思想中根深蒂固,這種簡單的現象往往會被忽略的,特別是你已經滿足於你寫的程式已經能夠成功的執行之時。

接下來,當我試圖用 pair 結構體取代二維點、矩形、圓形、鏈節形狀等結構體的時候,我就開始走上了『泛型』的道路。C 語言裡沒有 C++ 模板這種工具可以用,所以我只能依賴 void *,而且為了簡化 double 型別的資料向 void * 的轉化,所以定義了:

如果你對 C++ 的泛型程式設計有所瞭解,一定會覺得 pair_for_double_type 函式其實就是對 pair 進行特化。因為本來我是希望 pair 能儲存任意型別的資料的,但是現在我需要頻繁的用它來儲存一對 double 型別的資料,那麼我就應該去製造一個專用的 pair 結構。

當我發現我需要頻繁的產生 pair 例項,並向它的 firstsecond 指標中儲存某些型別的資料儲存空間的基地址,所以我就將這種共性抽象為:

最終使得 create_chain_node 函式的定義即簡潔又清晰:

原來我用物件導向程式設計正規化所寫的程式碼是 104 行,換成泛型程式設計正規化所寫的程式碼是 75 行。那麼我可以斷定,是泛型程式設計拯救了物件導向嗎?當然不能!因為我們的程式還沒有寫完,我們還需要物件導向。

物件的迴歸

先擺出 create_chain_node 函式:

create_chain_node 函式可以建立鏈節,它是藉助很抽象的 pair 結構體將很多種型別的資料層層封裝到了 chain+node 結構體中,那麼我們如何從 chain_node 結構體中提取這些資料,並使之重現它們所模擬的現實事物?

例如,我們怎樣從 chain_node 結構體中獲取一個 left_hole 的資訊?顯然,下面的程式碼

並不能解決我們的問題,因為 left_hole 中只是兩個 void * 指標,而我們需要知道的是 left_hole 的中心與半徑。那麼我們繼續:

依然沒有解決我們的問題,因為我們想要的是 left_hole 的中心,而不是一個包含著兩個 void * 指標的 center,所以需要繼續:

最後我們得到了三個 double 型別的資料,即 center_x, center_y, radius,於是似乎我們的任務完成了,但是你如何將上述過程寫成一個函式 get_left_hole? C 語言中的函式只能有一個返回值。如果通過函式的引數來返回一些值,那麼 get_left_hole 是能寫出來的,例如:

但是,如果你真的這麼寫了,那隻能說明再好的程式語言也無法挽救你的品味。

我們應該繼續挖掘指標的功能,像下面這樣定義 get_left_hole會更好一些:

好在哪?我們充分利用了 C 編譯器對資料型別的隱式轉換,這實際上就是 C 編譯器的一種編譯期計算。這樣做可以避免在程式碼中出現 *((double *)(...)) 這樣的程式碼。void * 指標總是能通過賦值語句自動轉換為左值,前提是你需要保證左值的型別就是 void * 的原有型別。這是 C 語言的一條清規戒律,不能遵守這條戒律的程式猿,也許再好的程式語言也無法挽救他。

C++ 這個叛徒,所以無論它有多麼強大,也無法拯救那些無法保證左值的型別就是 void * 原有型別的程式猿。用 C++ 編譯器迫使程式猿必須將

寫成:

否則程式碼就無法通過編譯。這樣做,除了讓程式碼更加混亂之外,依然無法挽救那些無法保證左值的型別就是 void * 原有型別的程式猿,只會讓他們對裸指標以及型別轉換這些事非常畏懼,逐漸就走上了惟型別安全的形而上學的道路。C++ 11 帶來了新的智慧指標以及右值引用,希望他們能得到這些新 C++ 式的拯救吧。

當我們用物件導向的思路實現了 get_left_hole 之後,就可以像下面這樣使用它:

一切都建立在指標上了,只是在最後要輸出資料的需用 * 對指標進行解引用。

上述程式碼中有個特點,left_hole 並不佔用記憶體,它僅僅是對 t 所引用的記憶體空間的再度引用。可能有人會擔心 left_hole 具有直接訪問 t 所引用的記憶體空間的能力是非常危險的……有什麼危險呢?你只需要清楚 left_hole 只是對其他空間的引用,而這種直覺很容易在使用一段時間的指標之後就能夠建立。有了指標,你想修改 left_hole 所引用的記憶體空間中的資料,就可以 do it,不想修改就不去 do it,這有何難?如果自己並不打算去修改 left_hole 所引用的記憶體空間中的資料,但是又擔心自己或他人會因為失誤而修改了這些資料……你應該將這些擔心寫到有關 get_left_hole 函式的文件裡。試圖從語言自身的層面來確保記憶體空間的讀寫許可權,結果必然會讓程式碼充滿了與所解決的問題毫無關係的雜碎程式碼,你變成了裝在套子裡的人,你的程式變成了裝在套子裡的程式。

對於只需要稍加註意就可以很大程度上避免掉的事,非要從程式語言的語法層面來避免,這真的是小題大作了。如果我們在程式設計中對於 void * 指標的隱式型別正確轉換率高達 99%,為何要為 1% 的失誤而修改程式語言,使之充滿各種巧妙迂迴的技巧並使得程式碼愈加晦澀難懂呢?

《C 陷阱與缺陷》的作者給出了一個很好的比喻,在烹飪時,你用菜刀的時候是否失手切傷過自己的手?怎樣改進菜刀讓它在使用中更安全?你是否願意使用這樣一把經過改良的菜刀?作者給出的答案是:我們很容易想到辦法讓一個工具更安全,代價是原來簡單的工具現在要變得複雜一些。食品加工機一般有連鎖裝置,可以保護使用者的手指不會受傷。然而菜刀卻不同,如果給菜刀這種簡單、靈活的工具安裝可以保護手指的裝置,只能讓它失去簡單性與靈活性。實際上,這樣做得到的結果也許是一臺食品加工機,而不再是一把菜刀。

我成功的將本節的題目歪到了指標上。現在再歪回來,我們來談談物件。其實已經沒什麼好談的了,get_left_hole 返回的是泛型指標的型別具化,藉助這種型別具化的指標我們可以有效避免對 pair 中的 void * 指標進行型別轉換的繁瑣過程。

將函式變成資料

再來看一下經過大幅簡化的 create_chain_node 函式:

這個函式對於我們的示例而言,沒有什麼問題,但是它只能產生特定形狀的鏈節,這顯然不夠通用。如果我們想更換一下鏈節的形狀,例如將原來的帶兩個小孔的矩形鐵片換成帶兩個小孔的橢圓形鐵片,那麼我們將不得不重寫一個 create_elliptic_chain_node 函式。當我們這樣做的時候,很容易發現 create_elliptic_chain_node 函式中同樣需要下面這段程式碼:

如果我們要生產 100 種形狀的鏈節,那麼上述程式碼在不同的鏈節建構函式的實現中要重複出現 100 次,這樣肯定不夠好,因為會出現 500 行重複的程式碼。太多的重複的程式碼,這是對程式猿的最大的羞辱。

物件導向的程式猿可能會想到,我們可以為 chain_node 做一個基類,然後將上述共同的程式碼封裝到基類的建構函式,然後在各個 chain_node 各個派生類的建構函式中製造不同形狀的鏈節……在你要將事情搞複雜之前,建議先看一下這樣的程式碼:

看到了吧,我將 create_chain_node 函式原定義中負責建立鏈節形狀的程式碼全部的抽離了出去,將它們封裝到 rectangle_shape 函式中,然後再讓 create_chain_node 函式接受一個函式指標形式的引數。這樣,當我們需要建立帶兩個小孔的矩形形狀的鏈節時,只需:

如果我們像建立帶兩個小孔的橢圓形狀的鏈節,可以先定義一個 elliptic_shape 函式,然後將其作為引數傳給 create_chain_node,即:

這樣做,豈不是要比弄出一大堆類與繼承的程式碼更簡潔有效嗎?

在 C 語言中,函式名也是一種指標,它引用了函式程式碼所在記憶體空間的基地址。所以,我們可以將 rectangle_shape 這樣函式作為引數傳遞給 create_chain_node 函式,然後在後者中呼叫前者。

由於我們已經將 chain_node 結構體中的 shape 指標定義為 void * 指標了,因此對於 create_chain_node 函式所接受的函式,其返回值是 void * 沒什麼問題。不僅沒問題,更重要的是 void *(*fp)(void) 對所有不接受引數且返回指標型別資料的函式的一種抽象。這意味著對於鏈節的形狀,無論它的形狀有多麼特殊,我們總是能夠定義一個不接受引數且返回指標的函式來產生這種形狀,於是 create_chain_node 函式就因此具備了無限的擴充套件能力。

如果阿基米的德還活著,也許他會豪放的說,給我一個函式指標與一個 void *,我就能描述宇宙!

程式碼簡化的基本原則

當你採用一切都是物件的世界觀編寫程式碼時,一旦發現一些類之間存在著共性的資料抽象,這往往意味著你需要創造一種泛型的資料容器,然後用這種容器與具體型別的資料的組合來消除那些類。

當你打算從泛型的資料容器中取資料,並希望所取的資料能夠直觀的模擬現實中的事物時,這往往意味著你要創造一些資料結構,然後讓泛型的資料容器中儲存的資料流入這些資料結構中,從而轉化為有型別且具名的資料。這些資料結構就類似於各種各樣的觀察器或 Parser,我們通過它們解讀或修改泛型容器中的資料。

當某個函式 f 中有一部分程式碼是與具體的問題息息相關,而另一部分程式碼則與具體的問題無關。為了讓這個函式具備強大的擴充套件性,你需要將那些與具體問題息息相關的程式碼抽離到專用的函式中,然後再將這些專用函式傳遞給 f

迴避 C 指標是要付出代價的

在 C 語言中,在執行上述的程式碼簡化基本原則時,指標是最簡單明快的工具,像是著名廚師庖丁手裡的刀。在靜態型別語言中,任何企圖迴避指標的行為,必然會導致程式語言的語法複雜化或者削弱語言的表達能力。

在 C++ 中為了迴避指標,發明了引用——本質上一種被弱化了的指標,結果導致 C++ 初學者經常要問『什麼時候用指標,什麼時候用引用』這樣的問題。在智慧指標未問世之前,STL 提供的泛型容器無法儲存引用,為了避免在容器中儲存物件時發生過多的記憶體複製,往往需要將指標存到容器中。當某個函式在內部建立了一個比較大的物件時,這個函式想將這個物件傳遞給其他物件時,這時如果不借助指標,那隻能是將這個大物件作為返回值,然後引發了物件資料不止一次被複制的過程。如果在函式中 new 一個大物件,然後以指標的形式將其返回,這又與 C++ 一直想向使用者掩蓋指標的理想發生了矛盾……為了解決這個問題,終於在 C++ 11 裡搞出來一個挺複雜挺扭曲的右值引用的辦法,解決了在類的複製建構函式中偷偷的使用指標,但是類的使用者卻看不到指標這樣的問題……

Java 迴避指標的策略比 C++ 要高明一些。在 Java 中,即沒有指標也沒有引用。只要是類的例項(物件),無論是將其作為引數傳遞給函式,還是作為函式的返回值,還是將其複製給同類的其他物件,都是在傳地址,而不是在傳值。也就是說,Java 將所有的類例項都潛在的作為指標來用的,只有那些基本型別才是作為值來傳遞的。這種對資料型別進行了明確的區分的態度是值得點讚的,但是當 Java 想將一個函式(方法)傳遞給另一個函式(方法)時,程式碼就出現了扭曲,完全不能做到像 C 語言以指標的形式傳遞函式那樣簡潔直觀。

C# 在指標的處理上似乎要比 Java 好得多,但是將那些使用指標的程式碼標定為 unsafe,這是一種歧視,類似於『嗟,來食!』。另外 C# 的指標只能用於操作值型別,也不能在泛型程式碼中使用。

在動態型別語言中,例如 Python,據說是一切皆引用,這樣很好。也可以直接將一個函式作為引數傳遞給另一個函式,甚至還能在一個函式中返回一個函式,這樣更好。動態型別語言在語法、抽象能力、型別安全以及資源管理方面很大程度上超越了 C、C++、Java 這些靜態型別語言,但是用前者編寫的程式的計算速度卻往往比後者慢上一倍。

沒有完美的指標,也不會有完美的程式語言,這一切皆因我們是在機器上程式設計,而不是在我們的大腦程式設計,更不是在教科書裡程式設計。

C 程式猿的指標信條

篡改《步槍兵信條》,自娛自樂。

這是我的指標。雖有很多相似的,但這個是我的。我的指標是我的摯友,如同我的生命。我將運用它如同運用我的生命。指標沒了我便是廢物,我沒了指標便成為廢人。我將準確無誤的使用我的指標,我將比敵人用的更好,我將在他的程式速度超過我之前超過他,我會超過他的。

我與我的指標知道,程式設計中不論動用多麼優雅的語言,動用多麼強大的標準庫,面向多麼強大的程式設計正規化,都是沒意義的。只有解決問題才有意義。我們會解決的。

我的指標是人性的,就像我一樣,因為它如同我的生命。因此我將像對兄弟一樣地瞭解它。我將瞭解它的弱點,它的強項,它的構成,它所指的和指向它的。我將對指標持續建立完善的知識與技藝,使它們就如同我一般整裝待發。我們會成為彼此的一部分。

在上帝面前我對這信條宣誓。我與我的指標是計算機的守衛者,我們是問題的剋星,我們將拯救我的程式。但願如此,直到不需要程式設計,沒有問題,只有休息。

相關文章