c++标识符的隐藏与歧义现象

publish time : 2021-02-05T02:01:00+08:00


1. 内容提要

  • 标识符的隐藏 (hidden)

  • 标识符的歧义 (ambiguous)

  • 作用域的分类

  • 编译器的标识符查找规则

   本文主要讨论c中标识符的隐藏(hidden)和歧义(ambiguous,或称二义性)现象以及作用域的分类和编译器的标识符查找规则。顺带提及c11中using的新用法可能引发的命名冲突(name conflict)。讨论环境为c++11标准。

   如有不够详尽乃至谬误之处,还望不吝斧正。


2.先行案例

  首先我们先观察以下两个关于隐藏例子,预判一下它们的输出。

2.1 例1

  输出结果是“package::var”还是“super::var”?

// @brief 例1:输出结果是“package::var”还是“super::var”?
// @author Qiujun Lv
// @date 2021-02-05T02:01:00+08:00
// @standard c++11
// @compile 代码的测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、
//   X86-64 msvc v19.27、X86 msvc v19.27、mingw 73_64、mingw 73_32、
//   ARM64 gcc 6.4、ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
#include <iostream>
namespace package
{
std::string var = "package::var";
}//namespace package

void Func()
{
  std::string var = "super::var";
  {//一个函数体内部的代码块
    //使用指令(using-directive):导入了命名空间“package”,其中包含一个“var”标识符
    using namespace package ;
    //同时在上层代码块中也存在一个“var”,下行代码将访问到哪一个var呢?
    std::cout<< var/*?*/ << std::endl;
  }
}

int main()
{
  Func();//将输出什么信息?
  return 0;
}

2.2 例2

  输出结果是“package::var”还是“super::var”?

// @brief 例2:输出结果是“package::var”还是“super::var”?
// @author Qiujun Lv
// @date 2021-02-05T02:01:00+08:00
// @standard c++11
// @compile 代码的测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、
//   X86-64 msvc v19.27、X86 msvc v19.27、mingw 73_64、mingw 73_32、
//   ARM64 gcc 6.4、ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
#include <iostream>
namespace package
{
std::string var = "package::var";
}//namespace package

void Func()
{
  std::string var = "super::var";
  {//一个函数体内部的代码块
    //使用声明(using-declaration):导入了命名空间“package”中的“var”标识符
    using package::var ;
    //同时在上层代码块中也存在一个“var”,下行代码将访问到哪一个var呢?
    std::cout<< var/*?*/ << std::endl;
  }
}

int main()
{
  Func();//将输出什么信息?
  return 0;
}

  例1的输出结果为“super::var”、例2的输出结果为“package::var”(ps:为了方便,推荐使用在线编译器快速测试本文中的示例代码)。
  以上二例对于初学者来说可能存在一定的迷惑性,初学c++时,隐藏现象首次给我带来困扰是下面的例3。

2.3 例3

  继承和覆盖似乎没问题,但重载的函数怎么不见了?

// @brief 例3:继承和覆盖似乎没问题,但重载的函数怎么不见了?
// @author Qiujun Lv
// @date 2021-02-05T02:01:00+08:00
// @standard c++11
// @compile 本篇代码须修改后才能通过编译。
//   代码的测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、
//   X86-64 msvc v19.27、X86 msvc v19.27、mingw 73_64、mingw 73_32、
//   ARM64 gcc 6.4、ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
class BaseClass
{
public:
  virtual void Func(){ }
  void Func(int){ }
  void Func(int,int){ }
};

class DerivedClass : public BaseClass
{
public:
  void Func() override { }
};

int main()
{
  DerivedClass obj;
  obj.Func();
  obj.Func(0);//错误:'Func'函数参数过多;编译器认为不存在Func(int)这个函数
  obj.Func(0,1);//错误:'Func'函数参数过多;编译器认为不存在Func(int,int)这个函数
  return 0;
}

  诸如此类的问题层出不穷,但事物都是越接近具体,变化越多,而众多外现的本质往往只是几个简单抽象概念的组合。

  产生各类隐藏现象的本质原因是作用域分类和编译器标识符查找规则,所以在对隐藏(hidden)下定义前,我们先行讨论作用域分类和标识符查找规则。


3.问题归纳

3.1 作用域的分类

  任何标识符的声明/定义必须绑定于一个具体的作用域。简单地,按作用域的包含关系(由内及外地)可分为以下四类:

  1. 局部作用域:函数体内部的代码块“{}”,包括流程结构附带的代码块“{}”(例如:if() { })。局部作用域可嵌套包含其他的“局部作用域”,嵌套式每层代码块被视为不同的作用域个体。

  2. 函数作用域:函数体代码块直接包含的区域及参数列表;函数作用域可以嵌套包含局部作用域

  3. 类作用域:类的顶级代码块直接包含的区域(此处不对类和对象再做细分);类作用域可嵌套包含函数作用域、其他类作用域(内部类),同时,类作用域还可以拥有若干个直接/间接的基类作用域

  4. 命名空间作用域:由“namespace x {}”直接包含的常规命名空间、未被任何“{}”包含的全局命名空间以及被不具名的namespace{}包含的匿名命名空间;命名空间作用域可嵌套包含类作用域函数作用域及其他命名空间作用域

