概述
指針是C語言的重點,同時也是讓初學者認為最難理解的部分。有人說它是C語言的靈魂,只有深入理解指針才能說理解了C語言。暫且撇開這些觀點不談。這章是我在閱讀《C和指針》這本書的讀書筆記。在談指針的同時我們也要談談數組,數組可以說和指針密不可分的,故把它倆放在一起談。
一.指針
1.初級指針
內存和地址
硬件存儲中有一個值得注意的地方是邊界對齊。在要求邊界對齊的機器上,整型值存儲的起始位置只能是特定的字節,通常是2或4的倍數。對於程序員還要知道的是:(1)內存中每個位置由一個獨一無二的地址標識;(2)內存中每個位置都包含一個值。
下面的例子顯示了內存中的5個字的內容。
但是記住他們的地址太麻煩了,所以高級語言提供通過名字而不是地址來訪問內存位置的功能,下面用名字代替地址:
這些名字我們稱之為變量。名字和內存位置之間的關聯並不是硬件提供的,而是編譯器為我們實現的,硬件仍然通過地址訪問內存位置。
值和類型
假設我們對上面的第三個位置聲明如下:
float c=3.14;
我們可以看到這和c中存儲的值並不一樣,我們聲明的是一個浮點數,二內存中顯示c是一個整數。實際上聲明並沒有錯,原因是每個變量中包含一連串的1或0。它可以被解釋為浮點數也可以被解釋為整數,這取決於它們被使用的方式。如果使用的是整型算數指令,那么它就被解釋為整數,如果使用的是浮點型運算指令,那么它就是個浮點數。所以不能簡單通過檢查一個值得位來判斷它的類型。
指針變量的內容
先看看這些變量的表達式:
int a = 112; int b = -1; float c = 3.14; int *d = &a; int *e = &c;
我們必須明確的是,一個變量的值就是分配給這個變量的內存位置所存儲的值,即使是值針變量也不例外。所以a的值為112,b的值為-1,c的值為3.14,值得注意的是d的值是100而不是112,e的值是108而不是3.14。如果你認為d和e是指針所以就能自動獲取存儲於位置100和108的值那就錯了。
間接訪問操作符
通過一個指針訪問它所指的地址的過程稱為間接訪問或解引用指針。操作符為*。根據前面的聲明我們有:
我們可以知道,d的值為100。對d進行間接訪問操作符時(*d),它表示訪問內存位置100並查看那里的值。
NULL指針
標准定義了NULL指針,它作為一個特殊指針變量,表示不指向任何東西。要使一個指針變量為NULL,你可以給他賦一個零值。NULL指針是個很有用的概念,因為它給你一種方法,表示某個特定的指針目前並未指向任何東西。對於一個NULL指針進行解引用是非法的,因為它並未指向任何東西。如果你知道指針將被初始化為什么地址,就把它初始化為該地址,否則就把它初始化為NULL。
指針、間接訪問和左值
回到早些時候的例子,給定下面聲明:
int a; int *d = &a;
考慮下面表達式:
間接訪問操作符所需要的操作數是個右值,但它所產生的結果是個左值。指針變量可以作為左值,並不是因為它們是指針,而是因為它們是變量。
指針、間接訪問和變量
給出下面表達式:
*&a = 25;
這個表達式的含義是,把25賦值給a。&操作符取得a的地址,它是一個指針常量,接着*對他進行間接訪問其操作數所表示的地址。操作數是a的地址,所以值25存儲於a中。它實際等價於a=25。
指針常量
假定變量a存儲於位置100,則下面表達式表示什么意思?
*100=25;
看上去是把25賦值給a,因為a是位置100所存儲的變量,但實際上是錯誤的。因為字面值100的類型是整型,二間接訪問操作只能作用於指針類型表達式。如果確實想把25存儲於位置100,則必須進行強制類型轉換,可進行以下操作:
*(int *)100=25;
指針的指針
考慮下面的聲明:
int a=12; int *b=&a;
... c=&b;
上述聲明是合法的,那么c是什么類型呢?顯然它是一個指針,確切的說是"指向整型的指針"的指針,即指針的指針。那么表達式**c的類型就是int,注意*操作符具有從右向左結合性。所以c應該聲明如下:
int a=12; int *b=&a; int **c=&b;
指針表達式
觀察以下聲明:
char ch='a'; char *cp=&ch;
這樣,我們就有了兩個變量,它們初始化如下:
圖中還顯示了ch后面那個內存的位置,因為我們所求值得有些表達式將訪問到它。由於我們並不知道它的初值,所以用個問號表示。我們用黑色橢圓來表示一個數的右值,用方框來表示一個數的左值。例如表達式:
ch
當它用作右值使用時,表達式的值為'a',如下圖所示:
當它當作左值使用時,它是這個內存的地址而不是該內存所包含的值。如下圖表示:
接下來的表達式將以表格的形式出現,每個表的后面是表達式求值過程描述。
作為右值,這個表達式的值是變量ch的地址。這個表達式不是一個合法的左值,因為當表達式&ch進行求值時,它的結果應該存儲與計算機的什么地方我們不清楚,素以它不是一個合法的左值。
我們之前有討論過,它的右值就是cp的值,左值為cp所在的位置。
這個例子與&ch類似,但是這次我們取得是指針變量的地址。同理,這個結果的類型為指向字符的指針的指針。這個值得存儲位置我們不清楚,所以它的左值是非法的。
加入了間接操作,右值及為它所指向的地址的值'a',左值為其指向的地址。
由於*的優先級比+高,所以先執行間接訪問操作,得到它的值(虛線橢圓內)。我們取這個值得一份拷貝,並把它與1相加得到b。由於我們不清楚b的具體位置,所以它不是個合法的左值。優先級表格證實+的結果不能作為左值。
相比於上個表達式,我們添加了括號使它先執行加法操作。即把cp所指向的地址向后移動一個位置。取它的右值即為該地址所存儲的值,左值即為該位置的地址。但是這個表達式所訪問的是ch后面的那個內存位置,我們如何知道原先存儲於那個地方的是什么東西?我們無法知道,所以這樣的表達式是非法的。
前置++符,這個表達式我們增加指針變量cp的值。表達式的結果是增值后的指針的一份拷貝,因為前綴++先增加它的操作數的值再返回這個結果。這份拷貝的位置沒有清晰定義,所以它的左值是非法的。
后綴++同樣增加了cp的值,但它先返回cp的值的一份拷貝然后再增加cp的值。這樣,這個表達式的值就是cp原來值得一份拷貝。
相比於前面兩個表達式,我們添加了間接訪問操作符。所以它的右值是ch后面的內存地址的值,而它的左值就是那個位置本身。
同理,該表達式的右值為ch內存地址里的值,左值為ch的位置。
這個表達式中,兩個操作符都是從右向左,所以先對cp執行間接訪問操作。然后,cp所指向的位置的值加1,表達式的結果是增值后的值的一份拷貝。
表達式先執行括號里的間接訪問操作符,再執行后綴++,與前一個表達式類似,表達式得到的是ch里面的值增值前的原先值。
從右結合,我們先計算*++p得到ch位置后面一個存儲空間的值,再把它的值加1。最后我們得到ch后面存儲空間的值增值1后的一份拷貝。同理它的左值是非法的。
執行順序為++(*(cp++)),最后表達式的右值為ch地址內的值增值1后的一份拷貝。它的左值是非法的。之后cp指向ch后面的位置。
指針的運算
C的指針的算數運算只包含以下兩種形式:
(1)指針 +/- 整數
(2)指針 - 指針
標准定義第一種形式只能用於指向數組中的某個元素,和整數相加減就是讓指針在數組中前后移動位置。值得注意的是,指針的移動是按數組中的類型決定的,假如數組類型是char類型,指針加一表示向后移動一個字節。而在int類型的數組中,指針加一是移動四個字節,並非一個,這個注意區分。
第二種形式的條件式兩個指針都指向同一個數組,相減的結果是兩個指針在內存中的距離。加入一個float類型的數組,每個類型占4個字節。如果數組的其實位置是1000,指針p1的值是1004,p2的值是1024。則p1-p2的結果是5。因為兩個指針的差值(20)將除以每個元素的長度(4)。
對於指針的關系運算有:
< <= > >=
不過前提是它們都指向同一個數組中的元素。
2.高級指針
指向指針的指針
先看下面的聲明,看你是否能了解它們的功能:
int i; int *pi; int **ppi; ppi=π *ppi=&i;
上述聲明實際可用下面的圖來說明:
下面的聲明具有相同的效果:
int i='a'; int *pi='a'; int **ppi='a';
但是我們有時候並不知道變量i里面的具體值,例如鏈表的插入等操作。所以我們必須掌握第一種比較復雜的聲明定義方法。當然我們應該盡可能的善用這些復雜的聲明方式,除非它是必須的。
高級聲明
首先看個簡單的例子:
int f; //一個整型變量 int *f; //一個指向整型的指針
再看看下面的聲明:
int * f, g;
實際上上述聲明並沒有聲明兩個指針,而是把f聲明為指向整型的指針,把g聲明為整型。
觀察下面一些新的聲明,看你是否能說出它們的具體含義
int f(); int *f(); int (*f) (); int *(*f) ();
第一行很容易理解,它把f聲明為一個函數,它的返回值是一個整數。
第二行中,首先執行的是函數調用操作符(),因為它的優先級高於間接訪問操作符。因此f是一個函數,它的返回值類型是一個指向整型的指針。
第三行中,先執行括號中的*f,再執行后面的函數調用(),所以f是一個函數指針,它所指向的函數返回一個整型值。因為函數存放於內存中的某個位置,所以完全可以擁有指向那個位置的指針,即函數指針。
第四行結合上個聲明很好理解,f還是個函數指針,它所指向的函數返回值是一個整型指針。
接下來的聲明我們引入數組:
int f[]; // 1 int *f[]; // 2 int f() []; // 3 int f[] (); // 4 int (*f[]) (); // 5 int *(*f[])(); // 6
第一行聲明f是個整形數組。
第二行由於下標運算符優先級高於*,所以f是一個數組,它的元素類型是指向整型的指針。
第三行f是一個函數,它的返回值是一個整型數組。但是這個聲明是非法的,因為函數只能返回標量,不能返回數組。
第四行似乎把f聲明為一個數組,它的元素類型是返回為整型的函數。但是這個聲明也是非法的,因為數組元素必須具有相同的長度,但不同的函數顯然具有不同的長度。
第五行首先執行括號內的*f[],所以f是一個元素是某種類型的指針的數組。表達式末尾的()是函數調用操作符,所以f肯定是一個數組,數組元素的類型是函數指針,它所指向的函數的返回值是一個整型值。
第六行和上一行的區別是多了個*,所以這個聲明創建了一個指針數組,自還真所指向的類型的返回值為整型指針的函數。
函數指針
先看以下的聲明:
int f(int); int (*pf) (int)=&f;
上述聲明創建函數指針pf,並把它初始化為指向函數f。其中初始化表達式中的&是可選的,因為函數名被使用時編譯器總是把它轉換為函數指針。
在函數指針被聲明並且初始化后,我們可以使用三種方式調用函數:
int ans; ans=f(25); ans=(*pf)(25); ans=pf(25);
上述三年中調用的效果都是一樣的。函數指針最常見的用途是把函數指針作為參數傳遞給函數以及用於轉換表。
二.數組
1.一維數組
數組名
先看下面表達式:
int a[10]; int b[10]; int *c; c=&b[0];
對於第一行,a[4]表示一個整形,那么a的類型又是什么呢?答案是它表示數組元素的第一個地址,類型為取決於數組元素的類型,在此數組元素的類型為int,所以數組名a的類型為”指向int的常量指針“(注意是指針常量而不是指針變量)。只有在兩種場合下,數組名並不用指針常量來表示——當數組名作為sizeof操作符或單目操作符&的操作數時。sizeof返回整個數組長度,取數組名地址所產生的是指向數組的指針。
表達式&b[0]是一個指向數組第一個元素的指針,也是數組名本身的值,所以等價於:
c=b;
但是以下表達式是錯誤的:
a=c;
a=b;
第一行,a為指針常量,而c是指針變量,不能把一個變量賦值給常量。第二行是非法的,不能用賦值符把一個數組的所有元素賦值到另一個數組,必須使用一個循環,每次賦值一個元素。
下標引用
對於上文的環境,有如下表達式:
*(b+3)
這個操作相當於把指向數組的第一個位置向后移動三個位置(間接訪問),然后取其右值,相當於b[3](下標引用)。除了優先級之外,下標引用和間接訪問完全相同。
對於以下表達式:
int array[10]; int *ap=array+2;
指針與下標
既然指針與下表表達式一樣的,那么該用哪一種呢?結論是下標絕不會比指針更有效率,但是指針有時候會比下標更有效率。通過下面的例子說明:
例子1: int array[10],a; for(a=0;a<10;a+=1) array[a]=0; 例子2: int array[10],*ap; for(ap=array;ap<array+10;ap++) *ap=0;
例子1中為了對下標表達式求值,編譯器在程序中插入指令,取得a的值,並把它與整形的長度相乘(即乘以4)。這個乘法需要花費一定的時間與空間。
例子2並不存在下標,但是也有乘法,這個乘法就是for語句中的ap++,同理1這個值必須與整形相乘,然后再與指針相加。但是區別是,因為每次循環都是執行1*4,所以這個乘法在編譯時只執行一次,程序現在包含一條指令,把4與指針相加。程序在運行時並不執行加法運算。所以例子2效率比例子1更高。
我們有以下結論:
(1)當你根據某個固定數目的增量在一個數組中移動時,使用指針將比使用下標更有效率。
(2)聲明為寄存器變量的指針通常比位於靜態內存和堆棧中的指針效率更高。
(3)如果你可以通過測試一些已經成功初始化並經過調整的內容來判斷循環是否應該終止,那么你就不需要使用一個單獨的計數器。
(4)那些必須在運行時求值得表達式較之諸如&array[size]或array+size,前者代價往往比較高。
作為函數參數的數組名
通過前面的學習我們知道,數組名的值就是指向數組第一個元素的指針。所以當一個數組名作為參數傳遞給一個函數時,此時傳遞給函數的是一份該指針的拷貝。所以函數一顆自由的操縱它的指針形參,而不必擔心會修改對應的作為實參的指針。但是也可以通過形參改變數組對應位置的值,從而更改數組。
聲明數組參數
對於把數組名當作參數的函數,因為調用函數時實際傳遞的是一個指針,所以函數的形參實際上是個指針,所以以下兩個聲明都是正確的:
int strlen (char *string ); int strlen( char string[]);
值得注意的是第一種聲明無法知道數組的長度,所以函數如果需要知道數組的長度,它必須作為一個顯式的參數傳遞給函數。
不完整的初始化
int arr[5]={1,2,3,4,5,6}; int arr[5]={1,2,3,4};
第一個聲明是錯誤的,數組的空間為5,無法把6個元素放到數組中。第二個聲明是合法的,它為數組的前四個元素提供了初始值,最后一個元素初始化為0;
自動計算數組長度
int arr[]={1,2,3,4,5,6};
當聲明中未說明數組長度時,編譯器將根據數組中元素的個數分配恰好夠裝入全部元素的空間。
字符數組的初始化
char arr[]={'h','e','l','l','o'}; char arr[]={"hello"};
以上兩種聲明是一樣的。
2.多維數組
當數組維數不止一個時,我們可以聲明多維數組。
存儲順序
對於下面數組:
int arr[3];
它的存儲結構如下:
當上面的數組中每個元素都是包含6個元素的數組時,它的聲明為:
int arr[3][6];
它在內存中的存儲形式為:
黑線方框表示第一維的3個元素,黃線表示第二維的6個元素。下標為arr[0][0]到arr[2][5],多維數組存儲順序按照最右邊下標先變化的原則,即行主序。
數組名
多維數組的數組名也是個指針常量,但是和一維數組不同,多維數組的數組名是指向數組第一行的常量指針,而不是指向第一個元素。
下標
例如如下聲明:
int arr[3][10];
指向數組的指針
下面有兩個聲明:
int a1[10], *p1=a1; int a2[3][10], *p2=a2;
第一個聲明是合法的,a1是指向int類型的指針,聲明p1也是指向整型的指針。第二個聲明是不合法的,a2是指向整型數組的指針,而p2是指向整型的指針。所以正確的聲明如下:
int (*p2)[10]=a2;
它使p2指向a2的第一行。下面的兩個聲明都是使p2指向a2的第一個整型元素:
int *p2=&a2[0][0]; int *p2=a2[0];
作為函數參數的多維數組
作為函數參數的多為數組名的傳遞方式和一維數組相同——實際傳遞的是數組的第一個元素。兩者的區別是,多位數組的每個元素本身是一個數組,所以以下聲明:
int arr[3][10]; ... fun(arr);
這里,參數arr的類型是指向包含十個整形元素的數組的指針。fun的原型為如下兩種形式中的任何一種:
void fun(int (*arr)[10]); void fun(int arr[][10]);
初始化
多維數組的存儲順序是根據最右邊的下標率的原則確定的。可用{}來包圍每行元素,例如:
int array[2][3]={ {1,2,3}, {4,5,6} };
數組長度自動計算
在多維數組中,只有第一維才能根據列表初始化列表缺省的提供。剩下的幾維必須顯式的顯示出來,這樣編譯器就能推斷出每個子數組維數的長度。例如:
int arr[][5] = { {1,2,3}, {4,5}, {6,7,8,9} };
這樣,編譯器可以推斷出最左邊一維為3。
三.指針和數組
1.概念區分
指針和數組雖然密不可分,但是卻不是相等的,考慮以下兩個聲明
int a[5]; int *b;
它們都具有指針值,它們都可以進行間接訪問和下標引用操作。但是還是有很大的區別:
聲明一個數組,編譯器將根據數組的大小為它分配內存空間,而聲明一個指針,編譯器只為指針本身保留內存空間。在上述聲明之后,表達式*a是合法的,但表達式*b卻是非法的。*b將訪問內存中某個不確定的位置,或者導致程序終止。另一方面,表達式b++可以通過編譯,而a++卻不行,因為a的值是個常量。對指針和數組的正確區分有助於理解c語言的結構語法。
再來看下面一個例子:
char arr[]="hello"; char *arr2="hello";
前一個聲明表示字符數組,后一個聲明表示字符串常量,它們的區別如下:
2.指針數組
看下面一個例子:
int *api[10];
因為下標引用的優先級高於間接訪問,所以表達式先執行下標操作,所以api是某種類型的數組。對數組的某個元素執行間接訪問操作后,得到一個整型,所以api的元素類型為指向整型的指針。下面是它的一個應用例子:
char const *keyword[]={ "how", "are", "you" };
參考文獻
《C++ PRIMER》 中文版
《C和指針》