《C++ Primer Plus 6th.ed》讀書筆記之五:淺談Lambda表達式和函數對象

Preface

所謂函數對象,是指重載了operator()的類型,在行爲上這種類型可以當做函數調用,如下例所示:

class Base
{
public:
	// 重載的括號運算符
	void operator()(void) { std::cout << "This is a Base class!" << std::endl; }
};

int main()
{
	// 調用括號運算符
	Base x;
	x();
}

但是這種調用必須先實例化一個類,而Lambda表達式則免去了定義和實例化的繁瑣:

int main()
{
	// 定義一個Lambda
	auto f = []() { std::cout << "I'm a Lambda!" << std::endl; };
	// 調用它
	f();
}

注意上面這段代碼,首先f只是Lambda表達式的別名,它並不是表達式本體;觀察定義式賦值的右邊,[]取代了函數名,使之成爲了一個不具名函數,()是形參列表,這裏使用了一個空的形參列表,最後{}中是函數體,決定了該不具名函數的具體實現和功能——這就是一個完整的Lambda表達式,或者說叫做匿名函數
在定義Lambda表達式的別名時,一定注意使用auto關鍵字做自動類型推導,因爲在定義表達式的作用域中,每個表達式的類型都是獨一無二的,也無法使用基本類型進行描述
當然,從本質上說,Lambda表達式就是一個不具名的函數對象,因此Lambda表達式可以作爲函數參數傳入——傳入一個函數作爲參數,在C語言中也有這樣的語法,雖然只能傳遞函數指針
下面筆者將從C語言的函數指針開始,詳細描述一下函數指針、函數對象和Lambda的特性和使用

從C語言的函數指針說開去……

對大部分人來說,C語言的指針簡直就是一個噩夢,即便是熟悉指針語法的開發者,偶爾也會因爲野指針的問題而規避指針的使用——在這樣的大環境下,函數指針就無可避免的成爲了C語言中一個較爲小衆的語法……
我們已經知道在C語言中,各種變量(包括由register關鍵字修飾的)都有自己的地址——譬如,一個指針指向某一個地址,它自己也被存儲在一個地址中——函數在編譯後產生的二進制指令在執行時必然要放在內存中某處,那麼是否可以用一個指針指向此處?答案很明確,可以
可以說,在C語言中,任何可調用對象,包括變量和函數,都擁有自己的地址,即便變量被register關鍵字修飾——該關鍵字只是建議編譯器將變量放入寄存器中,實際上該變量仍然有可能被存放在內存中,只是無法取地址而已——只要有地址,即可表示爲指針……下面是一個簡單的例子:

int func(int x, int y) { return x+y; }	// 函數定義
int (*f)(int, int) = func;				// 函數指針定義
int z = f(1, 2);						// 調用函數指針,z=3

注意在聲明函數指針時,必須使用()*和指針名結合在一起,利用()的優先級聲明這個變量是指向某處的指針而非返回指針的函數;其次函數指針的類型就是要指向的函數的返回類型;除此之外,函數指針必須擁有和要指向的函數一致的形參列表
函數指針可以在聲明時完成初始化,如上例;也可以先聲明,在合適的時機再完成初始化,初始化的時候在賦值運算符兩側均可省略返回類型和形參列表
到這裏可能有讀者要問了,這麼搞豈不是多此一舉?當然不是,考慮這麼一個場景:設計一個對數組進行排序的函數,要求可以實現升序/降序兩種排序方式
對這個需求進行分析,排序無非是由比較和交換兩種操作組合成的——顯然,升序還是降序這個問題肯定是由比較操作說了算——但是升序和降序是互斥的,C語言沒有函數重載特性,不可能說一個函數名實現兩種比較方式,這個時候,某位開發者靈機一動,爲什麼不讓用戶決定排序的方式呢?允許用戶傳入一個比較函數作爲參數,似乎是一種更省力的方式……
那麼基於這種想法,可以給出這個排序函數一種可能的聲明(只針對整形):

void qsort(
			int* base, 
			size_t _sizeofElements, 
			bool (*cmp)(const void* a, const void* b)	// 函數指針做形參
);

顯然這個函數有三個參數,第一參數是指定開始排序的地址,第二參數是指定參與排序的元素的數目,第三參數就是由用戶決定的比較函數的指針,這樣傳入不同的比較函數,那麼排序的結果也不相同
實際上,C語言標準庫中的qsort()函數就是使用類似的方法聲明和實現的,截止到C11標準,想在函數中應用某種“規則”,依然只能依賴函數指針的方式
不過在介紹向函數中傳入函數對象或者Lambda之前,先要介紹一下Lambda中的捕獲(Capture)

Lambda表達式的具體語法

在開篇中已經介紹過Lambda的語法,這裏對一些特殊語法進行補充說明

空的形參列表

如果Lambda函數具有一個空的形參列表,那麼這個形參列表可以省略:

auto f = [] { std::cout << "Lambda without ()!" << std::endl; };
返回類型

如果定義的Lambda函數擁有返回類型,返回類型需要附在形參列表後,以如下的方式聲明返回類型:

auto f = [](const int&x , const int& y) -> bool { return x > y; };	// 定義一個比較兩數大小的函數
bool _flag = f(2, 3);	// false

