C++ 模板參數(shù)推導(dǎo)問(wèn)題小記(非推導(dǎo)上下文)
最近遇到一個(gè)模板參數(shù)推導(dǎo)的問(wèn)題,代碼如下:
代碼
template<typename T>
using scalar = std::enable_if_t<std::is_arithmetic_v<T>, T>;
template<typename T>
void foo(scalar<T> val)
{
...
}
foo(5);
這是我突發(fā)奇想寫(xiě)出來(lái)的,模板別名 scalar 限制函數(shù)參數(shù)為數(shù)值類(lèi)型,可以在多處復(fù)用,這個(gè)代碼無(wú)法通過(guò)編譯,編譯器提示沒(méi)有匹配的函數(shù)調(diào)用。
代碼很簡(jiǎn)單,看起來(lái)也沒(méi)什么不妥,為什么出錯(cuò)了?這個(gè)看起來(lái)簡(jiǎn)單的問(wèn)題同樣難倒了幾個(gè)常用的 AI 編程助手(AI 的回答放在文章最后一節(jié))。
問(wèn)題分析
在正常的模板實(shí)例化過(guò)程中,編譯器結(jié)合模板形參模式和實(shí)例化時(shí)提供的參數(shù)類(lèi)型,確定一個(gè)或一組模板實(shí)參類(lèi)型,將這些實(shí)參替換到形參后能夠形成與實(shí)例化參數(shù)相匹配的參數(shù)列表。
在 foo(5) 這一調(diào)用中,形參是 scalar<T>, 實(shí)例化參數(shù)是 int 類(lèi)型,編譯器需要確定一個(gè)類(lèi)型 T,使得scalar<T> 匹配 int。
我們來(lái)理一下這個(gè)過(guò)程:
我們會(huì)說(shuō),這不是一眼就能看出 T 就是 int 嘛,std::enable_if<std::is_arithmetic<T>::value, T>::type成功實(shí)例化的結(jié)果就是 T 本身。但是站在編譯器的角度來(lái)看,可不能這樣下定論,有些情況下,這部分可能并不是一個(gè)可以反推出固定類(lèi)型的模板,舉一個(gè)最簡(jiǎn)單的例子:
代碼
template<typename T>
struct wrapper
{
using type = int;
};
template<typename T>
using scalar_confused = typename wrapper<T>::type;
template<typename T>
void foo_confused(scalar_confused<T> val)
{
...
}
foo_confused(5);
這個(gè)模板 wrapper 無(wú)論用什么類(lèi)型實(shí)例化都能取到 int,也就是說(shuō)在反推 T 時(shí)無(wú)法確定一個(gè)唯一的類(lèi)型,這對(duì)于編譯器來(lái)說(shuō)是無(wú)法處理的,于是它實(shí)例化不出任何 foo_confused 的實(shí)例。
其實(shí) C++ 標(biāo)準(zhǔn)已經(jīng)對(duì)這類(lèi)問(wèn)題作出了說(shuō)明,官方的命名是非推導(dǎo)上下文(non-deduced context):
我們代碼的問(wèn)題就是上圖指出的這種情況,如果模板參數(shù)只出現(xiàn)在嵌套名稱(chēng)說(shuō)明符內(nèi)(即 :: 符號(hào)左邊的部分),編譯器將不會(huì)嘗試從實(shí)例化參數(shù)中推導(dǎo)該模板參數(shù),只能使用已經(jīng)推導(dǎo)出的或顯式指定的參數(shù)類(lèi)型。
StackOverflow 上這篇文章(What is a non deduced context?)還有它提到的一些鏈接把這個(gè)概念講的很清楚。
如果把 scalar 直接展開(kāi)到使用位置,我們的代碼等價(jià)于:
代碼
template<typename T>
void foo(typename std::enable_if<std::is_arithmetic<T>::value, T>::type val)
{
...
}
這樣看來(lái)問(wèn)題就清晰了,我們拐了一個(gè)彎創(chuàng)造了一個(gè)非推導(dǎo)上下文。編譯器在解析到這一步時(shí),就已經(jīng)拒絕后續(xù)的推導(dǎo)了,后面我們關(guān)于反推的分析實(shí)際都沒(méi)有發(fā)生。
如何解決
問(wèn)題找到了,那么應(yīng)該如何解決呢?把推導(dǎo)移到模板參數(shù)列表里面,讓它在模板參數(shù)替換時(shí)先推導(dǎo)出來(lái),后面再引用行不行:
代碼
template<typename T, typename S = std::enable_if_t<std::is_arithmetic_v<T>, T>>
using scalar = S;
可惜還是不行,而且增加了一層間接,編譯器仍舊會(huì)失敗在相同的位置:
其實(shí)解決方法很簡(jiǎn)單,別名模板只將 scalar<T> 展開(kāi)為 T,限制條件獨(dú)立出來(lái)作為一個(gè)模板參數(shù)用于排除不滿(mǎn)足條件的實(shí)例化類(lèi)型:
代碼
template<typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>>
using scalar = T;
template<typename T>
void foo(scalar<T> val)
{
...
}
foo(5);
現(xiàn)在調(diào)用 foo(5) 時(shí),模板參數(shù)推導(dǎo)過(guò)程變?yōu)椋?/p>
現(xiàn)在的邏輯變?yōu)椋魏?scalar<T> 都是 T,但是只有當(dāng) T 是算術(shù)類(lèi)型時(shí),scalar<T> 才有效。
有人可能會(huì)問(wèn),為什么要編寫(xiě)一個(gè)這樣的模板,而不是直接限制 foo 的參數(shù)類(lèi)型:
代碼
template<typename T, typename = std::enable_if_t<std::is_arithmetic_v<T>>>
void foo(T val)
{
...
}
原因前面已經(jīng)說(shuō)過(guò),為了復(fù)用,scalar<T> 比那一長(zhǎng)串檢測(cè)更簡(jiǎn)潔。這也是 C++20 concept 的低級(jí)實(shí)現(xiàn)版本:
代碼
template<typename T>
concept scalar = std::is_arithmetic_v<T>;
template<scalar S>
void foo(S val)
{
...
}
其他考量
我在另一篇筆記 限制模板實(shí)參類(lèi)型 中提到過(guò),使用靜態(tài)斷言,可在發(fā)生編譯錯(cuò)誤時(shí)提供可讀性更高的錯(cuò)誤提示,我們的這個(gè)例子恰好很符合這一情況:
代碼
template<typename T>
struct arithmetic_guard
{
static_assert(std::is_arithmetic_v<T>, "instantiation requires arithmetic type");
using type = T;
}
template<typename T, typename = typename arithmetic_guard<T>::type>
using scalar = T;
改造之后的 scalar 模板如果使用非算術(shù)類(lèi)型進(jìn)行實(shí)例化,就會(huì)在編譯時(shí)指出需要算術(shù)類(lèi)型。
但是靜態(tài)斷言版本存在一個(gè)缺點(diǎn),就是和 SFINAE 不兼容,假如我們想使用 scalar 來(lái)編寫(xiě)一個(gè)這樣的模板:
代碼
template<typename T>
auto selected_type_impl(int) -> decltype(std::declval<scalar<T>>(), 0.0);
template<typename>
int selected_type_impl(...);
template<typename T>
using selected_type = decltype(selected_type_impl<T>(0));
如果 scalar 使用靜態(tài)斷言版本實(shí)現(xiàn),那么我們使用非算術(shù)類(lèi)型實(shí)例化 selected_type 時(shí),得到的不是一個(gè) int 類(lèi)型,而是編譯錯(cuò)誤。因?yàn)?SFINAE 的發(fā)生時(shí)機(jī)是在模板參數(shù)替換階段,將判斷從模板參數(shù)列表移入 static_assert 內(nèi)的后果就是任何 selected_type_impl 版本都會(huì)進(jìn)行實(shí)例化而不會(huì)被靜默移除,不符合條件的版本將在這一過(guò)程中拋出錯(cuò)誤。在實(shí)際編碼時(shí),可根據(jù)具體需求選擇合適的實(shí)現(xiàn)版本。
一些想法
C++ 語(yǔ)言在不斷嘗試簡(jiǎn)化模板元編程,C++26 會(huì)將靜態(tài)反射加入語(yǔ)言標(biāo)準(zhǔn),屆時(shí)程序的元信息可以直接獲取,而不是通過(guò)編寫(xiě)七彎八繞的模板來(lái)“套出”這些信息。
但是復(fù)雜性不是模板元編程的缺陷,相反它能容納更多的可能性。優(yōu)秀的模板庫(kù)在缺乏編譯器支持的年代解決問(wèn)題的思路,很多令人拍案叫絕,成為經(jīng)典用法甚至推動(dòng)了語(yǔ)言標(biāo)準(zhǔn)的發(fā)展,為更高階的功能實(shí)現(xiàn)奠定基礎(chǔ)。
研究并掌握這些復(fù)雜巧妙的實(shí)現(xiàn),運(yùn)用它們?cè)诂F(xiàn)實(shí)問(wèn)題之前逢山開(kāi)路遇水搭橋,不斷磨煉我們的思維,而不是對(duì)它們望而卻步。這樣在面對(duì)語(yǔ)言標(biāo)準(zhǔn)提供的新特性時(shí),我們才能敏銳察覺(jué)到它們的設(shè)計(jì)意圖,善于恰當(dāng)?shù)丶右赃\(yùn)用,而不是淺嘗輒止。
問(wèn)題總結(jié)
思緒飄忽說(shuō)了一些廢話(huà),回到代碼的問(wèn)題上,其實(shí)是自己對(duì)模板推導(dǎo)規(guī)則了解太淺,臆造出一個(gè)看似可行的實(shí)現(xiàn),一廂情愿地認(rèn)為編譯器會(huì)如此工作。以后還須多多看書(shū)和實(shí)踐,增加知識(shí)儲(chǔ)備。
AI 有什么表現(xiàn)
出于好奇,我拿這個(gè)問(wèn)題問(wèn) AI,看它們能否分析出來(lái),以下是問(wèn)題的結(jié)果。
DeepSeek 的推理能力比較不錯(cuò),而且完全免費(fèi),使用它的深度思考模式提問(wèn),得到的結(jié)論是兩個(gè)調(diào)用都沒(méi)有問(wèn)題:
微軟 Edge 自帶的免費(fèi)版 Copilot 作為日常代碼問(wèn)題咨詢(xún)以及閑聊對(duì)象很方便,ThinkDeeper 模式下,它很確定兩種都能正確編譯:
Claude 生成代碼的能力非常強(qiáng),廣受好評(píng),使用它的 concise 模式(普通模式下詢(xún)問(wèn)代碼問(wèn)題,Claude 會(huì)在展開(kāi)頁(yè)分析代碼,難以完整截圖)回答這個(gè)問(wèn)題,它認(rèn)為兩個(gè)都不能通過(guò)編譯:
號(hào)稱(chēng)地表最強(qiáng)的 Grok 經(jīng)過(guò)仔細(xì)分析后,也沒(méi)能得出正確的結(jié)論:
這四個(gè)都沒(méi)有完全分析正確,這讓我有一點(diǎn)意外。清除聊天上下文后拿相同的問(wèn)題再提問(wèn),它們每次幾乎都會(huì)給出不一樣的結(jié)論,偶爾能正確地預(yù)測(cè)。持續(xù)聊天并引導(dǎo)它們分析問(wèn)題,它們中很少能夠準(zhǔn)確說(shuō)出問(wèn)題出在非推導(dǎo)上下文這個(gè)點(diǎn)上。
這很難讓人完全放心的將代碼完全交由 AI 編寫(xiě),目前來(lái)看,使用它們咨詢(xún)一些編碼問(wèn)題,從中得到啟發(fā)并親自確認(rèn)或者深入研究才是比較穩(wěn)妥的做法。

浙公網(wǎng)安備 33010602011771號(hào)