MSVC已经支持“Two-phase name lookup”

[原文发表地址] Two-phase name lookup support comes to MSVC

[原文作者] Andrew Pardoe [MSFT]

[原文发表时间] 2017/9/11

“Two-phase name lookup” 是一个非正式术语,指的是一组被用于模板声明中控制名称解析的规则。这些规则在二十年前已经被形式化,试图调和两个相反的模板编译模型:包含模型(大多数开发人员知道的模板模型)和分离模型(模板原始设计的基点)。你可以在基础论文“Proposed Revisions to the Template Specification”找到依赖名称的起源,坚定地依据一个定义原则。如果你有兴趣深究细节的话,你可以在C++17标准草案的17.6节((stable name [temp.res]))中查找这些规则。在过去的几个月,MSVC编译器还不支持“Two-phase name lookup”规则下大多数可用代码。我们将在未来的VS2017更新中去完成此功能。

你需要使用‘/permissive-’一致性开关在VS2017 15.3打开“Two-phase name lookup”功能。“Two-phase name lookup”彻底改变了一些代码的含义,因此在当前的MSVC版本中默认情况下不开启此功能。

这篇文章准确的讲了”Two-phase name lookup”意味着什么,MSVC目前实现了什么,以及如何有效地利用MSVC对“Two-phase name lookup”的支持。即使你希望你其余代码严格符合标准,我们也会告诉你如何停用此功能。最后,我们解释为什么我们花了很长时间才走到这里——这些规则至少已经是25年了。

什么是“ two-phase name lookup ”?

C++最初模板设计意味着完全按照“模板”术语进行实现:一个模板可以压制类和函数。它允许和鼓励,但不强求提前检查非依赖名称。因此,在解析模板定义时不需要检查标识符,相反,编译器被允许延迟查找名称,直到模板被实例化。类似地,实例化之前,模板的语法不需要验证。基本上,在模板实例化时,模板中使用的名称才被确定。

按照这些原始规则, 以前版本的MSVC做了非常有限的模板解析。特别地,在实例化之前模板函数体没有被解析,编译器记录模板体为当模板需要初始化的时候被反复的令牌流,那么这个模板是可能是候选的。

我们来看看这段代码是什么意思。 链接提供给在线编译器,以便您在阅读本文时可以使用代码。

[cpp]
#include <cstdio>

void func(void*) { std::puts("The call resolves to void*") ;}

template<typename T> void g(T x)
{
func(0);
}

void func(int) { std::puts("The call resolves to int"); }

int main()
{
g(3.14);
}
[/cpp]

第7行调用哪一个重载函数呢?void*重载在模板定义的时候已经定义在了第5行。而函数void func(int)在模板定义时还不存在。因此,14行的模板函数void g(T x)应在第5行调用第3行的函数void func(void*)。

当编译符合标准规则时,程序输出“The call resolves to void*“。你可以用在线编译器查看GCC的输出使用VS2015时,还不支持”Two-phase name lookup”程序输出错误的结果” The call resolves to int “。

为什么MSVC过去得到错误的结果?我们用于解析模板的机制在模板很简单的时候工作,但限制了当”Two-phase name lookup”机制被使用的时候编译器的行为。MSVC之前将模板的主体记录为令牌流,并将该流存储在实例化时被反复。MSVC从记录的令牌流中的模板替换行为类似于宏替换的行为,因为有限的解析是由模板体完成的。

在这个例子中,MSVC存储了一个令牌流给函数模板void g(T x)。如果编译器遇到的时候已经分析了函数调用,只有函数void func(void*)申明已经被设定。(注意:这是一个有效的对于func(0)的匹配,因为C++允许0作为一个空常量指针可以被转换为任何指针类型。)

函数void func(int)也可以匹配func(0)的调用,除了它不应该在函数模板void g(T x)被匹配的位置重载。但是,直到实例化的时候,将void func(int) 的声明添加到重载集之后,在这一点上编译器为整数参数选择了更好的匹配:int而不是void *。

