C++ 模板參數推導問題小記(模板類的模板構造函數)
本篇主要是為了記錄在編寫一個模板類的模板構造函數中遇到的初始化問題,以及針對這個問題展開的相關知識整理,文章就以引發這個問題的代碼為標題了。
問題代碼
在編寫一個代表空間點的模板類 point 時,我打算為它添加一個模板構造函數:
代碼
template<typename T, std::size_t N>
struct point
{
using value_type = scalar<T>;
value_type _v[N];
point() : _v{ value_type{} } {}
template<typename U>
explicit point(const U (&arr)[N])
{
if constexpr(std::is_same_v<value_type, U>)
memcpy(_v, arr, n * sizeof(value_type));
else
{
for(std::size_t i = 0; i != N; ++i)
_v[i] = static_cast<value_type>(arr[i]);
}
}
};
point<int, 3> pi3({ 0, 1, 2 });
代碼中的 scalar 是 這篇 筆記中提到用于類型限制的別名模板,用以排除非數值類型的模板實例化。
template<typename U> point(const U (&arr)[N]) 這個構造函數的意圖是 point 只接受長度為 N 的數組進行初始化。
一切看起來沒什么問題,但是當我寫下這樣的初始化代碼時,發現代碼仍然能夠正常通過編譯:
代碼
point<int, 3> pi3({ 0, 1 });
為什么料想之中的長度限制并沒有起作用?
問題分析
分析 point<int, 3> pi3({ 0, 1 }); 這句代碼,編譯器是如何處理它的:
1. point<int, 3> pi3 指定了 pi3 這個實例的 T 為 int,N 為 3;
2. pi3({ 0, 1 }) 是一個單參數構造語句,嘗試匹配接受單個參數的構造函數,匹配到接受數組引用的自定義構造函數 template<typename U> point<int, 3>::point(const U (&arr)[3]);
3. 根據調用參數 { 0, 1 },即 [int, int] 推導 U 為 int,構造函數實例化為 point<int, 3>::point<int>(const int (&arr)[3]);
4. 使用 { 0, 1 } 對一個臨時的 int [3] 進行列表初始化,初始化結果為 { 0, 1, 0 },隨后傳入構造函數。
point 類的模板參數 N 在類的實例化時被指定為 3,在成員模板構造函數實例化期間它是已知的,函數參數推導過程對它沒有任何影響,這句代碼能夠通過編譯的根本原因是長度為 3 的數組能夠被只有 2 個元素的初始化列表初始化。
而我由于對初始化細節了解不全面,加之模板代碼對問題分析有一定的干擾,一時沒有抓住本質,寫出了這段一廂情愿的代碼。
問題解決
解決方法很簡單,把數組的維度也作為模板參數參與推導,然后對它進行約束就能實現這個目的了:
代碼
template<typename U, std::size_t M, typename = std::enable_if_t<M == N>>
explicit point(const U (&arr)[M])
{
//...
};
int iarr[] = { 0, 1, 2 };
point<int, 3> pi30(iarr);//OK
point<int, 3> pi31({ 0, 1, 2 });//OK
point<int, 3> pi3({ 0, 1 });//無法通過編譯
現在數組的維度 M 需要從構造函數的參數推導出來,如果 M 與 N 不相等,構造函數實例化失敗。
問題到此就可以結束了,但是不妨來分析一下 ({ ... }) 這種初始化寫法。
C++ 的初始化
首先復習一下基礎知識,不考慮拷貝構造的情況下,C++ 的初始化有兩種:
1. (...),即直接初始化
這種調用適用于類類型,直接要求調用類的某個構造函數。所有用戶自定義和編譯器合成版本的構造函數都會被加入候選列表,隨后根據重載函數匹配規則選出匹配度最高的一個進行調用,無匹配項或多個項都具有最佳匹配度時匹配失敗。
這種初始化語法有個缺陷 —— 可能會被解析為函數聲明,在這些情況下,解析的結果往往很反直覺,所以被稱為 最令人煩惱的解析。
2. = { ... } & { ... },即 列表初始化
在 C++11 標準之前,列表初始化只能用來對 聚合類型 進行初始化。上文中使用 { 0, 1 } 將一個臨時的 int [3] 初始化為 { 0, 1, 0 } 就屬于聚合類型的列表初始化。更加詳細的規則不是本文的重點關注對象,感興趣的話可以到 這里 閱讀。
值得一提的是,MSVC(測試版本為 _MSC_VER=1943)支持使用 (...) 對聚合類型進行列表初始化,但這并不被 C++ 標準采納,屬于 MSVC 方言,不具備可移植性,使用時須當心。
C++11 引入了統一初始化語法,使得任何類型都能夠使用列表初始化語法進行初始化,同時新增了 std::initializer_list 來支持統一的列表初始化語法。
列表初始化語法杜絕了將初始化語句解析為函數聲明語句的可能,并且阻止了 窄化轉換,使初始化更加簡潔安全。
在使用列表初始化器初始化對象時,接受 std::initializer_list 的構造函數具有無與倫比的重載匹配優先級,即使無法正確構造一個 std::initializer_list 且其他函數能夠精確匹配參數時也可能直接屏蔽其他構造函數,直接報錯而不嘗試其他重載版本(Scott Meyers, Effective Modern C++, Item 7)。所以除非你非常確定自己的類需要一個接受 std::initializer_list 的構造函數,并且你能夠正確處理它與其他構造函數的關系,不要輕易定義這個構造函數。
({ ... }) 是如何解析的
有了前面的鋪墊,這個初始化語句就很好理解了。外層的 () 指定要調用某個函數,該函數能夠匹配只有一個參數的調用形式,內層的 { ... } 作為一個初始化列表對這個參數進行初始化。
列表初始化 指定的情形是直接包含這種初始化形式的,并且解釋得非常詳細:
其實我們平時也經常在函數調用時使用這種語法:
代碼
void foo(int i, const std::vector &vec);//函數簽名
foo(0, { 0, 1, 2 });//調用
當它被用于類的初始化時,編譯器自動匹配構造函數調用,匹配規則與普通函數是一樣的。
再探 point 的初始化
前面對 point 構造的分析只是簡化版,讓我們再次詳細分析這一句代碼的解析過程:
point 這個調用中的 ({ 0, 1 }) 會匹配所有接受單個參數、名為 point 的函數。查看一下候選的函數,編譯器發現有三個:用戶定義的接受數組引用的構造函數,自動合成的拷貝構造函數,以及自動合成的移動構造函數。分別分析它們的匹配情況:
匹配接受數組引用的構造函數 point<int, 3>::point<int>(const int (&arr)[3]),此構造函數的形參是 const int (&)[3]。根據聚合類型的列表初始化規則,指定長度的數組可被元素數量小于或等于其長度的初始化列表初始化。此處創建一個臨時數組 int [3] 并且被初始化為 { 0, 1, 0 },綁定到數組引用形參上,函數匹配成功。
匹配拷貝構造函數 point<int, 3>::point(const point<int, 3> &),其形參是 const point<int, 3> &。這里需要創建一個臨時的 point<int, 3>,相當于 point<int, 3> temp{ 0, 1 };。顯然,point<int, 3> 既不是聚合類型,也沒有接受 (int, int) 的構造函數,更沒有接受 std::initializer_list 的構造函數。這樣一個臨時的變量無法被創建出來,所以拷貝構造函數匹配失敗。
移動構造函數的情形與拷貝構造函數相同,無法匹配。
最終,自定義的那個接受數組引用的構造函數被選中用來初始化這個 point<int, 3> 實例。
語義檢查與優化
point 類的定義使得 ({ 0, 1 }) 無法匹配到拷貝構造或移動構造函數,但是如果我們定義下面這樣一個能被兩個 int 參數構造的類:
代碼
struct S
{
S(int, int) {}
//S(const S &) = delete;//如果刪除拷貝構造函數,按照語言規則,移動構造函數也不會自動合成
};
S s({ 0, 1 });//若拷貝構造函數被刪除,無法通過編譯
S s1 = S{ 0, 1 };//若拷貝構造函數被刪除,可以通過編譯(C++17之后)
按照前面講述的,s 的構造會調用用戶定義的構造函數和編譯器合成的移動構造函數,即先構造一個臨時的 S 對象,再用這個臨時對象調用移動構造函數。這樣的構造過程顯然是冗余的,中間這個臨時對象被創建出來后立即用于后續的構造,沒有任何可能會被修改,所以它的構造完全可以直接發生在最終目標位置。
大多數編譯器確實會優化掉這個中間過程,但是如果我們像代碼注釋中那樣讓 S 的移動構造函數和拷貝構造函數不可用,編譯器會提示 s 的構造中引用了被刪除的函數。那些確實會執行這項優化的編譯器,為什么必須檢查一定不會被引用的函數的可用性呢?
這是因為編譯器執行代碼優化的基礎是代碼必須按照語言標準進行編寫,而確保代碼符合語言標準的工作是由語義檢查環節完成的。也就是說,代碼優化必須在語義檢查通過后才能執行(實際的編譯中這兩個環節之間還會有其他操作),那么為什么語義同樣是調用移動構造函數的 s1 構造語句不會報錯呢?
我們知道,從 C++17 開始,有一些 拷貝省略 是強制施行的,即原來這些被視作優化的拷貝省略形式被納入標準行為。其中就包括上述代碼中 s1 的構造情形:
既然 s1 構造中省略拷貝步驟已是標準行為,編譯器的語義檢測就只需要檢查對應的構造函數可用性就行了(前提是設置編譯器的語言標準大于 C++17)。
而 s 的構造語句形式不在強制拷貝省略的情形之列,所以如果拷貝構造函數和移動構造函數都不可用,語義檢查將不能通過。
總而言之,符合優化條件但未遵守語言標準的代碼絕不會因為能夠被優化而通過語義檢查。
編寫支持統一初始化語法的 point 類
point 必須以兩重括號的形式初始化總讓人感覺不自然,我們可以嘗試讓它支持統一初始化語法,在前面的基礎上需要保證三點:語法形式的支持、實參個數限制、窄化轉換限制。
利用 std::initializer_list
我們來嘗試一下利用 std::initializer_list:
代碼
template<typename T, std::size_t N>
struct point
{
using value_type = scalar<T>;
value_type _v[N];
point() : _v{ value_type{} } { }
template<typename U>
explicit point(std::initializer_list<U> il)
{
//static_assert(il.size() == N, "argument number mismatch.");//可行嗎?
//...
}
};
很快我們的嘗試就遇到一個問題,如何在編譯時限制傳入 std::initializer_list 的長度。上述代碼中這個靜態斷言會出現編譯錯誤,編譯器會提示 il.size() 不是編譯期常量,不能用于靜態斷言環境。
std::initializer_list::size() 從 C++14 開始被標記為 constexpr,為什么它無法用于靜態斷言?這涉及 constexpr 函數的特性,constexpr 標識符聲明一個返回值有可能在編譯期求值的函數,但是它必須滿足 編譯期求值的條件。此處傳入的這個 il 是一個運行期構造的變量,顯然不符合編譯期常量求值環境。
看來這條路走不通,得另尋他法。
可變參數模板構造函數
我們可以為 point 編寫一個接受參數包的模板構造函數:
代碼
template<typename T, std::size_t N>
struct point
{
using value_type = scalar<T>;
value_type _v[N];
point() : _v{ value_type{} } { }
template<typename... Args, typename = std::enable_if_t<sizeof...(Args) == N>>
explicit point(scalar<Args>&&... args)
{
//...
}
};
參數包展開中使用 scalar 限制參數為算術類型,這是為了防止這個模板函數被實例化為拷貝構造函數或者移動構造函數。在本例中即使不限制這個類型,也只在恰好 N == 1 時可能出現(實際的代碼中創建一個一維點沒什么意義),而且 explicit 的拷貝函數也很難匹配上大多數的拷貝語境,但是我在其他地方確實被這樣的匹配坑過,這樣的代碼行為可能會讓人意外,所以此處特意一提。
這個版本的構造函數已經能夠很好地支持統一初始化語法和參數數量限制了,但是它還有一點未實現:限制數值窄化轉換。
嘗試實現 為了實現這個目標,我首先看了一下標準庫是否直接提供這樣的 traits,可惜并未找到,于是去網上搜索是否有什么實現思路,確實找到了一篇 博客 詳細說明了實現思路,并指出關鍵點是 C++ 的拷貝列表初始化語法不允許窄化轉換:
這段話讓我大受啟發,雖然這篇博客中實現的窄化檢查是兩個類型之間的,對它稍加改造就可以檢測參數包了:
代碼
//僅用于類型推導的空類
struct dummy { };
//單參數窄化轉換檢查輔助函數
template<typename T>
dummy narrowing_conversion_check(T);
//參數包窄化轉換檢查類,通過折疊表達式對參數包逐一進行窄化轉換檢查
template<typename T, typename... Args>
struct narrowing_conversion_guard : decltype(((narrowing_conversion_check<T>({ std::declval<Args>() })), ...)) { };
template<typename... Args,
typename = std::enable_if_t<sizeof...(Args) == N>,
typename = narrowing_conversion_guard<T, Args...>>//在point的構造函數中使用它
point(scalar<Args>&&... args) : _v{ std::forward<Args>(args)... } { }
發現已有機制 剛寫完這個檢測類我就恍然大悟,數組成員 _v 是直接支持列表初始化的,編譯器自然而然會在列表初始化時進行窄化轉換檢查,所以在這種情況下沒有必要手動檢查,真是得來全不費功夫啊:
代碼
template<typename T, std::size_t N>
struct point
{
using value_type = scalar<T>;
value_type _v[N];
point() : _v{ value_type{} } { }
template<typename... Args, typename = std::enable_if_t<sizeof...(Args) == N>>
explicit point(scalar<Args>&&... args) : _v{ std::forward<Args>(args)... } { }
};
point<int, 3> pi30{ 0, 1, 2 };//OK
point<int, 3> pi31{ 0, 1 };//錯誤:需要3個參數,實際提供了2個參數
point<int, 2> pi2{ 1, 2.0 };//錯誤:列表初始化中無法將double窄化轉換為int
對于一個空間點類,要么默認構造為全 0,要么指定所有元素的初值是合理的,因為這兩種構造都符合一般直覺,并且空間點的維度 N 幾乎不會取到大于 4,不會在逐一指定初值時造成編碼負擔。如果我們設計一個構造函數,它接受的參數數量可以不等于點的元素數量,并且沒有給出注釋說明在兩者不相等時的構造行為,就會給使用者帶來一些困擾并且可能造成被誤用。
總結
作為一篇知識點整理筆記,本文內容寫的比較雜,此處作一個簡單總結:
1. 模板代碼往往比普通代碼需要更復雜的理解能力,所以在編寫模板代碼時,一些隱藏的語言規則問題更難被發覺;
2. 語義檢查和代碼優化的關系可以解釋這樣一個問題:為什么有些理論上會被執行的代碼最終卻沒有被執行,既然沒被執行,我們不定義(或阻止編譯器生成)它們為什么又無法成功編譯;
3. 正如 Scott Meyers, Effective C++, Item 18 所說,讓接口容易被正確使用,不易被誤用,花費時間琢磨如何設計更加符合普遍直覺且行為一致的接口是值得的。初始化作為基礎中的基礎,值得透徹研究,這有助于設計語義清晰,更易被正確使用的接口;

浙公網安備 33010602011771號