1. nullptr
nullptr 出現的目的是為了替代 NULL。
在某種意義上來說,傳統 C++ 會把 NULL、0 視為同一種東西,這取決于編譯器如何定義 NULL,有些編譯器會将 NULL 定義為 ((void*)0),有些則會直接将其定義為 0。
C++ 不允許直接将 void * 隐式轉換到其他類型,但如果 NULL 被定義為 ((void*)0),那麼當編譯 char *ch = NULL 時,NULL 隻好被定義為 0。
而這依然會産生問題,将導緻了 C++ 中重載特性會發生混亂,考慮:
void foo(char *);
void foo(int);
複制
對于這兩個函數來說,如果 NULL 又被定義為了 0 那麼 foo(NULL); 這個語句将會去調用 foo(int),進而導緻代碼違反直覺。
為了解決這個問題,C++11 引入了 nullptr 關鍵字,專門用來區分空指針、0。
nullptr 的類型為 nullptr_t,能夠隐式的轉換為任何指針或成員指針的類型,也能和他們進行相等或者不等的比較。
當需要使用 NULL 時候,養成直接使用 nullptr 的習慣。
2. 類型推導
C++11 引入了 auto 和 decltype 這兩個關鍵字實作了類型推導,讓編譯器來操心變量的類型。
auto
auto 在很早以前就已經進入了 C++,但是他始終作為一個存儲類型的訓示符存在,與 register 并存。在傳統 C++ 中,如果一個變量沒有聲明為 register 變量,将自動被視為一個 auto 變量。而随着 register 被棄用,對 auto 的語義變更也就非常自然了。
使用 auto 進行類型推導的一個最為常見而且顯著的例子就是疊代器。在以前我們需要這樣來書寫一個疊代器:
for(vector<int>::const_iterator itr = vec.cbegin(); itr != vec.cend(); ++itr)
複制
而有了 auto 之後可以:
// 由于 cbegin() 将傳回 vector<int>::const_iterator
// 是以 itr 也應該是 vector<int>::const_iterator 類型
for(auto itr = vec.cbegin(); itr != vec.cend(); ++itr);
複制
一些其他的常見用法:
auto i = 5; // i 被推導為 int
auto arr = new auto(10) // arr 被推導為 int *
複制
注意:auto 不能用于函數傳參,是以下面的做法是無法通過編譯的(考慮重載的問題,我們應該使用模闆):
int add(auto x, auto y);
複制
此外,auto 還不能用于推導數組類型:
#include <iostream>
int main() {
auto i = 5;
int arr[10] = {0};
auto auto_arr = arr;
auto auto_arr2[10] = arr;
return 0;
}
複制
decltype
decltype 關鍵字是為了解決 auto 關鍵字隻能對變量進行類型推導的缺陷而出現的。它的用法和 sizeof 很相似:
decltype(表達式)
複制
在此過程中,編譯器分析表達式并得到它的類型,卻不實際計算表達式的值。
有時候,我們可能需要計算某個表達式的類型,例如:
auto x = 1;
auto y = 2;
decltype(x+y) z;
複制
拖尾傳回類型、auto 與 decltype 配合
你可能會思考,auto 能不能用于推導函數的傳回類型。考慮這樣一個例子加法函數的例子,在傳統 C++ 中我們必須這麼寫:
template<typename R, typename T, typename U>
R add(T x, U y) {
return x+y
}
複制
這樣的代碼其實變得很醜陋,因為程式員在使用這個模闆函數的時候,必須明确指出傳回類型。但事實上我們并不知道 add() 這個函數會做什麼樣的操作,獲得一個什麼樣的傳回類型。
在 C++11 中這個問題得到解決。雖然你可能馬上回反應出來使用 decltype 推導 x+y 的類型,寫出這樣的代碼:
decltype(x+y) add(T x, U y);
複制
但事實上這樣的寫法并不能通過編譯。這是因為在編譯器讀到 decltype(x+y) 時,x 和 y 尚未被定義。為了解決這個問題,C++11 還引入了一個叫做拖尾傳回類型(trailing return type),利用 auto 關鍵字将傳回類型後置:
template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
return x+y;
}
複制
從 C++14 開始是可以直接讓普通函數具備傳回值推導,是以下面的寫法變得合法:
template<typename T, typename U>
auto add(T x, U y) {
return x+y;
}
複制
3. 區間疊代
基于範圍的 for 循環
C++11 引入了基于範圍的疊代寫法,我們擁有了能夠寫出像 Python 一樣簡潔的循環語句。
最常用的 std::vector 周遊将從原來的樣子:
std::vector<int> arr(5, 100);
for(std::vector<int>::iterator i = arr.begin(); i != arr.end(); ++i) {
std::cout << *i << std::endl;
}
複制
變得非常的簡單:
// & 啟用了引用
for(auto &i : arr) {
std::cout << i << std::endl;
}
複制
4. 初始化清單
C++11 提供了統一的文法來初始化任意的對象,例如:
struct A {
int a;
float b;
};
struct B {
B(int _a, float _b): a(_a), b(_b) {}
private:
int a;
float b;
};
A a {1, 1.1}; // 統一的初始化文法
B b {2, 2.2};
複制
C++11 還把初始化清單的概念綁定到了類型上,并将其稱之為 std::initializer_list,允許構造函數或其他函數像參數一樣使用初始化清單,這就為類對象的初始化與普通數組和 POD 的初始化方法提供了統一的橋梁,例如:
#include <initializer_list>
class Magic {
public:
Magic(std::initializer_list<int> list) {}
};
Magic magic = {1,2,3,4,5};
std::vector<int> v = {1, 2, 3, 4};
複制
5. 模闆增強
外部模闆
傳統 C++ 中,模闆隻有在使用時才會被編譯器執行個體化。隻要在每個編譯單元(檔案)中編譯的代碼中遇到了被完整定義的模闆,都會執行個體化。這就産生了重複執行個體化而導緻的編譯時間的增加。并且,我們沒有辦法通知編譯器不要觸發模闆執行個體化。
C++11 引入了外部模闆,擴充了原來的強制編譯器在特定位置執行個體化模闆的文法,使得能夠顯式的告訴編譯器何時進行模闆的執行個體化:
template class std::vector<bool>; // 強行執行個體化
extern template class std::vector<double>; // 不在該編譯檔案中執行個體化模闆
複制
尖括号 “>”
在傳統 C++ 的編譯器中,>> 一律被當做右移運算符來進行處理。但實際上我們很容易就寫出了嵌套模闆的代碼:
std::vector<std::vector<int>> wow;
複制
這在傳統C++編譯器下是不能夠被編譯的,而 C++11 開始,連續的右尖括号将變得合法,并且能夠順利通過編譯。
類型别名模闆
在傳統 C++中,typedef 可以為類型定義一個新的名稱,但是卻沒有辦法為模闆定義一個新的名稱。因為,模闆不是類型。例如:
template< typename T, typename U, int value>
class SuckType {
public:
T a;
U b;
SuckType():a(value),b(value){}
};
template< typename U>
typedef SuckType<std::vector<int>, U, 1> NewType; // 不合法
複制
C++11 使用 using 引入了下面這種形式的寫法,并且同時支援對傳統 typedef 相同的功效:
template <typename T>
using NewType = SuckType<int, T, 1>; // 合法
複制
預設模闆參數
我們可能定義了一個加法函數:
template<typename T, typename U>
auto add(T x, U y) -> decltype(x+y) {
return x+y
}
複制
但在使用時發現,要使用 add,就必須每次都指定其模闆參數的類型。
在 C++11 中提供了一種便利,可以指定模闆的預設參數:
template<typename T = int, typename U = int>
auto add(T x, U y) -> decltype(x+y) {
return x+y;
}
複制
6. 構造函數
委托構造
C++11 引入了委托構造的概念,這使得構造函數可以在同一個類中一個構造函數調用另一個構造函數,進而達到簡化代碼的目的:
class Base {
public:
int value1;
int value2;
Base() {
value1 = 1;
}
Base(int value) : Base() { // 委托 Base() 構造函數
value2 = 2;
}
};
複制
繼承構造
在繼承體系中,如果派生類想要使用基類的構造函數,需要在構造函數中顯式聲明。
假若基類擁有為數衆多的不同版本的構造函數,這樣,在派生類中得寫很多對應的“透傳”構造函數。如下:
struct A
{
A(int i) {}
A(double d,int i){}
A(float f,int i,const char* c){}
//...等等系列的構造函數版本
};
struct B:A
{
B(int i):A(i){}
B(double d,int i):A(d,i){}
B(folat f,int i,const char* c):A(f,i,e){}
//......等等好多個和基類構造函數對應的構造函數
};
複制
C++11的繼承構造:
struct A
{
A(int i) {}
A(double d,int i){}
A(float f,int i,const char* c){}
//...等等系列的構造函數版本
};
struct B:A
{
using A::A;
//關于基類各構造函數的繼承一句話搞定
//......
};
複制
如果一個繼承構造函數不被相關的代碼使用,編譯器不會為之産生真正的函數代碼,這樣比透傳基類各種構造函數更加節省目标代碼空間。
7. Lambda 表達式
Lambda 表達式,實際上就是提供了一個類似匿名函數的特性,而匿名函數則是在需要一個函數,但是又不想費力去命名一個函數的情況下去使用的。
Lambda 表達式的基本文法如下:
[ caputrue ] ( params ) opt -> ret { body; };
複制
- capture 是捕獲清單;
- params 是參數表;(選填)
-
opt 是函數選項;可以填 mutable,exception,attribute(選填)
mutable 說明 lambda 表達式體内的代碼可以修改被捕獲的變量,并且可以通路被捕獲的對象的 non-const 方法。
exception 說明 lambda 表達式是否抛出異常以及何種異常。
attribute 用來聲明屬性。
- ret 是傳回值類型(拖尾傳回類型)。(選填)
- body 是函數體。
捕獲清單:lambda 表達式的捕獲清單精細控制了 lambda 表達式能夠通路的外部變量,以及如何通路這些變量。
- []不捕獲任何變量。
- [&]捕獲外部作用域中所有變量,并作為引用在函數體中使用(按引用捕獲)。
- [=]捕獲外部作用域中所有變量,并作為副本在函數體中使用(按值捕獲)。注意值捕獲的前提是變量可以拷貝,且被捕獲的變量在 lambda 表達式被建立時拷貝,而非調用時才拷貝。如果希望 lambda 表達式在調用時能即時通路外部變量,我們應當使用引用方式捕獲。
int a = 0;
auto f = [=] { return a; };
a+=1;
cout << f() << endl; //輸出0
int a = 0;
auto f = [&a] { return a; };
a+=1;
cout << f() <<endl; //輸出1
複制
- [=, &foo] 按值捕獲外部作用域中所有變量,并按引用捕獲 foo 變量。
- [bar] 按值捕獲 bar 變量,同時不捕獲其他變量。
- [this] 捕獲目前類中的 this 指針,讓 lambda 表達式擁有和目前類成員函數同樣的通路權限。如果已經使用了 & 或者 =,就預設添加此選項。捕獲 this 的目的是可以在 lamda 中使用目前類的成員函數和成員變量。
class A
{
public:
int i_ = 0;
void func(int x,int y){
auto x1 = [] { return i_; }; //error,沒有捕獲外部變量
auto x2 = [=] { return i_ + x + y; }; //OK
auto x3 = [&] { return i_ + x + y; }; //OK
auto x4 = [this] { return i_; }; //OK
auto x5 = [this] { return i_ + x + y; }; //error,沒有捕獲x,y
auto x6 = [this, x, y] { return i_ + x + y; }; //OK
auto x7 = [this] { return i_++; }; //OK
};
int a=0 , b=1;
auto f1 = [] { return a; }; //error,沒有捕獲外部變量
auto f2 = [&] { return a++ }; //OK
auto f3 = [=] { return a; }; //OK
auto f4 = [=] {return a++; }; //error,a是以複制方式捕獲的,無法修改
auto f5 = [a] { return a+b; }; //error,沒有捕獲變量b
auto f6 = [a, &b] { return a + (b++); }; //OK
auto f7 = [=, &b] { return a + (b++); }; //OK
複制
注意 f4,雖然按值捕獲的變量值均複制一份存儲在 lambda 表達式變量中,修改他們也并不會真正影響到外部,但我們卻仍然無法修改它們。如果希望去修改按值捕獲的外部變量,需要顯示指明 lambda 表達式為mutable。被 mutable 修飾的 lambda 表達式就算沒有參數也要寫明參數清單。
原因:lambda 表達式可以說是就地定義仿函數閉包的“文法糖”。它的捕獲清單捕獲住的任何外部變量,最終會變為閉包類型的成員變量。按照 C++ 标準,lambda表達式的 operator() 預設是 const 的,一個 const 成員函數是無法修改成員變量的值的。而 mutable 的作用,就在于取消 operator() 的 const。
int a = 0;
auto f1 = [=] { return a++; }; //error
auto f2 = [=] () mutable { return a++; }; //OK
複制
lambda 表達式的大緻原理:每當你定義一個 lambda 表達式後,編譯器會自動生成一個匿名類(這個類重載了() 運算符),我們稱為閉包類型(closure type)。那麼在運作時,這個 lambda 表達式就會傳回一個匿名的閉包執行個體,是一個右值。是以,我們上面的 lambda 表達式的結果就是一個個閉包。對于複制傳值捕捉方式,類中會相應添加對應類型的非靜态資料成員。在運作時,會用複制的值初始化這些成員變量,進而生成閉包。對于引用捕獲方式,無論是否标記 mutable,都可以在 lambda 表達式中修改捕獲的值。至于閉包類中是否有對應成員,C++ 标準中給出的答案是:不清楚的,與具體實作有關。
lambda 表達式是不能被指派的:
auto a = [] { cout << "A" << endl; };
auto b = [] { cout << "B" << endl; };
a = b; // 非法,lambda無法指派
auto c = a; // 合法,生成一個副本
複制
閉包類型禁用了指派操作符,但是沒有禁用複制構造函數,是以你仍然可以用一個 lambda 表達式去初始化另外一個 lambda 表達式而産生副本。
在多種捕獲方式中,最好不要使用 [=] 和 [&] 預設捕獲所有變量。
預設引用捕獲所有變量,你有很大可能會出現懸挂引用(Dangling references),因為引用捕獲不會延長引用的變量的生命周期:
std::function<int(int)> add_x(int x)
{
return [&](int a) { return x + a; };
}
複制
上面函數傳回了一個 lambda 表達式,參數 x 僅是一個臨時變量,函數 add_x 調用後就被銷毀了,但是傳回的 lambda 表達式卻引用了該變量,當調用這個表達式時,引用的是一個垃圾值,會産生沒有意義的結果。上面這種情況,使用預設傳值方式可以避免懸挂引用問題。
但是采用預設值捕獲所有變量仍然有風險,看下面的例子:
class Filter
{
public:
Filter(int divisorVal):
divisor{divisorVal}
{}
std::function<bool(int)> getFilter()
{
return [=](int value) {return value % divisor == 0; };
}
private:
int divisor;
};
複制
這個類中有一個成員方法,可以傳回一個 lambda 表達式,這個表達式使用了類的資料成員 divisor。而且采用預設值方式捕捉所有變量。你可能認為這個 lambda 表達式也捕捉了 divisor 的一份副本,但是實際上并沒有。因為資料成員 divisor 對 lambda 表達式并不可見,你可以用下面的代碼驗證:
// 類的方法,下面無法編譯,因為divisor并不在lambda捕捉的範圍
std::function<bool(int)> getFilter()
{
return [divisor](int value) {return value % divisor == 0; };
}
複制
原代碼中,lambda 表達式實際上捕捉的是this指針的副本,是以原來的代碼等價于:
std::function<bool(int)> getFilter()
{
return [this](int value) {return value % this->divisor == 0; };
}
複制
盡管還是以值方式捕獲,但是捕獲的是指針,其實相當于以引用的方式捕獲了目前類對象,是以 lambda 表達式的閉包與一個類對象綁定在一起了,這很危險,因為你仍然有可能在類對象析構後使用這個 lambda 表達式,那麼類似“懸挂引用”的問題也會産生。是以,采用預設值捕捉所有變量仍然是不安全的,主要是由于指針變量的複制,實際上還是按引用傳值。
lambda 表達式可以指派給對應類型的函數指針。但是使用函數指針并不是那麼友善。是以 STL 定義在 < functional > 頭檔案提供了一個多态的函數對象封裝 std::function,其類似于函數指針。它可以綁定任何類函數對象,隻要參數與傳回類型相同。如下面的傳回一個 bool 且接收兩個int的函數包裝器:
std::function<bool(int, int)> wrapper = [](int x, int y) { return x < y; };
複制
lambda 表達式一個更重要的應用是其可以用于函數的參數,通過這種方式可以實作回調函數。
最常用的是在 STL 算法中,比如你要統計一個數組中滿足特定條件的元素數量,通過 lambda 表達式給出條件,傳遞給 count_if 函數:
int value = 3;
vector<int> v {1, 3, 5, 2, 6, 10};
int count = std::count_if(v.beigin(), v.end(), [value](int x) { return x > value; });
複制
再比如你想生成斐波那契數列,然後儲存在數組中,此時你可以使用 generate 函數,并輔助 lambda 表達式:
vector<int> v(10);
int a = 0;
int b = 1;
std::generate(v.begin(), v.end(), [&a, &b] { int value = b; b = b + a; a = value; return value; });
// 此時v {1, 1, 2, 3, 5, 8, 13, 21, 34, 55}
複制
當需要周遊容器并對每個元素進行操作時:
std::vector<int> v = { 1, 2, 3, 4, 5, 6 };
int even_count = 0;
for_each(v.begin(), v.end(), [&even_count](int val){
if(!(val & 1)){
++ even_count;
}
});
std::cout << "The number of even is " << even_count << std::endl;
複制
大部分 STL 算法,可以非常靈活地搭配 lambda 表達式來實作想要的效果。
8. 新增容器
std::array
std::array 儲存在棧記憶體中,相比堆記憶體中的 std::vector,我們能夠靈活的通路這裡面的元素,進而獲得更高的性能。
std::array 會在編譯時建立一個固定大小的數組,std::array 不能夠被隐式的轉換成指針,使用 std::array 隻需指定其類型和大小即可:
std::array<int, 4> arr= {1,2,3,4};
int len = 4;
std::array<int, len> arr = {1,2,3,4}; // 非法, 數組大小參數必須是常量表達式
複制
當我們開始用上了 std::array 時,難免會遇到要将其相容 C 風格的接口,這裡有三種做法:
void foo(int *p, int len) {
return;
}
std::array<int 4> arr = {1,2,3,4};
// C 風格接口傳參
// foo(arr, arr.size()); // 非法, 無法隐式轉換
foo(&arr[0], arr.size());
foo(arr.data(), arr.size());
// 使用 `std::sort`
std::sort(arr.begin(), arr.end());
複制
std::forward_list
std::forward_list 是一個清單容器,使用方法和 std::list 基本類似。
和 std::list 的雙向連結清單的實作不同,std::forward_list 使用單向連結清單進行實作,提供了 O(1) 複雜度的元素插入,不支援快速随機通路(這也是連結清單的特點),也是标準庫容器中唯一一個不提供 size() 方法的容器。當不需要雙向疊代時,具有比 std::list 更高的空間使用率。
無序容器
C++11 引入了兩組無序容器:
std::unordered_map/std::unordered_multimap 和 std::unordered_set/std::unordered_multiset。
無序容器中的元素是不進行排序的,内部通過 Hash 表實作,插入和搜尋元素的平均複雜度為 O(constant)。
元組 std::tuple
元組的使用有三個核心的函數:
std::make_tuple: 構造元組
std::get: 獲得元組某個位置的值
std::tie: 元組拆包
#include <tuple>
#include <iostream>
auto get_student(int id)
{
// 傳回類型被推斷為 std::tuple<double, char, std::string>
if (id == 0)
return std::make_tuple(3.8, 'A', "張三");
if (id == 1)
return std::make_tuple(2.9, 'C', "李四");
if (id == 2)
return std::make_tuple(1.7, 'D', "王五");
return std::make_tuple(0.0, 'D', "null");
// 如果隻寫 0 會出現推斷錯誤, 編譯失敗
}
int main()
{
auto student = get_student(0);
std::cout << "ID: 0, "
<< "GPA: " << std::get<0>(student) << ", "
<< "成績: " << std::get<1>(student) << ", "
<< "姓名: " << std::get<2>(student) << '\n';
double gpa;
char grade;
std::string name;
// 元組進行拆包
std::tie(gpa, grade, name) = get_student(1);
std::cout << "ID: 1, "
<< "GPA: " << gpa << ", "
<< "成績: " << grade << ", "
<< "姓名: " << name << '\n';
}
複制
合并兩個元組,可以通過 std::tuple_cat 來實作。
auto new_tuple = std::tuple_cat(get_student(1), std::move(t));
複制
9. 正規表達式
正規表達式描述了一種字元串比對的模式。一般使用正規表達式主要是實作下面三個需求:
- 檢查一個串是否包含某種形式的子串;
- 将比對的子串替換;
- 從某個串中取出符合條件的子串。
C++11 提供的正規表達式庫操作 std::string 對象,對模式 std::regex (本質是 std::basic_regex)進行初始化,通過 std::regex_match 進行比對,進而産生 std::smatch (本質是 std::match_results 對象)。
我們通過一個簡單的例子來簡單介紹這個庫的使用。考慮下面的正規表達式:
[a-z]+.txt: 在這個正規表達式中, [a-z] 表示比對一個小寫字母, + 可以使前面的表達式比對多次,是以 [a-z]+ 能夠比對一個及以上小寫字母組成的字元串。在正規表達式中一個 . 表示比對任意字元,而 . 轉義後則表示比對字元 . ,最後的 txt 表示嚴格比對 txt 這三個字母。是以這個正規表達式的所要比對的内容就是檔案名為純小寫字母的文本檔案。
std::regex_match 用于比對字元串和正規表達式,有很多不同的重載形式。最簡單的一個形式就是傳入std::string 以及一個 std::regex 進行比對,當比對成功時,會傳回 true,否則傳回 false。例如:
#include <iostream>
#include <string>
#include <regex>
int main() {
std::string fnames[] = {"foo.txt", "bar.txt", "test", "a0.txt", "AAA.txt"};
// 在 C++ 中 `\` 會被作為字元串内的轉義符,為使 `\.` 作為正規表達式傳遞進去生效,需要對 `\` 進行二次轉義,進而有 `\\.`
std::regex txt_regex("[a-z]+\\.txt");
for (const auto &fname: fnames)
std::cout << fname << ": " << std::regex_match(fname, txt_regex) << std::endl;
}
複制
另一種常用的形式就是依次傳入 std::string/std::smatch/std::regex 三個參數,其中 std::smatch 的本質其實是 std::match_results,在标準庫中, std::smatch 被定義為了 std::match_results,也就是一個子串疊代器類型的 match_results。使用 std::smatch 可以友善的對比對的結果進行擷取,例如:
std::regex base_regex("([a-z]+)\\.txt");
std::smatch base_match;
for(const auto &fname: fnames) {
if (std::regex_match(fname, base_match, base_regex)) {
// sub_match 的第一個元素比對整個字元串
// sub_match 的第二個元素比對了第一個括号表達式
if (base_match.size() == 2) {
std::string base = base_match[1].str();
std::cout << "sub-match[0]: " << base_match[0].str() << std::endl;
std::cout << fname << " sub-match[1]: " << base << std::endl;
}
}
}
複制
以上兩個代碼段的輸出結果為:
foo.txt: 1
bar.txt: 1
test: 0
a0.txt: 0
AAA.txt: 0
sub-match[0]: foo.txt
foo.txt sub-match[1]: foo
sub-match[0]: bar.txt
bar.txt sub-match[1]: bar
複制
10. 語言級線程支援
std::thread
std::mutex/std::unique_lock
std::future/std::packaged_task
std::condition_variable
代碼編譯需要使用 -pthread 選項
11. 右值引用和move語義
先看一個簡單的例子直覺感受下:
string a(x); // line 1
string b(x + y); // line 2
string c(some_function_returning_a_string()); // line 3
複制
如果使用以下拷貝構造函數:
string(const string& that)
{
size_t size = strlen(that.data) + 1;
data = new char[size];
memcpy(data, that.data, size);
}
複制
以上 3 行中,隻有第一行(line 1)的 x 深度拷貝是有必要的,因為我們可能會在後邊用到 x,x 是一個左值 (lvalues)。
第二行和第三行的參數則是右值,因為表達式産生的 string 對象是匿名對象,之後沒有辦法再使用了。
C++ 11 引入了一種新的機制叫做“右值引用”,以便我們通過重載直接使用右值參數。我們所要做的就是寫一個以右值引用為參數的構造函數:
string(string&& that) // string&& is an rvalue reference to a string
{
data = that.data;
that.data = 0;
}
複制
我們沒有深度拷貝堆記憶體中的資料,而是僅僅複制了指針,并把源對象的指針置空。事實上,我們“偷取”了屬于源對象的記憶體資料。由于源對象是一個右值,不會再被使用,是以客戶并不會覺察到源對象被改變了。在這裡,我們并沒有真正的複制,是以我們把這個構造函數叫做“轉移構造函數”(move constructor),他的工作就是把資源從一個對象轉移到另一個對象,而不是複制他們。
有了右值引用,再來看看指派操作符:
string& operator=(string that)
{
std::swap(data, that.data);
return *this;
}
複制
注意到我們是直接對參數 that 傳值,是以 that 會像其他任何對象一樣被初始化,那麼确切的說,that 是怎樣被初始化的呢?對于 C++ 98,答案是複制構造函數,但是對于 C++ 11,編譯器會依據參數是左值還是右值在複制構造函數和轉移構造函數間進行選擇。
如果是 a=b,這樣就會調用複制構造函數來初始化 that(因為 b 是左值),指派操作符會與新建立的對象交換資料,深度拷貝。這就是 copy and swap 慣用法的定義:構造一個副本,與副本交換資料,并讓副本在作用域内自動銷毀。這裡也一樣。
如果是 a = x + y,這樣就會調用轉移構造函數來初始化 that(因為 x+y 是右值),是以這裡沒有深度拷貝,隻有高效的資料轉移。相對于參數,that依然是一個獨立的對象,但是他的構造函數是無用的(trivial),是以堆中的資料沒有必要複制,而僅僅是轉移。沒有必要複制他,因為 x+y 是右值,再次,從右值指向的對象中轉移是沒有問題的。
總結一下:複制構造函數執行的是深度拷貝,因為源對象本身必須不能被改變。而轉移構造函數卻可以複制指針,把源對象的指針置空,這種形式下,這是安全的,因為使用者不可能再使用這個對象了。
下面我們進一步讨論右值引用和 move 語義。
C++98 标準庫中提供了一種唯一擁有性的智能指針 std::auto_ptr,該類型在 C++11 中已被廢棄,因為其“複制”行為是危險的。
auto_ptr<Shape> a(new Triangle);
auto_ptr<Shape> b(a);
複制
注意 b 是怎樣使用a進行初始化的,它不複制 triangle,而是把 triangle 的所有權從 a 傳遞給了 b,也可以說成“a 被轉移進了 b”或者“triangle 被從 a 轉移到了 b”。
auto_ptr 的複制構造函數可能看起來像這樣(簡化):
auto_ptr(auto_ptr& source) // note the missing const
{
p = source.p;
source.p = 0; // now the source no longer owns the object
}
複制
auto_ptr 的危險之處在于看上去應該是複制,但實際上确是轉移。調用被轉移過的 auto_ptr 的成員函數将會導緻不可預知的後果。是以你必須非常謹慎的使用 auto_ptr ,如果他被轉移過。
auto_ptr<Shape> make_triangle()
{
return auto_ptr<Shape>(new Triangle);
}
auto_ptr<Shape> c(make_triangle()); // move temporary into c
double area = make_triangle()->area(); // perfectly safe
auto_ptr<Shape> a(new Triangle); // create triangle
auto_ptr<Shape> b(a); // move a into b
double area = a->area(); // undefined behavior
複制
顯然,在持有 auto_ptr 對象的a表達式和持有調用函數傳回的 auto_ptr 值類型的 make_triangle() 表達式之間一定有一些潛在的差別,每調用一次後者就會建立一個新的 auto_ptr 對象。這裡 a 其實就是一個左值(lvalue)的例子,而 make_triangle() 就是右值(rvalue)的例子。
轉移像 a 這樣的左值是非常危險的,因為我們可能調用 a 的成員函數,這會導緻不可預知的行為。另一方面,轉移像 make_triangle() 這樣的右值卻是非常安全的,因為複制構造函數之後,我們不能再使用這個臨時對象了,因為這個轉移後的臨時對象會在下一行之前銷毀掉。
我們現在知道轉移左值是十分危險的,但是轉移右值卻是很安全的。如果 C++ 能從語言級别支援區分左值和右值參數,我就可以完全杜絕對左值轉移,或者把轉移左值在調用的時候暴露出來,以使我們不會不經意的轉移左值。
C++ 11 對這個問題的答案是右值引用。右值引用是針對右值的新的引用類型,文法是 X&&。以前的老的引用類型 X& 現在被稱作左值引用。
使用右值引用 X&& 作為參數的最有用的函數之一就是轉移構造函數 X::X(X&& source),它的主要作用是把源對象的本地資源轉移給目前對象。
C++ 11中,std::auto_ptr< T > 已經被 std::unique_ptr< T > 所取代,後者就是利用的右值引用。
其轉移構造函數:
unique_ptr(unique_ptr&& source) // note the rvalue reference
{
ptr = source.ptr;
source.ptr = nullptr;
}
複制
這個轉移構造函數跟 auto_ptr 中複制構造函數做的事情一樣,但是它卻隻能接受右值作為參數。
unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a); // error
unique_ptr<Shape> c(make_triangle()); // okay
複制
第二行不能編譯通過,因為 a 是左值,但是參數 unique_ptr&& source 隻能接受右值,這正是我們所需要的,杜絕危險的隐式轉移。第三行編譯沒有問題,因為 make_triangle() 是右值,轉移構造函數會将臨時對象的所有權轉移給對象 c,這正是我們需要的。
轉移左值
有時候,我們可能想轉移左值,也就是說,有時候我們想讓編譯器把左值當作右值對待,以便能使用轉移構造函數,即便這有點不安全。出于這個目的,C++ 11 在标準庫的頭檔案 < utility > 中提供了一個模闆函數std::move。實際上,std::move 僅僅是簡單地将左值轉換為右值,它本身并沒有轉移任何東西。它僅僅是讓對象可以轉移。
以下是如何正确的轉移左值:
unique_ptr<Shape> a(new Triangle);
unique_ptr<Shape> b(a); // still an error
unique_ptr<Shape> c(std::move(a)); // okay
複制
請注意,第三行之後,a 不再擁有 Triangle 對象。不過這沒有關系,因為通過明确的寫出 std::move(a),我們很清楚我們的意圖:親愛的轉移構造函數,你可以對 a 做任何想要做的事情來初始化 c;我不再需要 a 了,對于 a,您請自便。
當然,如果你在使用了 mova(a) 之後,還繼續使用 a,那無疑是搬起石頭砸自己的腳,還是會導緻嚴重的運作錯誤。
總之,std::move(some_lvalue) 将左值轉換為右值(可以了解為一種類型轉換),使接下來的轉移成為可能。
一個例子:
class Foo
{
unique_ptr<Shape> member;
public:
Foo(unique_ptr<Shape>&& parameter)
: member(parameter) // error
{}
};
複制
上面的 parameter,其類型是一個右值引用,隻能說明 parameter 是指向右值的引用,而 parameter 本身是個左值。(Things that are declared as rvalue reference can be lvalues or rvalues. The distinguishing criterion is: if it has a name, then it is an lvalue. Otherwise, it is an rvalue.)
是以以上對 parameter 的轉移是不允許的,需要使用 std::move 來顯示轉換成右值。