您可以在在线编译器上的这个代码示例中看到两个编译器的行为。 当第3行被注释掉时,GCC拒绝编译代码示例,而MSVC很高兴地匹配在模板被写入时甚至没有定义的函数。 如果它不是模板,它将被认为是非法代码,但是我们的破坏的模板替换机制允许编译器接受此代码。

C++标准委员会意识到,使用模板编写的代码不应该受到上下文环境的影响,同时也支持ODR。他们在模板中的名称绑定规则中也引入了依赖和非依赖名称的概念,因为在第10行上写的函数会改变上边代码的规则,这将是令人惊讶的行为。

标准列表部分中的规则有三种名称:

  1. 模板的名称和模板中定义的名称;
  2. 依赖于模板参数的名称;
  3. 模板定义范围内可见的名称。

第一类和第三类都是非依赖的名称,它们被绑定在模板定义的时候,而且在模板的每个实例化中依旧保持绑定关系,当模板实例化的时它们不会被查找。

第二类是依赖名称。依赖名称不会在模板定义时绑定,而是在实例化模板的时候查找这些名称。对于具有从属函数名称的调用,该名称绑定到模板定义中调用时可见的函数集。在模板定义和模板实例化的时候来自于参数依赖查找的附加重载被添加。

重点注意的是:模板定义之后但在模板实例化之前申明的重载只有在通过参数依赖查找找到时才被考虑,MSVC之前没有单独进行参数依赖找找,不合格查找,所以这种行为的变化可能是令人惊讶的。

考虑一下的代码案例,它同时也在在线编译器被支持

[cpp]
#include <cstdio>

void func(long) { std::puts("func(long)"); }

template <typename T> void meow(T t) {
func(t);
}

void func(int) { std::puts("func(int)"); }

namespace Kitty {
struct Peppermint {};
void func(Peppermint) { std::puts("Kitty::func(Kitty::Peppermint)"); }
}

int main() {
meow(1729);
Kitty::Peppermint pepper;
meow(pepper);
}
[/cpp]

meow(1792)调用void func(long)而不是void func(int)重载,这是因为不符合的func(int)是在模板定义之后出现的,而且也没有通过参数依赖查找。但是void func(Peppermint)却参与了参数依赖查找,因此它被添加到重载集中并调用meow(pepper)。

通过上边的例子,你可以看见两种阶段的“two-phase lookup”模板定义时的非依赖名称查找和模板实例化时的依赖名称查找规则。

Visual Studio 2017 15.3之前的MSVC行为

过去,遇到模板时,MSVC编译器执行以下步骤:

  • 当解析一个类模板,以前的MSVC仅解析了模板申明,类头部和基类列表。模板体被捕获为令牌流。没有函数体,初始值,默认参数或者noexcept参数被解析。类模板在“暂时”的类型上伪实例化,以验证类模板中的声明是否正确。看下这个例子:

template <typename T> class Derived : public Base<T> { ... }。这个模板申明template <typename T>,类头部class Derived 和基类列表public Base<T>都被解析了,但是模板体{...}却被捕获为令牌流。

  • 当解析一个函数模板,以前的MSVC仅解析函数签名。函数体从来不解析并且被捕获为一个令牌流。因此,如果一个模板体有语法错误且模板不被实例化,那么错误永远不会被发现。

MSVC不需要template和typename关键字就可以看出这种行为带来的错误解析的例子,这些关键字在某些位置需要消除编译器在查找第一阶段期间如何解析依赖名称的歧义。例如,思考这一行代码:

[cpp]
T::Foo<a || b>(c);
[/cpp]

这行代码是函数模板用“a||b”做参数的调用?还是逻辑或操作表达式T::foo <作为做操作符和> (c)作为有操作符呢?

一个符合标准的编译器将会解析Foo作为一个T范围类的变量,意味着这代码是两个比较数的或操作符。如果你想要Foo作为一个函数模板,你必须通过模板关键词指明这是一个模板。例如:

[cpp]
T::template Foo<a || b>(c);
[/cpp]

