【硬着頭皮啃C++ Primer】第2章 變量和基本類型

第2章 變量和基本類型

2.1 基本內置類型

  基本內置類型分兩類:算術類型和空類型。後者不對應具體的值,僅應用於一些特殊場合,比如,函數沒有返回值時返回值類型就是空類型。
  算數類型是我們主要了解的。算數類型分兩類:整型和浮點型;包括字符、整型數、布爾值和浮點數。要知道,字符和布爾型都算在整型裏面。C++對不同的類型規定了最小長度,但允許編譯器賦予它們更大的長度。
  對於字符類型,也就是char,有如下規定:一個char的空間應該確保可以存放機器基本字符集中任意一個字符對應的數字值。也就是說,一個char的長度和一個機器字節一樣。
  這裏我出現了兩個疑惑:其一,“機器基本字符集”是個啥?是什麼UTF-8麼?其二,機器字節是啥?
  上網才發現,機器基本字符集這個定義,僅出現在本書中,英文對應“machine’s basic character set”,並沒有明確的定義,一般來說,可以把ASCII當作一個機器基本字符集。那麼有一個疑問,ASCII,UTF-8這些都是啥,有什麼區別?
  ASCII,全稱美國信息交換標準代碼,是用七位或八位二進制數表達字符的方法。也就是說,對於任何七位或八位二進制數,我們都能把它翻譯成對應的字符;我們要想保存任何字符,需要把它的ASCII碼保存在硬盤裏。UTF-8,是從Unicode衍生而來的。由於各國都有自己的編碼標準,因此互相交流很成問題,就用Unicode來統一,Unicode規定,每個字符佔16位,爲了兼容ASCII,就讓後八位對應ASCII,前八位爲0。但是大家發現,相比於ASCII,Unicode太佔空間,太佔帶寬了,就衍生出了UTF-8等編碼。UTF-8等編碼是對Unicode的再編碼,從而節約了帶寬等。另外,中國有自己的GBK2312和GBK18030編碼,這是爲了表達中國漢字而設計的。也被囊括在了Unicode中。
  綜上所述,如果我們有一篇用UTF-8編碼的中文文檔,想解碼,那麼就是由UTF-8解碼得到Unicode,再由Unicode得到GBK2312,然後轉換成漢字顯示出來。
  這樣一看,機器基本字符集算是比較基礎的字符集,UTF-8相比於它算是高級字符集了。
  第二個問題,機器字節是啥。這問題沒查到答案。但按文中所說,一個char的長度一般是8位,那麼一個機器字節也就是8位,八成就是內存的一個地址對應的位數吧。
  上文我們說了好幾種編碼方式,只有ASCII可以直接用char儲存,其餘的,用char根本存不下,因此C++對char類型進行了擴展,如wchar_t,char16_t等。wchar_t用於確保可以保存機器最大擴展字符集中的任意一個字符,char16_t則用於保存Unicode類型的字符。
  除了布爾型和擴展的字符型,其他整型(除了char)都可分爲帶符號的和無符號的。int等本身就是帶符號的,前面加上unsigned就變成了無符號的。單獨的unsigned表示unsigned int。
  char可分爲三種:char、signed char、unsigned char。儘管它分爲了三種,但是實際表示僅會是有符號的或者是無符號的,char會表現爲其餘兩種中的一種,具體表現爲哪種,由編譯器決定。由於char的這個特性,一般不要讓char參與運算,這會導致在不同的平臺上出現兼容問題。
  這裏我又想到了一個問題:平時我直接用’8’這種形式讓它參與運算,這裏的’8’是什麼類型呢?想知道這個問題,就得知道C++用什麼函數判斷變量的類型。查閱得知,可以用typeid函數,這個函數屬於typeinfo庫:

#include <iostream>
#include <typeinfo>
int main()
{
	int a = 0;
	std::cout << typeid(a).name() << std::endl;
	std::cout << typeid('8').name() << std::endl;
	return 0;
}

就可以得到變量類型了。這裏返回的a的類型爲int,'8’的類型爲char。
  接下來討論類型轉換。類型轉換在我們使用一種類型的對象,但實際應該使用另外一種類型的對象時出現。類型所能表示的值的範圍決定了轉換的過程:
  布爾類型轉換爲非布爾類型時,false轉換爲0,true轉換爲1。這裏我就想吐槽,return 0表示程序正常,但是0表示false。你說晦氣不晦氣。
  非布爾類型的算術值賦給布爾類型時,初始值爲0轉換爲false;不爲0轉換爲true。
  給無符號類型賦給一個超出表示範圍的值時,結果是初始值對無符號類型表示數值總數取模後的餘數。比如,-1賦給unsigned char結果是255,因爲-1對256取模得255。
  給帶符號類型賦給一個超出它表示範圍的值時,結果未定義。這就是溢出了,要報錯。
  當一個表達式中既有帶符號數又有無符號數時,這個帶符號數會先轉換成無符號數再參與運算,具體的轉換方式就是正數不變,負數加上無符號數的模。簡而言之,當一個表達式中出現一個無符號變量,那麼整個表達式最後的值都被拽到了無符號這個檔次。可以看一下練習2.3:

#include <iostream>
#include <typeinfo>
int main()
{
	unsigned u = 10, u2 = 42;
	std::cout << u2 - u << std::endl;//32
	std::cout << u - u2 << std::endl;//-32+2^32=4294967264
	int i = 10, i2 = 42;
	std::cout << i2 - i << std::endl;//32
	std::cout << i - i2 << std::endl;//-32
	std::cout << i - u << std::endl;//0
	std::cout << u - i << std::endl;//0
}

