首先,我把 C++ 跟 C#/VB 在這方面的根本差異處先條列在前面。沒有學過 C++ 跟沒有學過 C#/VB 的人同樣可以把下列重點當作本文的前題說明。
- C++ 並不像 C#/VB 一樣會做嚴格的記憶體位址的 boundary check。換句話說,在 C++ 中你可以很容易的讀寫未經正式配置的記憶體位址 (雖然有時候會被 runtime check 攔截並發出 exception)。在 C#/VB 中絕對不會允許這種事情發生 (應該也辦不到 - 除非你寫 unmanaged code)。
- 在 C++ 程式中,你必須很謹慎的管理你的變數,否則你很容易在不小心的情形下在 heap 中留下未受控制 (orphaned) 的記憶體區塊,從而造成 memory leak 的情況。同樣的,這種事情在 C#/VB 中也不會發生。
- 在 C++ 中各種型別並不像 C#/VB 一樣區分得很清楚。在 C#/VB 中型別之間的轉換在 compiler 這一關就會被嚴格檢驗,在 C++ 並不會。例如你可以使用 *pStrArray 取得 *pStrArray[0] 的值 (是的,pStrArray 是個指向陣列資料的指標),甚至改寫其值。如果你的同事寫程式都不按照規範的話 (例如他總是學不會把指標變數取一個以 p 開頭的名字),我想你絕對不會很樂意去維護他的程式的。
- 在 C++ 中,char* 的指標和其它型別的指標在行為上有很大的差異。在其它型別中 (例如一個指向 int* 的指標 pInt),使用 *pInt 即可取得資料,使用 pInt 即可取得資料的位址,對 char* 指標卻完全不是這回事。要取得 char* 指標所指向的資料所在的位址必須透過迂迴的方式,已列在後面的範例程式中。
- 傳統 C++ 和 C++/CLI (即從 CLR 樣版頁籤下新增的專案類型) 在許多地方有相當大的差異,和 C#/VB 也幾乎完全不一樣。讀者應該仔細分辨以免搞混。
宣告指標
語法:- int* pNumber; // 可以把星號標在 int 後面
- int *pNumber; // 也可以把星號標在變數名稱前面
- int *pNumber, number = 5;
// 可以在同一行中同時宣告一個指標變數跟一個普通變數;但如果你選擇把星號標在 int 後面,建議你還是分做兩行吧!
- int *pNumber = NULL; // 宣告這個指標並未指向任何值
- int *pNumber;
pNumber = NULL; // 這兩行程式和上面 int *pNumber = NULL; 是同樣的意思
- int *pNumber = 0; // 此種寫法與 *pNumber = NULL 一樣。注意:不能使用 0 以外的任何數字當做初始值。
取址運算子 "&"
語法:- int *pNumber = NULL, number = 5; // 宣告一個指標變數跟一個普通變數
pNumber = &number; // 使用取址運算子 & 取得 number 變數的位址並賦予 pNumber,現在這兩個變數其實已指向同一個位址
cout << *pNumber << endl; // 此時 pNumber 已指向 number 變數的位址,再使用 *pNumber 取得其值
cout << hex << &number << endl; // 以十六進位觀察 number 的位址
cout << hex << pNumber << endl; // 以十六進位觀察 pNumber 的值,應該與上值相同
- int *pNumber = 0;
解參考運算子 "*"
解參考(de-reference)運算子也稱為間接運算子,因為程式必須先取得一個「位址」,再去取這個位址所儲存的「值」,所以是一種間接取值的方式。我們已經在宣告指標變數的語法中看過這個運算子了,只是解釋起來方向略有不同而已。語法:
- int *pNumber = NULL, number = 5; // 宣告一個指標變數跟一個普通變數
pNumber = &number; // 使用取址運算子 & 取得 number 變數的位址並賦予 pNumber,現在這兩個變數其實已指向同一個位址
cout << *pNumber << endl; // 此時 pNumber 已指向 number 變數的位址,再使用 *pNumber 取得其值
- int number = 5;
int *pNumber = &number; // 也可以寫成這樣
在 C++ 中要判斷一個指標變數是不是 NULL,可以使用如下的判斷式:
- if (pNumber == NULL) // 或者
- if (pNumber == 0) // 或者
- if (!pNumber)
為何需要指標?
為何需要指標?我認為這是一個很難回答的問題。在 C#/VB/Java 裡面是沒有指標的 (其實並不是真的沒有,只是我們通常不會用到),我們照樣可以把事情辦好。然而,我在前面提過,如果你寫過組合語言的話,你就比較容易體會出使用指標的感䁷。這種感覺有點像我們在使用 delegate 的樣子。大部份使用 delegate 的場合都可以以不使用 delegate 的方法取代,但是一旦你習慣使用 delegate 的話,你並不會認為使用 delegate 是什麼難以理解或多此一舉的做法。C 跟 C++ 是介於組合語言與其它高階語言中間的一種比較偏低階的語言。它的發展背景處於軟體資源與硬體效能比較受限的年代,那時候的語言開發者都是習慣於組合語言寫法的工程師 (至少我是這麼認為),而組合語言中通常是大量使用指標的。所以我們不太容易在 C/C++ 裡面看到高階語言裡面那些可以隨意配置動態記憶體,或者可以隨便使用 linked list 串來串去的資料結構。C/C++ 似乎較少套用那種大格局的程式寫法,但是它的彈性其實是比較大的。因此,即便 .Net 已經發展了十年,我們在市面上看到的大多數重要的產品仍然是使用 C++ 所開發的。
字串指標
在 .Net 程式中,字串是個特別的型別,在 C++ 中也是。只不過在 C++ 中對於字串的處理方式恐怕不是習慣於 C#/VB 程式的人可以一下子就搞懂的。語法:
- char *pStr("A great failure is better than an excusable absence."); // 或者
- char *pStr = "A great failure is better than an excusable absence.";
cout << pStr << endl; // 輸出 "A great failure is better than an excusable absence." 這串字
cout << strlen(pStr) << endl; // 使用 strlen 函式得到字串長度 (不包含尾巴的 \0 字元)
事實上,如果你把 *pStr 這個字最前面的星號拿掉,這行指令就沒辦法編譯。因此,正確地講,我是對一個型別為 char 的指標變數賦予了字串 (或者說字元陣列) 的初值。
如果你夠細心的話,你或許會留意到,我在上面舉過的例子中提到,指標變數在宣告時不能同時賦予一個常數值 (例如 int *pNumber = 5; 這道指令是錯的)。那為什麼在這裡卻又可以?
這是因為 C++ runtime 會自動幫你建立起初始值的一串常數字元陣列 (而且會自動在這字串的最後面補上一個 \0 表示結尾),然後把其位址指向這個指標變數。這可是 char* 型別的指標變數才能享有的特殊禮遇,其它型別的指標變數都沒有 (請乖乖使用 new 指令 - 後面會介紹)。
或許你也同時發現 cout << pStr << endl; 這行指令似乎有點玄機。是的,如果這不是一個 char* 變數,而是一個 int* 變數的話,它的輸出應該是該指標變數中儲存的位址。但是對 char* 變數而言,它卻會把它所指向的整個字串輸出。
此外,在本例中我們使用 strlen 函式以得到字串的長度,該函式的邏輯是從字串開頭一直計數到發現 \0 字元為止的累積長度。但是如果此處的 pStr 指標已經指向一個不正確的地方,那麼使用這個函式的結果有可能是錯誤、甚至有點糟糕的。那麼,什麼時候指標會指向「不正確」的地方呢?我在下面會進一步說明。
如果在本例程式中你使用了中文字串的話,使用 strlen 得到的會是 byte 數而不是字元數。在 C++ 中要使用計算中文字串的長度,必須使用 wchar_t 型別而不是 char 型別;記算字元數的函式也不能使用 strlen 而必須使用 wcslen,如下例:
- wchar_t *pChStr = L"點部落";cout << wcslen(pChStr) << endl; // 結果是 3
對於中英混雜的字串,也必須使用 wchar_t / wcslen 才能得到字元數,否則都會得到 byte 數。
指標與陣列的混用
上例中的 pStr 雖然是一個以 char* 型別宣告的指標變數,你卻可以把它當作 char array 來使用:- for (int i = 0; pStr[i] != 0; i++)cout << pStr[i]; // 輸出 "A great failure is better than an excusable absence." 這串字
- int *pNumber = NULL, number = 5;pNumber = &number;
cout << pNumber << endl; // 輸出值為 pNumber 的值,亦即真正值的位址
cout << *pNumber << endl; // 輸出值為 5cout << pNumber[0] << endl; // 輸出值為 5cout << pNumber[1] << endl; // 輸出值為隨機的無意義數字
我們再來看看把指標變數指向傳統陣列的情況:
- double *pData, data[3] = { 0.1, 0.2, 0.3 };pData = data; // 傳統陣列變數本身也是指標,所以你不能再使用 &data 來取得它所指向的資料的位址
cout << pData[0] << endl; // 輸出 0.1cout << pData[1] << endl; // 輸出 0.2cout << pData[2] << endl; // 輸出 0.3cout << pData[3] << endl; // 輸出奇怪的數字
仿上例,如果我們把這個指標變數指向陣列中的某個元素的位址,情況又是如何?
- double *pData, data[3] = { 0.1, 0.2, 0.3 };
pData = &data[0]; // data[0] 已經是資料了,所以你可以透過取址運算子取得它的位址cout << *pData << endl; // 輸出 0.1
cout << *(++pData) << endl; // 輸出 0.2
cout << *(++pData) << endl; // 輸出 0.3
cout << *(++pData) << endl; // 輸出奇怪的數字
從以上的例子中,我們不難得到一個結論,也就是陣列實際上可以當作一個指標來對待。我們再看一個例子:
- char pStr1[10] = "Johnny", *pStr2 = " Lee";strcat(pStr1, pStr2);cout << pStr1 << endl; // 這兩行也可以簡化為 cout << strcat(pStr1, pStr2) << endl; 一行
有趣的是,如果你把第一行程式改成 char *pStr1 = "Johnny", *pStr2 = " Lee"; 則雖然可以通過編譯,卻根本無法執行。主要是因為 char *pStr1 = "Johnny" 等同於 char pStr1[7] = "Johnny",所以發生錯誤的原因基本上是一模一樣的,雖然發出錯誤的機制看起來有點不同。
看到這裡,C#/VB 使用者恐怕會覺得不可思議。我相信已經有很多人對於到底何時該加上或不該加上 "*" 這個解參考運算子感到迷糊了。但容我再強調一次,在 C++ 程式裡就是這樣!這實在不是一個難或不難、合理或不合理的問題;只要你的觀念夠清楚,剩下的就只是單純的習慣不習慣的問題而已。
指標陣列
前面談的都是把指標指向陣列;那麼,如果一個陣列本身就是由指標變數所組成的 (特別是 char* 陣列),那又是什麼情況?語法:
- char *pStr[] = { "a", "b", "c" }; // 宣告一個指標陣列且賦予初始值
cout << "\"" << pStr[0] << "\"" << endl; // "a"cout << "\"" << pStr[1] << "\"" << endl; // "b"cout << "\"" << pStr[2] << "\"" << endl; // "c"
cout << "Size of long integer is " << sizeof(long) << endl; // 對照一下一個 long integer 的大小
cout << "pStr has " << (sizeof pStr) / (sizeof pStr[0]) << " members" << endl; // 必須使用迂迴的方式才能得出陣列的元素個數
你必須自已把本例程式拿去 VS 上面 (開一個 Win32 的空白專案) 執行一下,才會明白我在上面到底寫些什麼。如果你只是用看的,你大概會以為我是在繞口令,並且害你很快速的睡著。
接下來,我要繼續介紹如何動態配置記憶體的方法。但是在開始之前,我要先把 C++ 對於記憶體的分配原則說明一下。
C++ 對記憶體的運用
在一個 C++ 程式中,記憶體可以分為四塊:- 程式碼區域 (Code Area) - 編譯好的可執行程式碼存放的地方
- 全域記憶體 (Global Area) - 存放全域變數的地方
- Heap (也稱為 Free Store) - 動態配置的變數存放的地方
- Stack (也稱為 Call Stack) - 程式內參數與局部變數存放的地方
動態記憶體配置
我們在前面所舉出的例子中,多半使用常數值。然而常數值在實際應用上,用處實在並不大。要讓程式的彈性變大、應用範圍變廣,一定要能動態配置記憶體。語法:
- int *pInt = NULL;
pInt = new int(10); // 使用 new 配置一個 int 型別的值,並將其設為 10
cout << *pInt; // 輸出 10
delete pInt; // 使用 delete 歸還配置的記憶體
- char *pStr = new char[10] ;cout << "pStr points to " << static_cast<void *>(pStr) << endl; // 輸出 pStr 位址char *pStr1 = pStr;cout << "pStr1 points to " << static_cast<void *>(pStr1) << endl; // 輸出 pStr1 位址*pStr1 = 'J'; // 把開頭字元變成 J 以方便辨識cout << "pStr = " << pStr << endl;cout << "pStr1 = " << pStr1 << endl;delete [] pStr; // 現在將原先配置給 pStr 的記憶體空間歸還系統pStr = NULL; // 養成重設指標的好習慣cout << "pStr1 points to " << static_cast<void *>(pStr1) << endl; // 再次確認 pStr1 仍指向同一個地方cout << "pStr1 = " << pStr1 << endl; // 這時發現原來內容已不被保留cin >> input;
當我們使用 new 運算子建立一個型別為 int 的變數實體時,C++ runtime 會在 heap 中配置一塊 int 寬度 (4 bytes) 的記憶體,並且把這塊記憶的開頭位址記錄在 pInt 的儲存值,之後我們便可以使用 *pInt 寫入或取出。請注意:如果我們在本程式中使用 new int 而不是 new int(0) 的話,pInt 所指向的值就是原來那一小塊記憶體的既有值,也等於是一個隨機的值 - 這是必須避免的。
當你不使用配置的記憶體時,可以使用 delete 關鍵字將以使用 new 配置的記憶體歸還給 C++ run-time 的管理,使得它可以再被配置出去。
有一點請特別注意:即使你接連著配置兩塊記憶體 (無論是同一型別或不同型別),這兩塊記憶體並不一定是相連的。其實這也是多工作業系統的特性之一。
在第二例程式中,我們建立一個字元指標變數 pStr 和另一個對照用的 pStr1。我們同時使用 new 關鍵字建立起一個 char[] 的陣列。
首先,我們先把 pStr 所指向的位址複製給 pStr1,再把二者所存的位址輸出以便檢查它們確實指向同一個地方。然後把剛才配置的陣列的第一個元素改成 J 以便辨識。此外我又把 pStr 與 pStr1 所指向的字串輸出來以再度檢查它們是否確實指向同一個地方。
接著,我把 pStr 原本所指向陣列以 delete [] 方式歸還給系統 (這是歸還陣列資料空間的語法),並且將 pStr 設為 NULL (這是一個必須養成的習慣)。但由於 pStr1 仍保留著原來的位址,所以我們就可以經由輸出 pStr1 所指向的字串,來看看原來的位址的內容有沒有什麼改變。果然,我們可以發現原來位址所存放的內容已經改變,表示這塊記憶體的內容已不再被系統所保留。
程式的執行結果如下圖所示:
我在「指標與陣列的混用」一節中的最後舉了一個字元指標與字元陣列混用的例子。對於那種動不動就可能使陣列索引超出界限之外的情形,如果我們可以動態配置記憶體,會不會有所改善呢?
在 C#/VB 中,字串的操作實際上都是使用拷貝原值到新字串的方式來處理的,如此我們才能隨心所欲的靈活操作字串。那麼,我們能不能模擬 C#/VB 的做法呢?如下例:
- char *pStr1 = "Johnny", *pStr2 = " Lee";char *pStrNew = new char[strlen(pStr1) + strlen(pStr2) + 1];strcat(strcpy(pStrNew, pStr1), pStr2);cout << pStrNew << endl;
此外,與 Stack 比較起來,Heap 的空間大得多了!例如:
- int main(){
int inStack[100000000];
}
不管你的電腦記憶體有多大,這個程式一定會觸發 Stack Overflow 錯誤。我在上面提到過,區域變數是放置在 Stack 裡面,而程式內的 Stack 記憶體是很小的。
那麼,如果放在 Heap 裡面呢?如下例:- int *pHeap = new int[100000000];
參考
參考 (Reference) 變數是一個用起來很像指標,卻不是指標的東西。你可以說參考變數其實只是一個一般變數的別名 (alias)。語法:
- int number = 10;
int &rNumber = number; // & 符號可以放在變數的前面
- int& rNumber = number; // & 符號也可以放在型別的後面
跟指標變數不同,在宣告參考變數時,一定要同時賦予初始值,如同本例所示。如果你只宣告參考變數而未賦予初始值 (如 int &rNumber;),會引發 compiler 錯誤。
同樣的,你最好將參考變數取名以 r 開頭的名稱以便識別。
當你為一個普通變數建立參考之後,這個參考變數就完全等於原來的變數,如下例:
- long number = 10;long &rNumber = number; // & 符號可以放在變數的前面rNumber +=10;cout << &rNumber << endl; // 使用 &rNumber 可以取出資料所在的位址cout << &number << endl; // 此時 rNumber 與 number 都指向同一個位址cout << rNumber << endl; // 使用 rNumber 可以取出資料的值cout << number << endl; // number 的值與 rNumber 相同
Handle
我前面一再提到 C++ 對於動態記憶體配置的不足之處,亦即開發人員必須很謹慎的處理自已配置的記憶體,否則很容易造成 memory leak。而身為 .Net 的一份子,C++/CLI 則提供了變數的 Handle (也稱為 Tracking Handle),用來追蹤變數。被列入追蹤的變數會被隨時監視,一旦有記憶體不再被任何變數所指向 (即前面提過的 "Orphaned" 的情況),就會被 .Net 的 GC 機制回收,如此我們就不必費心的自行處理變數的廢棄空間。再強調一次,這個功能只在 C++/CLI 程式中生效。所以傳統 C++ (像前面的 Win32 專案) 程式中是無法使用的。如果你想測試本節中的程式,請新增一個 CLR 主控台應用程式。
語法:
- int ^hInt = 10, ^hResult = 0;hResult = *hInt * 2;Console::WriteLine(hResult);
事實上,你也可以強迫加上間接運算子,如下例:
- int ^hInt = 10, ^hResult = 0;*hResult = *hInt * 2;Console::WriteLine(*hResult);
和指標一樣,我建議你為此類變數取一個以 h 開頭的名字,以利識別。
不過,以上兩個程式會引發編譯上的警告 (不是錯誤),主要是因為 ^hResult = 0 這道指令。C++ compiler 會很親切 (或者說雞婆) 的提醒你,如果你要把 handle 變數指向 NULL 的話,不能採用和指標相同的方法 (即賦予 0),而是應該使用像 ^hResult = nullptr 的方法。不過我們在這裡並沒有打算把 hResult 指向 NULL,而是要賦予 hResult 的對應值為 0,所以我們完全不需要理會那個警告。
當我們在程式中下達 ^hInt = 10, ^hResult = 0 這兩道指令時,不但宣告了這兩個 handle 變數,也同時在 heap 裡面 new 了兩個值,一個是10,一個是 0,並且把這兩塊記憶體的位址傳給了這兩個 handle 變數。如果你未來把 handle 變數指向另一個地方,那麼原來的記憶體就會自動歸還給 C++ run-time,不需要你額外下 delete 指令 (事實上也沒這個指令可以下)。
C++/CLI 中的 handle 變數也可以指向陣列。不過其語法和用法與傳統 C++ 差異很大,如下例:
- array<int> ^hIntArray = gcnew array<int>(3) { 2, 4, 6 };for (int i = 0; i < hIntArray -> Length; i++)Console::Write("{0, 3}", hIntArray[i] - 1);
沒有留言:
張貼留言