3.2 标识符查找规则

  除重载函数外,在每个作用域实例内声明/定义的标识符名字必须是唯一的。 反过来讲,在不同的作用域中完全有可能存在着一些名字相同的标识符。
  若非使用作用域限定符“::”加以明确,仅通过指定一个名字的方式来访问某个标识符时,标识符查找规则决定了编译器如何行动,进而决定最终访问的是哪个作用域中的标识符。假定我们在某个类的成员函数内部的局部作用域中,试图访问一个标识符,编译器的查找工作将遵循就近规则基于当前位置将各相关作用域划分为4个组别依次进行查找:所有的局部作用域函数作用域所有的类作用域所有的命名空间作用域,直至首次匹配成功、遍历无果或发现歧义。一旦某个组别汇报匹配成功,则不再进行后续组别的查找;若全部组别均未匹配成功,则汇报“未定义的标识符”,代码将无法通过编译;若在某个组别内查找时发现歧义,则汇报“ambiguous”(模棱两可的),代码无法编译通过。在各组别内的具体查找规则如下:

  1. 所有的局部作用域:由于访问标识符的代码所在的局部作用域(代码块)的外层可能嵌套着其他局部作用域(代码块),该组别的查找顺序将按嵌套层级自内向外进行,直至首次匹配成功(即找到名字相符的标识符)或遍历完顶级的局部作用域(即被函数体直接包含的代码块)。

  2. 函数作用域:直到匹配成功或遍历无果。

  3. 所有的类作用域:访问标识符的代码所在的类作用域(class的顶级代码块)可能拥有多个直接和间接基类,即可能存在一条或多条继承链路,本组别内的查找将遍历全部继承链路,每条继承链路按继承的层级将自下向上进行(如果存在上级的话),直到匹配成功或遍历完顶级基类。当全部的继承链路都查找完毕时,将存在以下三种结果:

    • 全部继承链路均未匹配到标识符,本组别查找结束;

    • 有且只有一条继承链路匹配到了标识符,查找成功;

    • 在不止一条的继承链路上匹配到了标识符,确认存在歧义,汇报“存在歧义”(ambiguous,或称二义性);

  4. 所有的命名空间作用域:在全局命名空间作用域和所有使用using关键字导入的名字空间作用域中查找,遍历完成后,将存在以下三种结果:

    • 全部命名空间作用域均未匹配到标识符,本组别查找结束;

    • 有且只有一个命名空间作用域匹配到了标识符,查找成功;

    • 在不止一个命名空间作用域中匹配到了标识符,确认存在歧义,汇报“存在歧义”(ambiguous,或称二义性);

  编译器的标识符查找行为具体包含哪些作用域组别取决于访问指令的位置。例如访问指令被直接包含于一个全局函数的函数体,则需要查找的组别将只包括函数作用域和所有的命名空间作用域。至于编译器的具体行为是等同于深度优先抑或广度优先遍历我们无需再做讨论,因为这不会影响结果。

3.3 访问歧义与重复定义

  同名标识符可导致两种无法通过编译的情况:

  1. 在同一作用域内存在不止一个同名的标识符(重载函数除外),称重复定义(redefinition);

  2. 访问某标识符时,在与访问位置相关的全部作用域中存在不止一个同名的标识符且无法通过标识符查找规则加以明确的情况,称访问歧义(ambiguous,或称二义性);

3.4 隐藏现象

  综上所述,在任何不同的作用域内声明/定义同名标识符都是合法的(局部作用域/名字空间作用域发生嵌套的情况下,各代码块视作不同的作用域),但访问存在歧义的标识符时将无法通过编译,此时需要作用域符“::”加以明确。接下来我们尝试对标识符的隐藏(hidden)现象进行定义:

  隐藏(hidden)指因临近作用域的标识符被查找行为优先匹配而导致的其他相关作用域中全部同名标识符被屏蔽的现象。隐藏(hidden)是编译器标识符查找规则的外现。

  上文例3中基类的重载函数消失的原因正在于此,由于子类作用域中存在标识符“Func”而导致基类作用域中的三个同名标识符被全部隐藏了。
  跨类型的函数重载在c++11中可按如下方法实现:

