c++11-語言運行期的強化

一、本節內容

本節內容包括:

  • 語言運行期的強化
    • lambda 表達式
      • lambda 表達式基礎
        • 值捕獲
        • 引用捕獲
        • 隱式捕獲
        • 表達式捕獲
      • 泛型 lambda
    • 函數對象包裝器
      • std::function
      • std::bind/std::placeholder
    • 右值引用
      • 左值、右值的純右值、將亡值、右值
      • 右值引用和左值引用
      • 移動語義
      • 完美轉發

二、Lambda 表達式

Lambda 表達式是 C++11 中最重要的新特性之一,而 Lambda 表達式,實際上就是提供了一個類似匿名函數的特性,而匿名函數則是在需要一個函數,但是又不想費力去命名一個函數的情況下去使用的。這樣的場景其實有很多很多,所以匿名函數幾乎是現代編程語言的標配。

Lambda 表達式基礎

Lambda 表達式的基本語法如下:

[捕獲列表](參數列表) mutable(可選) 異常屬性 -> 返回類型 {
    // 函數體
}

上面的語法規則除了 [捕獲列表] 內的東西外,其他部分都很好理解,只是一般函數的函數名被略去,返回值使用了一個 ->的形式進行(我們在上一節前面的尾返回類型已經提到過這種寫法了)。

所謂捕獲列表,其實可以理解爲參數的一種類型,lambda 表達式內部函數體在默認情況下是不能夠使用函數體外部的變量的,這時候捕獲列表可以起到傳遞外部數據的作用。根據傳遞的行爲,捕獲列表也分爲以下幾種:

1. 值捕獲

與參數傳值類似,值捕獲的前期是變量可以拷貝,不同之處則在於,被捕獲的變量在 lambda 表達式被創建時拷貝,而非調用時才拷貝:

void learn_lambda_func_1() {
    int value_1 = 1;
    auto copy_value_1 = [value_1] {
        return value_1;
    };
    value_1 = 100;
    auto stored_value_1 = copy_value_1();
    // 這時, stored_value_1 == 1, 而 value_1 == 100.
    // 因爲 copy_value_1 在創建時就保存了一份 value_1 的拷貝
}

2. 引用捕獲

與引用傳參類似,引用捕獲保存的是引用,值會發生變化。

void learn_lambda_func_2() {
    int value_2 = 1;
    auto copy_value_2 = [&value_2] {
        return value_2;
    };
    value_2 = 100;
    auto stored_value_2 = copy_value_2();
    // 這時, stored_value_2 == 100, value_1 == 100.
    // 因爲 copy_value_2 保存的是引用
}

3. 隱式捕獲

手動書寫捕獲列表有時候是非常複雜的,這種機械性的工作可以交給編譯器來處理,這時候可以在捕獲列表中寫一個 & 或 =向編譯器聲明採用 引用捕獲或者值捕獲.

總結一下,捕獲提供了lambda 表達式對外部值進行使用的功能,捕獲列表的最常用的四種形式可以是:

  • [] 空捕獲列表
  • [name1, name2, ...] 捕獲一系列變量
  • [&] 引用捕獲, 讓編譯器自行推導捕獲列表
  • [=] 值捕獲, 讓編譯器執行推導應用列表

4. 表達式捕獲(C++14)

這部分內容需要了解後面馬上要提到的右值引用以及智能指針

上面提到的值捕獲、引用捕獲都是已經在外層作用域聲明的變量,因此這些捕獲方式捕獲的均爲左值,而不能捕獲右值。

C++14 給與了我們方便,允許捕獲的成員用任意的表達式進行初始化,這就允許了右值的捕獲,被聲明的捕獲變量類型會根據表達式進行判斷,判斷方式與使用 auto 本質上是相同的:

#include <iostream>
#include <utility>

int main() {
    auto important = std::make_unique<int>(1);
    auto add = [v1 = 1, v2 = std::move(important)](int x, int y) -> int {
        return x+y+v1+(*v2);
    };
    std::cout << add(3,4) << std::endl;
    return 0;
}

在上面的代碼中,important 是一個獨佔指針,是不能夠被捕獲到的,這時候我們需要將其轉移爲右值,在表達式中初始化。

泛型 Lambda (C++14)

上一節中我們提到了 auto 關鍵字不能夠用在參數表裏,這是因爲這樣的寫法會與模板的功能產生衝突。但是 Lambda 表達式並不是普通函數,所以 Lambda 表達式並不能夠模板化。這就爲我們造成了一定程度上的麻煩:參數表不能夠泛化,必須明確參數表類型。

