【感謝馮上(@治不好你我就不是獸醫 )的熱心翻譯。如果其他朋友也有不錯的原創或譯文,可以嘗試推薦給伯樂線上。】
在C++11新標準中,語言本身和標準庫都增加了很多新內容,本文只涉及了一些皮毛。不過我相信這些新特性當中有一些,應該成為所有C++開發者的常規裝備。你也許看到過許多類似介紹各種C++11特性的文章。下面是我總結的,C++開發者都需要學習和使用的C++11新特性。
auto
在C++11之前,auto關鍵字用來指定儲存期。在新標準中,它的功能變為型別推斷。auto現在成了一個型別的佔位符,通知編譯器去根據初始化程式碼推斷所宣告變數的真實型別。各種作用域內宣告變數都可以用到它。例如,名空間中,程式塊中,或是for迴圈的初始化語句中。
1 2 3 |
auto i = 42; // i is an int auto l = 42LL; // l is an long long auto p = new foo(); // p is a foo* |
使用auto通常意味著更短的程式碼(除非你所用型別是int,它會比auto少一個字母)。試想一下當你遍歷STL容器時需要宣告的那些迭代器(iterator)。現在不需要去宣告那些typedef就可以得到簡潔的程式碼了。
1 2 3 4 |
std::map<std::string, std::vector<int>> map; for(auto it = begin(map); it != end(map); ++it) { } |
需要注意的是,auto不能用來宣告函式的返回值。但如果函式有一個尾隨的返回型別時,auto是可以出現在函式宣告中返回值位置。這種情況下,auto並不是告訴編譯器去推斷返回型別,而是指引編譯器去函式的末端尋找返回值型別。在下面這個例子中,函式的返回值型別就是operator+操作符作用在T1、T2型別變數上的返回值型別。
1 2 3 4 5 6 |
template <typename T1, typename T2> auto compose(T1 t1, T2 t2) -> decltype(t1 + t2) { return t1+t2; } auto v = compose(2, 3.14); // v's type is double |
nullptr
以前都是用0來表示空指標的,但由於0可以被隱式型別轉換為整形,這就會存在一些問題。關鍵字nullptr是std::nullptr_t型別的值,用來指代空指標。nullptr和任何指標型別以及類成員指標型別的空值之間可以發生隱式型別轉換,同樣也可以隱式轉換為bool型(取值為false)。但是不存在到整形的隱式型別轉換。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
void foo(int* p) {} void bar(std::shared_ptr<int> p) {} int* p1 = NULL; int* p2 = nullptr; if(p1 == p2) { } foo(nullptr); bar(nullptr); bool f = nullptr; int i = nullptr; // error: A native nullptr can only be converted to bool or, using reinterpret_cast, to an integral type |
為了向前相容,0仍然是個合法的空指標值。
Range-based for loops (基於範圍的for迴圈)
為了在遍歷容器時支援”foreach”用法,C++11擴充套件了for語句的語法。用這個新的寫法,可以遍歷C型別的陣列、初始化列表以及任何過載了非成員的begin()和end()函式的型別。
如果你只是想對集合或陣列的每個元素做一些操作,而不關心下標、迭代器位置或者元素個數,那麼這種foreach的for迴圈將會非常有用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
std::map<std::string, std::vector<int>> map; std::vector<int> v; v.push_back(1); v.push_back(2); v.push_back(3); map["one"] = v; for(const auto& kvp : map) { std::cout << kvp.first << std::endl; for(auto v : kvp.second) { std::cout << v << std::endl; } } int arr[] = {1,2,3,4,5}; for(int& e : arr) { e = e*e; } |
Override和final
我總覺得 C++中虛擬函式的設計很差勁,因為時至今日仍然沒有一個強制的機制來標識虛擬函式會在派生類裡被改寫。vitual關鍵字是可選的,這使得閱讀程式碼變得很費勁。因為可能需要追溯到繼承體系的源頭才能確定某個方法是否是虛擬函式。為了增加可讀性,我總是在派生類裡也寫上virtual關鍵字,並且也鼓勵大家都這麼做。即使這樣,仍然會產生一些微妙的錯誤。看下面這個例子:
1 2 3 4 5 6 7 8 9 10 11 |
class B { public: virtual void f(short) {std::cout << "B::f" << std::endl;} }; class D : public B { public: virtual void f(int) {std::cout << "D::f" << std::endl;} }; |
D::f 按理應當重寫 B::f。然而二者的宣告是不同的,一個引數是short,另一個是int。因此D::f(原文為B::f,可能是作者筆誤——譯者注)只是擁有同樣名字的另一個函式(過載)而不是重寫。當你通過B型別的指標呼叫f()可能會期望列印出D::f,但實際上則會打出 B::f 。
另一個很微妙的錯誤情況:引數相同,但是基類的函式是const的,派生類的函式卻不是。
1 2 3 4 5 6 7 8 9 10 11 |
class B { public: virtual void f(int) const {std::cout << "B::f " << std::endl;} }; class D : public B { public: virtual void f(int) {std::cout << "D::f" << std::endl;} }; |
同樣,這兩個函式是過載而不是重寫,所以你通過B型別指標呼叫f()將列印B::f,而不是D::f。
幸運的是,現在有一種方式能描述你的意圖。新標準加入了兩個新的識別符號(不是關鍵字)::
- override,表示函式應當重寫基類中的虛擬函式。
- final,表示派生類不應當重寫這個虛擬函式。
第一個的例子如下:
1 2 3 4 5 6 7 8 9 10 11 |
class B { public: virtual void f(short) {std::cout << "B::f" << std::endl;} }; class D : public B { public: virtual void f(int) override {std::cout << "D::f" << std::endl;} }; |
現在這將觸發一個編譯錯誤(後面那個例子,如果也寫上override標識,會得到相同的錯誤提示):
1 |
'D::f' : method with override specifier 'override' did not override any base class methods |
另一方面,如果你希望函式不要再被派生類進一步重寫,你可以把它標識為final。可以在基類或任何派生類中使用final。在派生類中,可以同時使用override和final標識。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
class B { public: virtual void f(int) {std::cout << "B::f" << std::endl;} }; class D : public B { public: virtual void f(int) override final {std::cout << "D::f" << std::endl;} }; class F : public D { public: virtual void f(int) override {std::cout << "F::f" << std::endl;} }; |
被標記成final的函式將不能再被F::f重寫。
Strongly-typed enums 強型別列舉
傳統的C++列舉型別存在一些缺陷:它們會將列舉常量暴露在外層作用域中(這可能導致名字衝突,如果同一個作用域中存在兩個不同的列舉型別,但是具有相同的列舉常量就會衝突),而且它們會被隱式轉換為整形,無法擁有特定的使用者定義型別。
在C++11中通過引入了一個稱為強型別列舉的新型別,修正了這種情況。強型別列舉由關鍵字enum class標識。它不會將列舉常量暴露到外層作用域中,也不會隱式轉換為整形,並且擁有使用者指定的特定型別(傳統列舉也增加了這個性質)。
1 2 |
enum class Options {None, One, All}; Options o = Options::All; |
Smart Pointers 智慧指標
已經有成千上萬的文章討論這個問題了,所以我只想說:現在能使用的,帶引用計數,並且能自動釋放記憶體的智慧指標包括以下幾種:
- unique_ptr: 如果記憶體資源的所有權不需要共享,就應當使用這個(它沒有拷貝建構函式),但是它可以轉讓給另一個unique_ptr(存在move建構函式)。
- shared_ptr: 如果記憶體資源需要共享,那麼使用這個(所以叫這個名字)。
- weak_ptr: 持有被shared_ptr所管理物件的引用,但是不會改變引用計數值。它被用來打破依賴迴圈(想象在一個tree結構中,父節點通過一個共享所有權的引用(chared_ptr)引用子節點,同時子節點又必須持有父節點的引用。如果這第二個引用也共享所有權,就會導致一個迴圈,最終兩個節點記憶體都無法釋放)。
另一方面,auto_ptr已經被廢棄,不會再使用了。
什麼時候使用unique_ptr,什麼時候使用shared_ptr取決於對所有權的需求,我建議閱讀以下的討論:http://stackoverflow.com/questions/15648844/using-smart-pointers-for-class-members
以下第一個例子使用了unique_ptr。如果你想把物件所有權轉移給另一個unique_ptr,需要使用std::move(我會在最後幾段討論這個函式)。在所有權轉移後,交出所有權的智慧指標將為空,get()函式將返回nullptr。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
void foo(int* p) { std::cout << *p << std::endl; } std::unique_ptr<int> p1(new int(42)); std::unique_ptr<int> p2 = std::move(p1); // transfer ownership if(p1) foo(p1.get()); (*p2)++; if(p2) foo(p2.get()); |
第二個例子展示了shared_ptr。用法相似,但語義不同,此時所有權是共享的。
1 2 3 4 5 6 7 8 9 10 11 12 |
void foo(int* p) { } void bar(std::shared_ptr<int> p) { ++(*p); } std::shared_ptr<int> p1(new int(42)); std::shared_ptr<int> p2 = p1; bar(p1); foo(p2.get()); |
第一個宣告和以下這行是等價的:
1 |
auto p3 = std::make_shared<int>(42); |
make_shared<T>是一個非成員函式,使用它的好處是可以一次性分配共享物件和智慧指標自身的記憶體。而顯示地使用shared_ptr建構函式來構造則至少需要兩次記憶體分配。除了會產生額外的開銷,還可能會導致記憶體洩漏。在下面這個例子中,如果seed()丟擲一個錯誤就會產生記憶體洩漏。
1 2 3 4 5 |
void foo(std::shared_ptr<int> p, int init) { *p = init; } foo(std::shared_ptr<int>(new int(42)), seed()); |
如果使用make_shared就不會有這個問題了。第三個例子展示了weak_ptr。注意,你必須呼叫lock()來獲得被引用物件的shared_ptr,通過它才能訪問這個物件。
1 2 3 4 5 6 7 8 9 10 11 12 |
auto p = std::make_shared<int>(42); std::weak_ptr<int> wp = p; { auto sp = wp.lock(); std::cout << *sp << std::endl; } p.reset(); if(wp.expired()) std::cout << "expired" << std::endl; |
如果你試圖鎖定(lock)一個過期(指被弱引用物件已經被釋放)的weak_ptr,那你將獲得一個空的shared_ptr.
Lambdas
匿名函式(也叫lambda)已經加入到C++中,並很快異軍突起。這個從函數語言程式設計中借來的強大特性,使很多其他特性以及類庫得以實現。你可以在任何使用函式物件或者函子(functor)或std::function的地方使用lambda。你可以從這裡(http://msdn.microsoft.com/en-us/library/dd293603.aspx)找到語法說明。
1 2 3 4 5 6 7 8 9 10 11 |
std::vector<int> v; v.push_back(1); v.push_back(2); v.push_back(3); std::for_each(std::begin(v), std::end(v), [](int n) {std::cout << n << std::endl;}); auto is_odd = [](int n) {return n%2==1;}; auto pos = std::find_if(std::begin(v), std::end(v), is_odd); if(pos != std::end(v)) std::cout << *pos << std::endl; |
更復雜的是遞迴lambda。考慮一個實現Fibonacci函式的lambda。如果你試圖用auto來宣告,就會得到一個編譯錯誤。
1 |
auto fib = [&fib](int n) {return n < 2 ? 1 : fib(n-1) + fib(n-2);}; |
1 2 3 4 |
error C3533: 'auto &': a parameter cannot have a type that contains 'auto' error C3531: 'fib': a symbol whose type contains 'auto' must have an initializer error C3536: 'fib': cannot be used before it is initialized error C2064: term does not evaluate to a function taking 1 arguments |
問題出在auto意味著物件型別由初始表示式決定,然而初始表示式又包含了對其自身的引用,因此要求先知道它的型別,這就導致了無窮遞迴。解決問題的關鍵就是打破這種迴圈依賴,用std::function顯式的指定函式型別:
1 |
std::function<int(int)> lfib = [&lfib](int n) {return n < 2 ? 1 : lfib(n-1) + lfib(n-2);}; |
非成員begin()和end()
也許你注意到了,我在前面的例子中已經用到了非成員begin()和end()函式。他們是新加入標準庫的,除了能提高了程式碼一致性,還有助於更多地使用泛型程式設計。它們和所有的STL容器相容。更重要的是,他們是可過載的。所以它們可以被擴充套件到支援任何型別。對C型別陣列的過載已經包含在標準庫中了。
我們還用上一個例子中的程式碼來說明,在這個例子中我列印了一個陣列然後查詢它的第一個偶數元素。如果std::vector被替換成C型別陣列。程式碼可能看起來是這樣的:
1 2 3 4 5 6 7 8 9 |
int arr[] = {1,2,3}; std::for_each(&arr[0], &arr[0]+sizeof(arr)/sizeof(arr[0]), [](int n) {std::cout << n << std::endl;}); auto is_odd = [](int n) {return n%2==1;}; auto begin = &arr[0]; auto end = &arr[0]+sizeof(arr)/sizeof(arr[0]); auto pos = std::find_if(begin, end, is_odd); if(pos != end) std::cout << *pos << std::endl; |
如果使用非成員的begin()和end()來實現,就會是以下這樣的:
1 2 3 4 5 6 7 |
int arr[] = {1,2,3}; std::for_each(std::begin(arr), std::end(arr), [](int n) {std::cout << n << std::endl;}); auto is_odd = [](int n) {return n%2==1;}; auto pos = std::find_if(std::begin(arr), std::end(arr), is_odd); if(pos != std::end(arr)) std::cout << *pos << std::endl; |
這基本上和使用std::vecto的程式碼是完全一樣的。這就意味著我們可以寫一個泛型函式處理所有支援begin()和end()的型別。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
template <typename Iterator> void bar(Iterator begin, Iterator end) { std::for_each(begin, end, [](int n) {std::cout << n << std::endl;}); auto is_odd = [](int n) {return n%2==1;}; auto pos = std::find_if(begin, end, is_odd); if(pos != end) std::cout << *pos << std::endl; } template <typename C> void foo(C c) { bar(std::begin(c), std::end(c)); } template <typename T, size_t N> void foo(T(&arr)[N]) { bar(std::begin(arr), std::end(arr)); } int arr[] = {1,2,3}; foo(arr); std::vector<int> v; v.push_back(1); v.push_back(2); v.push_back(3); foo(v); |
static_assert和 type traits
static_assert提供一個編譯時的斷言檢查。如果斷言為真,什麼也不會發生。如果斷言為假,編譯器會列印一個特殊的錯誤資訊。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
template <typename T, size_t Size> class Vector { static_assert(Size < 3, "Size is too small"); T _points[Size]; }; int main() { Vector<int, 16> a1; Vector<double, 2> a2; return 0; } |
1 2 3 4 5 6 7 |
error C2338: Size is too small see reference to class template instantiation 'Vector<T,Size>' being compiled with [ T=double, Size=2 ] |
static_assert和type traits一起使用能發揮更大的威力。type traits是一些class,在編譯時提供關於型別的資訊。在標頭檔案<type_traits>中可以找到它們。這個標頭檔案中有好幾種class: helper class,用來產生編譯時常量。type traits class,用來在編譯時獲取型別資訊,還有就是type transformation class,他們可以將已存在的型別變換為新的型別。
下面這段程式碼原本期望只做用於整數型別。
1 2 3 4 5 |
template <typename T1, typename T2> auto add(T1 t1, T2 t2) -> decltype(t1 + t2) { return t1 + t2; } |
但是如果有人寫出如下程式碼,編譯器並不會報錯
1 2 |
std::cout << add(1, 3.14) << std::endl; std::cout << add("one", 2) << std::endl; |
程式會列印出4.14和”e”。但是如果我們加上編譯時斷言,那麼以上兩行將產生編譯錯誤。
1 2 3 4 5 6 7 8 |
template <typename T1, typename T2> auto add(T1 t1, T2 t2) -> decltype(t1 + t2) { static_assert(std::is_integral<T1>::value, "Type T1 must be integral"); static_assert(std::is_integral<T2>::value, "Type T2 must be integral"); return t1 + t2; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
error C2338: Type T2 must be integral see reference to function template instantiation 'T2 add<int,double>(T1,T2)' being compiled with [ T2=double, T1=int ] error C2338: Type T1 must be integral see reference to function template instantiation 'T1 add<const char*,int>(T1,T2)' being compiled with [ T1=const char *, T2=int ] |
Move semantics (Move語義)
這是C++11中所涵蓋的另一個重要話題。就這個話題可以寫出一系列文章,僅用一個段落來說明顯然是不夠的。因此在這裡我不會過多的深入細節,如果你還不是很熟悉這個話題,我鼓勵你去閱讀更多地資料。
C++11加入了右值引用(rvalue reference)的概念(用&&標識),用來區分對左值和右值的引用。左值就是一個有名字的物件,而右值則是一個無名物件(臨時物件)。move語義允許修改右值(以前右值被看作是不可修改的,等同於const T&型別)。
C++的class或者struct以前都有一些隱含的成員函式:預設建構函式(僅當沒有顯示定義任何其他建構函式時才存在),拷貝建構函式,解構函式還有拷貝賦值操作符。拷貝建構函式和拷貝賦值操作符提供bit-wise的拷貝(淺拷貝),也就是逐個bit拷貝物件。也就是說,如果你有一個類包含指向其他物件的指標,拷貝時只會拷貝指標的值而不會管指向的物件。在某些情況下這種做法是沒問題的,但在很多情況下,實際上你需要的是深拷貝,也就是說你希望拷貝指標所指向的物件。而不是拷貝指標的值。這種情況下,你需要顯示地提供拷貝建構函式與拷貝賦值操作符來進行深拷貝。
如果你用來初始化或拷貝的源物件是個右值(臨時物件)會怎麼樣呢?你仍然需要拷貝它的值,但隨後很快右值就會被釋放。這意味著產生了額外的操作開銷,包括原本並不需要的空間分配以及記憶體拷貝。
現在說說move constructor和move assignment operator。這兩個函式接收T&&型別的引數,也就是一個右值。在這種情況下,它們可以修改右值物件,例如“偷走”它們內部指標所指向的物件。舉個例子,一個容器的實現(例如vector或者queue)可能包含一個指向元素陣列的指標。當用一個臨時物件初始化一個物件時,我們不需要分配另一個陣列,從臨時物件中把值複製過來,然後在臨時物件析構時釋放它的記憶體。我們只需要將指向陣列記憶體的指標值複製過來,由此節約了一次記憶體分配,一次元陣列的複製以及後來的記憶體釋放。
以下程式碼實現了一個簡易的buffer。這個buffer有一個成員記錄buffer名稱(為了便於以下的說明),一個指標(封裝在unique_ptr中)指向元素為T型別的陣列,還有一個記錄陣列長度的變數。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
template <typename T> class Buffer { std::string _name; size_t _size; std::unique_ptr<T[]> _buffer; public: // default constructor Buffer(): _size(16), _buffer(new T[16]) {} // constructor Buffer(const std::string& name, size_t size): _name(name), _size(size), _buffer(new T[size]) {} // copy constructor Buffer(const Buffer& copy): _name(copy._name), _size(copy._size), _buffer(new T[copy._size]) { T* source = copy._buffer.get(); T* dest = _buffer.get(); std::copy(source, source + copy._size, dest); } // copy assignment operator Buffer& operator=(const Buffer& copy) { if(this != ©) { _name = copy._name; if(_size != copy._size) { _buffer = nullptr; _size = copy._size; _buffer = _size > 0 > new T[_size] : nullptr; } T* source = copy._buffer.get(); T* dest = _buffer.get(); std::copy(source, source + copy._size, dest); } return *this; } // move constructor Buffer(Buffer&& temp): _name(std::move(temp._name)), _size(temp._size), _buffer(std::move(temp._buffer)) { temp._buffer = nullptr; temp._size = 0; } // move assignment operator Buffer& operator=(Buffer&& temp) { assert(this != &temp); // assert if this is not a temporary _buffer = nullptr; _size = temp._size; _buffer = std::move(temp._buffer); _name = std::move(temp._name); temp._buffer = nullptr; temp._size = 0; return *this; } }; template <typename T> Buffer<T> getBuffer(const std::string& name) { Buffer<T> b(name, 128); return b; } int main() { Buffer<int> b1; Buffer<int> b2("buf2", 64); Buffer<int> b3 = b2; Buffer<int> b4 = getBuffer<int>("buf4"); b1 = getBuffer<int>("buf5"); return 0; } |
預設的copy constructor以及copy assignment operator大家應該很熟悉了。C++11中新增的是move constructor以及move assignment operator,這兩個函式根據上文所描述的move語義實現。如果你執行這段程式碼,你就會發現b4構造時,move constructor會被呼叫。同樣,對b1賦值時,move assignment operator會被呼叫。原因就在於getBuffer()的返回值是一個臨時物件——也就是右值。
你也許注意到了,move constuctor中當我們初始化變數name和指向buffer的指標時,我們使用了std::move。name實際上是一個string,std::string實現了move語義。std::unique_ptr也一樣。但是如果我們寫_name(temp._name),那麼copy constructor將會被呼叫。不過對於_buffer來說不能這麼寫,因為std::unique_ptr沒有copy constructor。但為什麼std::string的move constructor此時沒有被調到呢?這是因為雖然我們使用一個右值呼叫了Buffer的move constructor,但在這個建構函式內,它實際上是個左值。為什麼?因為它是有名字的——“temp”。一個有名字的物件就是左值。為了再把它變為右值(以便呼叫move constructor)必須使用std::move。這個函式僅僅是把一個左值引用變為一個右值引用。
更新:雖然這個例子是為了說明如何實現move constructor以及move assignment operator,但具體的實現方式並不是唯一的。在本文的回覆中Member 7805758同學提供了另一種可能的實現。為了方便檢視,我把它也列在下面:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
template <typename T> class Buffer { std::string _name; size_t _size; std::unique_ptr<T[]> _buffer; public: // constructor Buffer(const std::string& name = "", size_t size = 16): _name(name), _size(size), _buffer(size? new T[size] : nullptr) {} // copy constructor Buffer(const Buffer& copy): _name(copy._name), _size(copy._size), _buffer(copy._size? new T[copy._size] : nullptr) { T* source = copy._buffer.get(); T* dest = _buffer.get(); std::copy(source, source + copy._size, dest); } // copy assignment operator Buffer& operator=(Buffer copy) { swap(*this, copy); return *this; } // move constructor Buffer(Buffer&& temp):Buffer() { swap(*this, temp); } friend void swap(Buffer& first, Buffer& second) noexcept { using std::swap; swap(first._name , second._name); swap(first._size , second._size); swap(first._buffer, second._buffer); } }; |
結論
關於C++11還有很多要說的。本文只是各種入門介紹中的一個。本文展示了一系列C++開發者應當使用的核心語言特性與標準庫函式。然而我建議你能更加深入地學習,至少也要再看看本文所介紹的特性中的部分。