// @brief 跨类型的函数重载在c++11中的实现示例
// @author Qiujun Lv
// @date 2021-02-05T02:01:00+08:00
// @standard c++11
// @compile 码的测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、
//   X86-64 msvc v19.27、X86 msvc v19.27、mingw 73_64、mingw 73_32、
//   ARM64 gcc 6.4、ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
class BaseClass
{
public:
  virtual void Func(){ }
  void Func(int){ }
  void Func(int,int){ }
};

class DerivedClass : public BaseClass
{
public:
  void Func() override { }
  //使用声明(using-declaration):导入BaseClass中全部名为Func的标识符
  using BaseClass::Func; 
};

int main()
{
  DerivedClass obj ;
  obj.Func();
  obj.Func(0);//可以通过编译了 
  obj.Func(0,1);//可以通过编译了
  return 0;
}

  更多关于c++11中using的语义请移步参阅我的另一篇博文《using语义的扩展及汇总》


4.后续案例

  接下来,我们一起看下面的几个案例,其中有隐藏、访问歧义和重复定义。

4.1 例4

  常规标识符查找,本例演示了各组别作用域存在大量同名标识符的情况。
  可以结合章节3.2所述的查找规则进行思想实验,模拟本例中编译器的6次标识符查找过程。

// @brief 例4:标识符隐藏,本例演示了各组别作用域存在大量同名标识符的情况
// @author Qiujun Lv
// @date 2021-02-05T02:01:00+08:00
// @standard c++11
// @compile 本篇代码须修改后才能通过编译。
//   代码的测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、
//   X86-64 msvc v19.27、X86 msvc v19.27、mingw 73_64、mingw 73_32、
//   ARM64 gcc 6.4、ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
#include <iostream>

namespace package
{//名字空间作用域
std::string var0 = "namespace::var0";
std::string var1 = "namespace::var1";
std::string var2 = "namespace::var2";
std::string var3 = "namespace::var3";
std::string var4 = "namespace::var4";
std::string var5 = "namespace::var5";
}

class BaseClass
{//类作用域(基类)
public:
  std::string var0 ;//糟糕的可见性和命名,请参见《google开源项目风格指南》
  std::string var1 ;
  std::string var2 ;
  std::string var3 ;
  std::string var4 ;

  BaseClass()
  {
    var0 = "Class(Base)::var0";
    var1 = "Class(Base)::var1";
    var2 = "Class(Base)::var2";
    var3 = "Class(Base)::var3";
    var4 = "Class(Base)::var4"; 
  }
};

class DerivedClass : public BaseClass
{//类作用域(派生类)
public:
  std::string var0 ;
  std::string var1 ;
  std::string var2 ;
  std::string var3 ;

  DerivedClass() : BaseClass()
  {
    var0 = "Class(Derived)::var0";
    var1 = "Class(Derived)::var1";
    var2 = "Class(Derived)::var2";
    var3 = "Class(Derived)::var3";
  }

  void Func()
  {//函数作用域
    std::string var0 = "Fuction::var0";
    std::string var1 = "Fuction::var1";
    std::string var2 = "Fuction::var2";
    {//局部作用域(外层)
      std::string var0 = "local(outer)::var0";
      std::string var1 = "local(outer)::var1";

      {//局部作用域(内层)
        using namespace package ;
        std::string var0 = "local(inner)::var0";

        std::cout << var0 << std::endl;// local(inner)::var0 - 局部作用域(内层)
        std::cout << var1 << std::endl;// local(outer)::var1 - 局部作用域(外层)
        std::cout << var2 << std::endl;// Fuction::var2 - 函数作用域
        std::cout << var3 << std::endl;// Class(Base)::var3 - 类作用域(本类)
        std::cout << var4 << std::endl;// Class(Derived)::var4 - 类作用域(基类)
        std::cout << var5 << std::endl;// namespace::var5 - 名字空间作用域
      }
    }
  }
};

int main()
{
  DerivedClass obj_b;
  obj_b.Func();
  return 0;
}
// 预期输出:
// local(inner)::var0
// local(outer)::var1
// Fuction::var2
// Class(Derived)::var3
// Class(Base)::var4

4.2 例5

  命名空间作用域查找组别内的访问歧义。

// @brief 例5:命名空间作用域查找组别内的访问歧义
// @author Qiujun Lv
// @date 2021-02-05T02:01:00+08:00
// @standard c++11
// @compile 本篇代码须修改后才能通过编译。
//   代码的测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、
//   X86-64 msvc v19.27、X86 msvc v19.27、mingw 73_64、mingw 73_32、
//   ARM64 gcc 6.4、ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
#include <iostream>

namespace package_one
{
  std::string var0 = "package_one::var0";
  std::string var1 = "package_one::var1";
}

namespace package_two
{
  std::string var1 = "package_two::var1";
}


