1. 程式人生 > >C++11 lambda表示式與函式物件

C++11 lambda表示式與函式物件

C++ lambda表示式與函式物件

lambda表示式是C++11中引入的一項新技術,利用lambda表示式可以編寫內嵌的匿名函式,用以替換獨立函式或者函式物件,並且使程式碼更可讀。但是從本質上來講,lambda表示式只是一種語法糖,因為所有其能完成的工作都可以用其它稍微複雜的程式碼來實現。但是它簡便的語法卻給C++帶來了深遠的影響。如果從廣義上說,lamdba表示式產生的是函式物件。在類中,可以過載函式呼叫運算子(),此時類的物件可以將具有類似函式的行為,我們稱這些物件為函式物件(Function Object)或者仿函式(Functor)。相比lambda表示式,函式物件有自己獨特的優勢。下面我們開始具體講解這兩項黑科技。

lambda表示式

我們先從簡答的例子開始,我們定義一個可以輸出字串的lambda表示式,表示式一般都是從方括號[]開始,然後結束於花括號{},花括號裡面就像定義函式那樣,包含了lamdba表示式體:

// 定義簡單的lambda表示式
auto basicLambda = [] { cout << "Hello, world!" << endl; };
// 呼叫
basicLambda();   // 輸出:Hello, world!

上面是最簡單的lambda表示式,沒有引數。如果需要引數,那麼就要像函式那樣,放在圓括號裡面,如果有返回值,返回型別要放在->後面,即拖尾返回型別,當然你也可以忽略返回型別,lambda

會幫你自動推斷出返回型別:

// 指明返回型別
auto add = [](int a, int b) -> int { return a + b; };
// 自動推斷返回型別
auto multiply = [](int a, int b) { return a * b; };

int sum = add(2, 5);   // 輸出:7
int product = multiply(2, 5);  // 輸出:10

大家可能會想lambda表示式最前面的方括號的意義何在?其實這是lambda表示式一個很要的功能,就是閉包。這裡我們先講一下lambda表示式的大致原理:每當你定義一個lambda表示式後,編譯器會自動生成一個匿名類(這個類當然過載了()

運算子),我們稱為閉包型別(closure type)。那麼在執行時,這個lambda表示式就會返回一個匿名的閉包例項,其實一個右值。所以,我們上面的lambda表示式的結果就是一個個閉包。閉包的一個強大之處是其可以通過傳值或者引用的方式捕捉其封裝作用域內的變數,前面的方括號就是用來定義捕捉模式以及變數,我們又將其稱為lambda捕捉塊。看下面的例子:

int main()
{
    int x = 10;

    auto add_x = [x](int a) { return a + x; };  // 複製捕捉x
    auto multiply_x = [&x](int a) { return a * x; };  // 引用捕捉x

    cout << add_x(10) << " " << multiply_x(10) << endl;
    // 輸出:20 100
    return 0;
}

lambda捕捉塊為空時,表示沒有捕捉任何變數。但是上面的add_x是以複製的形式捕捉變數x,而multiply是以引用的方式捕捉x。前面講過,lambda表示式是產生一個閉包類,那麼捕捉是回事?對於複製傳值捕捉方式,類中會相應新增對應型別的非靜態資料成員。在執行時,會用複製的值初始化這些成員變數,從而生成閉包。前面說過,閉包類也實現了函式呼叫運算子的過載,一般情況是:

class ClosureType
{
public:
    // ...
    ReturnType operator(params) const { body };
}

這意味著lambda表示式無法修改通過複製形式捕捉的變數,因為函式呼叫運算子的過載方法是const屬性的。有時候,你想改動傳值方式捕獲的值,那麼就要使用mutable,例子如下:

int main()
{
    int x = 10;

    auto add_x = [x](int a) mutable { x *= 2; return a + x; };  // 複製捕捉x

    cout << add_x(10) << endl; // 輸出 30
    return 0;
}