如果返回類型是基本類型,則可以省略

捕獲

Lambda表達式最重要的功能就是捕獲了,通過捕獲Lambda可以訪問同作用域裏其他的局部變量或者全局變量,與函數傳參類似,默認的捕獲方式也分爲兩種:

  1. 複製捕獲
    []添加=,即可構成複製捕獲,如同按值傳遞一樣,在Lambda表達式內部將訪問一個捕獲變量的副本,對這個副本的任何操作都不會影響到原本的變量(在沒有mutable說明符的情況下,也不允許修改捕獲的變量)
    int x = 1;
    auto f = [=] { std::cout << x << std::endl; }	// 打印捕獲到的變量x
    
  2. 引用捕獲
    引用捕獲類似於複製捕獲,將=替換爲&即可,如同按引用傳遞一樣,在Lambda內部可以任意修改被捕獲的變量,且修改會影響到變量本身的值:
    int x = 1;				// x = 1
    [&]() { x = 2; }();		// x = 2
    

如上,兩種默認捕獲方式可以捕獲同一作用域中所有的變量,也可以在[]中聲明要捕獲的變量以及捕獲方式,寫成捕獲列表的形式:

// 0. 僅按值捕獲變量x
[x] {};
// 1. 僅按引用捕獲變量x
[&x] {};
// 2. 按值捕獲變量x,對其他變量按引用捕獲
[&, x] {};
// 3. 按引用捕獲變量x,按值捕獲其他變量
[=, &x] {};

當然捕獲列表可以寫很長,但是無一例外都存在着三條規則:

  • 當默認捕獲符爲=時,後續對特定變量的捕獲必須帶有&
  • 當默認捕獲符爲&時,後續對特定變量的捕獲不能出現&;
  • 一個捕獲列表中不能出現相同的捕獲符,同一個變量名也不能出現兩次
說明符

在形參列表和返回值中間,可以添加說明符,在C++11中只有一個可用的說明符,mutable——該說明符意味着允許修改使用複製捕獲獲取的變量,事實上對於完全按引用捕獲的Lambda表達式,這個說明符是沒有意義的:

// 假設作用域中存在變量x = 0
auto f1 = [=] { x = 1; };			// Error: 不能通過編譯
auto f2 = [=] mutable { x = 1; };	// Correct: x = 0
auto f3 = [&] { x = 2; };			// Correct: x = 2
auto f4 = [&] mutable { x = 0; };	// Correct: x = 0

重載operator()

相對於其他可重載運算符的參數類型和個數都有限制,operator()可謂是非常自由——返回類型不固定,接收參數的類型不固定,接收參數的個數也不確定——當然這些在重載運算符時都要確定下來,對該運算符重載的唯一限制就是必須以成員函數的形式重載它

向函數中傳入函數對象或者Lambda

這裏通過一個簡單的例子展示一下函數對象和Lambda的使用
使用隨機數函數產生一個隨機序列,並統計其中可以被3和13整除的數字的個數,使用STL產生這個序列的代碼如下:

#include <vector>
#include <algorithm>
#include <cmath>
#include <iostream>

...;

std::vector<int> nums(1000);
std::generate(nums.begin(), nums.end(), std::rand);

使用count_if函數進行計數,該函數需要傳入一個規則或者條件,在滿足該條件時計數加一,首先嚐試使用函數對象表示此規則:

class f_mod
{
private: 
	int dv;		// 除數
public:
	f_mod(int d = 1) : dv(d) {}
	bool operator()(int x) { return x % dv == 0; }
}

int count1 = count_if(nums.begin(), nums.end(), f_mod(3));
int count2 = count_if(nums.begin(), nums.end(), f_mod(13));

可以看到f_mod類重載了小括號運算符,接受一個整形參數,返回一個布爾類型,表示該參數是否可以被dv整除
下面是使用不帶捕獲的Lambda表達式定義規則的情形:

auto f1 = [](int x) -> bool { return x % 3 == 0; };
auto f2 = [](int x) -> bool { return x % 13 == 0;};

int count1 = count_if(nums.begin(), nums.end(), f1);
int count2 = count_if(nums.begin(), nums.end(), f2);

如果使用帶捕獲的Lambda表達式,我們甚至可以拋棄count_if函數:

int count1 = 0, count2 = 0;
auto f = [&](int x) { count1 += (x % 3 == 0); count2 += (x % 13 == 0); }

std::for_each(nums.begin(), nums.end(), f);

這裏的邏輯就更爲簡單了,捕獲count1count2,當x可以被3整除時,表達式x % 3 == 0的值爲true,該值和整形相加時自動被轉換爲整數1,就實現了當x可被3整除時,count1加一的效果——這樣在遍歷完整個數組的同時,要統計的數據也就完整的得到了

結語

函數指針、函數對象、Lambda表達式都起到了傳遞一個“函數謂詞”的作用,其實可以認爲Lambda表達式是前二者的語法糖,但是即便如此,爲了程序的可讀性,應該少用Lambda表達式,除非是像文中這樣表達某種“規則”的時候
雖然這只是C++中一個很小的特性,但是用在正確的地方,也許就有事半功倍的效果呢


下一篇該系列的文章也許會談談C++中深拷貝與淺拷貝問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章