int main()
{
  using namespace package_one;
  using namespace package_two;

  std::cout << var0 << std::endl ;
  std::cout << var1 << std::endl ; //无法通过编译:该访问在命名空间作用域组别中存在歧义(ambiguous)
  //解决方案:使用添加作用域符“::”排除歧义后,编译通过
  std::cout << package_two::var1 << std::endl ;
  return 0;
}

4.3 例6

  类作用域查找组别内的访问歧义

// @brief 例6:类作用域查找组别内的访问歧义
// @author Qiujun Lv
// @date 2021-02-05T02:01:00+08:00
// @standard c++11
// @compile 本篇代码须修改后才能通过编译。
// 测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、X86-64、
//   msvc v19.27、mingw 73_64、mingw 73_32、X86 msvc v19.27、ARM64 gcc 6.4、
//   ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
#include <iostream>

class ClassA
{
public:
  std::string var0 = "ClassA::var0";
  std::string var1 = "ClassA::var1";
};

class ClassB : public ClassA
{
public:
  std::string var1 = "ClassB::var1";
  std::string var2 = "ClassB::var2";
};
class ClassC
{
public:
  std::string var0 = "ClassC::var0";
  std::string var2 = "ClassC::var2";
};

class ClassD:public ClassB,public ClassC
{
public:
  void Func(){
    //无法通过编译,var0存在歧义(ambiguous),详见“图1”。应当使用作用域限定符加以明确:
    //ClassA::var0(还可书写为this->ClassA::var0)或ClassC::var0
    std::cout<< var0 << std::endl;
    //编译通过,访问的是ClassB::var1,ClassA::var1被隐藏,详见“图2”
    std::cout<< var1 << std::endl;
    //无法通过编译,var2存在歧义(ambiguous),详见“图3”。应当使用作用域限定符加以明确:
    //ClassB::var2或ClassC::var2
    std::cout<< var2 << std::endl;
  }
};

int main()
{
    ClassD obj ;
    obj.Func();
    return 0;
}

  本例中针对var1的访问是明确的,而访问var0或var2将产生歧义,下面三张示意图简单模拟了编译器的查找行为:

image0
图1 - 标识符var0查找示意

image1
图2 - 标识符var1查找示意

image2
图3 - 标识符var2查找示意

  注意,以上三张示意图目的在于阐述标识符查找规则,谨做参考。其中灰色箭头代表的动作可能并不存在,步骤(1)至步骤(4)所体现的时序也不是严格的规则,这与具体编译器的实现相关,但在c++11标准下各编译器查找的结果将与示意图的结果一致。

4.4 例7

  使用声明(using-declaration,c++11新增)导致的重复定义。

// @brief 例7:使用声明(using-declaration,c++11新增)导致的命名冲突
// @author Qiujun Lv
// @standard c++11
// @compile 本篇代码须修改后才能通过编译。
// 测试编译器列表:X86-64 gcc 10.1、X86-64 clang 11.0.0、X86-64、
//   msvc v19.27、mingw 73_64、mingw 73_32、X86 msvc v19.27、ARM64 gcc 6.4、
//   ARM gcc 6.4、 ARM64 gcc 6.3.0(linux)
#include <iostream>

namespace package
{
std::string var0 = "package::var0";
std::string var1 = "package::var1";
}

class ClassA
{
public :
  void Func()
  {
    {
      using package::var0;//using : 使用声明(using-declaration)
      //编译失败:因命名冲突而无法通过编译,
      //使用声明“using x”视同在当前作用域声明标识符,标识符的定义保持不变
      std::string var0 = "Function::var0";
    }

    {
      using namespace package;//using : 使用指令(using-directive)
      std::string var0 = "Function::var0";
      //编译通过,下行var0标识符最终访问的是局部作用域中的var0,命名空间中的var0被隐藏
      std::cout << var0 << std::endl ;
      std::cout << var1 << std::endl ;//编译通过:本行访问的是命名空间中的var1
    }
  }
};

int main()
{
    ClassA obj;
    obj.Func();
    return 0;
}

5.结束语

  • 声明/定义标识符时:仅在当前作用域中已有同名标识符时才会引发重复定义(redefinition)错误而无法通过编译;

  • 访问标识符时:若存在歧义则无法通过编译;若无歧义则可能存在被隐藏的标识符;

  • 标识符的隐藏歧义重复定义的根据是作用域规则编译器标识符查找规则

  全局命名空间作用域的滥用或过度使用“using”显然容易导致访问歧义,而时常带着冗长的名字空间和作用域限定符来访问标识符的痛苦也是切实的。

  深入了解相关规则后,值得思考下个问题是如何通过合理地规划作用域、规范标识符命名规则、使用using关键字、设置别名等手段,实现不易混淆(产生歧义/重复定义/名字冲突)、易于阅读、编写方便的优质代码。

  这里推荐参考《google开源项目风格指南》

  感谢阅读,江湖再会。


本文章使用limfx的vscode插件快速发布