記錄一個比較基礎的東東…… C 語言的指針,一直讓人又愛又恨,愛它的人覺得它既靈活又強大,恨它的人覺得它太過於靈活太過於強大以至於容易將人繞暈。最早接觸 C 語言,還是在剛進入大學的時候,算起來有好些年頭了;我當年做過的一個最糟糕的決定(也是如今回想起來依然覺得很 2B 的決定)也和 C 語言有關(和本文主題無關,略去不表)…… 由此說來,和 C 的緣分還是蠻重的。可惜,今天,我還是在一個關於指針的問題上,小小迷糊了一下…… 曾經還自詡熟讀《The C programming language》,真慚愧……
兩個案例。
1. 拿今天碰到的實際例子來展開講。
對於靜態類型的語言,「類型」的概念是根深蒂固、深入到原語操作級別的。如 int i = 5, j = 6; j = i; 里的第二條語句,就是將 i 所表征的一塊內存區域的內容,拷貝到 j 所表征的一塊同樣大小的內存區域里去,而內存區域的大小,則是 int 類型的 in-memory storage size(固定長度),即編譯期就完成的 sizeof (data_type) 操作結果。正是這種「面向機器的最淺抽象」,使得 C 語言變得很強大;你可以很清楚的了解,某個變量的長度是多少,因為其類型是固定的,而「變量名」本身,則不過是你希望記住的某 memory block 的「別名」。
切入正題。如下三行代碼(隱去了數據結構的具體名稱等細節),p 是新定義的指針(指向結構體類型 T),data_buffer 則是一種 void * 型內存塊,其中存儲的,實則是由指向結構體 T 的指針構成的「指針數組」。要做的很簡單,就是將位置在第 i 處的指針,拷貝給新定義的 p 指針。聽起來很簡單,對嗎?
struct T *p = nullptr; p = (struct T *) ((char *)data_buffer + elem_size * i); // code1 memcpy(&p, (char *)data_buffer + elem_size * i, sizeof(struct T *)); // code2 p = ((struct T **)data_buffer)[i]; // code3
ok,雖然這事兒不說也沒人知道,但哥還是決定自黑一下!=_=
首先,我隨手就寫上了第二行代碼(簡稱 code1 …)。
唔,這是值得被鄙視的一行代碼,更值得被鄙視的,是哥第一時間 code review 時並沒有發現其中的問題,debug 時才突然意識到掉進了這么經典的坑里,后悔不迭(就差面紅耳赤了)…… 問題在哪兒呢?
指針本身只是一個長度為 sizeof (void *) 的區域里存儲的 value,而 value 本身則是另一個變量的地址;使用指針類型的 explicit conversion(強制類型轉換)並賦值,如「p = (struct T *) q」,其含義,是指 q 本身是一個結構體的起始地址,從而將 q 的地址拷貝給 p 指針。
所以,上文里提到的 code1 自然是不對了。於是,我改成了第三行代碼即 code2。
這種寫法深刻秉承了「拷貝區塊」的機器操作理念,自然是沒錯了,可惜還是不夠美觀。趕着吃飯匆匆 commit 后,才在吃飯期間突然意識到,妹的,這不就是一個指針數組操作嘛!於是,最終形成了 code3 的模樣……
多么簡單的一個問題。多么深刻的領悟。多么痛的自黑。
2. 再舉一個 two star programming 的例子。
來源於一篇博客,也是 Linus Torvalds 在一次訪談里特地提及的「喜歡的 really core low-level kind of coding」。
既然已經把此文寫的如此深入淺出了,也就不怕索性再多花點時間,寫的更深入淺出一點了…… 囧 上圖:
從循環的 invariant(不變量)角度來看,entry 里存儲的,一直是下一個「判斷是否刪除」的鏈表實體,curr 里存儲的,則一直是「被判斷是否刪除的鏈表實體的前一個實體」。
這需要兩方面保證:一,是結構體第一個成員必須是 next 指針,否則 head / initial entry 會存在不一致性;二,傳入的參數是 double star pointer 以保證 head 指針本身可被修改,而不是像博客里第一種方法那樣,僅傳入一個 pointer 指針(C 語言的函數參數都是傳值調用),需要 return head 的返回值,否則也存在不一致性。
根據上面這個圖,是不是更容易理解博客代碼里,遍歷鏈表的機制呢? :-)
願天底下所有指針終成眷屬。