數組與指針
長期以來,在C/C++中,數組名和指向數組首元素的指針常量到底是以一種什么關系,一直困擾着很多人。很多地方,甚至是一些教科書中都在說,“數組名就是一個指向數組首元素的指針常量”。但事實是,這是一種錯誤的說法!我們可以在很多場合中把數組名看作一個指向數組首元素的指針常量,但絕不能將這兩者當成同一個東西。
真實的關系
數組是數組,指針是指針,這是兩種不同的類型。
數組既可以表示一種數據類型,也可以表示這種類型的一個對象(非面向對象之對象,下同),表示對象時也可以稱之為數組變量,和其他類型變量一樣,數組變量有地址也有值。
數組的地址就是數組所占據內存空間的第一塊存儲單元的編號,而數組的值是由數組所有元素的值構成。
數組名既不是指針,也不是指針變量,而是數組變量的名字,與數組名相對應的是指針變量的變量名,都是符號。
空說費勁,看代碼說話:
int a[10];
int *const p = a;
std::cout << sizeof(a); // 40
std::cout << sizeof(p); // 8
作為數組,a擁有可以存放10個int型數據的空間,可以將一個int型的值存儲到a中任意一個元素中。但作為一個指針的p,只能存儲一個地址。
sizeof
操作符可以獲取到一種數據類型所占據的內存大小,指針類型在x64位機器上的大小是8,而數組類型的大小是所有元素大小之和,上例中即為10個int型的大小,是40。
誤解從何而來
可以將數組名賦值給一個指針,而賦值后的指針是指向數組首元素的,這讓數組名看起來確像一個指針。
直接輸出數組名會得到數組首元素的地址,這讓人們誤以為“數組名的值就是數組首元素地址“,符合指針的定義。
數組名可以像指針一樣運算,對數組的索引和指針的運算看起來也是相同的。
#include <stdio.h>
int main(){
int a[] = {1,2,3};
int * p = a;
printf("a:\%#x, p:%#x, &a[0]:%#x\n", a, p, &a[0]);
printf("*a:\%d, *p:%d, a[0]:%d, p[0]:%d\n", *a, *p, a[0], p[0]);
printf("*(a+1):\%d, *(p+1):%d, a[1]:%d, p[1]:%d\n", *(a+1), *(p+1), a[1], p[1]);
return 0;
}
輸出:
a:0x5fcaf0, p:0x5fcaf0, &a[0]:0x5fcaf0
*a:1, *p:1, a[0]:1, p[0]:1
*(a+1):2, *(p+1):2, a[1]:2, p[1]:2
從 &a 與 &a[0] 說起
數組的地址和數組首元素的地址雖然值相同,但意義不同。
值相同是因為,一個變量無論在在內存中占據多大空間,它的地址總是該空間第一個內存單元的地址。而數組的元素依次連續分布在整塊數組空間中,數組空間的第一個內存單元被數組首元素占據,必然也同時是數組首元素所占空間的第一塊空間單元,所以數組的地址與數組首元素的地址相同。
意義不同是因為,數組地址代表了整塊數組所占據的內存空間,而數組首元素的地址只代表了首元素所占據的空間。
&a 表示取數組的地址,其結果是一個指向該數組的指針,它可以賦值給另一個同類型的指針。
&a[0]表示取數組首元素的地址,其結果是指向該數組首元素的指針,可以賦值給另一個同類型的指針。
注意:指向數組的指針和指向數組首元素的指針是兩種不同類型的指針。
#include <stdio.h>
int main(){
int a[]={1,2,3};
int (* pa)[3];
int * pi;
pa = &a;
pi = &a[0];
printf("&a=%#x, &a[0]=%#x\n",&a, &a[0]);
printf("pa=%#x, sizeof(a)=%d, pa+1=%#x\n", pa, sizeof(a), pa+1);
printf("pi=%#x, sizeof(a[0])=%d, pi+1=%#x\n", pi, sizeof(a[0]), pi+1);
return 0;
}
編譯后運行,輸如下:
&a=0x5fcaf0, &a[0]=0x5fcaf0
pa=0x5fcaf0, sizeof(a)=12, pa+1=0x5fcafc
pi=0x5fcaf0, sizeof(a[0])=4, pi+1=0x5fcaf4
我們發現,取數組地址(&a)得到的指針pa和取數組首元素(&a[0])得到的指針pi是兩種不同類型的指針,pa是一個指向有三個int型元素的數組的指針,pi是一個指向int型對象的指針。雖然pi和pa的值相同,但所指的內存空間不同,pi所指的空間處於pa所指空間的內部,而且是內部最靠前的部分。pi和pa所指內存塊的大小顯然是不同的,因此我們看到pa+1並不等於pi+1。
由指針運算規則可知,pa+1的值就是pa所指空間的下一個空間的地址,所以pa+1的值就是pa的地址向后偏移一段后的地址,這個偏移量就是pa所指的數組a的大小,即12個內存單元。同樣,pi+1的值是pi向后偏移4個單位(int型的大小)后的地址。
ps: 看到有些地方說地址就是指針,我覺得這個說法不對。地址就是地址,它是數據對象的一個屬性,表明它在內存中的位置。指針本身也有地址,總不能說“地址的地址”吧?此外,指針不光帶有地址信息,它還附帶有所指對象的類型信息,這就是為什么指針知道如何准確的指向下一個地址。
權威的解釋——decay
C11標准中,6.3.2.1 [Lvalues, arrays, and function designators] 第3段,有如下表述:
Except when it is the operand of the sizeof operator, the _Alignof operator, or the unary & operator, or is a string literal used to initialize an array, an expression that has type "array of type" is converted to an expression with type "pointer to type" that points to the initial element of the array object and is not an lvalue.
同樣的轉換在C++中也是一樣,在 C++11 標准(ISO/IEC 14882)的 4.2 Array-to-pointer conversion 一節中有如下表述:
An expression of type “array of N T”, “array of runtime bound of T”, or “array of unknown bound of T” can be converted to a prvalue of type “pointer to T”. The result is a pointer to the first element of the array.
可見,除了作為 sizeof 、_Alignof 和 & 這3個操作符的操作數以及用於初始化數組的串字面量外, 表達式中的數組都會被自動轉換為指向其首元素的指針 ,轉換而來的指針不是一個左值(lvalue)。因為這種轉換丟失了數組的大小和類型,因此有一個專用的稱謂叫做 “decay” 。
於是,所有的疑惑都有了最權威的解釋。
char a[10];
char * p = a; /* 這里的a被轉換為 char* 型指針,所以可以賦值給p */
a = p; /* ERROR! a雖然被轉換為指針了,但轉換后得到的指針無確切地址,不是lvalue,不能被賦值 */
char (*pa) [10] = &a; /* a是&的操作數,沒有發生轉換,&a意為取數組a的地址,得到一個指向數組a的指針 */
sizeof(a); /* a未發生轉換,仍是數組,所以表達式得到的值是數組的大小 */
&a; /* a未發生轉換,仍是數組,所以表達式得到的值是數組的地址 */
*a; /* a被轉換為指針,操作符*是作用於指針的,而非數組 */
*(a+1); /* a被轉換為指針,所以並不是數組a與1相加,而是轉換后得到的指針在參與運算 */
a[0]; /* a被轉換為指針,所謂數組的下標本質是指針運算 */
a == a; a - a; /* a被轉換為指針,本質是指針運算 */
我們發現,一旦有了 decay ,表達式中所有讓一個數組看上去像個指針的現象都合情合理了。除賦值外,可用於指針的運算都可以適用於會發生轉換的數組。不可賦值是因為先要轉換,轉換后不是左值。
ps:lvalue這個術語翻譯成左值會過分強調它可以作為賦值操作符(=)的左操作數,實際上lvalue的核心在於location,而不是left,雖然它最初被命名為lvalue確是因為left。lvalue意味着在內存中有確切位置(location),可以定位(locator)。所以,數組被轉換為指針后不是lvalue的原因是沒有確切地址,而不能被賦值是結果。
可能讀到這里會有一個疑問,那就是轉換后不是左值不可以賦值,那么 a[0] = 'x';
卻怎么解釋?注意,轉換后得到左值的是a,而非a[0]。什么意思呢?把這個表達式中除去a的部分([0])看成是對轉換后得到的指針的繼續運算,結果就是數組第一個元素,有確切地址,那么a[0]整體就是一個左值了。 於是賦值成功!
值得注意的是,取址時取到的是數組地址而非轉換后指針的地址,因為取址時數組不會發生轉換,實際上,轉換后得到的指針沒有確切地址不是左值,是無法取到地址的。這里多說這么幾句是因為,有一些博客中糾結於 “數組名作為一個指針常量有沒有被分配空間?分配到的地址是什么?” 這樣傷神的問題中。
轉換規則的例外情況
C規范中指出了四種不發生轉換的例外情況。
前三種情況是說數組作為 sizeof 、_Alignof 和 & 這3個操作符的操作數時是不會被轉換為指針,這個較好理解,就是這三個操作符直接作用於數組時,不會發生轉換,如 sizeof(a)
和 &a
中的a都不會被轉換。而像 &a[0]
這樣的表達式中,&的優先級不是最高的,所以&的直接作用對象是 a[0]
這個子表達式,此時a轉換為指針后進行運算,得到數組的第一個元素,&作用於這個元素后取得其地址,得到的最終結果指向數組首元素的指針。
下面的代碼也說明了這種規則的關鍵是直接作用:
int a[4];
printf("%d\n", sizeof(a)); /* 不轉換,輸出數組a的大小 16 */
printf("%d\n", sizeof(a+0)); /* 轉換,輸出指針類型大小 8 */
那么用於初始化數組的串字面量說的又是什么呢?
按 ISO/IEC 9899:201x ,6.4.5 [String literals] 節中對串字面量的規范可知,編譯期間,串字面量所聲明的多字節字符序列(multibyte character sequence)會先行合並(如果有多個相鄰)並在結尾追加一個零值( '\0'
),然后用此序列初始化一個靜態存儲周期(static storage duration)的等長度字符數組。
所以,串字面量作為一個原始表達式(Primary expressions),其結果是一個字符數組!因為地址可知,它是一個 lvalue 。
需要注意的是,程序中對這種數組試圖修改的行為在C11標准中是未定義的,C11標准同樣也沒有說明對於內容相同的這種數組是否可以可以視為一個(只存儲一份)[^ISO/IEC 9899:201x §6.4.5 para7]。
看下面的代碼:
char a[4] = "abc"; /* 注意,此處是初始化,而非賦值!*/
char * p = "abc";
a[1] = 'x'; /* OK! */
p[1] = 'x'; /* ERROR! */
printf("%d\n", sizeof("abc")); /* 輸出 4 */
printf("%#x\n", &("abc")); /* 本機輸出 0x403031 ,證明沒有轉換,因為轉換后非lvalue,無法取值 */
第一行代碼中的串字面量 "abc"
的本質是一個長度為4(被追加了'\0'
)的字符數組,其用於初始化另一個數組a時不會發生轉換。這就是所謂的用於初始化數組的串字面量不會decay。
第二行代碼中的串字面量同第一行中的一樣,也是一個長度為4的字符數組,只是是否和上一行的是同一個就不得而知了,C標准沒有規定。這個字符數組此刻並未用於初始化一個數組,所以它被轉換為指向其首元素的指針,然后用於初始化另一個指針p了。
所以第一行可以認為是用數組初始化數組,第二行是用指針初始化指針。不過因為轉換規則的存在,可用於初始化數組的“數組”僅限於串字面量。
第三行很好理解,a是我們新初始化的一個數組,和初始化它的串字面量已經是兩回事了,所以修改a是合法的操作。但是第四行在大多數系統中會報錯,因為p指向的是一個串字面量,對串字面量的修改行為未被C標准所定義,因為串字面量本質是即一個靜態存儲周期的字符數組,大多數系統對其有寫保護因而修改出錯。
如果嘗試將串字面量作為 sizeof 、_Alignof 和 & 這3個操作符的操作數,我們發現這個“字符數組”也沒有轉換。
作為表達式結果的數組
在討論串字面量本質的時候,我們發現,在轉換概念的范圍內,所謂數組不光是指在程序中被我們明確定義的數組,也可以是表達式計算的結果。如原始表達式串字面量的結果就是字符數組。果真如此嗎?我們來看看下面的情況。
試想對於一個數組a,表達式 (&a)[0]
和表達式 &a[0]
有什么不同?
&a[0]
這個表達式我們在前面已經分析過了,它的結果是一個指向a的首元素的指針。
而表達式 (&a)[0]
的不同之處在於提高了取址操作符&的優先級。於是,在 (&a)[0]
中,數組 a 作為操作符&的操作數,不會發生轉換。子表達式 &a
是取數組a的地址得到指向該數組的指針,而接下來的運算就是指針運算了,結果便是數組a本身。
那么作為表達式 (&a)[0]
的結果的數組會不會有轉換行為呢?答案是肯定的。
空口無憑,再看代碼:
char a[4] = "abc";
char * p;
p = &a[0];
p = (&a)[0];
printf("%d\n", (&a)[0] == &a[0]); /* 輸出1*/
printf("%d\n", sizeof((&a)[0])); /* 輸出 4,數組a的大小 */
printf("%d\n", sizeof(&a[0])); /* 輸出 8,x64系統中指針的大小 */
char (*pa)[4];
pa = &((&a)[0]);
表達式 &a[0]
的結果是char * 型的指針,它可以賦值給同類型的指針p是理所當然的。但奇怪的是,表達式 (&a)[0]
結果是一個數組,竟然也可以賦值給指針p。考慮下轉換規則,這個情況就完全合理了,這說明作為計算結果的數組,也會在符合轉換條件的情況下發生轉換。
將這兩個表達式進行邏輯比較,得到的結論是它們是相同的,這也說明表達式 (&a)[0]
發生了轉換。我們再嘗試將這兩個表達式分別作用於操作符 sizeof ,根據規則,作為sizeof的操作數,它們不會發生轉換,事實確是如此。
對一個數組取地址會得到指向該數組的指針,我們將表達式 (&a)[0]
作為操作符&的操作數(此時也沒有轉換)得到的確實是指向數組類型的指針。如果,我們腦洞大一些,將 &a[0]
作為&的操作數會怎么樣? &a[0]
是一個指針,可能我們會覺得是不是會得到一個指向指針的指針?答案是不會! &a[0]
是一個指針沒錯,但沒有確切地址,不是lvalue,無法取址, &(&a[0])
會編譯錯誤!
轉換的基礎
數組和指針之所以有這么微妙的關系,內存分配才是關鍵因素。上面討論的表達式 (&a)[0]
的結果就是數組a本身,如果我們把方括號中的整數0改為1或者2這樣的值,那又意味着什么?
char a[4] = "abc";
char * p = &a[1]; /* p指向數組a的第二個元素 */
p = (&a)[1];/* p不指向數組第二個元素 */
printf("%d\n", sizeof((&a)[1])); /* 輸出數組大小 4 */
printf("%d\n", (&a)[1] == &a[0]+4 ); /* 輸出 1 */
char (*pa)[4] = &a;
printf("%c\n", *(pa+1)[0]); /* 即((&a)[1]))[0], 輸出值不確定 */
我們先看看表達式 (&a)[1]
的結果是什么,首先, &a
將得到指向數組a的一個指針,而指針的下標運算 [1]
表示指針所指空間相鄰的同大小空間內的對象,雖然我們知道,數組a的旁邊有什么並不確定,但至少不應該是個和a一樣的數組,因為我們此前並沒有定義一個數組的數組。盡管如此,我們發現系統依然照着a的樣子,在內存中為我們找到了它旁邊一塊同樣大的空間,並“假裝”它保存的也是一個同類型的數組,我們用sizeof測試時會得到數組大小的值。而且 (&a)[1]
這個“數組”還可以轉換為一個指向它首元素的指針,我們將它和 &a[0]+4
(可以看作指向從a的首元素起后面的第5個元素的指針,即&a[4])比較發現,它們是相等的。
我們嘗試按照取數組a中元素那樣,從a旁邊這個並不存在的數組中讀取數據,雖然輸出值沒有意義,但這卻並非是一種非法操作,C語言允許我們這么做。
順便說下,數組下標在C語言中是被定義為指針運算的,ISO/IEC 9899:201 §6.5.2.1 [Array subscripting] para 2 中的的原文如下:
A postfix expression followed by an expression in square brackets [] is a subscripted designation of an element of an array object. The definition of the subscript operator [] is that E1[E2] is identical to (*((E1)+(E2))). Because of the conversion rules that apply to the binary + operator, if E1 is an array object (equivalently, a pointer to the initial element of an array object) and E2 is an integer, E1[E2] designates the E2-th element of E1 (counting from zero).
因此像 a[-1]
這樣的負數下標雖然沒有意義,但也是合法的。小於0 或大於等於數組元素數的下標值是無意義的,那我們如何看待這些越界的下標呢?實際上,下標是定義給指針的。 E1[E2]
中的兩個子表達式E1和E2一個是指針類型一個是整數類型[^ISO/IEC 9899:201x §6.5.2.1 para1],因為轉換規則的存在,數組才可以成為E1和E2中的一個。
為什么說是E1和E2中的一個?難道數組類型的不應該是E1嗎?看下面的代碼示例:
int a[5] = { 0, 1, 2, 3, 4 };
cout << a[2] << endl; // 輸出 2
cout << 2[a] << endl; // 輸出 2
cout << 5["abcdef"] << endl; // 輸出 f
對於a[2]的輸出我們沒有異議,但后面兩個表達式有點"詭異"。前面在引用C11中下標規范時提到, E1[E2]
與 (*((E1)+(E2)))
是等同的(identical )。那么就會有:
E1[E2]
(*((E1)+(E2))) 下標定義
(*((E2)+(E1))) 加法交換律
E2[E1] 下標定義
所以, E1[E2]
與 E2[E1]
是等同的。因此,a[2]和2[a]都輸出2,而 5["abcdef"]
其實是 "abcdef"[5]
,結果是串字面量(字符數組)中的第5個(從0計)字符f。
我們發現,數組和指針在從內存訪問數據上並沒有本質區別,數組貌似僅意味着數據連續不間斷的存放而已,而數組類型都有相應的指針類型對應,如int型數組對應有指向int型的指針,這種類型信息提供了指針訪問內存時每個步長的移動跨度。除此之外,數組好像也不能再給我們太多信息了,它甚至不能做出越界檢查。
從轉換來看,數組可謂是C語言世界的二等公民,在一切可以的情況下,它都轉換為一個指向自身首元素指針。
作為函數參數的數組
函數調用中,作為實參的數組會在函數調用前被轉換為一個指向其首元素的指針。因此,數組永遠都不會真正地被傳遞給一個函數。這樣的話,看上去將函數的參數聲明未數組類型就會變得沒有意義。事實上,函數的參數類型列表中但凡被聲明為數組類型的,在編譯期間會被調整為對應的指針類型,這意味着,在函數體內操作一個被聲明為數組的形參,其實是在操作一個指針。
有關函數參數中的數組的調整和轉換在C11規范中有多處說明[1],感興趣可自行查看。
需要注意的是,雖然編譯器會做調整,但我們不應將數組類型的形參聲明都改為指針類型。因為數組和指針意義不同,在定義函數時不要理會編譯器的調整有助於我們寫出意義更加明確可讀性更高的代碼。
void f(char str[]) /* 會被調整為: void f(char *str) */
{
cout << sizeof(str) << endl; /* str是指針,而不是數組 */
if(str[0] == '\0')
str = "none"; /* 這里並不是數組被賦值,而是指針str被賦值 */
cout << str << endl;
}
int main(){
char a[3];
f(a);
a[0] = 'x';
f(a);
return 0;
}
在上面例子中,函數f聲明了一個字符數組型的參數str,實際上在編譯時str會被調整為指向字符的指針類型。在f內,盡管str被我們顯式地聲明為數組類型,但函數內部它就是作為一個指針來用的,sizeof操作符給出的結果是指針大小,而且我們看到str可以被直接賦值,要知道數組是沒有這個特性的。
字符數組a出現在函數調用時的實參列表中時,並不代表a本身,而是被轉換為指向a首元素的指針,因此真正的實參是一個指針而非數組a。如果忽略f中sizeof那一行,那么f的功能就可被描述為:輸出一個字符數組,如果字符數組為空則輸出“none”。
指向數組的指針
有本書叫做《當我們談論愛情時我們在談論什么》,這里我們套用一下。
當我們談論指向數組的指針時我們在討論什么?
是的,如果我們並不是特別刻意地說起指向數組的指針時,其實我們想表達的是指向數組首元素的指針。
因為轉換規則的存在,可以很容易地得到一個指向數組首元素的指針,通過這個指針的下標運算和自增減運算就可以非常方便的訪問數組中各個元素。然而真正意義上的指向數組的指針,是指向整個數組的,這意味着對這種指針的移動會讓操作地址偏移跨過整個數組空間,因此對它的下標和自增減運算通常都沒有意義(本文前面已經有過描述)。所以,想要通過指向數組的指針訪問數組元素是舍近求遠並且最終還得繞回來的做法。
int a[3] = {0, 1, 2};
int (*pa)[3]; // pointer to array [3] of int
pa = &a;
printf("%d\n", **pa); // 子表達式*pa的結果是數組a,隨后轉為指向a首元素的指針,再次*后得到 0
pa++; // 無意義!指向a以外的空間
printf("%d\n", **pa); // ISO/IEC 9899:201x §6.5.3.2 para4,undefined
難道指向數組的指針就沒有一點兒用武之地了么?答案是當然有,但真是一點兒。如果我們遇到了數組的數組(也就是二維甚至多維數組)時,就可以使用指向數組的指針。
int D2Matrix[2][4] = {{11, 12, 13, 14},
{21, 22, 23, 24}};
int (*pa)[4]; /* pointer to array [4] of int */
pa = D2Matrix; /* pa = &D2Matrix[0] */
printf("%d\n", **pa); /* prints 11 */
pa++; /* steps over entire (sub)array */
printf("%d\n", **pa); /* prints 21 */
printf("%d\n", (*pa)[1]); /* prints 22 */
[^ISO/IEC 9899:201x §6.4.5 para7]: It is unspecified whether these arrays are distinct provided their elements have the appropriate values. If the program attempts to modify such an array, the behavior is undefined.
[^ISO/IEC 9899:201x §6.5.2.1 para1]: One of the expressions shall have type ''pointer to complete object type'', the other expression shall have integer type, and the result has type ''type''.
參考:
http://stackoverflow.com/questions/1641957/is-an-array-name-a-pointer-in-c
本文原創,與個人博客 zhiheng.me 同步發布,標題:C語言中數組與指針的關系。
水平有限,謬誤難免,歡迎指正,互相學習!
謝絕惡意評論,噴子繞行!
ISO/IEC 9899:201x: §6.9.1 para10,ISO/IEC 9899:201x : Footnote 103,ISO/IEC 9899:201x : §6.7.6.3 para 7, ISO/IEC 9899:201x : Footnote 93 ↩︎