C++ Two Phase Lookup导致的模板代码编译错误
<p>猜猜下面这段代码的输出是什么:</p><pre><code class="language-c++">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();
}
</code></pre>
<p>肯定有人会说是A,但实际上是编译错误:</p>
<pre><code class="language-console">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)
</code></pre>
<p>给的报错信息很让人迷惑,因为<code>DoThings</code>是明确声明定义在<code>Base<T></code>中的,这里居然在说它未被声明。</p>
<p>这其实是c++的Two Phase Lookup导致的。</p>
<p><code>Two Phase Lookup</code>如其字面意思,对于任何模板代码,编译器需要进行两次检查:</p>
<ol>
<li>Phase 1,第一步检查,只检查模板代码是否有语法错误,但涉及到和模板类型参数相关的部分会跳过。检查的范围包括是否有明显的语法错误比如用了不存在的关键字、少了分号等,其中也会检查那些和模板类型参数无关的函数、类型、方法是否已经被声明,这和编译器检查普通代码的流程很相似</li>
<li>Phase 2,这一步会往模板的参数里带入实际的类型,编译器会重新推导整个模板代码在当前的类型下是否合法</li>
</ol>
<p>两步骤是为了更快速地将类型参数不相关的问题排除,这样在保证模板代码语法正确性的同时尽量保证了泛型代码的灵活性,理想中也能让模板的编写者更快发现问题而不是把问题延迟到类型推导之后。</p>
<p>但坏处就是会让模板产生一下诡异的编译错误了,比如上面的<code>DoThings</code>。<code>DoThings</code>在这里是非限定名称,但没有参数,同时它也和<code>Derived</code>模板的类型参数不直接相关,这导致对<code>DoThings</code>的检查会在Phase 1执行,而Phase 1会忽略所有的模板参数相关内容,这导致<code>Base<T></code>在这时不可见,而我们又没有在其他地方定义<code>DoThings</code>,所以编译器认为我们在使用一个未声明的符号,于是报了语法错误。</p>
<p>解决方法也很简单,让<code>DoThings</code>和类型参数相关即可,或者通过this去调用,this代表了泛型模板类自身,也算和模板参数相关:</p>
<pre><code class="language-diff">template <typename T>
struct Derived: Base<T> {
void Do() {
- DoThings();
+ this->DoThings();
+ // Base<T>::DoThings(); 也可以
}
};
</code></pre>
<p>另外如果我们提供了自由函数<code>DoThings</code>,那么在Phase 1中就会把对应的名字认定为是在调用自由函数,这时编译器不再报错,但<code>Base<T></code>的方法永远调用不到了:</p>
<pre><code class="language-c++">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被调用
}
</code></pre>
<p>这很违反直觉,因为普通的非模板子类在这种时候会去基类的作用域里寻找同名的方法,但因为<code>Two Phase Lookup</code>,编译器在Phase 1把函数绑定到了全局的自由函数上,这导致了非预期的结果。</p>
<h2 id="总结">总结</h2>
<p>模板会有<code>Two Phase Lookup</code>做两遍检查,因此它和普通的代码行为上会有区别。</p>
<p>除了上面说的让方法和模板参数关联,其他补救措施还有很多,一种在GCC给出的编译报错里:加上<code>-fpermissive</code>启用permissive模式。在这个模式下不会进行<code>Two Phase Lookup</code>,模板会在实例化的时候再做检查,可以避免报错,但实测gcc-15上无法避免错误调用自由函数的问题。另外permissive模式会大幅改变语言和编译器的行为,贸然启用会出现很多意外问题。因此这一措施我并不推荐。</p>
<p>让方法名和模板参数相关也不能解决所有问题,因为还有很多时候我们需要利用非限定名称来自动选取合适的函数/方法,碰到这种情况就只能特殊场景特殊处理了。</p>
<p>简单地说,没有银弹,不存在一种万金油方法彻底规避这类错误。这也只是c++模板黑暗面的冰山一角罢了。</p><br><br>
来源:https://www.cnblogs.com/apocelipes/p/19205655 感谢分享!
又学到了一招,之前写模板代码的时候确实遇到过类似的奇怪报错,当时还以为是编译器的bug,原来是Two Phase Lookup在搞鬼。
补充一个小细节,除了你提到的用和调用之外,还可以使用using声明来强制引入基类名称:
template <typename T>
struct Derived: Base<T> {
using Base<T>::DoThings;
void Do() {
DoThings(); // 现在可以正常工作了
}
};
这样在派生类作用域内显式把基类的名字引入进来,Phase 1的时候就能找到声明了。
另外还有一个容易被坑的地方是,如果基类有多个模板参数特化版本,Two Phase Lookup有时候也会带来一些意想不到的行为。之前项目里就因为这个debug了很久,后来学乖了,写模板的时候尽量都用限定名称来调用基类成员,省得出问题。
C++模板确实坑多,不过理解了这些底层机制之后写代码就能避开很多暗坑了。版主这个帖子写得通俗易懂,赞一个!👍
頁:
[1]