假设你第一次开车去朋友家,给你的地址是Mill City的245 Front Street。当你到达Mill City时,你拿出地图,却发现Mill City实际上有两条不同的Front Street,它们在城市的两端!你会去哪一个?
除非有一些额外的线索帮助你决定(例如,你记得朋友的家在河边),否则你得打电话给朋友,请求更多信息。因为这种情况会让人感到困惑且效率低下(特别是对于邮递员来说),所以在大多数国家,城市内的所有街道名称和房屋地址都必须是唯一的。
C++中的命名冲突
同样,C++要求所有标识符都是非模糊的。如果两个相同的标识符以一种方式被引入到同一个程序中,以至于编译器或链接器无法区分它们,编译器或链接器将产生一个错误。这个错误通常被称为命名冲突(或命名冲突)。
如果发生冲突的标识符被引入到同一个文件中,结果将是一个编译器错误。如果发生冲突的标识符被引入到属于同一个程序的不同文件中,结果将是一个链接器错误。
命名冲突的实例分析
一个命名冲突的例子 a.cpp:
#include <iostream>
void myFcn(int x)
{
std::cout << x;
}
main.cpp:
#include <iostream>
void myFcn(int x)
{
std::cout << 2 * x;
}
int main()
{
return 0;
}
当编译器编译这个程序时,它会独立编译a.cpp和main.cpp,每个文件都会顺利编译,没有问题。
然而,当链接器执行时,它会将a.cpp和main.cpp中的所有定义链接在一起,并发现函数myFcn()有冲突的定义。然后链接器会报错并中止。请注意,即使从未调用过myFcn(),也会发生这个错误!
命名冲突的常见情况
大多数命名冲突发生在两种情况下:
两个(或更多)同名的函数(或全局变量)被引入到属于同一个程序的不同文件中。这将导致链接器错误,如上所示。
两个(或更多)同名的函数(或全局变量)被引入到同一个文件中。这将导致编译器错误。
随着程序变得越来越大,使用的标识符越来越多,引入命名冲突的可能性显著增加。好消息是C++提供了大量避免命名冲突的机制。局部作用域就是其中之一,它保持在函数内部定义的局部变量不相互冲突。但是局部作用域不适用于函数名。那么我们如何防止函数名相互冲突呢?
作用域区域的概念
回到我们的地址类比,有两个Front Street之所以有问题,是因为这些街道存在于同一个城市内。另一方面,如果你需要递送邮件到两个地址,一个在Mill City的245 Front Street,另一个在Jonesville的245 Front Street,那么你就不会对去哪里感到困惑。换句话说,城市提供了分组,使我们能够消除可能相互冲突的地址。
作用域区域是源代码中的一个区域,其中声明的所有标识符都被认为是与其他作用域中声明的名称不同的(就像我们类比中的城市)。两个同名的标识符可以在不同的作用域区域中声明,而不会导致命名冲突。然而,在一个给定的作用域区域内,所有标识符必须是唯一的,否则将导致命名冲突。
函数的主体就是一个作用域区域的例子。两个同名的标识符可以在不同的函数中定义,而不会有问题——因为每个函数提供了一个独立的作用域区域,所以不会有冲突。然而,如果你尝试在同一个函数中定义两个同名的标识符,将会导致命名冲突,编译器会报错。
命名空间的作用
命名空间提供了另一种类型的作用域区域(称为命名空间作用域),允许你在其中声明或定义名称,以便于消除歧义。在命名空间中声明的名称与在其他作用域中声明的名称是隔离的,允许这些名称存在而不会发生冲突。
关键见解
在作用域区域(如命名空间)内声明的名称与在另一个作用域中声明的任何相同名称都是不同的。
例如,两个具有相同声明的函数可以定义在不同的命名空间内,不会产生命名冲突或歧义。 命名空间只能包含声明和定义(例如变量和函数)。除非它们是定义的一部分(例如在函数内),否则不允许执行语句。
关键见解
命名空间只能包含声明和定义。执行语句只允许作为定义的一部分(例如函数的定义)。
命名空间通常用于在大型项目中对相关标识符进行分组,以确保它们不会意外地与其他标识符发生冲突。例如,如果你将所有的数学函数都放在一个名为math的命名空间内,那么你的数学函数就不会与math命名空间外同名的函数发生冲突。
我们将在以后的课程中讨论如何创建自己的命名空间。
全局命名空间
在C++中,任何不在类、函数或命名空间内定义的名称都被认为是属于一个隐式定义的命名空间,称为全局命名空间(有时也称为全局作用域)。
在上面的课程示例中,main()函数和两个版本的myFcn()都是在全局命名空间内定义的。示例中遇到的命名冲突是因为两个版本的myFcn()最终都在全局命名空间内,这违反了作用域区域内所有名称必须是唯一的规则。
目前,有两件事你应该知道:
在全局作用域内声明的标识符从声明点到文件末尾都是有效的。
尽管可以在全局命名空间中定义变量,但通常应该避免这样做(我们将在第7.8课——为什么(非常量)全局变量是邪恶的中讨论原因)。
例如:
#include <iostream> // 将std::cout的声明导入到全局作用域
// 以下所有语句都是全局命名空间的一部分
void foo(); // 可以:函数前置声明
int x; // 编译通过但强烈不推荐:非常量全局变量定义(无初始化器)
int y { 5 }; // 编译通过但强烈不推荐:非常量全局变量定义(有初始化器)
x = 5; // 编译错误:不允许在命名空间中执行语句
int main() // 可以:函数定义
{
return 0;
}
void goo(); // 可以:函数前置声明
std命名空间的重要性
当C++最初设计时,所有C++标准库中的标识符(包括std::cin和std::cout)都可以在没有std::前缀的情况下使用(它们是全局命名空间的一部分)。然而,这意味着标准库中的任何标识符都可能与你为你自己标识符选择的任何名称发生冲突(也定义在全局命名空间中)。曾经可以工作的代码可能在你包含标准库的不同部分时突然发生命名冲突。
或者更糟糕的是,代码在C++的一个版本下可以编译,但在下一个版本下可能无法编译,因为新引入到标准库中的标识符可能与已经编写的代码发生命名冲突。因此C++将标准库的所有功能都移到了一个名为std的命名空间中(std是"standard"的缩写)。
事实证明,std::cout的名字实际上并不是std::cout。它实际上是cout,std是包含标识符cout的命名空间的名称。因为cout在std命名空间内定义,所以cout的名字不会与我们在std命名空间外创建的任何名为cout的对象或函数发生冲突(例如在全局命名空间内)。
关键见解
当你使用一个在非全局命名空间(例如std命名空间)内定义的标识符时,你需要告诉编译器该标识符位于命名空间内。"
有几种不同的方式来做到这一点。
访问命名空间中的标识符
显式命名空间限定符std::
最直接的告诉编译器我们想使用std命名空间中的cout的方法是明确使用std::前缀。例如:
#include <iostream>
int main()
{
std::cout << "Hello world!"; // 当我们说cout时,我们的意思是在std命名空间中定义的cout
return 0;
}
::符号是一个称为作用域解析运算符的操作符。::符号左侧的标识符标识了::符号右侧的名称所在的命名空间。如果没有提供::符号左侧的标识符,则假定为全局命名空间。
所以当我们说std::cout时,我们的意思是"在std命名空间中声明的cout"。 这是使用cout的最安全方式,因为没有任何关于我们引用的是哪个cout的歧义(std命名空间中的那一个)。
最佳实践 使用显式命名空间前缀来访问在命名空间中定义的标识符。
当一个标识符包含命名空间前缀时,该标识符被称为限定名称。
using-directive的使用与风险
另一种访问命名空间内标识符的方法是使用using-directive语句。这是我们原始的"Hello world"程序,使用了一个using-directive:
#include <iostream>
using namespace std; // 这是一个using-directive,允许我们在没有命名空间前缀的情况下访问std命名空间中的名称
int main()
{
cout << "Hello world!";
return 0;
}
一个using directive允许我们在不使用命名空间前缀的情况下访问命名空间中的名称。所以在上述示例中,当编译器确定cout是什么标识符时,它会匹配std::cout,因为有了using-directive,它就可以作为cout访问。
许多文本、教程甚至一些IDE推荐或使用在程序顶部的using-directive。然而,以这种方式使用,这是一个不好的做法,并且强烈不推荐。
考虑以下程序:
#include <iostream> // 将std::cout的声明导入到全局作用域
using namespace std; // 使std::cout可以作为"cout"访问
int cout() // 在全局命名空间中定义我们自己的"cout"函数
{
return 5;
}
int main()
{
cout << "Hello, world!"; // 编译错误!我们想要这里的哪个cout?我们上面定义的那一个,还是std命名空间中的那一个?
return 0;
}
上述程序无法编译,因为编译器现在无法确定我们想要我们定义的cout函数,还是std::cout。
当以这种方式使用using-directive时,我们定义的任何标识符都可能与std命名空间中同名的标识符发生冲突。更糟糕的是,尽管一个标识符名称今天可能没有冲突,但它可能与未来语言修订中添加到std命名空间的新标识符发生冲突。这正是将所有标准库中的标识符移到std命名空间的最初原因!
警告 避免在程序或头文件顶部使用using-directives(例如using namespace std;)。它们违反了添加命名空间的最初原因。
在C++中,花括号通常用于界定一个嵌套在另一个作用域区域内的作用域区域(花括号也用于一些与作用域无关的目的,例如列表初始化)。例如,定义在全局作用域区域内的函数使用花括号将函数的作用域区域与全局作用域分开。
在某些情况下,定义在花括号外的标识符可能仍然是由花括号定义的作用域区域的一部分,而不是周围作用域的一部分——函数参数就是一个很好的例子。
例如:
#include <iostream> // 将std::cout的声明导入到全局作用域
void foo(int x) // foo在全局作用域内定义,x在foo()的作用域内定义
{ // 使用花括号来界定函数foo()的嵌套作用域区域
std::cout << x << '\n';
} // 在这里x的作用域结束
int main()
{ // 使用花括号来界定函数main()的嵌套作用域区域
foo(5);
int x { 6 }; // x在main()的作用域内定义
std::cout << x << '\n';
return 0;
} // 在这里x的作用域结束
// 在这里foo和main(以及std::cout)的作用域结束(文件末尾)
存在于嵌套作用域区域内的代码通常缩进一级,既为了可读性,也为了帮助表明它存在于一个单独的作用域区域内。
#include和foo()以及main()的函数定义存在于全局作用域区域内,因此它们没有缩进。每个函数内的语句存在于函数的嵌套作用域区域内,因此它们缩进一级。