幸運的是,這種麻煩只存在於 C++11 中,從 C++14 開始,Lambda 函數的形式參數可以使用 auto 關鍵字來產生意義上的泛型:

auto add = [](auto x, auto y) {
    return x+y;
};

add(1, 2);
add(1.1, 2.2);

二、函數對象包裝器

這部分內容雖然屬於標準庫的一部分,但是從本質上來看,它卻增強了 C++ 語言運行時的能力,這部分內容也相當重要,所以放到這裏來進行介紹。

std::function

Lambda 表達式的本質是一個函數對象,當 Lambda 表達式的捕獲列表爲空時,Lambda 表達式還能夠作爲一個函數指針進行傳遞,例如:

#include <iostream>

using foo = void(int);  // 定義函數指針, using 的使用見上一節中的別名語法
void functional(foo f) {
    f(1);
}

int main() {
    auto f = [](int value) {
        std::cout << value << std::endl;
    };
    functional(f);  // 函數指針調用
    f(1);           // lambda 表達式調用
    return 0;
}

上面的代碼給出了兩種不同的調用形式,一種是將 Lambda 作爲函數指針傳遞進行調用,而另一種則是直接調用 Lambda 表達式,在 C++11 中,統一了這些概念,將能夠被調用的對象的類型,統一稱之爲可調用類型。而這種類型,便是通過 std::function 引入的。

C++11 std::function 是一種通用、多態的函數封裝,它的實例可以對任何可以調用的目標實體進行存儲、複製和調用操作,它也是對 C++中現有的可調用實體的一種類型安全的包裹(相對來說,函數指針的調用不是類型安全的),換句話說,就是函數的容器。當我們有了函數的容器之後便能夠更加方便的將函數、函數指針作爲對象進行處理。例如:

#include <functional>
#include <iostream>

int foo(int para) {
    return para;
}

int main() {
    // std::function 包裝了一個返回值爲 int, 參數爲 int 的函數
    std::function<int(int)> func = foo;

    int important = 10;
    std::function<int(int)> func2 = [&](int value) -> int {
        return 1+value+important;
    };
    std::cout << func(10) << std::endl;
    std::cout << func2(10) << std::endl;
}

std::bind/std::placeholder

而 std::bind 則是用來綁定函數調用的參數的,它解決的需求是我們有時候可能並不一定能夠一次性獲得調用某個函數的全部參數,通過這個函數,我們可以將部分調用參數提前綁定到函數身上成爲一個新的對象,然後在參數齊全後,完成調用。例如:

int foo(int a, int b, int c) {
    ;
}
int main() {
    // 將參數1,2綁定到函數 foo 上,但是使用 std::placeholders::_1 來對第一個參數進行佔位
    auto bindFoo = std::bind(foo, std::placeholders::_1, 1,2);
    // 這時調用 bindFoo 時,只需要提供第一個參數即可
    bindFoo(1);
}

提示:注意 auto 關鍵字的妙用。有時候我們可能不太熟悉一個函數的返回值類型,但是我們卻可以通過 auto 的使用來規避這一問題的出現。

三、右值引用

右值引用是 C++11 引入的與 Lambda 表達式齊名的重要特性之一。它的引入解決了 C++ 中大量的歷史遺留問題,消除了諸如 std::vectorstd::string 之類的額外開銷,也才使得函數對象容器 std::function 成爲了可能。

左值、右值的純右值、將亡值、右值

要弄明白右值引用到底是怎麼一回事,必須要對左值和右值做一個明確的理解。

左值(lvalue, left value),顧名思義就是賦值符號左邊的值。準確來說,左值是表達式(不一定是賦值表達式)後依然存在的持久對象。

右值(rvalue, right value),右邊的值,是指表達式結束後就不再存在的臨時對象。

而 C++11 中爲了引入強大的右值引用,將右值的概念進行了進一步的劃分,分爲:純右值、將亡值。

純右值(prvalue, pure rvalue),純粹的右值,要麼是純粹的字面量,例如 10true;要麼是求值結果相當於字面量或匿名臨時對象,例如 1+2。非引用返回的臨時變量、運算表達式產生的臨時變量、原始字面量、Lambda 表達式都屬於純右值。

將亡值(xvalue, expiring value),是 C++11 爲了引入右值引用而提出的概念(因此在傳統 C++中,純右值和右值是統一個概念),也就是即將被銷燬、卻能夠被移動的值。