VS2017 15.3 以前,MSVC允许代码没有template关键字,因为MSVC以非常有限的方式解析模板。上边的代码不会在第一阶段被解析。在第二阶段有足够的代码去告诉T::Foo 是一个模板而不是一个变量,因此MSVC没有强制使用这个关键字。

这种行为也可以通过在函数模板体,初始值,默认参数和noexcept参数中删除关键字typename被看见。思考这段代码:

[cpp]
template<typename T>
typename T::TYPE func(typename T::TYPE*)
{
typename T::TYPE i;
}
[/cpp]

如果你删除关键字typename在函数体的第4行,MSVC依然会编译这段代码,而具有一致性的编译器会拒绝这段代码。你需要typename关键字去指明这个类型是依赖的。因为之前的MSVC没有解析函数体所以不需要typename关键字。 你可以在在线编译器查看这个例子。由于编译例如这样的代码在MSVC一致性模式下(/permissive-)将会得到错误,在当未来新的MSVC 19.11版本中将会在代码中确认查找丢失typename的地方。

类似地,在这个例子中:

[cpp]
template<typename T>
typename T::template X<T>::TYPE func(typename T::TYPE)
{
typename T::template X<T>::TYPE i;
}
[/cpp]

之前的MSVC只需要第2行的template关键字。具有一致性的编译器还需要在第4行需要template关键字来指明T::X<T>是一个模板。在在线编译器去掉这个例子中的关键字去观察错误的。再一次,请记住这个缺失的关键字当你在将来写代码的时候。

VS2017 15.3 中的“ Two Phase Name Lookup

我们介绍了在VS2017中的“一致性模式”开关。在VS2017 V141 compiler工具集发布时,你可以使用/permissive-开关去打开这个一致性模式。(在下一个主要的编译器版本,一致性模式将会是默认开启的,在那时你将使用/permissive开关去请求非一致性模式(去掉-)和其他编译器-fpermissive开关非常相似)。当我们引入/permissive-开关时,缺少的一大功能就是“Two Phase Name Lookup”,现在已经在VS2017 15.3的编译器中部分实现了。

这里有一些“Two Phase Name Lookup”丢失的部分——看下一部分的 “接下来会发生什么” 的详细信息。但是MSVC编译器现在是解析正确,而且严格地强制语法规则:

  • 类模板
  • 函数模板体和类模板的成员函数
  • 初始值,包含成员初始值
  • 默认参数
  • noexcept参数

另外,MSVC的实施STL是完全干净的two-phase(通过在MSVC验证了/permissive-,还验证了Clang的-fno-ms-compatibility -fno-delayed-template-parsing)。我们最近得到了ATL的two-phase也是干净的。如果你发现了任何拖延的bug请一定让我们知道。

但是,你可以依赖旧的代码和MSVC错误的行为为你遗留的代码做些什么?即使你的代码尚未准备好让模板体解析和依赖名称正确地绑定,你任然可以使用/permissive-其余的一致性改进。只需通过/Zc:twoPhase-开关关闭模板解析和依赖名称绑定。使用此开关将会导致MSVC编译器使用具有非标准语法的旧行为,使你有机会使用符合MSVC编译器来正确的编译代码。

如果你正在使用具有/permissive-开关的Windows RedStone2 SDK则需要使用/Zc:twoPhase-开关暂时禁用“two-phase name lookup”,直到Windows RedStone3 SDK可用。这是因为Windows团队一直在与MSVC团队合作,是的SDK头文件可以正常工作且具有“two-phase name lookup”功能。在RedStone3 Windows SDK发布之前,它们的更改将不可用,也不会将“two-phase name lookup”的更改移回RedStone2 Windows SDK。

接下来会发生什么