這是為什麼呢?因為你一旦將lambda表示式標記為mutable,那麼實現的了函式呼叫運算子是非const屬性的:

class ClosureType
{
public:
    // ...
    ReturnType operator(params) { body };
}

對於引用捕獲方式,無論是否標記mutable,都可以在lambda表示式中修改捕獲的值。至於閉包類中是否有對應成員,C++標準中給出的答案是:不清楚的,看來與具體實現有關。既然說到了深處,還有一點要注意:lambda表示式是不能被賦值的:

auto a = [] { cout << "A" << endl; };
auto b = [] { cout << "B" << endl; };

a = b;   // 非法,lambda無法賦值
auto c = a;   // 合法,生成一個副本

你可能會想ab對應的函式型別是一致的(編譯器也顯示是相同型別:lambda [] void () -> void),為什麼不能相互賦值呢?因為禁用了賦值操作符:

ClosureType& operator=(const ClosureType&) = delete;

但是沒有禁用複製建構函式,所以你仍然可以用一個lambda表示式去初始化另外一個lambda表示式而產生副本。並且lambda表示式也可以賦值給相對應的函式指標,這也使得你完全可以把lambda表示式看成對應函式型別的指標。

閒話少說,歸入正題,捕獲的方式可以是引用也可以是複製,但是具體說來會有以下幾種情況來捕獲其所在作用域中的變數:

  • []:預設不捕獲任何變數;
  • [=]:預設以值捕獲所有變數;
  • [&]:預設以引用捕獲所有變數;
  • [x]:僅以值捕獲x,其它變數不捕獲;
  • [&x]:僅以引用捕獲x,其它變數不捕獲;
  • [=, &x]:預設以值捕獲所有變數,但是x是例外,通過引用捕獲;
  • [&, x]:預設以引用捕獲所有變數,但是x是例外,通過值捕獲;
  • [this]:通過引用捕獲當前物件(其實是複製指標);
  • [*this]:通過傳值方式捕獲當前物件;

在上面的捕獲方式中,注意最好不要使用[=][&]預設捕獲所有變數。首先說預設引用捕獲所有變數,你有很大可能會出現懸掛引用(Dangling references),因為引用捕獲不會延長引用的變數的宣告週期:

std::function<int(int)> add_x(int x)
{
    return [&](int a) { return x + a; };
}

因為引數x僅是一個臨時變數,函式呼叫後就被銷燬,但是返回的lambda表示式卻引用了該變數,但呼叫這個表示式時,引用的是一個垃圾值,所以會產生沒有意義的結果。你可能會想,可以通過傳值的方式來解決上面的問題:

std::function<int(int)> add_x(int x)
{
    return [=](int a) { return x + a; };
}

是的,使用預設傳值方式可以避免懸掛引用問題。但是採用預設值捕獲所有變數仍然有風險,看下面的例子:

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的一份副本,但是實際上大錯特錯。問題出現在哪裡呢?因為資料成員divisorlambda表示式並不可見,你可以用下面的程式碼驗證:

// 類的方法,下面無法編譯,因為divisor並不在lambda捕捉的範圍
std::function<bool(int)> getFilter() 
{
    return [divisor](int value) {return value % divisor == 0; };
}

那麼原來的程式碼為什麼能夠捕捉到呢?仔細想想,原來每個非靜態方法都有一個this指標變數,利用this指標,你可以接近任何成員變數,所以lambda表示式實際上捕捉的是this指標的副本,所以原來的程式碼等價於:

std::function<bool(int)> getFilter() 
{
    return [this](int value) {return value % this->divisor == 0; };
}

儘管還是以值方式捕獲,但是捕獲的是指標,其實相當於以引用的方式捕獲了當前類物件,所以lambda表示式的閉包與一個類物件繫結在一起了,這也很危險,因為你仍然有可能在類物件析構後使用這個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}