答案就在註釋裏面。
  接下來來看字面值常量。每個字面值常量都對應一種數據類型。一個常數就是一個字面值常量,常數有整數和小數,所以有整型和浮點型字面值。對於整型字面值,我們可以用十進制、八進制或者十六進制表達。正常的就是十進制,0開頭的是八進制,0x或0X開頭的是十六進制。因此,20、024、0x14表達的都是十進制的20,都是整型字面值常量。如果我們不指定一個整型字面值的具體類型,那麼它有一個默認類型:對於十進制,默認類型是int、long、long long中能夠保存字面值而不溢出的最小的那個;對於八進制和十六進制,則是int、unsigned int、long、unsigned long、long long、unsigned long long中能夠保存的最小的那個。如果一個字面值太大,將產生錯誤。用如下的代碼來檢查一下:

#include <iostream>
#include <typeinfo>
int main()
{
	std::cout << typeid(10).name() << std::endl;
	std::cout << typeid(-10).name() << std::endl;
	std::cout << typeid(024).name() << std::endl;
	std::cout << typeid(0x14).name() << std::endl;
	std::cout << typeid(0xffffffffffffff).name() << std::endl;
}

其中,前四個的輸出都是int,第五個輸出是__int64,我就很好奇,這個__int64哪裏來的,前面不是說應該是long long麼,怎麼變這個東西了。上網一查發現,vc系列用的叫__int64,而g++用的就叫long long。我用的是VS2015,因此是__int64。
  儘管整型的字面值是儲存在帶符號的類型中的,但是嚴格來說,十進制的字面值不會是負數,也就是說,如果我們有一個-42的字面值,這裏的42纔是字面值常量,-用於將這個常量取負罷了。
  浮點字面值的默認類型就很簡單,全是double。
  字符字面值是用單引號括起來的一個字符,字符串字面值則是由雙引號括起來的一組字符。後者實際上是由常量字符構成的數組。編譯器在每個字符串的末尾添加一個空字符(’\0’),因此,字符串的長度要比它的內容多一。如果兩個字符串常量之間僅僅用空格、縮進和換行符分隔,那實際上它們是一個整體。怎麼理解呢?如果我有一個好長的字符串,可以用兩對雙引號把它們括起來,變成兩個字符串,這倆字符串用換行分隔,那麼顯示起來就容易看一些,並且編譯器仍然認爲它們是同一個字符串。
  有一些字符在C++中有特殊含義,另外有一些沒有可視字符的符號,想要在字符串中表達出來,就需要轉義序列了。轉義序列都是用反斜槓開始,我們常用的比如說’\n’是換行,’\t’是製表符等。還可以使用泛化的轉義序列。形式是\x後面跟着一個或多個十六進制數字,或\後面跟着一個,兩個或三個八進制數字。泛化的轉義序列的本質就是直接用ASCII碼來表示。比如說M,其ASCII碼十進制是77,八進制是0115,十六進制是0x4d,那麼,如下代碼均輸出M:

#include <iostream>
#include <typeinfo>
int main()
{
	std::cout << 'M' << std::endl;
	std::cout << '\x4d' << std::endl;
	std::cout << '\115' << std::endl;
}

另外我還注意到,加了endl就會換行。
  注意到上面對轉義序列爲八進制時有約束:如果反斜線後面跟着超過三個八進制數,那麼就只有前面三個被轉義,後面的都按正常的數字解釋。對於十六進制則沒有這個限制。那麼問題就來了,假設我有一個轉義序列,比如\x1234,這個轉義序列由十六位二進制數唯一確定,但是這是個轉義序列的同時,其類型就是char,char只有八位,出現了矛盾。該怎麼辦?這時就需要強制指定字面值類型。

#include <iostream>
#include <typeinfo>
int main()
{
	std::cout << u'\x1234' << std::endl;
}

如上面的代碼,在轉義序列的單引號外面加上u,就表示將這個轉義序列指定爲char16_t類型,這一類型就可以容納16位的字符了。
  它前面加的這個u,名字叫做前綴。對於字符及字符串類型的字面值,使用前綴來指定字面值類型。比如u指定爲char16_t,U指定爲char32_t等。u8則指定爲char,僅用於字符串類型字面值。整型以及浮點型字面值則是用後綴來指定:對於整型,後綴爲u或U表示對應的八進制或者十六進制字面值僅從三個unsigned中選擇類型;l或L表示指定爲long;ll或LL表示指定爲long long。對於浮點型,f或F指定爲float;l或L指定爲long double。
  下面來看練習2.5的第一題(a):回答’a’、L’a’、“a”、L"a"是什麼類型。第一個和第二個很簡單,按上面的來說,是char和wchar_t類型。但是後面兩個,是字符串,是什麼類型呢?按VS的解釋,是char const [2]和wchar_t const [2]的類型。也就是說,是字符串數組。再看©,3.14L的類型,我以爲是long long,沒想到是long double。那麼long long和long double有啥區別呢?前者是大整數,後者是大小數,我真是愚蠢。使用如下代碼即可驗證:

#include <iostream>
#include <typeinfo>
#include <limits>  
int main()
{
	std::cout << "long: \t\t" << "所佔字節數:" << sizeof(long);
	std::cout << "\t最大值:" << (std::numeric_limits<long>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<long>::min)() << std::endl;
	std::cout << "double: \t\t" << "所佔字節數:" << sizeof(double);
	std::cout << "\t最大值:" << (std::numeric_limits<double>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<double>::min)() << std::endl;
	std::cout << "long long: \t\t" << "所佔字節數:" << sizeof(long long);
	std::cout << "\t最大值:" << (std::numeric_limits<long long>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<long long>::min)() << std::endl;
	std::cout << "long double: \t\t" << "所佔字節數:" << sizeof(long double);
	std::cout << "\t最大值:" << (std::numeric_limits<long double>::max)();
	std::cout << "\t最小值:" << (std::numeric_limits<long double>::min)() << std::endl;
}

然而,其輸出卻有一定的可商榷性:long所佔的字節數爲4,其餘三個爲8;在VS2015中,double和long double的範圍一樣。均爲2.22507e-308到1.79769e+308。

2.2 變量

  如果我們想要使用一個變量,那麼首先,我們要定義一個變量。在定義變量時,如果給定這個變量一個值,那麼稱將該變量進行初始化;否則,稱對該變量進行默認初始化。在這裏,我很小心的不去使用賦值這個詞,因爲初始化和賦值是兩種完全不同的操作:初始化是創建變量時賦予其一個初始值;賦值則是將當前值擦除,然後給它一個新值來替代。在初始化的時候,變量就沒有當前值,肯定就算不上初始值了。
  定義是很簡單的,使用類型說明符加上變量名就是定義了。有一個小地方需要注意:當一條語句中定義兩個或多個變量時,對象的名字隨着定義就馬上可以使用了。因此可以用先定義的變量定義後定義的變量。
  初始化就複雜得多了。首先,如果我們明確在定義時候給了變量一個初值,那麼它就被初始化。如果沒給,則進行默認初始化。默認初始化有以下幾種情況:對於內置類型,如果是在任何函數體之外定義的,則其初始化爲0;如果在函數體內部,則不進行初始化,直接使用會導致錯誤。對於其他的類,對象的初值則由其自己決定。大多數類允許無需顯示初始化而定義對象,這樣的類提供了合適的默認值,比如,string類型的對象的默認值是"",另外一些類則規定,必須顯示初始化對象。
  c++11引入了一種新的初始化方式,即列表初始化。使用花括號將初始值括起來進行初始化。當使用內置類型的變量進行列表初始化的時候,該初始化方法有一個重要特點:若初始化的值存在丟失信息的風險,則會報錯。比如溢出等,就會報錯。如下代碼:

#include <iostream>
#include <typeinfo>
#include <limits>  
int main()
{
	int a = 0;
	int b = { 3.1415926 };
	int c{ 3.1415926 };
	int d(3.1415926);
	std::cout << a << " " << b << " " << c << " " << d << std::endl;
}

在上述代碼中,只有a的初始化成功,b和c的初始化會報錯需要收縮轉換,d則會報warning,存在數據丟失的風險。
  練習2.9有兩行很有趣的代碼,如下:

int a=b=0;
std::cin>>int c;

這兩行代碼都是錯的,代碼是從左往右運行的,那麼當用b爲a初始化的時候,b還未初始化,那麼就會報錯。同樣的,我們讀入輸入,存放在cin裏,然後保存在c中,但是在存放之後,c還未定義,也會出現錯誤。
  接下來討論一個很容易暈的問題:變量聲明和定義。這個問題來源於C++的分離式編譯,對於一個大型工程,無法在一個文件中完成所有功能,那麼就需要多個文件協同工作。那麼就出現了一個問題:文件A想要使用文件B中的變量c,這時該怎麼辦?用我們常用的例子:cin是在標準庫中定義的,我們可以在任何文件中使用它。
  c++爲了實現這個功能,將變量的聲明和定義分開來:聲明使得名字爲程序所知,一個文件如果想使用別處定義的名字則必須包含對該名字的聲明。而定義則負責創建與名字關聯的實體。可以把聲明看作是談戀愛,而定義看作是結婚。定義代價更大。
  聲明和定義都需要規定變量的名字和類型,但定義還需要申請存儲空間(買房子),也可能給變量賦一個初始值。如果我們想聲明而不是定義一個變量,那麼就在變量類型前面加上extern,並且不要顯示的初始化變量。任何顯示初始化變量的聲明都會成爲定義。
  在函數體內部,如果我們想初始化一個由extern標記的變量,會發生錯誤。使用如下代碼來輔助理解:

#include <iostream>
#include <typeinfo>
#include <limits>  
#include<string>
extern int i = 0;
int main()
{
	extern int j = 0;
	std::cout << i << j << std::endl;
	return 0;
}

其中,i的聲明強制轉換爲初始化,j則直接報錯。
  變量的定義必須出現且只能出現在一個文件中,其他文件中必須對該變量進行聲明,卻決不能重複定義。
  接下來記錄一個小玩意:在c++中,給變量起名字的禁忌:必須以字母或者下劃線開頭;不能出現連續兩個下劃線;不能以下劃線加大寫字母開頭;定義在函數體外的變量不能以下劃線開頭。
  作用域是一個挺蛋疼的東西。它用一對花括號標記。分全局作用域和塊作用域。全局作用域就是在所有的花括號之外的區域;塊作用域則是在花括號之內的部分。c++、允許作用域嵌套,即內層作用域可以直接使用外層作用域聲明的變量;內層作用域也可以對外層的變量進行重新定義,但這樣會極大破壞函數的可讀性,不要這麼做。但如果我們必須這麼做怎麼辦?例如,全局作用域中有變量a,塊作用域中也有變量a,現在我想在塊作用域中調用塊作用域的a,那麼直接輸入a即可,但是若想調用全局作用域的a,則需要使用::a。::爲作用域操作符,全局作用域沒有名字,因此::左側爲空,即是直接向全局作用域申請調用變量a。

