第7章 C++世界的奇人異事
在武俠小說中,初入武林的毛頭小子總是要遇到幾位奇人,發生幾件異事,經過高人的指點,經歷一番磨煉,方能武功精進,從新手成長為高手。在C++世界,同樣有諸多的奇人異事。在C++世界中游歷學習的我們,是否也同樣期望著遇到幾位奇人,經歷幾件異事,而後從一個C++新手成長為C++高手呢?
武林中的奇人異事可遇而不可求,但是C++世界中的奇人異事卻可以為你一一引見。
C++世界中什麼最難?指針!C++世界中什麼最強?指針!
指針作為C++世界中一種特殊的訪問數據的方式,因為其使用方式的靈活而使得它在C++世界中顯得威力無比;然而,也正是因為它的靈活,使得它成為初學者最難掌握的C++技能。它就像一只吊睛白額“大老虎”,雖然威力無比但是難以掌握控制,用好了可以方便高效地解決問題,但如果使用不當卻又很可能給程序帶來災難性的後果。今天就來打倒指針這只“紙老虎”,徹底掌握控制指針。
從本質上講,指針也是一種數據,只不過這種數據有點特殊而已。我們通常所見的數據就是各種數值數據文字數據等,而指針所表示的是內存地址數據。既然是數據,那麼自然就涉及到了數據的運算。像普通數據一樣,指針也可以參與部分運算,包括算術運算、關系運算和賦值運算,而我們最常用的就是指針的算術加減運算。
如果指針的值是某個內存位置的地址值,那麼我們就說指針指向這個內存位置。而指針的加減運算,實際上是讓指針的指向發生偏轉,指向另外的內存位置。通過這種指針的偏轉,可以靈活地訪問到該指針起始位置附近的內存。如果這種偏移是在某個范圍內連續發生的話,則可以通過指針訪問到某一連續內存區域的數據。例如,在3.6節中介紹過數組,數組名實際上就是數組數據所在內存區域的首地址,表示數組在內存中的起始位置。可以通過把首地址賦值給指針,然後對該指針進行加減運算,使指針發生偏轉指向數組中的其他元素,從而遍歷整個數組。例如:
int nArray[3] = { 1, 2, 3 }; // 定義一個數組 int* pIndex = nArray; // 將數組的起始地址賦值給指針pIndex cout<<"指針指向的地址是:"<<pIndex<<endl; // 輸出指針指向的地址 cout<<"指針所指向的數據的值是:"<<*pIndex<<endl; // 輸出這個位置上的數據 pIndex++; // 對指針進行加運算,使其指向數組中的下一個值 cout<<"指針指向的地址是:"<<pIndex<<endl; // 輸出指針指向的地址 cout<<"指針所指向的數據的值是:"<<*pIndex<<endl; // 輸出數據
這段程序執行後,可以得到這樣的輸出:
指針指向的地址是:0016FA38
指針所指向的數據的值是:1
指針指向的地址是:0016FA3C
指針所指向的數據的值是:2
從輸出結果中可以看到,pIndex指針初始指向的地址是0016FA38,也就是nArray這個數組的首地址。換句話說,也就是pIndex指向的是數組中的第一個數據,所以輸出“*pIndex”的值是1。而在對指針進行加1運算後,指針指向的地址變為0016FA3C,它向地址增大的方向偏移了4個字節,指向了數組中的第二個數據,輸出“*pIndex”的值自然也就變成了2。
這裡大家肯定會奇怪,對指針進行的是加1的運算,怎麼指針指向的地址卻增加了4個單位?這是因為指針的加減運算跟它所指向的數據的真正數據類型相關,指針加1或者減1,會使指針指向的地址增加或者減少一個對應的數據類型的字節數。比如以上代碼中的pIndex指針,它可以指向的是int類型的數據,所以它的加1運算就使地址增加了4個字節,也就是一個int類型數的字節數。同樣的道理,對於可以指向char類型數據的char*類型指針,加1會使指針偏移1個字節;而對於可以指向double類型數據的double*類型指針,加2會使指針偏移16(8*2)個字節。指針偏轉流程如圖7-1所示。
圖7-1 指針運算引起的指針10:43:4010:43:41偏轉
除了指針的加減算術運算之外,常用到的還有指針的關系運算。指針的關系運算通常用“==”或“!=”來判斷兩個相同類型的指針是否相等,也就是判斷它們是否指向同一地址上的同一數據,以此作為條件或循環結構中的條件判斷語句。例如:
int nArray[3] = { 1, 2, 3 }; // 定義一個數組 int* pIndex = nArray; // 將數組的起始地址賦值給指針pIndex int* pEnd = nArray + 3; // 計算數組的結束地址並賦值給pEnd while( pIndex != pEnd ) // 在while的條件語句中判斷兩個指針是否相等, // 也就是判斷當前指針是否已經偏轉到結束地址 { cout<<*pIndex<<endl; // 輸出當前指針指向的數據 // 對指針進行加1 運算, // 使其偏移到下一個內存位置,指向數組中的下一個數據 ++pIndex; }
在以上這段代碼中,利用表示數組當前位置的指針pIndex跟表示結束位置的指針pEnd進行相等與否的比較,如果不相等,則意味著pIndex尚未偏移到數組的結束位置,循環可以繼續對pIndex進行加1運算,使其偏移至下一個位置指向數組中的下一個元素;如果相等,則意味著pIndex正好偏移到數組的結束位置,while循環已經遍歷了整個數組,循環可以結束。
另外,指針變量也常和nullptr關鍵字進行相等比較,來判斷指針是否已經被初識化而指向正確的內存位置,也就是判斷這個指針是否有效。雖然我們提倡在定義指針的同時就完成對它的初始化,可有時在定義指針的時候,並沒有合適的初始值可以賦給它,但如果讓它保持最開始的隨機值,又會產生不可預見的結果。在這種情況下,我們會在定義這個指針的同時將這個指針賦值為nullptr,表示這個指針還沒有被初始化,處於不可用的狀態。而等到合適的時候,再將真正有意義的值賦值給它來完成這個指針的初始化,這時指針的值將不再是nullptr,也就意味著這個指針處於可用的狀態。所以,將nullptr跟某個指針進行相等比較,是判斷這個指針是否可用的常用手段。下面是一個典型的例子:
int* pInt; // 定義一個指針,這時的指針是一個隨機值,指向隨機的一個內存地址 // 將指針賦值為nullptr,表示指針還沒有合適的值,處於不可用的狀態 pInt = nullptr; //… int nArray[10] = {0}; pInt = nArray; // 將數組首地址賦值給指針 if( nullptr != pInt ) // 判斷指針是否已經完成初始化處於可用狀態 { // 指針可用,開始使用指針訪問它指向的數據 }
因為通過指針可以直接訪問它所指向的內存,所以對尚未初始化的指針的訪問,有可能帶帶來非常嚴重的後果。而將指針與nullptr進行相等比較,可以有效地避免指針的非法訪問。雖然在業務邏輯上這不是必須的,但這樣做可以讓我們的程序更加健壯,所以這也是一條非常好的編程經驗。
C++是一種強類型的語言,其中的變量都有自己的數據類型,保存著與之相應類型的數據。比如,一個int類型的變量可以保存數值1,而不能保存數值1.1,它需要一個與之相應的double類型的變量來保存。相應數據類型的變量保存相應的數據,本來相安無事過的好好的。但是,在C++世界中卻出現了一個異類,那就是void類型。從本質上講,void類型並不是一個真正的數據類型,我們並不能定義一個void類型的變量。void更多的是體現一種抽象,在程序中,void類型更多的是用於“修飾”和“限制”一個函數。例如,如果一個函數沒有返回值,則可用void作為這個函數的返回值類型,代替具體的返回值數據類型;如果一個函數沒有形式參數列表,也可用void作為其形式參數,表示這個函數不需要任何參數。
跟void類型對函數的“修飾”作用不同,void類型指針作為指向抽象數據的指針,它可以成為兩個具有特定類型的指針之間相互轉換的橋梁。眾所周知,在用一個指針對另一個指針進行賦值時,如果兩個指針的類型相同,那麼可以直接在這兩個指針之間進行賦值;如果兩個指針的類型不同,則必須使用強制類型轉換,把賦值操作符右邊的指針類型轉換為左邊的指針類型,然後才能進行賦值。例如:
int* pInt; // 指向整型數的指針 float* pFloat; // 指向浮點數的指針 pInt = pFloat; // 直接賦值會產生編譯錯誤 pInt = (int*)pFloat; // 強制類型轉換後進行賦值
但是,當使用void類型指針時,就沒有類型轉換的麻煩。void類型指針顯得八面玲珑,任何其他類型的指針都可以直接賦值給void類型指針,例如:
void* pVoid; // void類型指針 pVoid = pInt; // 任何其他類型的指針都可以直接賦值給void類型指針 pVoid = pFloat;
雖然任何類型的指針都可以直接賦值給void類型指針,但這並不意味著void類型指針也可以直接賦值給其他類型的指針。要完成這個賦值,必須經過強制類型轉換,讓“無類型”變成“有類型”。例如:
pInt = (int*)pVoid; // 通過強制類型轉換,將void類型指針轉換成int類型指針 pFloat = (float*)pVoid; // 通過強制類型轉換,將void類型指針轉換成float類型指針
雖然通過強制類型轉換,void類型指針可以在其他類型指針之間自由轉換,但是,這種轉換應當遵循一定的規則,void類型指針所轉換成的其他類型,必須與它所指向的數據的真實類型相符。比如把int類型指針賦值給void類型指針,那麼這個void類型指針指向的就是int類型數據,這時如果再把這個void類型指針強制轉換成double類型指針並通過它訪問它所指向的數據,那麼很可能得到錯誤的結果。因為void類型指針對它所指向的內存數據類型並沒有要求,所以它可以用來代表任何類型的指針,如果函數可以接受任何類型的指針,那麼應該將其參數聲明為void類型指針。例如內存復制函數:
void* memcpy(void* dest, const void* src, size_t len);
在這裡,任何類型的指針都可以作為參數傳入memcpy()函數中,這也真實地體現了內存操作函數的意義,因為它操作的對象僅僅是一片內存,而不論這片內存上的數據是什麼數據類型。如果memcpy()函數的參數類型不是void類型指針,而是char類型指針或者其他類型指針,那麼在使用其他類型的指針作為參數調用memcpy()函數時,就需要進行指針類型的轉換以適應它對參數類型的要求,糾纏於具體的數據類型,這樣的memcpy()函數明顯不是一個“純粹的、脫離低級趣味的”內存復制函數。
最佳實踐:11:06:42指針類型的轉換
雖然指針類型的轉換可能會帶來一些不可預料的麻煩,但在某些特殊情況下,例如,需要將某個指針轉換成函數參數所要求的指針類型,以達到調用這個函數的目的時,指針類型的轉換就成為一種必需。
在C++中,可以使用C風格的強制類型轉換進行指針類型的轉換。其形式非常簡單,只需要在指針前的小括號內指明新的指針類型,就可以將指針轉換成新的類型。例如:
int* pInt; // int*類型指針 float* pFloat = (float*)pInt; // 強制類型轉換成float*類型指針
在這裡,我們通過在int類型指針pInt之前加上“(float*)”而將其強制轉換成了一個float類型指針。雖然這種強制類型轉換的方式比較直接,但是卻顯得非常粗魯。因為它允許我們在任何類型之間進行轉換,而不管這種轉換是否合理。另外,這種方式在程序語句中很難識別,代碼閱讀者可能會忽略類型轉換的語句。
為了克服C風格類型轉換的這些缺點,C++引進了新的類型轉換操作符static_cast。在C風格類型轉換中,我們使用如下的方式進行類型轉換:
(類型說明符)表達式
現在,使用static_cast應該寫成這樣:
static_cast<類型說明符>(表達式)
其中,表達式是已有的舊數據類型的數據,而類型說明符就是要轉換成的新數據類型。在使用上,static_cast的用法與C風格的類型轉換的用法相似。例如,兩個int類型的變量相除時,為了讓結果是比較精確的小數形式,我們需要用類型轉換將其中一個變量轉換為double類型。如果用C風格的類型轉換,可以這樣寫:
int nVal1 = 2; int nVal2 = 3; double fRes = ((double)nVal1)/nVal2;
如果用static_cast進行類型轉換,則應該這樣寫:
double fRes = static_cast<double>(nVal1)/nVal2;
使用C++風格的類型轉換,不論是對代碼閱讀者還是對程序都很容易識別。我們應該在代碼中盡量避免進行類型轉換,但如果類型轉換無可避免,那麼使用C++風格的類型轉換在一定程度上既可增加代碼的可讀性,也是對類型轉換損失的一種補償。