此外,lambda表示式還用於物件的排序準則:

class Person
{
public:
    Person(const string& first, const string& last):
        firstName{first}, lastName{last}
    {}

    Person() = default;

    string first() const { return firstName; }
    string last() const { return lastName; }
private:
    string firstName;
    string lastName;
};

int main()
{
    vector<Person> vp;
    // ... 新增Person資訊

    // 按照姓名排序
    std::sort(vp.begin(), vp.end(), [](const Person& p1, const Person& p2)
    { return p1.last() < p2.last() || (p1.last() == p2.last() && p1.first() < p2.first()); });
        // ...
    return 0;
}

總之,對於大部分STL演算法,可以非常靈活地搭配lambda表示式來實現想要的效果。

前面講完了lambda表示式的基本使用,最後給出lambda表示式的完整語法:

// 完整語法
[ capture-list ] ( params ) mutable(optional) constexpr(optional)(c++17) exception attribute -> ret { body } 

// 可選的簡化語法
[ capture-list ] ( params ) -> ret { body }     
[ capture-list ] ( params ) { body }     
[ capture-list ] { body }

第一個是完整的語法,後面3個是可選的語法。這意味著lambda表示式相當靈活,但是照樣有一定的限制,比如你使用了拖尾返回型別,那麼就不能省略引數列表,儘管其可能是空的。針對完整的語法,我們對各個部分做一個說明:

  • capture-list:捕捉列表,這個不用多說,前面已經講過,記住它不能省略;
  • params:引數列表,可以省略(但是後面必須緊跟函式體);
  • mutable:可選,將lambda表示式標記為mutable後,函式體就可以修改傳值方式捕獲的變數;
  • constexpr:可選,C++17,可以指定lambda表示式是一個常量函式;
  • exception:可選,指定lambda表示式可以丟擲的異常;
  • attribute:可選,指定lambda表示式的特性;
  • ret:可選,返回值型別;
  • body:函式執行體。

lambda新特性

C++14中,lambda又得到了增強,一個是泛型lambda表示式,一個是lambda可以捕捉表示式。這裡我們對這兩項新特點進行簡單介紹。

lambda捕捉表示式

前面講過,lambda表示式可以按複製或者引用捕獲在其作用域範圍內的變數。而有時候,我們希望捕捉不在其作用域範圍內的變數,而且最重要的是我們希望捕捉右值。所以C++14中引入了表示式捕捉,其允許用任何型別的表示式初始化捕捉的變數。看下面的例子:

// 利用表示式捕獲,可以更靈活地處理作用域內的變數
int x = 4;
auto y = [&r = x, x = x + 1] { r += 2; return x * x; }();
// 此時 x 更新為6,y 為25

// 直接用字面值初始化變數
auto z = [str = "string"]{ return str; }();
// 此時z是const char* 型別,儲存字串 string

可以看到捕捉表示式擴大了lambda表示式的捕捉能力,有時候你可以用std::move初始化變數。這對不能複製只能移動的物件很重要,比如std::unique_ptr,因為其不支援複製操作,你無法以值方式捕捉到它。但是利用lambda捕捉表示式,可以通過移動來捕捉它:

auto myPi = std::make_unique<double>(3.1415);

auto circle_area = [pi = std::move(myPi)](double r) { return *pi * r * r; };
cout << circle_area(1.0) << endl; // 3.1415

其實用表示式初始化捕捉變數,與使用auto宣告一個變數的機理是類似的。

泛型lambda表示式

C++14開始,lambda表示式支援泛型:其引數可以使用自動推斷型別的功能,而不需要顯示地宣告具體型別。這就如同函式模板一樣,引數要使用型別自動推斷功能,只需要將其型別指定為auto,型別推斷規則與函式模板一樣。這裡給出一個簡單例子:

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

int x = add(2, 3);   // 5
double y = add(2.5, 3.5);  // 6.0

函式物件

