C語言數組與指針的那些事兒
在C語言中,要說到哪一部分最難搞,首當其沖就是指針,指針永遠是個讓人又愛又恨的東西,用好了可以事半功倍,用不好,就會有改不完的bug和通不完的宵。但是程序員一般都有一種迷之自信,總認為自己是天選之人,明知山有虎,偏向虎山行,直到最后用C的人都要被指針虐一遍。
指針
首先,明確一個概念,指針是什么,一旦提到這個老生常談且富有爭議性的話題,那真是1000個人有1000種看法。
在國內的很多教材中,給出的定義一般就是"指針就是地址",從初步理解指針的角度來說,這種說法是最容易理解的,但是這種說法明顯有它的缺陷所在。
"指針就是地址"這種說法相當於"指針=字面值地址(或者說一個具體的右值)",這種說法的錯誤所在就是弄錯了指針的本質屬性:指針是變量!
試想一下,如果指針是地址成立,那么二級指針怎么理解呢?地址的地址嗎,這明顯是錯誤的。
下面我們從指針是變量這個原則出發,來分析什么是指針:
- 作為一個變量,肯定有自己的地址
- 作為一個變量,肯定有自己的值,和普通變量的區別就是指針變量的值是地址。
- 從第二點延伸過來,既然指針變量的值是地址,那么那個地址上的內容就是指針變量指向的數據,指針的類型就是指針變量指向數據的類型。
- 指針有本身的類型,這個本身的類型區別於指向對象的類型。
在這里,最容易弄混的就是指針本身的類型和指針的類型,指針本身的類型是int型,一般情況下同一平台上所有類型指針都是一樣的(注①),長度則是平台相關,一般情況下32位機中為4字節,64位機中為8字節,事實上,指針的大小由處理器中所使用的地址總線寬度決定,指針本身的類型有什么意義呢?
(為什么說一般情況下同一平台上所有類型指針都是一樣,而不是所有情況呢?事實上,在某些地址總線寬度與數據總線寬度不同的特殊機器上指針類型可能不一致)
內存的訪問是以字節為單位的,同時指針的值為一個地址,指針的類型就直接決定了指針的所能表示地址的上界和下界,32位指針訪問范圍為0~2^32字節,所以是4GB。
注:以下討論中,對於指針指向數據的類型統一稱為指針的類型,這篇博客主要討論指針的類型而非指針本身的類型
而指針指向數據的類型則是在定義時指定的,比如int *ptr,char str,在這里,ptr指針的數據類型就是int型,而str指針指向的類型是char型,區分指針指向數據的類型主要是用在對指針解引用時的不同,指針的值是具體的某一個位置,指向數據的不同則代表解引用的時候所取數據的不同,當ptr為int類型時,表示在ptr表示的地址處取sizeof(int)個數據,依次類推。
指針的地址:如果一個指針變量存儲的值是另一個指針的地址,那這個指針就是二級指針,同樣的定義可以遞推到多級指針。
指針的操作
解引用:用*來獲取指針指向的數據,這個不用多說。
指針的運算:加減運算,需要注意的是,指針的加減運算的粒度是基於指針類型的長度,在下例中:
int *p = (int*)0x1000;
char *str = (char*)0x1000;
p++;
str++;
print("p=%d,str=%d\r\n",p,str);
輸出結果:
p=0x1004,str=0x1001
可以看到,p指向int型數據,p++就相當於p+sizeof(int),而str++就相當於str+sizeof(char).
關於指針定義的爭議
怎么樣定義一個指針大家都知道,在編程時通常有兩種寫法:
int* ptr;
int *ptr;
咋一看,這倆不是一樣嗎?如果你仔細觀察就可以發現其中的不同,第一種定義方法中靠近類型,而第二種靠近變量,看到這里,有些朋友就要說了,你個杠精!這不就是個寫法問題嗎,至於這么糾結嗎!
這還真不僅僅是個寫法問題。這兩種寫法背后代表着不同的邏輯:
-
第一種寫法的背后的邏輯是,將int作為一個整體,將其視為一個類型,即int、char*與int、char這些一樣,都是一種獨立的類型,再用這些類型來定義指針變量,從這個角度來看,指針是比較好理解的,而且看起來更能解釋得通。
-
第二種寫法的背后邏輯是,在指針的定義中,*僅僅是一個標識符,如int p,表明后面所接的變量p是一個指針變量,指向數據類型為int型。
其實在早期,大家一直都更傾向於通過第一種去理解指針,后來又有第二種看起來比較生澀的理解,為什么會這樣呢?我們來看下面的例子:int* p1,p2;
p2=p1;
我們來編譯這個例子,結果是這樣:warning: assignment makes integer from pointer without a cast [-Wint-conversion]
編譯信息顯示,p2為普通int型變量,而p1是int型指針變量,這明顯違背我們的初衷。如果要定義兩個指針變量,我們應該這么做:int p1,p2;
p2=p1;
相信到這里,大家能夠看出來了,第一種寫法背后邏輯的缺陷所在。
所以現在越來越多的專業書籍都推薦第二種寫法,畢竟作為一門底層語言,嚴謹性比易讀性要重要。
對教材錯誤寫法的小看法
說實話,博主學習C語言也是從國內教材開始,一開始接觸到的也是“指針就是地址”的概念,其實於我而言,這種說法讓我快速地理解了指針,后來慢慢接觸到復雜的邏輯,看了一些更好的教材,慢慢地才開始有了更深入的理解。
其實博主更傾向於這樣去理解這個事情:就像小學老師會告訴我們0是最小的數,這個概念當然是錯的,但是這種教法正是可以剝去語言的外殼,讓我們避免陷入繁雜的分支和細節中,快速地理解使用和培養興趣,至於后面的進階,自然會有進階的書籍來糾正,就像高中或者大學以至於更高的平台,總會告訴你你之前建立的部分概念並不完全正確,關鍵是重新建立這個概念並不會太難,因為需要重新建立的時候往往是初級到中級的進階過程。
至於網絡上的一些比較過激的言論,我是不抱以支持態度的,無論如何,在我們沒有能力接觸國外教材且資源缺乏的時候,是這些不完美的教材使我們踏入了計算機的世界。
指針和數組的區別
廢話說了那么多,我們來回到正題,看看指針和數組。不得不說,指針和數組就像孿生兄弟,有時候讓人分不清楚,這種情況主要發生在函數參數傳遞的時候,當一個函數需要一個數組作為一個參數時,我們並不會將整個數組作為參數傳遞給函數,而是傳入一個同類型指針p,然后在函數中就可以使用p[N]來訪問數組中元素(這個大家都懂,就不放示例了)。
那么,指針和數組到底是不是同一個東西呢?
我們來看看下面的例子:
file1.c:
int buf[10];
file2.c:
extern int *buf;
編譯結果:
error: conflicting types for ‘buf’。
從這里可以看出,數組和指針並不相等。至於具體的區別,且聽我細細道來。
數據訪問的本質區別
毫無疑問,我們經常使用指針的數組,也經常混用。但是我們有沒有關注過它們背后的執行原理呢?我們看下面的代碼:
int buf[10] = {5};
int *p = buf;
*p = 10;
首先,有必要來講講數組的初始化,在定義時,如果我們不對數組進行初始化操作,有兩種情況:
- 數組為全局變量或者靜態變量時,在程序加載階段默認所有元素都被初始化為0。
- 數組為局部變量,因為數組數據在棧上分配,就延續了了棧上上一次的值,所以這個值是不確定的。
同時,我們可以對其進行初始化,可以全部初始化或者部分初始化,部分初始化時,未被初始化部分全部默認被初始化為0.所以我們常用buf[N]={0}來在定義時初始化一個數組。
根據C語言的規定,數組名=數組首元素指針,所以直接可以用數組名的解引用buf來訪問第一個元素,也可以使用(buf+N)來訪問第N個元素。
我們需要知道的是,在程序編譯的時候,會對所有的變量分配一個地址,這個地址和變量的對應在符號表中被呈現,數組和指針在符號表中的區別就體現在這里:
-
對於數組而言,符號表中存在的地址為數組首元素地址,所以當我們使用素組下標訪問元素N時,它執行的是這樣的操作
- 先取出數組首元素地址
- 目標地址=首地址+sizeof(type)*N,得到被訪問元素的地址,type是指針指向數據類型,指針加法參考上面。
- 解引用(相當於在變量前加*),從地址上取出被訪問元素。
-
對於指針變量而言,符號表中存儲的是指針變量的地址,它訪問元素時這樣的過程:
- 取出指針變量的地址,解引用以獲取指針變量
- 繼續對指針變量進行解引用,獲取目標元素的值。
看到這里,我想你已經知道了指針和數組訪問數據的本質區別,但是,我們在這里需要討論的情況並非這兩種.
而是:參數定義為指針,但是以數組的方式引用。這個在函數調用時才是發生得最頻繁的,那這時候會發生什么呢?
這個時候其實就是兩種訪問方式的結合了,假設定義了指針buf,那么在符號表中存在的就是buf指針的地址(注意是buf的地址,而且buf本身是個指針),參考上述指針的訪問方式.以獲取buf中第二個元素為例:
- 首先,根據buf變量的地址,獲取buf指針。
- 使用第一步中獲取的地址進行偏移,得到目標數組元素的地址,此時目標地址為(&buf[0]+2)
- 解引用(相當於在變量前加),從地址上取出被訪問元素,相當於執行(&buf[0]+2)。
到這里,我想你已經大概清楚了數組和指針的區別,以及參數傳遞時,指針的下標引用背后的原理。
數組指針和數組元素指針
在上一小節中,我指出了數組名=數組首元素指針的概念,如果朋友們不仔細看,或者自己不去寫代碼嘗試,很容易把它記成了數組名=數組的指針 這個概念,請特別注意,數組名=數組的指針這個概念是完全錯誤的,這也是數組中非常容易混淆和犯錯的地方,我們不妨來看下面的例子:
char buf[5]={0};
printf("address of origin buf = %x\r\n",buf);
printf("address of changed buf = %x\r\n",&buf+1);
輸出結果:
address of origin buf = de157880
address of changed buf = de157885
我們先定義一個長度為5的buf,buf中首元素地址為0xde157880,然后再打印&buf+1的值,顯示為0xde157885,那么問題就來了,為什么明明只是+1,而地址卻加了5,5正好是sizeof(buf)。我們再來看看下面的例子:
char buf[5]={0};
printf("address of changed buf = %x\r\n",(&buf+1)-buf);
編譯時信息如下:
error: invalid operands to binary - (have ‘char (*)[5]’ and ‘char *’)
從這個報錯信息,我們可以看出,&buf的類型為char (*)[5],為數組指針類型,而buf類型為char *,字符指針類型。
看到這里,問題也就慢慢地清晰了。在C語言中,數組名是一個特殊的存在,與我們慣有的思維相反,數組名代表數組首元素的指針,而不是數組指針,如果要聲明一個數組指針,我們可以這樣來聲明:char (*p)[5] = buf;
說了這么多,那么,區分數組指針和數組元素指針的意義在哪里呢?參考上面所說的指針的加減運算,即:指針的加減運算的粒度是基於指針類型的長度,數組指針的長度為sizeof(數組),而數組元素指針是sizeof(單個元素)(再啰嗦一次!數組名為數組元素指針而不是數組指針)。
指針數組和二維數組
數組指針是一個指針類型為數組的指針,比如定義一個帶有5個char元素數組的指針:char (*buf)[5]。
那么指針數組又是什么東西呢?其實指針數組要比數組指針容易理解,它就是一個普通數組,只不過特殊的是數組內所有元素都是指針,比如定義一個字符指針數組:char *buf[5],注意它們之間的區別;數組指針是一個指針,指針數組是一個數組。
二維數組,大家可能沒有使用過,但是一定聽過,二維數組的定義:char buf[x][y],其中x可缺省,y不能缺省。對於二維數組,我們可以這樣理解:二維數組是一維數組的嵌套,即一維數組中所有元素為同類型數組。 例如:char array[3][3],我們可以將其理解成array數組是一個一維數組,數組的元素分別是array[0],array[1],array[2]三個char[3]型數組,這種理解可以遞推到多維數組,從而來理解二維數組的內存模型。
下面詳細說說為什么需要將多維數組看成一維數組。
二維數組和二級指針
"既然一維數組和指針在一定程度上可以"混合使用",那么二維數組肯定也是可以使用二維指針來訪問了" —— 某不知名程序員語錄
問:上面這句話有沒有什么問題?
答:大錯特錯!
很慚愧,博主曾經也是這么認為的,二維數組肯定是可以像一維數組那樣使用指針訪問,只不過要用二級指針(二維嘛)。
話不多說,我們先看下面代碼:
char buf[2][2]={{1,2},{3,4}};
char **p = buf;
printf("buf[] = %d,%d,%d,%d\r\n",p[0][0],p[0][1],p[1][1],p[1][2]);
輸出結果:
Segmentation fault (core dumped)
在這個示例中,博主的本意是使用二級指針p賦值為二維數組名,然后使用p訪問數組中元素,但是結果明顯跑偏了,這是為什么?
有些朋友可能在學習上面的"數組和指針數據訪問的本質區別"的時候會想,我只要會用就行了,我要去關注這些底層細節有什么作用?在簡單的應用中當然沒什么作用,但是在這種時刻就需要對底層扎實的理解了。
我們來詳細分析一下上面代碼中的背后訪問邏輯:
-
第一點,我們需要確認的是,二維數組的數組名到底是什么類型的指針。是二維數組中第一個char型元素的指針嗎?還是按照上一節"指針數組和二維數組"中說的那樣,將二維數組看成一個一維數組,從一維數組的角度看,首元素為buf0,那二維數組名就是一個數組指針,類型為char (*)[2]。要驗證這個很簡單,我們分別編譯兩份代碼:
代碼1:
char buf[2][2]={{1,2},{3,4}};
char *p = buf;
編譯結果:warning: initialization from incompatible pointer type [-Wincompatible-pointer-types]
代碼2:
char buf[2][2]={{1,2},{3,4}};
char (*p)[2] = buf;
編譯結果:
無警告信息
所謂實踐出真知,結果很顯然,答案是第二種:我們應該將二維數組當成嵌套的一維數組,而數組名為首元素地址,注意,這里的首元素是從一維數組的角度出發,這個首元素的類型可能是普通變量,數組甚至是多維數組。 -
第二點,char **p = buf;這一條怎么去理解呢?根據上面的結論二維數組名buf是char (*)[2]類型,而p是char型二級指針,參數自然不匹配。
-
即使是參數不匹配,但是編譯只是警告,而非報錯,我們仍然可以執行它。那么執行這個程序的時候又發生了什么呢?我們根據"指針與數組數據訪問的本質區別"小節部分來分析:
- 首先,p的地址是在編譯時已知的,程序運行時,通過指針p的地址得到p的值,經過上面的分析,此時p = &buf[0],雖然&buf[0]是數組指針,但是p為char** 類型,所以&buf[0]被強制轉換成char**型指針。
- 在printf函數中訪問p[0][0],事實上訪問P[0][0]就先得訪問p[0],那么就先找到p的值,那么p的值又是多少呢?答案是p=buf[0][0],p不是一個地址,而是一個字面值1,所以此時p[0] = 1,訪問*p[0]自然會導致Segmentation fault (core dumped)。
鑒於上面的解析部分非常難以理解,而且僅僅是字面講解幾乎無法講清楚,博主就嘗試通過幾個示例來進行講解:
示例1:
char buf[2][2]={{1,2},{3,4}};
char **p = buf;
printf("array name--buf address = %x\r\n",buf);
printf("&buf[0] address = %x\r\n",&buf[0]);
printf("Secondary pointer address = %x\r\n",p);
輸出:
array name--buf address = a836a2c0
&buf[0] address = a836a2c0
&buf[0][0] address = a836a2c0
Secondary pointer address = a836a2c0
盡管編譯過程有好幾個Warning,暫時不去理會,結果顯示,至少從數值上來說 p = buf = &buf[0] = &buf[0][0]。
示例2:
char buf[2][2]={{1,2},{3,4}};
char **p = buf;
printf("p[0] = %x\r\n",p[0]);
輸出:
p[0] = 04030201
這個結果就非常有意思了,可以看到,指針p[0]的值,正好是數組buf的四個元素的值(內存中存儲順序將01020304反序存儲,這里涉及到大小端的存儲問題,不過多贅述)。可想而知,訪問p[0][0]的時候會發生什么?按照之前的講解,我們先將p[0]做相應位移,即p[0]=p[0]+sizeof(char)*0,然后再解引用獲取地址上的值,那就是直接取0x04030201地址上的值,結果當然不會是我們所期待的!
再回到示例,為什么p[0]的值會是0x04030201?
- 首先,我們要知道,p[0]是什么類型,p[0]即為*p,p是二級指針,*p也是一個指針,所以*p的本身的類型為int*,所以它的值為4個字節。
- 根據前面的分析,p = buf = &buf[0] = &buf[0][0],對p解引用(即p)相當於取出p地址處的數據,根據int類型,取四個字節數據,而這四個字節正好就是buf中四個元素。
那如果我們要使用指針來訪問二維數組中的元素,該怎么做呢?
看下面的代碼:
#define ROW 2
#define COLUMN 2
char buf[ROW][COLUMN]={{1,2},{3,4}};
char *p = (char*)buf;
//訪問buf[x][y],即訪問p[x*COLUMN+y]
printf("buf = %d,%d,%d,%d\r\n",p[COLUMN*0+0],p[COLUMN*0+1],p[COLUMN*1+0],p[COLUMN*1+1]);
如果你看懂了之前博主介紹的內容,理解這一份代碼是非常簡單的。
好了,關於C語言中指針和數組的討論就到此為止了,如果朋友們對於這個有什么疑問或者發現有文章中有什么錯誤,歡迎留言
個人郵箱:linux_downey@sina.com
原創博客,轉載請注明出處!
祝各位早日實現項目叢中過,bug不沾身.
(完)
結語:為了寫這一篇博文,感覺發際線又往上走了一公分...