2.3 複合類型

  複合類型是指基於其他類型定義的類型,這裏介紹兩種,引用和指針。我們之前提到,一條簡單的聲明語句由一個數據類型(如int)和緊隨其後的一個變量名列表(a,b,c)組成。但是更通用的描述是由一個基本數據類型和緊隨其後的聲明符列表組成。
  我們必須牢記,聲明符就是變量名。然後來看第一種複合類型:引用。引用就是爲變量起了另外一個名字,引用類型引用另外一種類型。這句話有點難斷句,引用類型/引用/另外一種/類型。也就是說,引用類型可能有許多類型,具體類型由他引用的類型決定。通過將聲明符,也就是變量名,寫成&+聲明符的形式來定義引用類型。
  引用必須被初始化,這是由引用的自身性質決定的。引用就是給變量起了一個別名,也就是說,必須現有一個變量,然後起一個別名。比如說,蕭炎是一個男生。我給他起個別名叫土豆兒子。這樣是合理的。我不能先起一個土豆兒子,然後十年以後蕭炎出生,我把名字給他。這樣是不行的。從程序實現角度來講,在定義引用時,程序會把引用和它的初始值綁定在一起,而不是把初始值拷貝給引用。一旦初始化完成,引用就和初始值鎖死了,鑰匙被扔了,因此無法令引用重新綁定給另外一個對象。
  引用不是對象!
  一切對引用的操作實際上都是對它的初始值的操作。爲引用賦值,等價於給初始值賦值;從引用獲得值,等價於獲取和引用綁定的對象的值。引用的引用是不被允許的,因爲引用必須綁定到對象上,而引用本身不是對象。而且,引用也不能綁定在字面值或者表達式的計算結果上,這個原因後面再說。
  我們允許一條語句中定義多個引用,要求每個聲明符前面都有&,並且引用的類型必須和綁定的對象類型一致。
  媽了個雞,引用這個名字實在是不好,因爲它本身既是動詞又是名詞,很容易理解錯誤。
  然後來看第二種複合類型:指針。指針和引用類似,都可以實現對其他對象的間接訪問。但是與引用不同,指針本身就是一個對象,允許對指針進行賦值和拷貝,在指針的生命週期裏,可以允許它指向不同的對象。也就是說,指針和對象僅僅是指向的關係,而不是拷貝。也可以這麼說,指針是戀愛,引用是結婚。指針無需在定義時賦初值,在塊作用域內定義的指針如果沒有被初始化,也將擁有一個不確定的值。
  指針的內容是某個對象的地址,也就是說,給指針賦值,需要使用地址來賦值。爲了得到一個對象的地址,需要使用取地址符&。那麼,可不可以定義一個指向引用的指針呢?不可以,因爲指針的內容是地址,引用不是對象,它沒有地址,因此不可以定義指向引用的指針。
  指針的值是一個地址,具體來說,會有四種情況:指向一個對象;指向緊鄰對象所佔空間的下一個位置;空指針;無效指針。試圖拷貝或者訪問無效指針都會引發錯誤。但是別以爲訪問第二種或者第三種情況的指針沒事,只是不會報錯,實際出現什麼也無法估計。
  要想訪問一個指針指向的對象,要使用解引用符*。這裏有一點要注意,指針的定義需要*,解引用也需要*,這兩種情況是不一樣的,見如下代碼:

int i=0;
int *j=&i;//表明j是一個指針,其初始值是i的地址
std::cout<<*j<<std::endl;//輸出j指向的對象,*是解引用符

注意,解引用操作僅適用於那些已經指向有效對象的指針。
  在訪問一個指針前,先檢查該指針是否爲空是一個好主意。但是如何得到一個空指針呢?有以下三種方法:

int *p1=nullptr;
int *p2=0;
//需要首先include<cstdlib>
int *p3=NULL;

最簡單且最直觀的是使用nullptr來爲指針賦初值,nullptr是一種特殊類型的字面值,可以被轉換成任意其他的指針類型。爲什麼這麼說呢?因爲所有類型的指針都可以用它賦初值,因此它必須是萬用類型纔可以。也可以用0作爲初值,但是沒有nullptr直觀。古老的程序也會使用NULL這個預處理變量,它的值就是0,在cstdlib這個頭文件中定義。
  注意,雖然我們可以用0給指針賦初值,但是並不能把一個變量賦給指針,即使這個變量的值爲0。這本質上是不能把int類型的變量轉換成int*類型的變量。變量和常量還是有本質區別的。另外,指向long的指針也不能用int的地址賦值,這裏不存在int*到long*的轉換。
  兩個指針可以使用==判斷是否相等,但要注意的是,如果指針a指向變量A,指針b指向變量B的下一個地址,這時a和b也可能相等。因爲變量A和變量B在內存中可能是相鄰存放的。
  void*類型的指針是一種特殊的指針,從字面意義上將,這個指針指向的對象是void,也就是空。因此它可用於存放任意類型的變量的地址。同時由於這一點,我們無法得知這個指針實際指向的到底是什麼類型,那麼就無法具體操作這個指針指向的對象。實際使用中,void*一般用於和其他指針的比較,或者作爲函數的輸入或者輸出。
  練習2.23提出了一個有趣的問題:給定一個指針p,你能否判斷它指向了一個合法的對象?答案是不能。因爲你如果想判斷,就需要訪問,而無論指針指向的對象是否合法,它都是有內容的,這個內容你有可能不懂,但不一定是非法的。一般在使用指針時,最好先初始化爲NULL,釋放後賦值爲NULL,判斷是否是空指針,來避免這些問題。在過程中是否非法就需要程序員自己把握了。
  接下來是一個很令人疑惑的部分:理解複合類型的聲明。如前所述,變量的定義包括一個基本數據類型和一組聲明符,雖然基本數據類型只有一個,但聲明符可以有很多:

int i=1024,*p=&i,&r=i;

這裏,一句話聲明瞭整型變量i,整型指針p和整型引用r。
  多個符號可以一起使用:比如**是指向指針的指針,對這種指針,訪問最原始的對象需要兩次解引用。因爲指針本身是一個對象,那麼就存在對指針的引用:

int i=0;
int *p=&i;
int *&r=p;

這裏的r就是對指針的引用。但是閱讀起來有一些困難,最簡單的就是從右向左閱讀r的定義,離r最近的符號(這裏是&)對r的類型有最直接的影響。這裏就說明r是一個引用,其餘部分可以確定r所引用的類型是什麼,這裏的*表示r引用的是一個指針。最後,最左邊的int表明r引用的是一個int指針。

2.4 const限定符

  有時候我們需要定義這樣一種變量,它的值不能被改變。例如,用一個變量來表示緩衝區的大小。使用變量的好處在於當我們覺得緩衝區的大小不再合適是,很容易對其進行調整。另一方面,也應隨時警惕防止程序一不小心改變了這個值。爲了滿足這個要求,可以用關鍵字const對變量的類型加以限定。
  由於const對象一旦創建以後,它的值就不能改變,所以它必須初始化。可以是運行時初始化,也可以是編譯時初始化:

const int i=get_size();//運行時初始化
const int j=0;//編譯時初始化

  初始化可以用常量,也可以用函數返回值,還可以使用變量。無論如何,在初始化之後它的值就會被確定了。與非const類型的變量相比,const類型的變量可以完成大多數操作,也可以轉換成布爾值,只是不能執行改變它內容的操作。
  默認狀況下,const僅在文件內有效,在編譯的時候,編譯器會把該文件中所有用到這個const變量的地方全部替換成對應的常量。爲了執行這一替換,編譯器必須知道const變量的初始值。我們知道,初始值是在變量定義的時候確定的。那麼如果有若干個文件,每個文件中都有const變量,那麼就必須保證每個文件中都有const變量的定義。爲了支持這個用法,同時防止重複定義,規定默認情況下const僅在文件內有效。
  那麼現在我有一個需求,一個文件中需要使用另外一個文件中的const變量,該如何實現呢?如果是常量表達式,很簡單,再定義一個就可以了。但如果不是常量表達式,是需要運行時初始化的,那麼如果再定義一個,可能會很麻煩,引入一大堆額外的類什麼的。這時就迫切需要可共享的const變量了。解決方法是對於一個需要在其他文件中使用的const變量,在定義和聲明的時候,都是用extern進行限定。也就是說,和普通的變量的區別就在於,const變量在定義的時候也需要extern進行限定。
  那麼既然const變量也是一個對象,那麼就可以對其進行引用,我們稱之爲常量引用。與普通引用不同的是,常量引用不能修改被引用的變量的值——這是廢話,能修改還叫個屁常量。如何聲明一個常量引用呢?如下

const int i=0;
const int &j=i;

這裏的j就是一個常量引用。同時注意,非常量的引用是無法綁定在常量對象上的。
  現在有一個燒腦筋的問題:如上面所說,一個非常量的引用,無法綁定在常量對象上。那麼,對於一個常量的引用,只能綁定在與之匹配的常量對象上麼?舉例來說,上面的j,是一個常量的引用,那麼它只能綁定i這種常量對象麼?
  不是的。這是一種特例。對常量的引用可以綁定非常量的對象,字面值,甚至是一個一般表達式:

int i=40;
const int &r0=i;//綁定在非常量的對象
const int &r1=42;//綁定在字面值
const int &r2=i*2;//綁定在一般表達式

這裏r0、r1、r2都沒有遵守引用和綁定的對象類型必須完全一致的規則。要想理解這種規則上的例外,就必須弄清楚當一個常量引用被綁定到另外一種類型上時到底發生了什麼:

double dval=3.14;
const int &ri=dval;

這裏的ri,最正統的應該是綁定到一個整型常量對象,但是卻綁定到一個浮點變量對象,不會報錯。爲了運算不出錯,就必須保證ri綁定一個整數,那麼編譯器就會加一句代碼:

double dval=3.14;
const int tmp=dval;
const int &ri=tmp;