MSVC对“two-phase name lookup”的支持是一项正在进行的工作。这里列出一些在未来VS2017中的MSVC更新。记住你需要在这些例子中使用/permissive-开关去开启” two-phase lookup“功能。

  1. 模板中未定义的标识符不被诊断。E.g.

    [cpp]
    template<class T>
    void f()
    {
    i = 1; // Missing error: `i` not declared in this scope
    }
    [/cpp]

    MSVC没有报错’i’是未定义的而且代码编译成功。添加一个f()的初始化将会导致错误的发生。

    [cpp]
    template<class T>
    void f()
    {
    i = 1; // Missing error: `i` not declared in this scope
    }

    void instantiate()
    {
    f<int>();
    }
    [/cpp]

    [text]
    C:\tmp> cl /c /permissive- /diagnostics:caret one.cpp
    Microsoft (R) C/C++ Optimizing Compiler Version 19.11.25618 for x64
    Copyright (C) Microsoft Corporation. All rights reserved.

    one.cpp
    c:\tmp\one.cpp(4,5): error C2065: 'i': undeclared identifier
    i = 1;
    ^
    c:\tmp\one.cpp(9): note: see reference to function template instantiation 'void f<int>(void)' being compiled
    f<int>();
    [/text]

  1. VS 2017 15.3 将会给出一个丢失template和typename关键字的错误,但是没有建议添加该关键字。编译器从来没有给出太多的诊断信息。

    [cpp]
    template <class T>
    void f() {
    T::Foo<int>();
    }
    [/cpp]

    MSVC编译器VS 2017 15.3给出以下错误:

    [text]
    C:\tmp>cl /c /permissive- /diagnostics:caret two.cpp
    Microsoft (R) C/C++ Optimizing Compiler Version 19.11.25506 for x64
    Copyright (C) Microsoft Corporation. All rights reserved.

    two.cpp
    two.cpp(3,16): error C2187: syntax error: ')' was unexpected here
    T::Foo<int>();
    ^
    [/text]

    不久的VS2017将会给出下边的详细错误:

    [text]
    C:\tmp>cl /c /permissive- /diagnostics:caret two.cpp
    Microsoft (R) C/C++ Optimizing Compiler Version 19.11.25618 for x64
    Copyright (C) Microsoft Corporation. All rights reserved.

    two.cpp
    two.cpp(3,7): error C7510: 'Foo': use of dependent template name must be prefixed with 'template'
    T::Foo<int>();
    ^
    two.cpp(3,4): error C2760: syntax error: unexpected token 'identifier', expected 'id-expression'
    T::Foo<int>();
    ^
    [/text]

  1. 编译器不可能在参数依赖查找时查找函。这将会导致运行时调用错误的函数。

    [cpp]
    #include <cstdio>

    namespace N
    {
    struct X {};
    struct Y : X {};
    void f(X&)
    {
    std::puts("X&");
    }
    }

    template<typename T>
    void g()
    {
    N::Y y;
    f(y); // This is non-dependent but it is not found during argument-dependent lookup so it is left unbound.
    }

    void f(N::Y&)
    {
    std::puts("Y&");
    }

    int main()
    {
    g<int>();
    }
    [/cpp]

    运行程序输出的是Y&但是应该是X&。

    [text]
    C:\tmp>cl /permissive- /diagnostics:caret three.cpp
    Microsoft (R) C/C++ Optimizing Compiler Version 19.11.25506 for x64
    Copyright (C) Microsoft Corporation. All rights reserved.

    three.cpp
    Microsoft (R) Incremental Linker Version 14.11.25506.0
    Copyright (C) Microsoft Corporation. All rights reserved.

    /out:three.exe
    three.obj

    C:\tmp>three
    Y&
    [/text]

  1. 包含本地申明的非依赖类型表达式不会被正确的分析。MSVC编译器目前解析类型为依赖类型,导致不正确的错误。

    [cpp]
    template<int> struct X
    {
    using TYPE = int;
    };

    template<typename>
    void f()
    {
    constexpr int i = 0;
    X<i>::TYPE j;
    }
    [/cpp]

    一个语法错误出现了,因为当第9行是非依赖的值表达式时i不会被正确的分析。

  2. [text]

  3. C:\tmp>cl /c /permissive- /diagnostics:caret four.cpp

  4. Microsoft (R) C/C++ Optimizing Compiler Version 19.11.25618 for x64

  5. Copyright (C) Microsoft Corporation. All rights reserved.

  6. four.cpp

  7. four.cpp(10,16): error C2760: syntax error: unexpected token 'identifier', expected ';'

  8. X<i>::TYPE j;

  9. ^

  10. four.cpp(10,5): error C7510: 'TYPE': use of dependent type name must be prefixed with 'typename'

  11. X<i>::TYPE j;

  12. ^

  13. [/text]

  1. 重定义模板参数和重定义模板函数参数作为本地名字从来都不报错:

    [cpp]
    template<class T>
    void f(int i)
    {
    double T = 0.0; // Missing error: Declaration of `T` shadows template parameter
    float i = 0; // Missing error: Redefinition of `i` with a different type
    }
    [/cpp]

  2. MSVC编译器在某种情况下不识别当前的初始化。使用关键字typename是合法的,而且帮助编译器正确的识别当前的初始化。

    [cpp]
    template<class T> struct A {
    typedef int TYPE;
    A::TYPE c1 = 0; // Incorrectly fails to compile
    A<T>::TYPE c2 = 0; // Incorrectly fails to compile
    };
    [/cpp]

    A的每一个初始化之前添加关键字typename都允许代码编译通过:

    [cpp]
    template<class T>
    struct A
    {
    typedef int TYPE;
    typename A::TYPE c1 = 0;
    typename A<T>::TYPE c2 = 0;
    };
    [/cpp]

  1. 未定义默认的参数是不被诊断出来的。这个例子示范了一种情况就是MSVC还在做one-phase查找。模板申明之后发现SIZE的定义,就好像它被定义在模板之前。

    [cpp]
    template<int N = SIZE> // Missing diagnostic: Use of undeclared identifier `SIZE`
    struct X
    {
    int a[N];
    };

    constexpr int SIZE = 42;

    X<> x;
    [/cpp]

