深入理解C語言中的指標與陣列之指標篇
前言
其實很早就想要寫一篇關於指標和陣列的文章,畢竟可以認為這是C語言的根本所在。相信,任意一家公司如果想要考察一個人對C語言的理解,指標和陣列絕對是必考的一部分。
但是之前一方面之前一直在忙各種事情,一直沒有時間靜下心來寫這些東西,畢竟這確實是一件非常耗費時間和精力的事情;一方面,個人對C語言的掌握和理解也還有限,怕寫出來的東西會對大家造成誤導。當然,今天寫的這些東西也肯定存在各種問題,不嚴謹甚至錯誤的地方肯定有,也希望大家來共同探討,相互改進。
我會慢慢的寫完這幾章,有想法的童鞋可以和我探討。
指針
預備知識
在深入理解指標之前,我認為有必要先複習或者學習一下計算機原理的基礎知識。
計算機是如何從記憶體中進行取指的?
計算機的匯流排可以分為3種:資料匯流排,地址匯流排和控制匯流排。這裡不對控制匯流排進行描述。資料匯流排用於進行資料資訊傳送。資料匯流排的位數一般與CPU的字長一致。一般而言,資料匯流排的位數跟當前機器int值的長度相等。例如在16位機器上,int的長度是16bit,32位機器則是32bit。這個計算機一條指令最多能夠讀取或者存取的資料長度。大於這個值,計算機將進行多次訪問。這也就是我們說的64位機器進行64位資料運算的效率比32位要高的原因,因為32位機要進行兩次取指和執行,而64位機卻只需要一次!
地址匯流排專門用於定址,CPU通過該地址進行資料的訪問,然後把處於該地址處的資料通過資料匯流排進行傳送,傳送的長度就是資料匯流排的位數。地址匯流排的位數決定了CPU可直接定址的記憶體空間大小,比如CPU匯流排長32位,其最大的直接定址空間長232
一般而言,計算機的地址匯流排和資料匯流排的寬度是一樣的,我們說32位的CPU,資料匯流排和地址匯流排的寬度都是32位。
計算機訪問某個資料的時候,首先要通過地址匯流排傳送資料儲存或者讀取的位置,然後在通過資料匯流排傳送需要儲存或者讀取的資料。一般地,int整型的位數等於資料匯流排的寬度,指標的位數等於地址匯流排的寬度。
計算機的基本訪問單元
學過C語言的人都知道,C語言的基本資料型別中,就屬char的位數最小,是8位。我們可以認為計算機以8位,即1個位元組為基本訪問單元。小於一個位元組的資料,必須通過位操作來進行訪問。
記憶體訪問方式
如圖1所示,計算機在進行資料訪問的時候,是以位元組為基本單元進行訪問的,所以可以認為,計算每次都是從第p個位元組開始訪問的。訪問的長度將由編譯器根據實際型別進行計算,這在後面將會進行講述。
圖1 記憶體訪問方式
想要了解更多,就去翻閱計算機組成原理和編譯原理吧。
sizeof關鍵字
sizeof關鍵字是編譯器用來計算某些型別的資料的長度的,以位元組為基本單位。例如:
sizeof(char)=1;
sizeof(int)=4;
sizeof(Type)的值是在編譯的時候就計算出來了的,可以認為這是一個常量!
什麼是指標
指標其實就是資料存放的地址,圖1中的p就是一個指標。在圖1中,n一般是CPU的位數,32位機上,n=32。因為指標需要能夠指向記憶體中的任意一個位置,因此,指標的長度應該是n位的,32位機器上指標長度就是32位。這和整型的長度是相等的!
在我個人的理解中,可以將指標理解成int整型,只不過它存放的資料是記憶體地址,而不是普通資料,我們通過這個地址值進行資料的訪問,假設它的是p,意思就是該資料存放位置為記憶體的第p個位元組。
當然,我們不能像對int型別的資料那樣進行各種加減乘除操作,這是編譯器不允許的,因為這樣錯是非常危險的!
圖2就是對指標的描述,指標的值是資料存放地址,因此,我們說,指標指向資料的存放位置。
圖2 指標
指標的長度
我們使用這樣的方式來定義一個指標:
Type *p;
我們說p是指向type型別的指標,type可以是任意型別,除了可以是char,short, int, long等基本型別外,還可以是指標型別,例如int *, int **, 或者更多級的指標,也可是是結構體,類或者函式等。於是,我們說:
int * 是指向int型別的指標;
int **,也即(int *) *,是指向int *型別的指標,也就是指向指標的指標;
int ***,也即(int **) *,是指向int**型別的指標,也就是指向指標的指標的指標;
…我想你應該懂了
struct xxx *,是指向struct xxx型別的指標;
其實,說這麼多,只是希望大家在看到指標的時候,不要被int ***這樣的東西嚇到,就像前面說的,指標就是指向某種型別的指標,我們只看最後一個*號,前面的只不過是type型別罷了。
細心一點的人應該發現了,在“什麼是指標”這一小節當中,已經表明了:指標的長度跟CPU的位數相等,大部分的CPU是32位的,因此我們說,指標的長度是32bit,也就是4個位元組!注意:任意指標的長度都是4個位元組,不管是什麼指標!(當然64位機自己去測一下,應該是8個位元組吧。。。)
於是:
Type *p;
sizeof(p)的值是4,Type可以是任意型別,char,int, long, struct, class, int **…
以後大家看到什麼sizeof(char*), sizeof(int *),sizeof(xxx *),不要理會,統統寫4,只要是指標,長度就是4個位元組,絕對不要被type型別迷惑!至於type是幹什麼用的,這個是給編譯器用的,用於指標運算,這個在下面的章節中會有詳細介紹。
取地址
我們說指標指向的是資料的存放地址,因此指標的值等於資料的存放地址。那麼給指標賦值的時候就需要進行資料的取地址操作,這個我想不用我多說,各位也知道是&符號,沒錯,是&符號。
我們可以這樣取地址:
Type v,*p=&v;
當然也可以:
Type v, *p;(或者Type v; Type *p)
p=&v;
這裡的Type依然是任意的型別,可以是N級指標、結構體、類或者函式什麼的。
指標運算
N多的面試會考這種東西了:
Type *p;
p++;
然後問你p的值變化了多少。
其實,也可以認為這是在考編譯器的基本知識。因此p的值並不像表面看到的+1那麼簡單,編譯器實際上對p進行的是加sizeof(Type)的操作。
看一個一段程式碼的測試結果:
char cv='a',*pcv=&cv;
short sv=1, *psv=&sv;
int iv=1, *piv=&iv;
long lv=1, *plv=&lv;
long long llv=1, *pllv=&llv;
float fv=1.0, *pfv=&fv;
double dv=1.0, *pdv=&dv;
long double ldv=1.0, *pldv=&ldv;
//cout<<"pcv:"<<pcv<<" pcv+1: "<<pcv+1<<endl;
cout<<"psv:"<<psv<<" psv+1: "<<psv+1<<endl;
cout<<"piv:"<<piv<<" piv+1: "<<piv+1<<endl;
cout<<"plv:"<<plv<<" plv+1: "<<plv+1<<endl;
cout<<"pllv:"<<pllv<<" pllv+1: "<<pllv+1<<endl;
cout<<"pfv:"<<pfv<<" pfv+1: "<<pfv+1<<endl;
cout<<"pdv:"<<pdv<<" pdv+1: "<<pdv+1<<endl;
cout<<"pldv:"<<pldv<<" pldv+1: "<<pldv+1<<endl;
cout<<endl;
(這裡註釋掉char一行的原因是因為cout<<(char*)會被當成字串輸出,而不是char的地址)
執行結果:
觀察結果,可以看出,他們的增長結果分別是:
2(sizeof(short))
4(sizeof(int))
4(sizeof(long))
8(sizeof(long long))
4(sizeof(float))
8(sizeof(double))
12(sizeof(long double))
喏,增加的值是不是sizeof(Type)呢?別的什麼struct,class之類的,就不驗證你,有興趣的自己去驗證。
我們再對這樣的一段程式碼進行彙編,檢視編譯器是如何進行指標的加法操作的:
int iv=1,*piv=&iv;
piv++;
cout<<piv<<endl;
piv=piv+4;
cout<<piv<<endl;
cout<<endl;
彙編結果:
call ___main
movl $1, -12(%ebp)
leal -12(%ebp), %eax
movl %eax, -8(%ebp)
addl $4, -8(%ebp) /** 這裡是piv++ **/
movl -8(%ebp), %eax
movl %eax, 4(%esp)
movl $__ZSt4cout, (%esp)
call __ZNSolsEPKv
movl $__ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_, 4(%esp)
movl %eax, (%esp)
call __ZNSolsEPFRSoS_E
addl $16, -8(%ebp) /** 這裡是piv+4 **/
movl -8(%ebp), %eax
movl %eax, 4(%esp)
movl $__ZSt4cout, (%esp)
call __ZNSolsEPKv
movl $__ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_, 4(%esp)
movl %eax, (%esp)
call __ZNSolsEPFRSoS_E
movl $__ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_, 4(%esp)
movl $__ZSt4cout, (%esp)
call __ZNSolsEPFRSoS_E
movl $0, %eax
addl $36, %esp
popl %ecx
popl %ebp
leal -4(%ecx), %esp
ret
注意看註釋部分的結果,我們看到,piv的值顯示加了4(sizeof(int)),然後又加了16(4*sizeof(int))。
總結一點:
指標的實際運算,將會由編譯器在編譯的時候,根據指標指向資料型別的大小進行實際的翻譯轉換。指標型別的作用就在於此,讓編譯器能夠正確的翻譯這些指令的操作,另一方面,也讓編譯器檢查程式設計師對指標的操作是否合法,保證程式的正確性和健壯性。
Type *p; p=p+i;
最終p的值實際上是(value of p) + i*sizeof(Type);
Type *p; p=p-i;
最終p的值實際上是(value of p) - i*sizeof(Type);
注意:指標只能進行加法和減法操作,不能進行乘除法!(指標畢竟不是普通的整數,乘除法的跨度太大了,出發還會搞出小數點神馬的,這是我個人的理解。但是編譯器不允許進行指標的乘除法。)
NULL指標
NULL是C語言標準定義的一個值,這個值其實就是0,只不過為了使得看起來更加具有意義,才定義了這樣的一個巨集,中文的意思是空,表明不指向任何東西。你懂得。不過這裡不討論空和零的區別,呵呵。
在C語言中,NULL其實就是0,就像前面說的指標可以理解成特殊的int,它總是有值的,p=NULL,其實就是p的值等於0。對於不多數機器而言,0地址是不能直接訪問的,設定為0,就表示該指標哪裡都沒指向。
當然,就機器內部而言,NULL指標的實際值可能與此不同,這種情況下,編譯器將負責零值和內部值之間的翻譯轉換。
NULL指標的概念非常有用,它給了你一種方法,表示某個特定的指標目前並未指向任何東西。例如,一個用於在某個陣列中查詢某個特定值的函式可能返回一個指向查詢到的陣列元素的指標。如果沒找到,則返回一個NULL指標。
在記憶體的動態分配上,NULL的意義非同凡響,我們使用它來避免記憶體被多次釋放,造成經常性的段錯誤(segmentation fault)。一般,在free或者delete掉動態分配的記憶體後,都應該立即把指標置空,避免出現所以的懸掛指標,致使出現各種記憶體錯誤!例如:
int *p=(int*)malloc(sizeof(int));
*p=23;
free(p);
p=NULL;
free函式是不會也不可能把p置空的。像下面這樣的程式碼就會出現記憶體段錯誤:
int *p=(int*)malloc(sizeof(int));
*p=23;
free(p);
free(p);
因為,第一次free操作之後,p指向的記憶體已經釋放了,但是p的值還沒有變化,free函式改不了這個值,再free一次的時候,p指向的記憶體區域已經被釋放了,這個地址已經變成了非法地址,這個操作將導致段錯誤的發生(此時,p指向的區域剛好又被分配出去了,但是這種概率非常低,而且對這樣一塊記憶體區域進行操作是非常危險的!)
但是下面這段程式碼就不會出現這樣的問題:
int *p=(int*)malloc(sizeof(int));
*p=23;
free(p);
p=NULL;
free(p);
因為p的值程式設計了NULL,free函式檢測到p為NULL,會直接返回,而不會發生錯誤。
這裡順便告訴大家一個記憶體釋放的小竅門,可以有效的避免因為忘記對指標進行置空而出現各種記憶體問題。這個方法就是自定義一個記憶體釋放函式,但是傳入的引數不知指標,而是指標的地址,在這個函式裡面置空,如下:
#include<iostream>
#include<stdlib.h>
using namespace std;
void my_free(void *p){
void **tp=(void **)p;
if(NULL==*tp)
return ;
free(*tp);
*tp=NULL;
}
int main(int argc, char **argv){
int *p=new int;
*p=1;
cout<<p<<endl;
my_free(&p);
cout<<p<<endl;
free(p);
return 0;
}
結果:
my_free呼叫了之後,p的值就變成了0(NULL),呼叫多少次free都不會報錯了!
另外一個方式也非常有效,那就是定義FREE巨集,在巨集裡面對他進行置空。例如
#include<iostream>
#include<stdlib.h>
using namespacestd;
#define FREE(x) if(x) free(x); x=NULL
int main(intargc, char **argv){
int *p=new int;
*p=1;
cout<<p<<endl;
FREE(p);
cout<<p<<endl;
free(p);
return 0;
}
執行結果同上面一樣,不會報段錯誤:
(關於記憶體的動態分配,這是個比較複雜的話題,有機會再專門開闢一章給各位講述一下吧,寫個帖子還是很花費時間和精力的,呵呵,寫過的童鞋應該都很清楚,所以順便插一句,轉帖可以,請註明出處,畢竟,大家都是本著共享的精神來討論問題的,寫的好壞都沒有向你所要什麼,請尊重每個人的勞動成果。)
void指標
雖然從字面上看,void的意思是空,但是void指標的意思,可不是空指標的意思,空指標指的是上面所說的NULL指標。
void指標實際上的意思是指向任意型別的指標。任意型別的指標都可以直接賦給void指標,而不需要進行強制轉換。
例如:
Type a, *p=&a;(Type等於char, int, struct, int *…)
void *pv;
pv=p;
就像前面說的,void指標的好處,就在於,任意的指標都可以直接賦值給它,這在某些場合非常有用,因此有些操作對於任意指標都是相同的。void指標最常用於記憶體管理。最典型的,也是大家最熟知的,就是標準庫的free函式。它的原型如下:
void free(void*ptr);
free函式的引數可以是任意指標,沒有誰見過free引數裡面的指標需要強壯為void*的吧?
malloc, calloc,realloc這些函式的返回值也是void指標,因為記憶體分配,實際上只需要知道分配的大小,然後返回新分配記憶體的地址就可以了,指標的值就是地址,返回的不管是何種指標,其實結果都是一樣的,因為所有的指標長度其實都是32位的(32位機器),它的值就是記憶體的地址,指標型別只是給編譯器看的,目的是讓編譯器在編譯的時候能夠正確的設定指標的值(參見指標運算章節)。如果malloc函式設定成下面這樣的原型,完全沒有問題。
char*malloc(size_t sz);
實際上設定成
Type*malloc(size_t sz);
也是完全正確的,使用void指標的原因,實際上就像前面說的,void指標意思是任意指標,這樣設計更加嚴謹一些,也更符合我們的直觀理解。如果對前面我說的指標概念理解的童鞋,肯定明白這一點。
未初始化和非法指標
經常有面試,會考這樣的程式碼校錯:
int *a;
…
*a=12;
這段程式碼,在*a=12這裡出了問題。這裡的問題就在於,a究竟指向哪裡?我們聲明瞭這個變數,但是從未對它進行初始化,一般而言,沒有初始化,a的值是任意的,隨機的。如果a是全域性變數或者static型別,它會被初始化為0(前面說過,其實指標可以理解成值是記憶體地址的int),但是不管哪種方式,這種方式的賦值都是非常危險的,如果你有著中體彩頭號彩票的運氣,a的值剛好等於某個變數或者分配記憶體的地址,那麼這裡的執行不會報錯,但這時候的運氣卻不是什麼好運,相反,是非常倒黴!因為這是對一塊不屬於你的記憶體進行操作,這實在是太危險了!如果a的初始值是個非法地址,這個賦值語句在執行的時候將會報錯,從而終止程式嗎,這個錯誤同樣是段錯誤(segmentation fault),如果是這樣,你是幸運的,因為你發現了它,這樣就可以修正它。
關於這種問題,編譯器可能會,也可能不會對它進行檢測。GNU的編譯器是會進行檢測的,會對未初始化的指標或變數輸出警告資訊。
多級指標(也叫指標的指標)
其實如果對前面的指標概念完全理解了,這裡都可以略過。指標的指標,無非就是指標指向的資料型別是指標罷了。
Type *p;
其中Type型別是指標,比如可以是int*,也可以是int **,這樣p對應的就是二級指標和三級指標。一級指標的值存放的是資料的地址,二級指標的值存放的一級指標的地址,三級指標的值存放的是二級指標的地址,依此類推…
函式指標
跟普通的變數一樣,每一個函式都是有其地址的,我們通過跳轉到這個地址執行程式碼來進行函式呼叫,只是,跟取普通資料不同的在於,函式有引數和返回值,在進行函式呼叫的時候,首先需要將引數壓入棧中,呼叫完成後又需要將引數壓入棧中。既然函式也是通過地址來進行訪問的,那它也可以使用指標來指向,事實上,每一個函式名都是一個指標,不過它是指標常量和指標常量,它的值是不能改的,指向的值也不能改。
(關於常量指標和指標常量什麼的,有時間在專門開闢一章來說明const這個東東吧,也是很有講頭的一個東東。。。)
函式指標一般用來幹什麼呢?函式指標最常用的場合就是回撥函式。回撥函式,顧名思義,就是某個函式會在適當的時候被別人呼叫。當期望你呼叫的函式能夠使用你的某些方式去操作的時候,回撥函式就很有用,比如,你期望某個排序函式在比較的時候,能夠使用你定義的比較方法去比較。
有過較深入的C程式設計經驗的人應該都接觸過。C的標準庫中就有使用,例如在strlib.h標頭檔案的qsort函式,它的原型為:
void qsort(void*__base, size_t __nmemb, size_t __size, int(*_compar)(const void *, const void*));
其中int(*_compar)(const void *, const void *)就是回撥函式,這個函式用於qsort函式用於資料的比較。下面,我會舉一個例子,來描述qsort函式的工作原理。
一般,我們使用下面這樣的方式來定義函式指標:
typedef int(*compare)(const void *x, const void *y);
這個時候,compare就是引數為const void *, const void *型別,返回值是int型別的函式。例如:
typedef int (*compare)(const void *x, const void *y);
int my_compare(const void *x, const void *y){
const int *a=(int *)x;
const int *b=(int *)y;
if(*a>*b)
return 1;
if(*a==*b)
return 0;
return -1;
}
void my_sort(void *data, int length, int size, compare){
char *d1,*d2;
//do something
if(compare(d1,d2)<0){
//do something
}else if(compare(d1,d2)==0){
//do something
}
else{
//do something
}
//do something
}
int main(int argc, char **argv){
int arr={2,4,3,656,23};
my_sort(arr, 5, sizeof(int), my_compare);
//do something
return 0;
}
用typedef來定義的好處,就是可以使用一個簡短的名稱來表示一種型別,而不需要總是使用很長的程式碼來,這樣不僅使得程式碼更加簡潔易讀,更是避免了程式碼敲寫容易出錯的問題。強烈推薦各位在定義結構體,指標(尤其是函式指標)等比較複雜的結構時,使用typedef來定義。