這樣,ri就綁定了tmp而不是dval,就實現了確保ri綁定的是一個整數。這裏的tmp稱爲臨時量,就是當編譯器需要一個空間來暫存表達式的求職結果時臨時創建的。
  一定要記住一點,常量引用僅僅對引用本身做了限制,對所綁定的對象是沒有限制的。也就是說,不能通過常量引用改變所綁定的對象,但是可以通過其他方法對對象進行改變,這是允許的。本質上,常量引用是對引用的約束,這樣看來,“對常量的引用”這個說法至少是不完全的。實際上應該是“常量是一個引用”。
  好了,既然常量是一個對象,那麼指針就可以指向它。類似於常量引用,也存在指向常量的指針,指向常量的指針也不能用於改變所指對象的值。要想存放常量對象的地址,只能使用指向常量的指針。令人啼笑皆非的是,雖然只有指向常量的指針能存放常量的地址,但是指向常量的指針本身是可以指向非常量的對象的。也就是說,無論是指向常量的指針還是常量引用,它們所關聯的對象是否是常量根本無關緊要,只不過是指針或者引用自以爲是指向了常量,然後按指向常量的規範要求自己罷了。這就好像一個舔狗,自詡爲女神的男朋友,一言一行都已男朋友自居,但女神自己是否承認和舔狗沒有任何關係。
  大家可能會好奇,爲啥不管“指向常量的指針”直接叫“常量指針”呢?因爲真的存在“常量指針”這個玩意啊。它和指向常量的指針是不同的東西。因爲指針是對象,所以指針本身可以定義爲常量。注意,一個是“本身”是常量,一個是“指向”常量。這就是它倆的區別。本身是常量的叫常量指針,它不能更改綁定的對象;指向的是常量的叫指向常量的指針,它可以更改綁定的對象,但不能更改對象的值。
  常量指針必須初始化,一旦初始化完成,則它的值就不能再改變了。下面咱們來分辨三種量:常量指針,指向常量的指針,指向常量的常量指針:

int errNumb=0;
const double pi=3.14159;
int *const curErr=&errNumb;//常量指針,離curErr最近的是const,表明它是一個常量
const double *pip=&pi;//指向常量的指針,離pip最近的是*,表明它是一個指針
const double *const pipi=&pi;//指向常量的常量指針,離pipi最近的是const,表明它是一個常量

指針本身是一個常量並不意味着不能通過指針修改器所指對象的值,能否修改完全依賴於它指向的是否是一個常量。比如說按上面的代碼,curErr可以修改errNumb的值,雖然它是一個常量指針。pip雖然是一個指針,但它不能修改pi的值,因爲它指向的是一個浮點常量。
  來看兩道題鞏固一下吧:

int i=-1,&r=0;//r是引用,引用綁定的必須是對象,0不是對象,錯誤
int *const p2=&i2;//p2是常量指針,正確
const int i=-1,&r=0;//r是常量引用,可以綁定字面值0,正確
const int *const p3=&i2;//p3是指向常量的常量指針,可以綁定i2,正確
const int *p1=&i2;//p1是指向常量的指針,可以綁定i2,正確 
const int &const r2;//r2本身是常量,它是一個常量的引用,是引用卻沒有初始化,錯誤
const int i2=i,&r=i;//r是常量引用,可以綁定i,正確

唯一一個疑惑應該是const int &const r2,r2是一個常量,它還是一個引用,這裏我將其改爲如下代碼就不再報錯:

const int &const r2=i2;

從這個角度來看,儘管r2是一個常量,但它還是一個引用,和一個常量對象綁定。那麼,r2自己到底是一個引用還是一個對象呢?雖然我用VS2015,上述代碼跑的通,但是用gcc就會報錯:
Main.cpp:13: 錯誤: ‘const’限定符不能應用到‘int&’上
也就是說,後一個const是無效的,它就是一個引用。

int i, *const cp;//cp是常量指針,需要初始化,沒初始化,錯誤
int *p1, *const p2;//p2也是常量指針,也沒初始化,錯誤
const int ic, &r = ic;//ic是常量,沒初始化,錯誤,r是常量引用,用ic初始化,錯誤
const int *const p3;//p3是指向常量的常量指針,沒初始化,錯誤
const int *p;//p是指向常量的指針,正確

  現在我們可以看到,const有兩種情況,第一種,變量本身是const,第二種,變量指向的對象是const。這兩種情況有本質的區別,因此我們稱第一種爲頂層const(血統純正,上層人士),第二種爲底層const。注意,指針類型既可以是頂層const也可以是底層const。用於聲明引用的const都是底層const,這也佐證了上文中第二個const無效的觀點。這也可以看出,只有複合類型纔會涉及底層const。const int v2 = 0;
int v1 = v2;
int *p1 = &v1, &r1 = v1;
const int *p2 = &v2, *const p3 = &i, &r2 = v2;
  在執行對象的拷貝操作時,常量是頂層const還是底層const區別明顯。如果對象本身是const,即頂層const,那麼可以隨便拷貝——又不更改常量的值,隨便來。但如果對象指向的是const,那麼拷貝的時候就要注意:拷入和拷出的對象必須具有相同的底層const資格,或者兩個對象的數據類型能夠轉換,一般是指非常量能夠轉換成常量。這有點難以理解,我們用下面的代碼來說明:

const int *const p3 = p2;
int *p = p3;
//錯誤,因爲p3指向一個常量,而p指向一個非常量,那麼在p看來,它所指向的是一個變量,可以修改,但實際不可以,因此出現的矛盾。

  來看兩個練習鞏固一下:

const int v2 = 0;//v2是頂層const
int v1 = v2;//v1不是const
int *p1 = &v1, &r1 = v1;//p1不是const,r1不是const
const int *p2 = &v2, *const p3 = &i, &r2 = v2;//p2是底層const,p3既是頂層const又是底層const,r2是底層const
r1 = v2;//r1是一個int引用,指向v1,該句等價於v1=v2,正確
p1 = p2;//p1是一個指針,指向v1;p2是底層const,指向v2,執行這句之後,p1也指向v2,即const int轉換爲int,錯誤
p2 = p1;//不考慮上面那句p2=p1,這句是讓p2指向v1,即int轉換爲const int,正確
p1 = p3;//p3指向const int i,讓p1指向i,即const int轉換爲int,錯誤
p2 = p3;//讓p2指向i,即const int 轉換爲const int,正確

  然後轉向介紹常量表達式。常量表達式定義爲值不會改變並且在編譯時就能獲得結果的表達式。一個對象能稱之爲常量表達式需要滿足兩個條件,其一,數據類型是常量;其二,初始值是常量表達式或字面值或者在編譯時就能得到的值。因此,不能令常量表達式的初始值是一個函數的返回值。
  在一個複雜系統中,幾乎肯定不能分辨一個初始值是不是常量表達式,那麼我們對於常量表達式的初始值的約束幾乎是完全無效的。這時,我們可以將變量聲明爲constexpr類型,來讓編譯器幫助我們驗證變量的值是否是一個常量表達式。聲明爲constexpr的變量一定是一個常量,而且必須用常量表達式初始化。這樣就不用我們自己費盡心思去判斷了。一般來說,如果你認定一個變量是常量表達式,那麼就把它聲明成constexpr就好了。
  由於常量表達式的值需要在編譯時就得到計算,那麼其類型就必須較爲簡單,稱這些類型爲字面值類型。算數類型、指針、引用都是字面值類型。儘管指針和引用可以定義爲字面值類型,但它們的初始值卻受到限制:一個constexpr指針的初始值必須是0或者null,或者儲存於某個固定地址的對象。這意味着什麼呢?意味着函數體內部的變量的地址都不能作爲constexpr指針的初始值,因爲它們的地址可變。由此可以推斷出,定義於所有函數體之外的對象的地址可以用來初始化constexpr指針。
  必須說明一點,用constexpr限制的指針,僅僅限制指針本身,而對於指針指向的對象是否是常量沒有限制。也就是說,constexpr指針一定是一個常量指針。

int null=0*p=null;

以上的代碼是錯誤的,因爲p是一個普通指針,null是一個int對象,不能把int轉換爲int*。不要認爲null的值爲0就誤認爲p是一個固定的地址,除非null是一個const int。當 null是一個const int,那麼p就可以初始化爲null,這種理解對麼?也不對,因爲指針的值不能是隨便一個常量。他只能是0。這裏不能理解爲int轉化爲int*,也不能理解爲用一個常量初始化int*,只能理解爲用一種迂迴的方式,將指針初始化爲0。

2.5 處理類型

  現在我們都有點暈了,類型太多太複雜了,有的類型好難拼又好難記,這怎麼辦?
  第一種方法是使用類型別名。類型別名是一個名字,是某種類型的同義詞。傳統上使用關鍵字typedef,typedef作爲聲明語句的基本數據類型的一部分出現,如下:

typedef double wages;
typedef wages base,*p;

含有typedef的語句定義的不再是變量而是別名,這裏,wages是double的別名,base是wages的別名,p是wages*的別名。
  新標準規定了一種新的方法,使用別名聲明定義類型的別名:

using SI=Sale_item;

這種方法使用using開始,其後緊跟別名和等號,作用是把等號左側的名字規定成右側類型的別名。
  注意,在理解別名出現困難時,將別名的原意帶回去是不正確的。這常常出現在別名是複合數據類型的情況中,如下:

typedef char *pstring;
const pstring cstr0=0;
const char *cstr1=0;

如果按照代入來理解,這兩種情況是等價的,但實際並不是。cstr0是常量指針,而cstr1則是指向常量的指針。本質上是因爲前者const修飾pstring,後者修飾char。
  然後來說一個很方便的東西,auto。我們知道,如果想把一個表達式的值賦給變量,就必須知道這個表達式的值的類型。如果表達式過於複雜,那麼就很難知道它的類型,容易弄錯。爲了防止這種情況的發生,我們使用auto來讓編譯器自己確定變量的類型。爲了讓其能夠確定,aoto定義的變量必須有初始值。使用auto也可以同時確定多個變量,注意所有變量的初始類型必須一樣。
  當初始值的類型是複合類型時,auto推斷出的類型可能和初始值的類型不同:比如說,初始值的類型是引用,則推斷出的類型是引用所綁定的類型。其次,auto會忽略頂層const,卻不會忽略底層const,想要讓auto稱爲頂層const則需要特殊說明。下面來看比較複雜的練習2.33:
  在做這個練習的時候,我遇到了一點疑惑,記錄如下:

const int *aaa = 0;
int const *bbb = 0;
std::cout << typeid(aaa).name() << std::endl;
std::cout << typeid(bbb).name() << std::endl;

aaa和bbb分別是指向常量的指針和常量指針,但是typeid沒法分辨出這個。這要注意。接下來可以看題目了:

int i = 0, &r = i;//r是int的引用
auto a = r;//a是int類型
const int ci = i, &cr = ci;//cr是常量引用
auto b = ci;//b是int
auto c = cr;//c是int
auto d = &i;//d是指向int的指針
auto e = &ci;//e是指向常量的指針,底層const,const不丟掉
const auto f = ci;//f是常量,頂層const
auto &g = ci;//g是一個整型常量引用,原因在於,ci的類型是整形常量,編譯器推斷出g的類型是整型常量引用,注意這個const不能被丟掉,因爲const修飾的是int而不是&
auto &h = 42;//h是一個錯誤,原因在於,42的類型是字面值,編譯器推斷出h的類型是字面值引用,但不存在字面值引用
const auto &j = 42;//j是一個整型常量引用,因爲有了const的限制,使得編譯器知道了j一定是一個常量引用,那麼,初始值可以是42的常量引用就是整型常量引用
a=42;//正確
b=42;//正確
c=42;//正確
d=42;//錯誤,不能給指針賦值除了0以外的字面值
e=42;//錯誤,同上
f=42;//錯誤
g=42;//錯誤,g綁定整型常量,不能賦值