將亡值可能稍有些難以理解,我們來看這樣的代碼:

std::vector<int> foo() {
    std::vector<int> temp = {1, 2, 3, 4};
    return temp;
}

std::vector<int> v = foo();

在這樣的代碼中,函數 foo 的返回值 temp 在內部創建然後被賦值給 v,然而 v 獲得這個對象時,會將整個 temp 拷貝一份,然後把 temp 銷燬,如果這個 temp 非常大,這將造成大量額外的開銷(這也就是傳統 C++ 一直被詬病的問題)。在最後一行中,v 是左值、foo() 返回的值就是右值(也是純右值)。

但是,v 可以被別的變量捕獲到,而 foo() 產生的那個返回值作爲一個臨時值,一旦被 v 複製後,將立即被銷燬,無法獲取、也不能修改。

將亡值就定義了這樣一種行爲:臨時的值能夠被識別、同時又能夠被移動。

右值引用和左值引用

需要拿到一個將亡值,就需要用到右值引用的申明:T &&,其中 T 是類型。右值引用的聲明讓這個臨時值的生命週期得以延長、只要變量還活着,那麼將亡值將繼續存活。

C++11 提供了 std::move 這個方法將左值參數無條件的轉換爲右值,有了它我們就能夠方便的獲得一個右值臨時對象,例如:

#include <iostream>
#include <string>

void reference(std::string& str) {
    std::cout << "左值" << std::endl;
}
void reference(std::string&& str) {
    std::cout << "右值" << std::endl;
}

int main()
{
    std::string  lv1 = "string,";       // lv1 是一個左值
    // std::string&& r1 = s1;           // 非法, 右值引用不能引用左值
    std::string&& rv1 = std::move(lv1); // 合法, std::move可以將左值轉移爲右值
    std::cout << rv1 << std::endl;      // string,

    const std::string& lv2 = lv1 + lv1; // 合法, 常量左值引用能夠延長臨時變量的申明週期
    // lv2 += "Test";                   // 非法, 引用的右值無法被修改
    std::cout << lv2 << std::endl;      // string,string

    std::string&& rv2 = lv1 + lv2;      // 合法, 右值引用延長臨時對象聲明週期
    rv2 += "Test";                      // 合法, 非常量引用能夠修改臨時變量
    std::cout << rv2 << std::endl;      // string,string,string,

    reference(rv2);                     // 輸出左值

    return 0;
}

注意rv2 雖然引用了一個右值,但由於它是一個引用,所以 rv2 依然是一個左值。

移動語義

傳統 C++ 通過拷貝構造函數和賦值操作符爲類對象設計了拷貝/複製的概念,但爲了實現對資源的移動操作,調用者必須使用先複製、再析構的方式,否則就需要自己實現移動對象的接口。試想,搬家的時候是把家裏的東西直接搬到新家去,而不是將所有東西複製一份(重買)再放到新家、再把原來的東西全部銷燬,這是非常反人類的一件事情。

傳統的 C++ 沒有區分『移動』和『拷貝』的概念,造成了大量的數據移動,浪費時間和空間。右值引用的出現恰好就解決了這兩個概念的混淆問題,例如:

#include <iostream>
class A {
public:
    int *pointer;
    A():pointer(new int(1)) { std::cout << "構造" << pointer << std::endl; }
    A(A& a):pointer(new int(*a.pointer)) { std::cout << "拷貝" << pointer << std::endl; }    // 無意義的對象拷貝
    A(A&& a):pointer(a.pointer) { a.pointer = nullptr;std::cout << "移動" << pointer << std::endl; }
    ~A(){ std::cout << "析構" << pointer << std::endl; delete pointer; }
};
// 防止編譯器優化
A return_rvalue(bool test) {
    A a,b;
    if(test) return a;
    else return b;
}
int main() {
    A obj = return_rvalue(false);
    std::cout << "obj:" << std::endl;
    std::cout << obj.pointer << std::endl;
    std::cout << *obj.pointer << std::endl;

    return 0;
}

在上面的代碼中:

  1. 首先會在 return_rvalue 內部構造兩個 A 對象,於是獲得兩個構造函數的輸出;
  2. 函數返回後,產生一個將亡值,被 A 的移動構造(A(A&&))引用,從而延長生命週期,並將這個右值中的指針拿到,保存到了 obj 中,而將亡值的指針被設置爲 nullptr,防止了這塊內存區域被銷燬。

從而避免了無意義的拷貝構造,加強了性能。再來看看涉及標準庫的例子:

#include <iostream> // std::cout
#include <utility>  // std::move
#include <vector>   // std::vector
#include <string>   // std::string

int main() {

    std::string str = "Hello world.";
    std::vector<std::string> v;

    // 將使用 push_back(const T&), 即產生拷貝行爲
    v.push_back(str);
    // 將輸出 "str: Hello world."
    std::cout << "str: " << str << std::endl;

    // 將使用 push_back(const T&&), 不會出現拷貝行爲
    // 而整個字符串會被移動到 vector 中,所以有時候 std::move 會用來減少拷貝出現的開銷
    // 這步操作後, str 中的值會變爲空
    v.push_back(std::move(str));
    // 將輸出 "str: "
    std::cout << "str: " << str << std::endl;

    return 0;
}

完美轉發

前面我們提到了,一個聲明的右值引用其實是一個左值。這就爲我們進行參數轉發(傳遞)造成了問題:

void reference(int& v) {
    std::cout << "左值" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通傳參:";
    reference(v);   // 始終調用 reference(int& )
}
int main() {
    std::cout << "傳遞右值:" << std::endl;
    pass(1);        // 1是右值, 但輸出左值

    std::cout << "傳遞左值:" << std::endl;    
    int v = 1;
    pass(v);        // v是左引用, 輸出左值

    return 0;
}

對於 pass(1) 來說,雖然傳遞的是右值,但由於 v 是一個引用,所以同時也是左值。因此 reference(v) 會調用 reference(int&),輸出『左值』。而對於pass(v)而言,v是一個左值,爲什麼會成功傳遞給 pass(T&&) 呢?

這是基於引用坍縮規則的:在傳統 C++ 中,我們不能夠對一個引用類型繼續進行引用,但 C++ 由於右值引用的出現而放寬了這一做法,從而產生了引用坍縮規則,允許我們對引用進行引用,既能左引用,又能右引用。但是卻遵循如下規則:

函數形參類型 實參參數類型 推導後函數形參類型
T& 左引用 T&
T& 右引用 T&
T&& 左引用 T&
T&& 右引用 T&&

因此,模板函數中使用 T&& 不一定能進行右值引用,當傳入左值時,此函數的引用將被推導爲左值。更準確的講,無論模板參數是什麼類型的引用,當且僅當實參類型爲右引用時,模板參數才能被推導爲右引用類型。這才使得 v 作爲左值的成功傳遞。

完美轉發就是基於上述規律產生的。所謂完美轉發,就是爲了讓我們在傳遞參數的時候,保持原來的參數類型(左引用保持左引用,右引用保持右引用)。爲了解決這個問題,我們應該使用 std::forward 來進行參數的轉發(傳遞):

#include <iostream>
#include <utility>
void reference(int& v) {
    std::cout << "左值引用" << std::endl;
}
void reference(int&& v) {
    std::cout << "右值引用" << std::endl;
}
template <typename T>
void pass(T&& v) {
    std::cout << "普通傳參:";
    reference(v);
    std::cout << "std::move 傳參:";
    reference(std::move(v));
    std::cout << "std::forward 傳參:";
    reference(std::forward<T>(v));

}
int main() {
    std::cout << "傳遞右值:" << std::endl;
    pass(1);

    std::cout << "傳遞左值:" << std::endl;
    int v = 1;
    pass(v);

    return 0;
}

輸出結果爲:

傳遞右值:
普通傳參:左值引用
std::move 傳參:右值引用
std::forward 傳參:右值引用
傳遞左值:
普通傳參:左值引用
std::move 傳參:右值引用
std::forward 傳參:左值引用

無論傳遞參數爲左值還是右值,普通傳參都會將參數作爲左值進行轉發,所以 std::move 總會接受到一個左值,從而轉發調用了reference(int&&) 輸出右值引用。

唯獨 std::forward 即沒有造成任何多餘的拷貝,同時完美轉發(傳遞)了函數的實參給了內部調用的其他函數。

std::forward 和 std::move 一樣,沒有做任何事情,std::move 單純的將左值轉化爲右值,std::forward 也只是單純的將參數做了一個類型的轉換,從是實現來看,std::forward<T>(v) 和 static_cast<T&&>(v) 是完全一樣的。

總結

本節介紹了 C++11/14 中對語言可用性的增強,這些特性都非常重要且有用,所有特性都是值得掌握的:

  1. Lambda 表達式
  2. 函數對象容器 std::function
  3. 右值引用
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章