函式物件是一個廣泛的概念,因為所有具有函式行為的物件都可以稱為函式物件。這是一個高階抽象,我們不關心物件到底是什麼,只要其具有函式行為。所謂的函式行為是指的是可以使用()呼叫並傳遞引數:

function(arg1, arg2, ...);   // 函式呼叫

這樣來說,lambda表示式也是一個函式物件。但是這裡我們所講的是一種特殊的函式物件,這種函式物件實際上是一個類的例項,只不過這個類實現了函式呼叫符()

class X
{
public:
    // 定義函式呼叫符
    ReturnType operator()(params) const;

    // ...
};

這樣,我們可以使用這個類的物件,並把它當做函式來使用:

X f;
// ...
f(arg1, arg2); // 等價於 f.operator()(arg1, arg2);

還是例子說話,下面我們定義一個列印一個整數的函式物件:

// T需要支援輸出流運算子
template <typename T>
class Print
{
public:
    void operator()(T elem) const
    {
        cout << elem << ' ' ;
    }
};


int main()
{
    vector<int> v(10);
    int init = 0;
    std::generate(v.begin(), v.end(), [&init] { return init++; });

    // 使用for_each輸出各個元素(送入一個Print例項)
    std::for_each(v.begin(), v.end(), Print<int>{});
    // 利用lambda表示式:std::for_each(v.begin(), v.end(), [](int x){ cout << x << ' ';});
    // 輸出:0, 1, 2, 3, 4, 5, 6, 7, 8, 9
    return 0;
}

可以看到Print<int>的例項可以傳入std::for_each,其表現可以像函式一樣,因此我們稱這個例項為函式物件。大家可能會想,for_each為什麼可以既接收lambda表示式,也可以接收函式物件,其實STL演算法是泛型實現的,其不關心接收的物件到底是什麼型別,但是必須要支援函式呼叫運算:

// for_each的類似實現
namespace std
{
    template <typename Iterator, typename Operation>
    Operation for_each(Iterator act, Iterator end, Operation op)
    {
        while (act != end)
        {
            op(*act);
            ++act;
        }
        return op;
    }
}

泛型提供了高階抽象,不論是lambda表示式、函式物件,還是函式指標,都可以傳入for_each演算法中。

本質上,函式物件是類物件,這也使得函式物件相比普通函式有自己的獨特優勢:

  • 函式物件帶有狀態:函式物件相對於普通函式是“智慧函式”,這就如同智慧指標相較於傳統指標。因為函式物件除了提供函式呼叫符方法,還可以擁有其他方法和資料成員。所以函式物件有狀態。即使同一個類例項化的不同的函式物件其狀態也不相同,這是普通函式所無法做到的。而且函式物件是可以在執行時建立。
  • 每個函式物件有自己的型別:對於普通函式來說,只要簽名一致,其型別就是相同的。但是這並不適用於函式物件,因為函式物件的型別是其類的型別。這樣,函式物件有自己的型別,這意味著函式物件可以用於模板引數,這對泛型程式設計有很大提升。
  • 函式物件一般快於普通函式:因為函式物件一般用於模板引數,模板一般會在編譯時會做一些優化。

這裡我們看一個可以擁有狀態的函式物件,其用於生成連續序列:

class IntSequence
{
public:
    IntSequence(int initVal) : value{ initVal } {}

    int operator()() { return ++value; }
private:
    int value;
};


int main()
{
    vector<int> v(10);
    std::generate(v.begin(), v.end(), IntSequence{ 0 });
    /*  lambda實現同樣效果
        int init = 0;
        std::generate(v.begin(), v.end(), [&init] { return ++init; });
    */
    std::for_each(v.begin(), v.end(), [](int x) { cout << x << ' '; });
    //輸出:1, 2, 3, 4, 5, 6, 7, 8, 9, 10

    return 0;
}

可以看到,函式物件可以擁有一個私有資料成