編譯器使用引用綁定的類型作爲auto推斷的類型!!!也就是說,編譯器並不是通過找能夠滿足初始值類型的類型往回找auto推斷的類型,如果是這樣,那麼ci推斷出g是整型常量引用,因爲只有整型常量引用可以綁定整型常量;這樣一來,也可以通過42推斷出h是整型常量引用,因爲只有整型常量引用可以綁定字面值。這種思路是錯誤的。必須牢牢把握“編譯器使用引用綁定的類型作爲auto推斷的類型”這句話。並且,const修飾auto會做出一定的限制。
  類型實在是太容易讓人迷惑了。如果我們想要得到一個表達式的值的變量類型,但又不想直接用這個值給變量賦值,比如說這個值要經過複雜的運算才能得到等,那該怎麼辦?使用decltype類型指示符。decltype和auto都是類型說明符,decltype的作用是選擇並返回操作數的數據類型,但並不進行計算。舉例如下:

decltype(f()) sum=x;

這裏就使用f函數的返回值的類型作爲sum的類型。這裏,編譯器並不實際執行函數f,而只是獲得了它的返回值的類型。注意,如果decltype使用的表達式是一個變量,則將這個變量的類型全盤轉移,無論是const還是引用,都會全部複製過去。只有在這裏,引用纔不是其所指對象的同義詞。那我又有問題,如果我想要引用所綁定的類型怎麼辦?(這事兒可真多)這就要用到decltype的一個特點了,如果使用的是表達式而不是變量,則使用表達式結果的類型作爲類型——表達式結果的類型總不能是引用了吧。如下:

int i=42,*p=&i,&r=i;
decltype(r) a;//錯誤,a的類型是引用,必須初始化
decltype(r+0) b;//正確,b的類型是r+0的結果的類型,是int
decltype(*p) c;//錯誤,c的類型是int&

我在這裏卡住了,爲什麼c的類型是int &呢???p是一個指針,解指針操作得到的應該是int啊,爲什麼是int&呢?查閱得到了這樣一個規定:
如果 decltype 的對象是一個表達式且表達式結果是一個左值,則結果是它的引用類型。
具體討論可以參考該網址
  接着說,如果我們使用一對括號括住了一個變量,那麼這個變量也就變成了一個表達式,但和+0的效果是不一樣的。如果是用括號括住,那麼表達式的結果是一個左值,+0,結果是一個右值。左值進行decltype得到的一定是引用,右值得到的是本身類型。上面的r+0是一個表達式,大家都知道,但是*p也是一個表達式。前者得到的結果是一個右值,而後者得到的結果是一個左值,因此前者類型是int,後者類型是int&。以以下三行代碼爲例:

int i = 42, *p = &i;
decltype(*p) a;//a的類型是int&
decltype(*p + 0) b;//b的類型是int

這可以總結爲一個如下規律:用雙括號括住一個變量,其類型一定是一個引用;用單括號括住一個變量,僅在這個變量本身是引用的時候纔是引用。賦值a=b也是一類表達式,表達式的結果的類型是引用類型,引用的類型是左值類型。

2.6 自定義數據結構

  簡單的,我們可以使用結構體來定義類。以關鍵字struct開始,緊跟着類名和類體。在類體中定義類的成員。現在我們只涉及數據成員。可以爲數據成員提供一個類內初始值,創建對象時,初始值用於初始化數據成員。沒有初始值的成員將被默認初始化。在之後,我們也可以使用class來定義類。
  如果在不同的文件中想使用同一個類,類的定義必須保持一致,爲了這個需求,類一般定義在頭文件中。頭文件通常包含那些只定義一次的實體,如類,const和constexpr變量。這樣就有一個問題,在頭文件A中可能包含頭文件B,使用頭文件A的文件中可能也需要頭文件B,那麼就可能出現多次包含的問題。解決這個問題的常用技術是預處理器。我們使用的#include就是一項預處理功能,當預處理器看到#include時就會用指定的頭文件的內容代替#include。
  我們還常用另外一項預處理功能:頭文件保護符,它依賴於預處理變量。預處理變量有兩種狀態:已定義和未定義。#define指令把一個名字設定爲預處理變量;#ifdef當且僅當變量已定義時候爲真;ifndef當且僅當變量未定義時候爲真。一旦檢查結果爲真,會執行後續操作直到遇到#endif爲止。
  例如,我們自己定義一個頭文件如下:

#ifndef SALES_DATA_H
#define SALE_DATA_H
#include<string>
struct Sales_data{
	std::string bookNo;
	unsigned units_sold=0;
	double revenue=0.0;
};
#endif

我們在第一次include這個頭文件的時候,會先判斷預處理變量SALES_DATA_H,如果它不存在,說明該頭文件之前沒有include,那麼就include string,定義類,結束定義。若第二次include這個頭文件,由於預處理變量SALES_DATA_H已定義,那麼直接跳轉到endif,不會產生多次include的錯誤。可能有人會問,string也可能多次包含啊?string裏面也會檢查名爲_STRING_的預處理變量啊。由於各個文件都會檢查預處理變量,因此預處理變量將會無視C++中有關作用域的規則。同時由於這一點,預處理變量的名字一定不能重複,最好全部大寫,名字基於頭文件給出。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章