現代潮語言愛談一級函式,然而,對於C++來說,傳遞函式並不是什麼新鮮事,端視我們傳遞函式的目的是為了什麼,因此,函式指標、函子(Functor)、lambda運算式,都可以是選擇之一。

函式指標與函子

關於函式可以傳遞的概念,已存在不少現代語言之中,然而,應該是JavaScript令許多開發者都知道了這件事。

記得過去有幾次看過C/C++開發者,以略帶不屑的口氣說到:「啊!這不就是函式指標?!」單就這句話來看,可以說對,也可以說不對。

說對的原因是:C++中若以函式名稱作為指定來源時,我們可以求得位址設置給函式指標,透過函式指標也可以呼叫函式,因此,傳遞函式不是什麼新鮮事。

就算是類別函式成員,也都可以傳遞,只不過函數指標的型態,往往難以閱讀或撰寫,開發者經常要停下來搜尋查閱語法,才能寫得正確。幸而到了C++ 11以後,我們可以使用auto、decltype,或者是function模版來減輕這類負擔。

而說不對的原因是:只談函式指標,不足以涵蓋JavaScript這類語言中談到的一級函式功能。

例如,外部變數的捕捉,想達到這類功能,我們可以定義函子,或稱為函式物件(function object),開發者能自定類別定義呼叫運算子「()」,實作內容為任務的函式流程,至於想捕捉的變數,我們可以在建構函子實例時指定,而當下要捕捉變數本身或是值,就看建構式接受的參數是採傳參,或是傳值。

從技術上來說,函子不等於C++函式,然而對C++來說,「函式」的概念有進一步的擴充。從比較簡化的說法來說,可以透過()調用並傳送引數的對象,都可視為Callable的實例,C++函式是其中之一,函子也是。

函子實例就是個自定類別的實例,既然如此,就有了附加的價值,可以令函式帶有狀態,也可以攜帶方法。就像JavaScript中的Function實例,可以取得參數長度,也可以有call、apply等方法。

lambda運算式

如果要臨時建立一個函式呢?我們是可以臨時定義一個匿名的類別並用來建立實例,然而可讀性不佳。不過,C++ 11以後提供了lambda運算式,大幅增加了可讀性,想想看,你會想寫auto ascending = [](int n1, int n2) { return n2 - n1; },還是如下使用函子?

struct {
int operator() (int n1, int n2) {
return n2 - n1;
}
} ascending;

就技術而言,lambda運算式並不等於C++函式,很大程度是函子的語法糖,它會建立匿名類別(稱為closure type)並用來建構實例,因為無法取得匿名類別的名稱,也就無法宣告其型態,而必須透過auto來自動推斷,或者透過function模版來宣告參數與傳回型態。

如果lambda運算式被指定給函式指標,那麼,lambda運算式會退化為函式位址,因此,既有的函式若參數定義為函式指標型態,也可以接受lambda運算式。

若與其他語言中的一級函式相比,lambda運算式必須帶有捕捉列[],而顯得與眾不同。空白的[],表示lambda不得捕捉任何外部變數,然而可以在捕捉列中指明哪些變數可以捕捉,而且,該捕捉變數本身,或者是變數的值,開發者都可以自行決定。

如此一來,雖然會令lambda運算式變得冗長一些,卻可以令變數捕捉這件事,不若其他語言中那麼神祕難解。

單看功能而論,lambda運算式能夠做到的,函子幾乎都能做到;然而,lambda運算式可以是匿名的,也可以在建立匿名的lambda運算式後,直接進行呼叫,也就是可建立所謂的IIFE(Immediately Invoked Functions Expression)。

當然,函子也能做到lambda運算式無法達成的一些功能,例如,攜帶額外的狀態或方法等。

高階函式

現今有許多開發者將C++ 11以後,視為現代C++(Modern C++),而令其具有現代感的元素之一,就是lambda運算式。已經在其他程式語言中熟悉一級函式特性的開發者,此時,可能會想到高階函式這類設計,也就是,函式可以設計為接受函式並傳回函式。

不過,如果在C++中,使用lambda運算式實現高階函式,可要留意了!

我們可以將lambda運算式從函式中傳回,然而在函式中建立的lambda運算式,其生命週期就是區域性、暫時性,亦即在函式執行後就沒了,因此傳回型態必須是傳值,不能是傳參。

若我們試圖以傳參方式傳回lambda運算式,編譯器會提出警訊,像是:參考了區域變數(傳回型態為lvalue參考時),或是傳回了暫時性參考(傳回型態為rvalue參考時)。

另一方面,lambda運算式不會擴展變數的生命週期,如果在捕捉列指定以參考方式捕捉外部變數,從函式傳回lambda運算式後,被捕捉的變數生命週期已經結束,此時,如果變數的位址已經變得無效了,lambda運算式所捕捉的參考,就會形成懸空參考(Dangling references),也就是參考了不合法的位址。

關於捕捉後的變數是否懸空這件事,有時不是那麼顯而易見,例如,lambda運算式可以形成遞迴:

function<int(int)> factorial = [&](int n) -> int {
return n == 0 ? 1 : n * factorial(n-1);
};

若想在函式中以factorial(10)呼叫是沒有問題,然而,如果將factorial從函式中傳回然後再呼叫,此時,因為factorial是區域變數,捕捉到的factorial已經懸空,因而會造成函式呼叫錯誤。

而談到擴展變數生命週期這件事,視需求而定,我們可以有不同的解法。例如,lambda運算式形成遞迴後,若又想從函式傳回,我們可以將之包裹在另一個lambda運算式後傳回(參考https://bit.ly/39OE8AY)。

傳遞函式的目的是?

現代不少程式語言強調其具有一級函式,真正的目的是能以函式的粒度來思考,也就是,可以用一個函式,而不是用一組函式(物件攜帶一組方法)的粒度來思考。單就這點而言,函式指標是可以解決問題。

然而,傳遞函式還會涉及如何捕捉變數、可否臨時創建函式,或者匿名函式、捕捉的變數生命週期,以及程式碼撰寫、維護是否方便等問題。基於這種種不同的考量,將會決定當時應該使用函子,或lambda運算式。

因此,傳遞函式這件事在C++中,看似囉嗦許多,實際上,開發者在傳遞函式時就該考量這些因素,並且搞清楚不同語言在這些因素上,有哪些必須注意的地方,如此才能在適當的需求上,採用適當的函式傳遞方式。

專欄作者

熱門新聞

Advertisement