以上的所有问题都被计划在下一个VS2017的重要更新版本中被解决。

为什么花费了这么长的时间?

其他编译器已经具有了“two-phase name lookup”相当长一段时间了,为什么MSVC却才刚刚拥有它?

实施“two-phase name lookup”需要MSVC的基建发生根本性的变化。这个巨大的改变需要写一个新的递归下降解析器来替换基于YACC的解析器,YACC我们已经使用了超过35年之久。

我们早点决定遵循增量路径,而不是从头开始重写编译器。 将老化的MSVC代码库转变为更现代化的代码库,而不是大改造成“灾难”,这样我们可以在编译现有代码时不引入不易察觉的bug和破坏性的改变,进行巨大的更改。 我们的“编译器复兴”工作需要仔细地桥接旧的代码和新的代码,确保所有的现有代码的大型测试套件的所有情况继续编译完全一样(除非我们有意想要改变以引入符合行为。 )以这种方式开展工作花了一点时间,但这让我们能够为开发人员提供增值的价值。 我们已经能够做出重大改变,而不会意外地破坏您现有的代码。

结束语

我们很高兴MSVC终于支持了“two-phase name lookup”。我们知道编译器任然不会正确的编译一些模板代码,如果你发现了这篇文章没有提到的情况,请联系我们,以便我们修复bug。

文章中的代码示例依据标准正确编译(或无法编译)。使用VS2017 15.3你将看到这种新行为,或者你可以使用每天的MSVC编译器的构建来尝试。

现在是开始使用/permissive-开关来转移代码的好时机。记住当你遇见模板解析错误时,添加以前没有要求的关键字template和typename(见上文)可能会修复错误。

如果您对我们有任何反馈或建议,请告诉我们。 我们可以通过电子邮件(visualcpp@microsoft.com)通过以下评论访问得到你的反馈,您可以通过帮助-->产品中的报告问题或通过开发者社区提供反馈。 你也可以在Twitter(@VisualC)和Facebook(msftvisualcpp)上找到我们。