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

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

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

指針是什麼?

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

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

那麼,指針中存儲著什麼數據?內存地址。

內存是以位元組為單位的空間,其中每個位元組都伴隨著一個地址,這個地址機器賦予的,並不是我們的程序編製的。你可以將整個內存空間想像成一棟大樓,將位元組想像為大樓中每個房間,將每個位元組的地址想像為房間的門牌號,於是指針中存儲的數據就類似於門牌號。

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

內存空間的有名與無名

現在來看兩行 C 代碼:

int foo = 10;

int *bar = &foo;

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 引用的是一個有名的內存空間。那麼有沒有無名的內存空間呢?看下面的代碼:

int *bar = malloc(sizeof(int));

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

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

看下面的代碼:

int foo = 10;

int *bar = &foo;printf("%d", *bar);

第三行的 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 章的導言部分給出了很好的答案,即:許多程序在設計時就是為了模擬複雜的現象,因為它們就常常需要構造出一些運算對象,為了能夠模擬真實世界中的現象的各個方面,需要將運算對象表示為一些組件的複合結構。

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

struct chain_node {

struct chain_node *prev; struct chain_node *next; void *shape;};

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

struct chain_node a, b, c;

a.next = &b;b.prev = &a;

b.next = &c;

c.prev = &b;c.next = &a;a.prev = &c;

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

struct chain_node *head = malloc(sizeof(struct chain_node));

struct chain_node *tail = head;for (int i = 0; i next = new_tail; new_tail->prev = tail; tail = new_tail;}

tail->next = head;

head->prev = tail;

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

事實上,struct chain_node 中的第三個指針 void *shape 還沒被用到。這是一個 void * 類型的指針,是喜歡用 C 代碼玩各種抽象的程序猿的最愛,因為它能引用任何類型數據所在內存空間的基地址。這就意味著 struct chain_node 可以藉助 shape 指針獲得強大的擴展能力。

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

struct point {

double x; double y;};struct rectangle { double width;

double height;

};struct circle { struct point *center; double radius;};struct chain_node_shape { struct rectangle *body; struct circle *holes[2] ;};

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

struct chain_node_shape *

create_chain_node_shape(struct circle *c1, struct circle *c2, struct rectangle *rect){ struct chain_node_shape *ret = malloc(sizeof(struct chain_node_shape)); ret->body = rect; ret->holes[0] = c1; ret->holes[1] = c2; return ret;

}

然後再為 create_chain_node_shape 所接受的兩種參數寫出相應的構造函數:

struct circle *

create_circle(struct point *center, double radius){ struct circle *ret = malloc(sizeof(struct circle)); ret->center = center; ret->radius = radius; return ret;}struct rectangle *create_rectangle(double w, double h){ struct rectangle *ret = malloc(sizeof(struct rectangle)); ret->width = w; ret->height = h; return ret;}

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

struct point *

create_point(double x, double y){ struct point *ret = malloc(sizeof(struct point)); ret->x = x; ret->y = y; return ret;}

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

struct chain_node *

create_chain_node(void){ double radius = 0.5; double left_x = 1.0; double left_y = 1.0; struct point *left_center = create_point(left_x, left_y); struct circle *left_hole = create_circle(left_center, radius); double right_x = 9.0; double right_y = 1.0; struct point *right_center = create_point(right_x, right_y); struct circle *right_hole = create_circle(right_center, radius); struct rectangle *body = create_rectangle(10.0, 2.0); struct chain_node *ret = malloc(sizeof(struct chain_node)); ret->prev = NULL; ret->next = NULL; ret->shape = create_chain_node_shape(left_hole, right_hole, body); return ret;}

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

struct chain_node *head = create_chain_node();

struct chain_node *tail = head;for (int i = 0; i next = new_tail; new_tail->prev = tail; tail = new_tail;}tail->next = head;head->prev = tail;

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

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

將結構體指針作為函數的參數與返回值,可以避免函數調用時發生過多的內存複製。

當一個結構體類型作為其他結構體的成員類型時,將前者聲明為指針類型,可以在後者的 create 函數中避免繁瑣的解引用。

void * 指針可以引用任意類型的數據存儲空間的基地址。例如在 create_chain_node函數的定義中,我們將一個 struct chain_node_shape 類型的指針賦給了 void * 類型的指針 shape。

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


推薦閱讀:
相关文章