C++11常用特性複習與速記(1)
C++11標準雖然出來已經很久,但以我一介井底之蛙的學生視角來看,它的推廣在初學者方面做得還蠻不夠的。尤其很多的教材和大學課程,還在使用著舊的特性,對新的更好用的功能涉及地很少。這裡藉助《深入理解C++11》(【加】Michael Wong,IBM XL編譯器中國開發團隊)一書和其他一些手頭上的資料溫習一下C++11的新特性。力圖學以致用。
1.__func__預定義識別符號
__func__預定於識別符號的基本功能是返回所在函式(結構體和類同樣適用)的名字。但注意它不可以作為函式引數的預設值。因為在引數宣告時,__func__還沒有定義。
本質上來說,__func__相當於函式體中的一句static const char* __func__ = "function name";
2.變長引數巨集和__VA_ARGS__
巨集定義引數列表中的最後一個引數現在可以為省略號"...",代表一定數量的引數。而__VA_ARGS__則可以在巨集定義的實現部分替換掉省略號所代表的字串。例如:
#define Print(...) printf(__VA_ARGS__)
3.斷言與靜態斷言
在C++中,標準在<cassert>(或者<assert.h>)標頭檔案中為程式設計師提供了assert巨集,用於在執行時進行斷言,以便快速定位那些違反了前提條件的程式錯誤。可以通過定義巨集NDBUG來禁用assert巨集(釋出程式時可能用到)。
事實上,assert巨集在<cassert>中的實現方式類似於以下形式:
#ifdef NDEBUG
#define assert(expr) (static_cast<void>(0))
#else
...
#endif
因此當我們定義NDBUG巨集時,assert巨集將被展開為一條無意義的C語句(通常會被編譯器優化掉)。
斷言assert巨集只有在程式執行時才能起作用。而#error只在編譯器預處理時才能起作用。有時候我們希望程式在編譯時能做一些斷言(例如在嘗試編譯器對某些特性的支援時,或者在模板編寫中使用sizeof來判斷模板型別長度時)。這時候的解決方案是進行編譯時期的斷言,即所謂的“靜態斷言”。事實上,利用語言規則實現靜態斷言的討論非常多,比較典型的是開源庫Boost內建的BOOST_STATIC_ASSERT斷言機制(使用sizeof操作符)。我們也可以利用“除0”報錯的特性來實現靜態斷言:
#define assert_static(e) \
do{ \
enum{ assert_static__ = 1/(e) }; \
} while (0)
這個方法的缺陷在於當條件e未通過時,輸出的診斷資訊不夠清晰(主要是除0錯誤),這可能導致難以準確定位錯誤的根源。
C++11標準引入了static_assert斷言來解決這個問題,它接收兩個引數,一個是斷言表示式,通常需要其返回一個bool值;一個則是警告資訊,通常也就是一段字串。當然了,static_assert的斷言表示式的結果必須在編譯期間就可以計算,即必須是常量表達式。如果使用變數會導致錯誤。
4.noexcept修飾符與noexcept操作符
noexcept形如其名,表示其修飾的函式不會丟擲異常。使用它可以阻止異常的傳播與擴散。noexcept修飾符的語法如下:
void excpt_func() noexcept (常量表達式);
常量表達式的結果會被轉換為一個bool型別的值。當然也可以省略掉常量表達式的內容,寫成 "void excpt_func() noexcept; "的形式,意義等同於 noexcept(true)。
noexcept作為操作符的時通常可以用於模板,例如:
template<class T> void func() noexcept(noexcept(T())) {}
這裡的第二個noexcept就是一個操作符,它將判斷T()表示式是否會丟擲異常,結果為true(無異常)或false(有異常)。
noexcept修飾的函式在丟擲異常時,編譯器可以通過std::terminate的呼叫來強制結束程式執行,這可能導致很多問題,比如無法保證物件的解構函式被正常呼叫,無法保證棧的自動釋放等,但很多時候,暴力終止程式不失為一個簡單有效的做法。不過,noexcept更大的作用是保護程式安全,例如類的解構函式和常被解構函式呼叫的delete函式預設就會被設定為noexcept的。
5.final和override控制
通常情況下,一旦在基類A中的成員函式func被宣告為virtual的,那麼對於其派生類B和B所有子類而言,func總是能夠被過載的(除非被重寫了)。但有時候B並不希望在自己的派生類中繼續過載func,這時候可以通過final關鍵字來阻止其繼續重寫。
注意A中的虛擬函式func也可以使用final控制,但不能被過載就失去了宣告為虛擬函式的意義。final通常只在繼承關係的“中途”起作用。
C++中過載還有一個特點,就是對於基類宣告為virtual的函式,之後的過載版本可以不再需要再宣告該過載函式為virtual。這會帶來閱讀上的一些困難。另外C++中有的虛擬函式會“跨層”,即沒有在父類中宣告的介面卻可能是祖父類的虛擬函式介面。這同樣會導致閱讀資訊的鬆散。為此,當我們編寫過載函式時,應當主動地新增override關鍵字,如果派生類在虛擬函式宣告時使用了override修飾符,那麼該函式必須過載其基類中的同名函式,否則無法通過編譯。
6.右值引用,移動語義和完美轉發
考慮這樣一個情形,在類A的複製建構函式中我們確保它是“深拷貝”的,同時又有這樣一個全域性的方法getA,在方法內部我們建立了一個A物件並返回之:A getA(){ return A();},那麼當我們在main方法中呼叫A a = getA()這樣的語句時,將會有2次A的拷貝建構函式呼叫和3次A的解構函式呼叫。
出現這種情況是因為getA()會在內部A()生成的區域性變數基礎上再呼叫一次A的拷貝建構函式,以便生成一個臨時值來用做返回,而這個步驟的最後A()生成的區域性變數將析構。顯然,如果A的深拷貝過程非常昂貴的話,這個步驟會帶來效能上的損失。因此,是否有方法使得臨時變數返回A的時候不分配記憶體呢?
答案是有的。這是一種稱之為“移動構造”的方法。換言之,getA()內部A()生成的區域性變數所擁有的堆記憶體資源將不會被析構,而是被返回的A“偷走”。這樣“偷”的行為稱之為“移動語義”。它的實現類似這樣:
A(const A& a);//作為參照,這是拷貝建構函式
A(A && a);//這是移動建構函式
通過一個例子來進一步理解:
我們在A中給出深拷貝建構函式和移動建構函式,移動建構函式中,使用hm的arr引數初始化了本物件的arr,隨後將hm的arr置為nullptr:
class A {
public:
A(int size) {
assert(size > 0);
this->size = size;
arr = new int[size];
}
A(A && hm) : size(hm.size), arr(hm.arr) {//移動構造
hm.arr = nullptr;//這是“偷”記憶體時必須做的,移動構造完成之後,臨時物件hm應立即被析構,如果不設定為nullptr,hm的析構會導致我們的arr成為野指標
}
//A(const A& copy) : arr(copy.arr), size(copy.size) {}//淺拷貝
A(const A& copy) {//深拷貝
this->size = copy.size;
this->arr = new int[this->size];
}
~A() {
delete[] arr;
}
int *arr;
int size;
};
A getTemp() {
A tmp = A(1024);
cout << hex << "Source from " << __func__ << " @" << tmp.arr << endl;
return tmp;
}
int main(){
A a = getTemp();
cout << hex << "Source from" << __func__ <<" @" << a.arr << endl;
}
結果如下:
Source from getTemp @000001A19D3D49F0
Source from main @000001A19D3D49F0
而如果註釋掉A中的移動建構函式:
Source from getTemp @000002B5AB672D70
Source from main @000002B5AB673DB0
可以看出沒有移動建構函式時getTemp()會呼叫拷貝建構函式,因此其tmp物件和最終返回物件的arr佔用了不同的堆記憶體。而移動建構函式將getTemp()內部臨時物件的arr移動給了返回值(或者說返回值竊取了臨時物件tmp的arr資源)。
但是這裡還存在一些問題,編譯器如何判斷產生了臨時物件?又如何將其應用於移動建構函式?是否只有臨時物件才可以用於移動構造?由此需要對左值,右值,引用等內容做一次清掃。
什麼是左值?什麼是右值?
似乎左值與右值並沒有一個嚴謹的定義,不過廣為人知的判別方法是:出現在等號左邊的就是“左值”,而在等號右邊的,則稱為“右值”。例如:
a = b + c;
在這個表示式中a是左值,b+c是右值。另外一種判別方式是:可以取地址的、有名字的就是左值,反之就是右值。在上式中&a是允許的,而&(b+c)則無法通過編譯。
C++11中對右值進行了更細緻的定義:將亡值(xvalue, eXpiring Value),純右值(prvalue, Rure Rvalue)。
純右值源於C++98中右值的概念,講的是用於辨識臨時變數和一些不與物件關聯的值。比如非引用返回函式返回的臨時變數值。一些運算表示式,如1+3產生的臨時值,不與物件相關的,如2,'c',true也是純右值。此外,型別轉換函式的返回值,lambda表示式等也是右值。
而將亡值是與移動物件緊密相關的。例如返回右值引用T&&的函式的返回值,std::move的返回值,轉換為T&&的型別轉換函式的返回值。
在C++11中,所有值必屬於左值,純右值,將亡值三者之一。
可以直接宣告一個右值引用:
T&& t = ReturnTemp();
這個表示式要求ReturnTemp()返回的是一個右值。通常情況下,右值引用是不能夠繫結到任何的左值的。例如下面的表示式就無法通過編譯:
T c;
T && d = c;
此外還有我們一直在用的常量左值引用,const T& t,往往當傳入引數不會在方法中改變時我們就會使用常量左值引用傳入,這樣做可以減少臨時變數的開銷。其實常量右值引用const T&& t也是合法的,但右值引用的主要目的就是為了移動語義,而移動語義需要右值是可以被修改的,所以目前來說常量右值引用在移動語義中沒有用武之地。
標準庫在<type_traits>標頭檔案中提供了3個模板類:is_rvalue_reference,is_lvalue_reference,is_reference來供我們判斷引用型別。
std::move
std::move的功能是將一個左值強制轉化為右值引用,繼而我們可以通過右值引用使用該值,以用於移動語義。從實現上來講,std::move基本等同於一個型別轉換:
static_cast<T&&>(lvalue);
需要注意的是,被轉化的左值,其生命週期並沒有隨著左右值的轉化而改變。因此使用std::move的時候,程式設計師需要確認被轉換為右值的lvalue不可以再使用,即lvalue應該是一個生命期即將結束的物件。
在前面例子A的基礎上再新增類B,注意B的移動建構函式:
class B {
public:
B() : i(new int(3)), a(1024) {}
B(B && bm) : i(bm.i), a(move(bm.a)) {
bm.i = nullptr;
}
~B() {
delete i;
}
int *i;
A a;
};
將getTemp修改如下:
B getTemp() {
B tmp = B();
cout << hex << "a from " << __func__ << " @" << tmp.a.arr << endl;
return tmp;
}
Main函式內容:
int main(){
B b = getTemp();
cout << hex << "a from " << __func__ <<" @" << b.a.arr << endl;
}
輸出結果如下:
a from getTemp @000002B8105D2F50
a from main @000002B8105D2F50
如果將B移動建構函式中a的初始化改為a(bm.a),將呼叫A的拷貝建構函式,這就使得移動語義沒有成功的向類的成員(這裡指B的成員a)傳遞。
完美轉發
完美轉發是指在函式模板中,完全按照模板的引數型別,將引數傳遞給函式模板中呼叫的另一個函式。例如:
template<typename T>
void Forwarding(T t) { ActualFunc(t); }
在這個例子中,Forwarding是一個轉發函式模板。而函式ActualFunc則是真正執行程式碼的目標函式。對於ActualFunc而言,它總是希望將引數按照傳入Forwarding時的型別傳遞,而不產生額外的開銷。
這其實並非一件簡單的事情。在上述的例子中,我們使用了最基本的型別進行轉發,該方法會導致引數在傳給ActuallFunc之前就產生了一次額外的臨時物件拷貝。這樣的轉發還談不上“完美”。
所以通常我們需要的是一個引用型別,引用型別不會有拷貝的開銷。其次還要考慮轉發函式對型別的接受能力,因為可能既需要能夠接受左值引用,又能夠接受右值引用。
為了解決這個問題,C++11中引入了一種稱為“引用摺疊”的新語言規則,並結合新的模板推導規則來完成完美轉發。
有一個比較直觀的例子:
在main方法中新增如下程式碼:
typedef int& T1;
typedef T1 TDef1;
typedef T1& TDef2;
typedef T1&& TDef3;
typedef int&& T2;
typedef T2 TDef4;
typedef T2& TDef5;
typedef T2&& TDef6;
cout << is_lvalue_reference<TDef1>::value << is_rvalue_reference<TDef1>::value << endl;
cout << is_lvalue_reference<TDef2>::value << is_rvalue_reference<TDef2>::value << endl;
cout << is_lvalue_reference<TDef3>::value << is_rvalue_reference<TDef3>::value << endl;
cout << is_lvalue_reference<TDef4>::value << is_rvalue_reference<TDef4>::value << endl;
cout << is_lvalue_reference<TDef5>::value << is_rvalue_reference<TDef5>::value << endl;
cout << is_lvalue_reference<TDef6>::value << is_rvalue_reference<TDef6>::value << endl;
它的輸出是:
10
10
10
01
10
01
將複雜的未知表示式摺疊為已知的簡單表示式的規則其實很好記:一旦定義中出現了左值引用,引用摺疊總是優先將其摺疊為左值引用。而模板對型別的推導規則同樣比較簡單:當轉發函式的實參是型別X的一個左值引用,那麼模板引數被推導為X&型別;如果轉發函式的實參是型別X的一個右值引用的話,那麼模板的引數被推導為X&&型別。
結合上述規則,我們可以把轉發函式寫成如下形式:
template<typename T>
void Forwarding(T && t){
ActualFunc(static_cast<T &&>(t));
}
這樣,即使我們呼叫轉發函式時傳入了一個X型別的左值引用,可以想象,轉發函式將被例項化為如下形式:
void Forwarding(X& && t){
ActualFunc(static_cast<X& &&>(t));
}
應用摺疊規則,就是:
void Forwarding(X& t){
ActualFunc(static_cast<X& >(t));
}
成功地傳遞了左值引用。值得注意的是,這裡的static_cast其實並沒有派上用場,它的主要作用是在函式中繼續傳遞右值,如果去掉這裡的static_cast,ActualFunc(t)中的t是個不折不扣的左值,因此需要std::move(上面已經提及,std::move通常就是一個static_cast)來進行左右值的轉換。
不過在C++11中,用於完美轉發的函式有另外一個名字:forward,上述轉發函式可以寫成如下形式:
template<typename T>
void Forwarding(T && t){
ActualFunc(forward(t));
}
完美轉發的主要作用在於包裝函式,並可以充分使用移動語義來完成一些小巧且好用的函式。