楔子
去年,周星星大哥曾經在VCKBASE/C++論壇發表過一篇文章“數組引用"以避免"數組降階”,當時我不能深入理解這種用法的含義;時隔一年,我的知識有幾經錘煉,終於對此文章漸有所悟,所以把吾所知作想詳細道來,竟也成了一篇文章。希望本文能對新手有所啟迪,同時也希望大家發現本文中的疏漏之處後不吝留言指教。
故事起源於周星星大哥給出的兩個Demo,為了節省地方,我把兩個Demo合二為一,也能說明同樣的問題:
#include <iostream>
其運行結果如下:
using namespace std;
void Foo1(int arr[100])
{
cout << "pass by pointer: " << sizeof(arr) << endl;
}
void Foo2(int (&arr)[100])
{
cout << "pass by reference: " << sizeof(arr) << endl;
}
void main()
{
int a[100];
cout << "In main function : " << sizeof(a) << endl;
Foo1(a);
Foo2(a);
}In main function : 400
pass by pointer: 4
pass by reference: 400
這段代碼說明了,如果數組形參是數組名形式(或者指針形式,下文討論)時,使用sizeof運算符,將得不到原來數組的長度;如果用傳遞原數組引用的方法,則沒有問題。
這段代碼的確很難理解,因為這短短的十幾行涉及到了形參與實參的關系、數組名和指針的關系、引用的意義、聲名和表達式的關系這4大類問題,只要有1條理解不透、或者理解不正確,就理解不透上面的這段代碼。本文也就從這4個問題入手,把這4個問題首先解決掉,然後再探討上面的這段代碼。雖然這樣看來很是繁復,但是我認為從根上入手來理解、學習,是條似遠實近的道路。
一、函數形參和實參的關系
void Foo(int a);
Foo(10);
這裡的a叫做形式參數(parameter),簡稱形參;這裡的10叫做實際參數(argument),簡稱實參。形參和式參之間是什麼關系呢?他們是賦值的關系,也就是說:把實參傳遞給形參的過程,可以看作是把實參賦值給形參的過程。上面的例子中,實參10傳遞給形參a,就相當於a=10;這個賦值的過程。(因為數據類型多的很,無法舉例子舉全面,所以這裡就不舉例子了;如果覺得不好理解,就在vc中寫個sample調試一下各種數據類型的情況,你就能夠驗證這個結論了。)
二、數組名和指針的關系
這個問題是個歷史性的問題了,在C語言中,數組名是當作指針來處理的。更確切的說,數組名就是指向數組首元素地址的指針,數組索引就是距數組首元素地址的偏移量。理解這一點很重要,很多數組應用的問題就是有此而起的。這也就是為什麼C語言中的數組是從0開始計數,因為這樣它的索引就比較好對應到偏移量上。在C語言中,編譯過程中遇到有數組名的表達式,都會把數組名替換成指針來處理;編譯器甚至無法區分a[4]和4[a]的區別!*2 但是下面這一點需要注意:
int a[100];
int *b;
這兩者並不等價,第一句話聲明了數組a,並定義了這個數組,它有100個int型元素,sizeof(a)將得到整個數組所占的內存大小,是400;第二句話只是聲明並定義了一個int型的指針,sizeof(b)將得到這個指針所占的內存大小,是4。所以說,雖然數組名在表達式中一般會當作指針來處理,但是數組名和指針還是有差距的,最起碼有a==&a[0]但是sizeof(a)!=sizeof(a[0])。
並且在ANSI C標准中,也明文規定:在函數參數的聲明中,數組名北邊一起當作指向該數組第一個元素的指針。所以,下面的幾種書寫形式是等效的:
void Foo1(int arr[100]){}
void Foo2(int arr[]){}
void Foo3(int *arr){}
C++盡可能的全面兼容C語言,所以這一部分的語法相同。
三、引用的意義
“引用“是C++中引進的概念,C語言中沒有。它的目的在於,在某些方面取代指針。如果你認為引用和指針並無大不同,肯定會為指針報不平,頗有一種“即生亮何生瑜”的感慨;但是,引用確實有新的特色,也確實在很多地方的表現和指針有所不同,本文就是一例。使用引用,我們要把握這它最最最重要的一點,這也是它和指針最大的區別:引用一經定義,就和被它引用的變量緊緊地結合在一起,再不分開,對引用的任何操作都反映在它引用的變量上;而指針,只是訪問它指向變量的另一種方式,兩者雖有聯系,但是並不像引用那樣密不可分。:)
#include <iostream>
using namespace std;
void main()
{
int a = 10;
int & a_ref = a;
int b = 20;
// 定義引用時就要初始化,說明引用跟它指向的元素密不可分
//int & b_ref ; // error C2530: ''b_ref'' : references must be initialized
int & b_ref = b;
int * p;
int * q;
//下面的結果證明了:引用一經定義,就不能再指向其他目標;
//把一個引用b_ref賦值給另一個引用a_ref,其實就是把b賦值給了a.
cout << a_ref << " " << b_ref << endl;
a_ref = b_ref;
cout << a_ref << " " << b_ref << endl;
cout << a << " " << b << endl;
cout << endl;
//即使對一個引用a_ref取地址,取得也是a的地址。已經“惡鬼附體”了:)
p = &a;
q = &a_ref;
cout << p << " " << q << endl;
cout << endl;
//下面這段代碼展示了指針與引用的不同
p = &a;
q = &b;
cout << p << " "<< q << endl;
p = q;
cout << p << " "<< q << endl;
cout << endl;
system("pause");
}
下面是運行的結果,以供參考:
10 20
四、聲明和表達式的關系
20 20
20 20
0012FED4 0012FED4
0012FED4 0012FEBC
0012FEBC 0012FEBC
這裡想說明的是,分析一個聲明可以把它看作一個表達式,按照表達式中的運算符優先級順序來聲明。比如int (&arr)[100],你首先要找到聲明器arr,那麼&arr說明arr是一個引用。什麼引用呢?在看括號外面,[]說明了這一個數組,100說明這個數組有100個元素,前面的int說明了這個數組的每個元素都是int型的。所以,這個聲明的意思就是:arr就是指向具有100個int型元素的數組的引用。如果你覺得這種理解很晦澀,那你就不妨用typedef來簡化聲明中的復雜的運算符優先級關系,比如下面的形式就很好理解,其效果是和最初的那個例子是一樣的:
#include <iostream>
using namespace std;
typedef int INTARR[100]; //這個,這個...也可以用表達式來理解,有點“GNU is not UNIX“的味道是吧?
void Foo(INTARR &arr) //noh,這樣看就很明白了,就是傳了個引用進去
{
cout << "pass by reference: " << sizeof(arr) << endl;
}
void main()
{
INTARR a; //用類型別名來定義a
INTARR &a_ref=a; //用類型別名來定義引用a_ref
cout << "In main function : " << sizeof(a) << endl;
Foo(a);
system("pause");
}
大結局
吐沫星亂飛了半天,大家感覺還好吧,快結束了,大家再忍耐一下。看看下面這段程序:
#include <iostream>
怎麼樣,是不是對輸出結果感到很自然呢?如果是,那就好辦了。我總結一下就下課哈!^_^ 數組名在表達式中,往往被當作是指向首元素a[0]地址的指針,但是在sizeof(a)中,返回的結果是數組a占用內存的大小;pa是指向a的指針,他也指向a[0],但是sizeof(pa)中,返回結果是pa這個指針所占內存空間的大小,之所以這樣,因為pa這個指針和數組a的結合不夠緊密,屬於訪問數組a的第二被選方案;a_ref這個引用,就是對數組a的引用,就像“惡鬼附體”一樣,一旦附體附上了,你怎麼也甩不掉它,對它的任何操作,全部都反映在a上。在看本文最初的那個例子,比這個例子所增加的操作就是函數實參到形參的傳遞,我們在上面說過了,從實參到形參的傳遞可以看作是把實參賦值給形參。所以本文最初的那個例子,其實際的操作過程就和本文最後的這個例子是一樣的。所以,並非函數把數組給“降階”了,而是它原原本本就該這樣,千萬不必奇怪。 :p
using namespace std;
void main()
{
int a[100];
int * pa = a;
int (&a_ref)[100] = a;
cout << sizeof(a) << endl;
cout << sizeof(pa) << endl;
cout << sizeof(a_ref) << endl;
system("pause");
}
意猶未盡,在PS一段:在C語言中,沒有引用,是怎麼解決這種問題呢。下面是常用的幾種作法:
傳遞數組的時候,在增加一個參數,用來記錄數組的元素個數或者長度。main(int argc, char ** args)就是這種做法;這種方法還可以防止溢出,安全性比較高。
在數組的最後一個有效元素後面作一個標志,指明數組已經結束了。C語言中用char數組表示字符串,傳給相關的字符串函數,用的就是這種做法。這種方法保證了C的所謂字符串是無限長度的,因為用一個變量表示數組的長度的話,終歸會受到這個變量類型的限制,比方說這個變量是unsigned byte型的,那麼字符串長度就不能超過256,否則這個變量就溢出了。
對於多維數組,通常的方法是在最後一個有效維後面做一行標志,比如a[3][3]={{1,0,2},{2,2,5},{-1,-1,-1}}。如果我的程序用不到-1,我可以拿-1來填充最後一行,作為標志。這樣在函數內部檢測到某一維的元素都是-1,就說明到底了。
方法是靈活多變的,關鍵看人怎麼用了。C老爹Dennis Ritchie曾經說過:C詭異離奇,缺陷重重,卻獲得了巨大的成功。
注1:本文將不再引用“降階”這個術語,原因是我認為這個“降階”的概念有種把類似2維數組壓扁到1維的意思,其實本文討論的並不是這個問題,本文討論的是數組形參傳遞過程中數組長度損失的問題(這麼說也不准確,還是看文中的討論吧)。
注2:C語言的編譯器遇到數組元素arr[i],就會替換成*(arr+i)的形式。