猜猜下面這段代碼的輸出是什麼:
template <typename T>
struct Base {
void DoThings() {
std::cout << "A\n";
}
};
template <typename T>
struct Derived: Base<T> {
void Do() {
DoThings();
}
};
int main() {
Derived<int> d;
d.Do();
}
肯定有人會説是A,但實際上是編譯錯誤:
test.cpp: In member function 'void Derived<T>::Do()':
test.cpp:9:17: error: there are no arguments to 'DoThings' that depend on a template parameter, so a declaration of 'DoThings' must be available [-Wtemplate-body]
9 | DoThings();
| ^~~~~~~~
test.cpp:9:17: note: (if you use '-fpermissive', G++ will accept your code, but allowing the use of an undeclared name is deprecated)
給的報錯信息很讓人迷惑,因為DoThings是明確聲明定義在Base<T>中的,這裏居然在説它未被聲明。
這其實是c++的Two Phase Lookup導致的。
Two Phase Lookup如其字面意思,對於任何模板代碼,編譯器需要進行兩次檢查:
- Phase 1,第一步檢查,只檢查模板代碼是否有語法錯誤,但涉及到和模板類型參數相關的部分會跳過。檢查的範圍包括是否有明顯的語法錯誤比如用了不存在的關鍵字、少了分號等,其中也會檢查哪些和模板類型參數無關的函數、類型、方法是否已經被聲明,這和編譯器檢查普通代碼的流程很相似
- Phase 2,這一步會往模板的參數裏帶入實際的類型,編譯器會重新推導整個模板代碼在當前的類型下是否合法
兩步驟是為了更快速地將類型參數不相關的問題排除,這樣在保證模板代碼語法正確性的同時儘量保證了泛型代碼的靈活性,理想中也能讓模板的編寫者更快發現問題而不是把問題延遲到類型推導之後。
但壞處就是會讓模板產生一下詭異的編譯錯誤了,比如上面的DoThings。DoThings在這裏是非限定名稱,但沒有參數,同時它也和Derived模板的類型參數不直接相關,這導致對DoThings的檢查會在Phase 1執行,而Phase 1會忽略所有的模板參數相關內容,這導致Base<T>在這時不可見,而我們又沒有在其他地方定義DoThings,所以編譯器認為我們在使用一個未聲明的符號,於是報了語法錯誤。
解決方法也很簡單,讓DoThings和類型參數相關即可,或者通過this去調用,this代表了泛型模板類自身,也算和模板參數相關:
template <typename T>
struct Derived: Base<T> {
void Do() {
- DoThings();
+ this->DoThings();
+ // Base<T>::DoThings(); 也可以
}
};
另外如果我們提供了自由函數DoThings,那麼在Phase 1中就會把對應的名字認定為是在調用自由函數,這時編譯器不再報錯,但Base<T>的方法永遠調用不到了:
template <typename T>
struct Base {
void DoThings() {
std::cout << "A\n";
}
};
void DoThings() {
std::cout << "B\n";
}
template <typename T>
struct Derived: Base<T> {
void Do() {
DoThings();
}
};
int main() {
Derived<int> d;
d.Do(); // 輸出B,自由函數DoThings被調用
}
這很違反直覺,因為普通的非模板子類在這種時候會去基類的作用域裏尋找同名的方法,但因為Two Phase Lookup,編譯器在Phase 1把函數綁定到了全局的自由函數上,這導致了非預期的結果。
總結
模板會有Two Phase Lookup做兩遍檢查,因此它和普通的代碼行為上會有區別。
除了上面説的讓方法和模板參數關聯,其他補救措施還有很多,一種在GCC給出的編譯報錯裏:加上-fpermissive啓用permissive模式。在這個模式下不會進行Two Phase Lookup,模板會在實例化的時候再做檢查,可以避免報錯,但實測gcc-15上無法避免錯誤調用自由函數的問題。另外permissive模式會大幅改變語言和編譯器的行為,貿然啓用會出現很多意外問題。因此這一措施我並不推薦。
讓方法名和模板參數相關也不能解決所有問題,因為還有很多時候我們需要利用非限定名稱來自動選取合適的函數/方法,碰到這種情況就只能特殊場景特殊處理了。
簡單地説,沒有銀彈,不存在一種萬金油方法徹底規避這類錯誤。這也只是c++模板黑暗面的冰山一角罷了。