最近偶然看到一篇 2006 年的老文章《 Tour de Babel 》 ( 中文翻譯 ),評論各種程式設計語言,其中提到 C++ 有太多容易引發混亂的特性,是以很難被用好 – 這真是一場災難。時隔 15 年,曆經 C++11 (重大版本),C++14,C++17 幾個版本,較新版本的 GCC 和 Clang 甚至開始支援 C++20 , C++ 越加臃腫複雜,難以駕馭。很多理智的程式設計指南常常告誡 C++ 開發者要高度克制, 禁止使用花哨技巧或進階特性 。本文嘗試以一段簡單代碼為例,說明此告誡的必要性。示例代碼使用 Clang 12 編譯測試。
v1 正常代碼
寫一個程式檢視
int
類型的最大值,C 語言宏
INT_MAX
記錄了此值。代碼如下:
#include <limits.h>
#include <iostream>
int main() {
std::cout << "int max: " << INT_MAX << std::endl;
}
C++ 不推薦使用宏,可使用
std::numeric_limits<T>
查詢數值類型相關資訊。代碼如下:
#include <limits>
#include <iostream>
int main() {
std::cout << "int max: " << std::numeric_limits<int>::max() << std::endl;
}
這種組織方式更加安全和規範統一。如果要再檢視
float
和
double
類型的最大值,你可能想不起對應的宏名稱 (不是
FLOAT_MAX
) ,但 C++ 中隻需要把同樣的用法重複 3 次,即得到 v1 版本的正常代碼 如下:
#include <limits>
#include <iostream>
int main() {
std::cout << "int max: " << std::numeric_limits<int>::max() << std::endl;
std::cout << "float max: " << std::numeric_limits<float>::max() << std::endl;
std::cout << "double max: " << std::numeric_limits<double>::max() << std::endl;
}
消除重複代碼
顯然,後兩行代碼是複制第一行代碼後稍加修改得到的,這 3 行代碼存在大量重複。有沒有辦法消除重複呢?我們可能希望有一個
for
循環,類似如下代碼:
for (typename T : <int, float, double>) {
std::cout << type<T>.name() << " max: " << std::numeric_limits<T>::max() << std::endl;
}
可惜,C++ 不支援這種文法。追根究底,
int
等是編譯時類型 (代碼結構),俗稱靜态類型,
for
循環是一個運作時結構,運作時代碼無法通路編譯時類型 (代碼結構) 資訊。
v2 模闆類 (C++11)
C++ 中有什麼代碼結構可以接受一個靜态類型清單作為參數嗎?有!模闆。模闆是一種通用的編譯時代碼結構,僅在編譯時存在,編譯完成後,被使用到的模闆必須被執行個體化為具體的代碼結構。注意,模闆執行個體化之後的結構可能仍然是一個編譯時結構,如模闆類。
編寫一個模闆類,C++11 可使用
typename... T
表示任意大小的類型清單,代碼如下:
template<typename... T>
struct for_each_v2 {
};
處理一個清單時,一次隻處理一個元素,這是基本的程式設計方法。剩下的元素怎麼處理呢?遞歸!使用繼承表達遞歸是常用的 C++ 模闆元程式設計方法。代碼如下:
template<typename T, typename... Tn>
struct for_each_v2: for_each_v2<Tn...> {
};
如果嘗試執行個體化這個模闆類(如執行
for_each_v2<int, float, double>()
),将出現編譯報錯如下:
error: too few template arguments for class template 'for_each_v2'
别着急,我們的遞歸結構還沒寫完整。分析報錯原因:
typename... Tn
表示任意大小的清單,
typename T
表示前面至少有一個元素
T
,遞歸過程中清單
Tn
的元素逐個減少,直至為空,而模闆參數要求至少有一個元素,以空清單
Tn
調用
for_each_v2<Tn...>
時報錯 “too few template arguments” 。漏了什麼?《The Little Schemer》第一誡:遞歸處理一個清單時,請先檢查清單是否為空。
是以,應定義一個接受空參數清單的模闆特化實作,作為遞歸終止條件 (終結子) 。但直接添加模闆特化終結子仍然會報錯,因為終結子 (接受空清單) 與遞歸調用 (要求至少一個元素) 模闆簽名不比對。是以需要再引入接受任意多個參數的初始模闆作為統一模闆簽名,相當于模闆聲明,而覆寫所有分支的模闆特化是具體實作。整理之後的代碼如下:
template<typename... T>
struct for_each_v2 {
};
// empty?
// specialization terminator
template<>
struct for_each_v2<> {
};
// else
template<typename T, typename... Tn>
struct for_each_v2<T, Tn...>: for_each_v2<Tn...> {
};
這才是完整的模闆類遞歸結構。當然,這不是我想出來的,是 C++ 模闆元程式設計大牛 (折騰愛好者) 想出來的通用辦法。 (參考
Boost C++ Metaprogramming)。
接下來,在模闆類中寫一個函數處理清單元素
T
,這個函數不需要傳回值,這裡可簡單使用構造函數,構造函數會自動調用基類的構造函數,相當于自動完成了遞歸調用。 C++
typeid
操作可查詢編譯時類型或運作時資料的類型資訊,但其傳回的類型名稱為内部名稱,可再使用
boost::core::demangle()
轉為可讀名稱。為了避免外部依賴,這裡暫簡單使用原始内部名稱吧。最後 v2 版本使用模闆類 (C++11) 的代碼 如下:
#include <limits>
#include <typeinfo>
#include <iostream>
template<typename... T>
struct for_each_v2 {
};
// empty?
// specialization terminator
template<>
struct for_each_v2<> {
};
// else
template<typename T, typename... Tn>
struct for_each_v2<T, Tn...>: for_each_v2<Tn...> {
for_each_v2() {
std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
}
};
int main() {
for_each_v2<int, float, double>();
}
調試跟蹤代碼,可看到調用棧展示了“遞歸”處理過程。同時看到這裡使用構造函數有個小問題,遞歸最深的基類構造函數最先執行,導緻清單元素的處理順序與傳入順序相反。同時看到此時還引入了遞歸函數調用的額外開銷 (構造函數不能被内聯?)。
for_each_v2<double>::for_each_v2 mp11-foreach.cpp:19
for_each_v2<float, double>::for_each_v2 mp11-foreach.cpp:18
for_each_v2<int, float, double>::for_each_v2 mp11-foreach.cpp:18
main mp11-foreach.cpp:24
v3 模闆函數 (C++17)
能不能直接使用模闆函數編寫遞歸調用呢?使用
sizeof...(Tn)
操作可擷取模闆參數清單大小,大小為 0 時即停止遞歸,代碼簡潔很多。初步嘗試如下:
template<typename T, typename... Tn>
void for_each_v3() {
std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
if (!!sizeof...(Tn)) {
for_each_v3<Tn...>();
}
}
很遺憾,嘗試執行個體化模闆函數時發生編譯錯誤:
error: no matching function for call to 'for_each_v3'
錯誤資訊不夠清晰,實際上與之前模闆類遞歸報錯的原因一樣,遞歸到模闆參數
Tn
為空時模闆執行個體化失敗。
Tn
為空時
if
分支不會被執行了呀,為什麼還會繼續執行個體化?因為
if
語句是運作時結構,編譯時編譯器不知道
if
分支是否會執行。
那,還是需要寫模闆特化終結子嗎?不用! C++17 引入了
constexpr if
特性,俗稱 (編譯時) 靜态
if
。在
if
條件前加
constexpr
關鍵字,即可把
if
語句從運作時結構變成編譯時結構,顯然,此時要求
if
條件必須在編譯時有确定的結果值。特别是,模闆執行個體化時将自動移除不會執行的分支代碼,進而不會觸發被移除代碼中的模闆執行個體化。模闆參數清單在編譯時确定,
sizeof...(Tn)
的值在編譯時确定,是以上述代碼隻需給
if
語句添加
constexpr
修改為靜态
if
即可。最後 v3 版本使用模闆函數 (C++17) 的代碼 如下:
#include <limits>
#include <typeinfo>
#include <iostream>
template<typename T, typename... Tn>
void for_each_v3() {
std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
if constexpr(!!sizeof...(Tn)) {
for_each_v3<Tn...>();
}
}
int main() {
for_each_v3<int, float, double>();
}
調試跟蹤代碼,可發現運作時已經沒有
if
語句了 (已被編譯時執行) 。可看到調用棧如下:
for_each_v3<double> mp11-foreach-v3.cpp:7
for_each_v3<float, double> mp11-foreach-v3.cpp:9
for_each_v3<int, float, double> mp11-foreach-v3.cpp:9
main mp11-foreach-v3.cpp:14
尾遞歸優化與内聯優化
在 Scheme 中,尾遞歸将被自動優化為線性疊代,不存在性能問題 (因為遞歸是如此重要)。而 C 家族語言似乎都不保證 (甚至完全不提供) 尾遞歸優化。仔細想想,上面代碼看起來是 “遞歸” ,其實不是,因為模闆執行個體化後将變成多個不同的函數,編譯後本質上是多個函數的串聯調用 (如調用棧所示) 。
簡短的函數調用,應該可以進行内聯優化,然而此時并沒有,顯式添加
inline
關鍵字,嘗試使用
-O3 -g
編譯,調試跟蹤似乎也沒有進行内聯優化 (
inline
關鍵字本身也不保證内聯優化),但函數傳回時貌似從調用最深層處直接傳回到了
main
函數 (可能作了某種調用優化?) 。
函數調用的開銷應該是可以被優化到極低的,可以忽略不計 (?) 。但如果沒有尾遞歸優化,調用層次過深時可能導緻棧溢出。當然,對由模闆參數清單長度控制的棧深度而言,這種情況也不可能發生。
v4 通用 lambda (C++20)
C++ 模闆類和模闆函數不能在函數内定義,這常常不太友善 – 将内部操作定義在函數内可以更好的組織代碼,同時減少命名沖突。幸運的是,函數内 (及内部作用域) 可以定義通用 lambda 。術語 lambda 源自
Alonzo Church發明的
lambda 演算,最早被 LISP 使用。實際上 lambda 就是函數,隻是它沒有名字 (或者你不知道它的名字)。通用 lambda 實際上也是模闆函數, C++20 支援為通用 lambda 顯式指定模闆參數。
v4 預備
嘗試将上述模闆函數修改為通用 lambda ,代碼如下:
constexpr auto for_each_v4 = []<typename T, typename... Tn>() {
std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
if constexpr(!!sizeof...(Tn)) {
for_each_v4<Tn...>();
}
};
- 注意: lambda 沒有名字,
隻是另一個儲存了 lambda 對象的變量的名字,不是 lambda 自身的名字。for_each_v4
- 我們說模闆是編譯時代碼結構,隻在編譯時存在,通用 lambda 為什麼能儲存到運作時可用的變量呢?這是因為 C++ lambda 實際上是一個 閉包 (Closure) ) 對象, lambda 定義的函數實際上是閉包類型下名為
的函數。operator()
- 我們不知道閉包對象的具體類型,但編譯器知道,使用
類型讓編譯器自動設定類型。auto
- 不捕獲外部資料的 lambda 可儲存為編譯時常量
,還可以轉化為普通函數 (非閉包) 使用。constexpr
遺憾的是,此代碼将在編譯時報錯如下:
error: variable 'for_each_v4' declared with deduced type 'const auto' cannot appear in its own initializer
for_each_v4<Tn...>();
^
可惡,它知道我們想做什麼,但就是不讓我們這麼做。實際上,上述代碼沒有捕獲外部資料,應該看不見
for_each_v4
這個名字 (看不見這個變量) 。如果嘗試在 lambda 捕獲表達式添加捕獲這個變量,無論值捕獲還是引用捕獲,都會出現同樣的報錯。盡管我們代碼沒寫對,編譯器已經察覺到我們想做什麼,并說這樣不行。
v4 沉思
報錯說: 一個自動推導類型的
auto
變量不能出現在它自身的初始化表達式中 (即不能被其自身的初始化表達式使用) 。這似乎是為了避免資料依賴循環:變量的值需要執行初始化表達式後才能知道,初始化表達式又要使用變量的值,不是就死循環了嗎。
實際上并非如此:
- 按引用捕獲閉包對象 (引用本質上是指針) 可避免資料存儲結構循環。實際上 C++ 允許變量初始化表達式通路自身位址 (如支援
這樣的寫法) 。boost::any ref{&ref};
- 建立 lambda 閉包對象時隻依賴被捕獲對象,并不依賴 lambda 函數的實作,編譯 lambda 函數時也不依賴被捕獲對象的具體值,是以按引用捕獲時不存在資料依賴循環。實際上 C++ 似乎并沒有真正足夠關注資料依賴問題,以至于像
這樣真正有資料依賴問題的代碼也能編譯通過。int n = n + 1;
其實 C++ 很容易實作允許 lambda 閉包對象按引用捕獲自身 (?) (這是個危險的想法,C++ 已經夠複雜了,還想讓它更複雜嗎?) ,但它就是不允許這樣做。
換個角度想,一個對象的函數不是可以直接使用
this
指針通路自身對象嗎,幹嘛還要捕獲自身的引用 (指針) 呢? 因為 C++ 雖然使用閉包對象作為 lambda 的底層實作,但 C++ 尊重了 lambda 的本義 – lambda 隻是一個函數,不是對象。是以 lambda 隻能看到被捕獲的資料,不能看到閉包對象自身,不能使用
this
指針通路 lambda 自身,如果願意的話倒是可以捕獲通路外層對象 (如果有的化) 的
this
指針。
如果 lambda 有确定的函數簽名 (非通用 lambda ,即非模闆函數) ,雖然同樣不允許捕獲
auto
類型的自身引用,但可以将其包裝到一個
std::function<...>
對象,再捕獲
std::function<...>
對象以間接調用自身。注意:
std::function<...>
不能構造為編譯時常量,同時這裡又需要按引用捕獲 (想一想,為什麼?),而對象銷毀後捕獲的引用将失效,這是一個暗坑!
為什麼 lambda 閉包對象不能被直接捕獲,卻可以被間接捕獲?實際上這與類型有關,被捕獲資料的類型将影響 lambda 閉包對象的類型定義 (?) ,當嘗試捕獲閉包對象自身時,其類型影響其類型,是以産生了類型依賴死循環。當将 lambda 儲存到
std::function<...>
等對象時,
std::function<...>
是确切類型,切斷了類型依賴死循環。 lambda 閉包對象類型也是一個匿名類型, C++ 是否可以直接自動生成一個與捕獲資料類型或函數簽名無關的确切類型,進而避免自身類型依賴死循環呢 (會不會增加複雜性?) ,或者 C++ 真的隻是單純的不想讓你在 lambda 中引用其自身 (?) 。
有确定函數簽名的 lambda (即非通用 lambda) 可以儲存到
std::function<...>
對象,但通用 lambda 卻不行,因為通用 lambda 是模闆函數,模闆是僅在編譯時存在的代碼結構。這正是 C++ 底層使用閉包對象表示 lambda 的聰明之處,閉包對象可以儲存為運作時資料,同時可通過閉包對象關聯的類型通路其關聯的模闆函數。如果希望在代碼中傳遞通用 lambda 時保持其通用 lambda 的特性,那麼保持其原始 lambda 閉包對象似乎是唯一選擇。
v4 起飛
有名字的函數可以通過名字遞歸調用自己,沒有名字的 lambda 函數如何遞歸調用自己呢?《The Little Schemer》第 9 章介紹了一種方法:将 lambda 自身的某種表示形式作為參數傳遞回 lambda 函數。 lambda 閉包對象就是 lambda 函數的一種表示形式,既然不能捕獲 lambda 閉包對象,就将其作為函數參數傳入吧。我們不知道 lambda 閉包對象的确切類型,是以這個參數仍使用
auto
類型。 (參考
C++ lambda 遞歸的三種寫法)。代碼架構如下:
constexpr auto f = []<typename T1, typename... Tn>(auto self) -> void {
// ... ...
};
f<int>(f);
想一想:這個寫法與直接捕獲 lambda 閉包對象有什麼差別呢?是編譯時的差別還是運作時的差別?
這個 lambda 函數的第一個參數必須為其閉包對象,這一要求是一個内部實作細節,可以再定義一個外層 lambda 函數将此内部細節隐藏起來。代碼架構如下:
constexpr auto for_each_v4 = []<typename... T>() {
constexpr auto f = []<typename T1, typename... Tn>(auto self) -> void {
// ... ...
};
f<T...>(f);
};
最後得到 v4 版本使用通用 lambda (C++20) 的代碼 如下:
#include <limits>
#include <typeinfo>
#include <iostream>
int main() {
constexpr auto for_each_v4 = []<typename... T>() {
constexpr auto f = []<typename T1, typename... Tn>(auto self) -> void {
std::cout << typeid(T1).name() << " max: " << std::numeric_limits<T1>::max() << std::endl;
if constexpr(!!sizeof...(Tn)) {
self.template operator()<Tn...>(self);
}
};
f.template operator()<T...>(f);
};
for_each_v4.operator()<int, float, double>();
}
如果 C++ 定義 lambda 時支援命名,或者支援以某種特殊形式調用自身 (這是個危險的想法,C++ 已經夠複雜了,還想讓它更複雜嗎?) ,或者支援在函數内定義模闆函數,那就簡單多了呀。
v5 通用 for_each (C++20)
以上代碼中,實作 for_each 的架構代碼和業務代碼混在了一起,這樣不僅看起來不清晰,而且不能代碼複用。我們應該将 for_each 架構代碼抽取成一個可複用的通用操作,其使用方式類似如下代碼:
for_each<typename... Tn>([]<typename T>() {
// 操作類型 T 的業務函數代碼
});
對通用 lambda 代碼稍加修改,不難實作這個
for_each
操作 (這個練習留給讀者吧) 。
實際上, C++ 标準庫有一個
std::for_each
函數可以周遊運作時資料清單。 Boost mp11 庫有一個
boost::mp11::mp_for_each函數 (下文簡稱
mp_for_each
) 可以周遊一個編譯時類型清單。但
mp_for_each
與我們想要的這個 for_each 有一些差別:
-
将要周遊的類型清單進一步封裝到一個mp_for_each
模闆類中。L<...>
-
自動調用被周遊類型的預設構造函數,生成預設對象作為業務函數的參數,是以要求被周遊的類型必須有可用的預設構造函數。mp_for_each
我們不希望 for_each 自動生成被周遊類型的對象,而且不要求被周遊類型有預設構造函數。能否适配複用
mp_for_each
呢?一個辦法就是将被周遊類型封裝到一個空模闆類型中,建立空模闆類型對象時實際上不會執行任何操作,
boost::mp11::mp_list<...>
就是一個專門為此設計的空模闆類型 (同時可用于封裝任意類型清單)。再改造業務函數增加一個
boost::mp11::mp_list<T>
空對象參數。建立通用 lambda 作為業務函數 (C++20 支援定義通用 lambda 時顯式指定模闆參數),調用通用 lambda 時,通過參數類型 (被周遊類型的空模闆包裝類型) 自動推導模闆參數 。
引入 Boost mp11 依賴 (這是一個純頭檔案依賴) ,得到複用
mp_for_each
的 v5 版本通用 for_each (C++20) 代碼如下:
#include <limits>
#include <typeinfo>
#include <iostream>
#include <boost/mp11.hpp>
int main() {
constexpr auto for_each_v5 = []<typename... T>(auto f) {
using L = boost::mp11::mp_transform<boost::mp11::mp_list, boost::mp11::mp_list<T...>>;
boost::mp11::mp_for_each<L>(f);
};
for_each_v5.operator()<int, float, double>([]<typename T>(boost::mp11::mp_list<T>) {
std::cout << typeid(T).name() << " max: " << std::numeric_limits<T>::max() << std::endl;
});
}
結語
回想一下 v1 版本最初的正常代碼,我們隻是想簡單重構一下代碼,卻意外經曆了如此燒腦的冒險曆程。你現在感覺如何?我再也不想寫 C++ 了。這個示例可能還有更好的實作方法,但這一切到底是為了什麼?重構之後的代碼真的更好嗎? C++ 不夠好,是以
D 語言、Go 語言、
Rust 語言等新語言不斷出現,而同時 C++ 自身也在不斷膨脹和演進。也許足夠克制的程式設計語言才是智慧的,比如偉大的 Scheme (
Racket是一種成熟的 Scheme 方言) , C 語言,以及 Java 。
回到最初的告誡, 編寫 C++ 代碼時請保持克制,隻在淺水區玩耍,不要靠近深水區,不要使用技巧或進階特性 。你說是嗎?