7-2 用户定义的命名空间与作用域解析运算符
在第2.9课——命名冲突与命名空间介绍中,我们介绍了命名冲突和命名空间的概念。需要提醒的是,当两个相同的标识符出现在同一作用域内时,就会发生命名冲突,此时编译器无法确定应使用哪个标识符。这种情况下,编译器或链接器会报错,因为它们缺乏足够的信息来消除歧义。
关键洞察
随着程序规模扩大,标识符数量随之增加,这将导致命名冲突发生的概率显著上升。由于特定作用域内的每个名称都可能与该作用域内的其他名称发生冲突,标识符数量的线性增长将导致潜在冲突呈指数级增长!这正是应在尽可能小的作用域内定义标识符的关键原因之一。
让我们重新审视一个命名冲突的例子,然后展示如何通过命名空间来改进。在下面的示例中,foo.cpp和goo.cpp是包含不同功能但具有相同名称和参数的函数的源文件。
foo.cpp:
// This doSomething() adds the value of its parameters
int doSomething(int x, int y)
{
return x + y;
}
goo.cpp:
// This doSomething() subtracts the value of its parameters
int doSomething(int x, int y)
{
return x - y;
}
main.cpp:
#include <iostream>
int doSomething(int x, int y); // forward declaration for doSomething
int main()
{
std::cout << doSomething(4, 3) << '\n'; // which doSomething will we get?
return 0;
}
如果该项目仅包含 foo.cpp 或 goo.cpp(但不包含两者),则编译运行均不会出现问题。然而,当将两者编译到同一个程序中时,我们便在同一作用域(全局作用域)内引入了两个名称相同且形参相同的函数,导致命名冲突。因此,链接器将报错:

请注意,此错误发生在重新定义的点上,因此函数 doSomething 是否被调用并不重要。
解决此问题的一种方法是重命名其中一个函数,使名称不再冲突。但这同时需要修改所有函数调用的名称,操作繁琐且易出错。更优的避免冲突方案是将函数置于专属命名空间中。正因如此,标准库才被移入std命名空间。
定义自己的命名空间
C++允许我们通过namespace关键字定义自己的命名空间。在程序中创建的命名空间通常被称为用户定义命名空间user-defined namespaces(尽管更准确的称呼应为程序定义命名空间program-defined namespaces)。
命名空间的语法如下:
namespace NamespaceIdentifier
{
// content of namespace here
}
我们从命名空间关键字开始,接着是命名空间的标识符,然后是花括号,其中包含命名空间的内容。
历史上,命名空间名称并未采用首字母大写形式,许多风格指南至今仍推荐这种惯例。
对于高级读者
首字母大写的命名空间名称更优的理由:
- 程序定义的类型通常采用首字母大写命名惯例。将此惯例应用于程序定义的命名空间可保持一致性(尤其在使用带限定名的场景下,如 Foo::x,其中 Foo 可能表示命名空间或类类型)。
- 此命名方式有助于避免与系统或库提供的全小写名称发生命名冲突。
- C++20标准文档采用此命名风格。
- C++核心指南文档采用此命名风格。
我们建议命名空间名称以大写字母开头。不过,两种写法均可接受。
命名空间必须定义在全局作用域内,或另一个命名空间内部。与函数内容类似,命名空间内容通常缩进一级。有时会在命名空间闭合花括号后看到可选的分号。
以下是前例文件使用命名空间重写的示例:
foo.cpp:
namespace Foo // define a namespace named Foo
{
// This doSomething() belongs to namespace Foo
int doSomething(int x, int y)
{
return x + y;
}
}
goo.cpp:
namespace Goo // define a namespace named Goo
{
// This doSomething() belongs to namespace Goo
int doSomething(int x, int y)
{
return x - y;
}
}
现在,foo.cpp 中的 doSomething() 位于 Foo 命名空间内,而 goo.cpp 中的 doSomething() 位于 Goo 命名空间内。让我们看看重新编译程序后会发生什么。
main.cpp:
int doSomething(int x, int y); // forward declaration for doSomething
int main()
{
std::cout << doSomething(4, 3) << '\n'; // which doSomething will we get?
return 0;
}
答案是:我们现在又遇到另一个错误!

在此情况下,编译器通过我们的前向声明获得了满足,但链接器无法在全局命名空间中找到 doSomething 的定义。这是因为两个版本的 doSomething 都已不存在于全局命名空间中!它们现在各自属于所属命名空间的作用域!
告知编译器使用哪个版本的 doSomething() 有两种方式:通过作用域解析运算符,或通过 using 语句(本章后续课程将讨论)。
为便于阅读,后续示例将整合为单文件解决方案。
使用作用域解析运算符 (::) 访问命名空间
告知编译器在特定命名空间中查找标识符的最佳方式是使用作用域解析运算符scope resolution operator (::)。该运算符指示编译器应在左操作数的范围内查找由右操作数指定的标识符。
以下示例演示如何通过作用域解析运算符明确告知编译器:我们需要使用位于 Foo 命名空间中的 doSomething() 函数:
#include <iostream>
namespace Foo // define a namespace named Foo
{
// This doSomething() belongs to namespace Foo
int doSomething(int x, int y)
{
return x + y;
}
}
namespace Goo // define a namespace named Goo
{
// This doSomething() belongs to namespace Goo
int doSomething(int x, int y)
{
return x - y;
}
}
int main()
{
std::cout << Foo::doSomething(4, 3) << '\n'; // use the doSomething() that exists in namespace Foo
return 0;
}
这产生了预期的结果:

如果我们想使用位于Goo中的doSomething()版本:
#include <iostream>
namespace Foo // define a namespace named Foo
{
// This doSomething() belongs to namespace Foo
int doSomething(int x, int y)
{
return x + y;
}
}
namespace Goo // define a namespace named Goo
{
// This doSomething() belongs to namespace Goo
int doSomething(int x, int y)
{
return x - y;
}
}
int main()
{
std::cout << Goo::doSomething(4, 3) << '\n'; // use the doSomething() that exists in namespace Goo
return 0;
}
这产生了以下结果:

作用域解析运算符之所以强大,在于它允许我们明确指定要查找的命名空间,从而避免潜在歧义。我们甚至可以这样做:
#include <iostream>
namespace Foo // define a namespace named Foo
{
// This doSomething() belongs to namespace Foo
int doSomething(int x, int y)
{
return x + y;
}
}
namespace Goo // define a namespace named Goo
{
// This doSomething() belongs to namespace Goo
int doSomething(int x, int y)
{
return x - y;
}
}
int main()
{
std::cout << Foo::doSomething(4, 3) << '\n'; // use the doSomething() that exists in namespace Foo
std::cout << Goo::doSomething(4, 3) << '\n'; // use the doSomething() that exists in namespace Goo
return 0;
}
这产生了以下结果:

在不带名称前缀的情况下使用作用域解析运算符
作用域解析运算符也可用于标识符前而不提供命名空间名称(例如 ::doSomething)。此时,系统将在全局命名空间中查找该标识符(例如 doSomething)。
#include <iostream>
void print() // this print() lives in the global namespace
{
std::cout << " there\n";
}
namespace Foo
{
void print() // this print() lives in the Foo namespace
{
std::cout << "Hello";
}
}
int main()
{
Foo::print(); // call print() in Foo namespace
::print(); // call print() in global namespace (same as just calling print() in this case)
return 0;
}

在上例中,::print() 的行为等同于直接调用 print()(无需作用域解析),因此在此情况下使用作用域解析运算符实属多余。但下例将展示一个无需命名空间的作用域解析运算符仍具实用价值的场景。
命名空间内的标识符解析
若在命名空间内使用标识符且未指定作用域解析,编译器将首先尝试在该命名空间内查找匹配的声明。若未找到匹配标识符,编译器将依次检查所有包含该标识符的父级命名空间,直至全局命名空间(最后检查)。
#include <iostream>
void print() // this print() lives in the global namespace
{
std::cout << " there\n";
}
namespace Foo
{
void print() // this print() lives in the Foo namespace
{
std::cout << "Hello";
}
void printHelloThere()
{
print(); // calls print() in Foo namespace
::print(); // calls print() in global namespace
}
}
int main()
{
Foo::printHelloThere();
return 0;
}
这将输出:

在上例中,print() 的调用未提供作用域解析。由于该调用位于 Foo 命名空间内,编译器会首先查找 Foo::print() 的声明。由于存在该声明,故调用 Foo::print()。
若未找到 Foo::print(),编译器将检查包含的命名空间(此处为全局命名空间),查看是否存在匹配的 print()。
请注意,我们还使用了不带命名空间的范围解析运算符(::print())来显式调用全局版本的 print()。
命名空间内内容的前向声明
在第2.11节——头文件中,我们讨论了如何使用头文件传播前向声明。对于命名空间内的标识符,这些前向声明也必须位于同一个命名空间内:
add.h
#ifndef ADD_H
#define ADD_H
namespace BasicMath
{
// function add() is part of namespace BasicMath
int add(int x, int y);
}
#endif
add.cpp
#include "add.h"
namespace BasicMath
{
// define the function add() inside namespace BasicMath
int add(int x, int y)
{
return x + y;
}
}
main.cpp
#include "add.h" // for BasicMath::add()
#include <iostream>
int main()
{
std::cout << BasicMath::add(4, 3) << '\n';
return 0;
}

如果 add() 的前向声明未置于 BasicMath 命名空间内,则 add() 将被声明在全局命名空间中,编译器会报错指出未找到 BasicMath::add(4, 3) 调用的声明。若函数 add() 的定义不在 BasicMath 命名空间内,链接器将报错指出无法找到 BasicMath::add(4, 3) 调用的匹配定义。
允许使用多个命名空间块
在多个位置(无论是跨多个文件,还是在同一文件内的多个位置)声明命名空间块都是合法的。命名空间内的所有声明都被视为该命名空间的一部分。
circle.h:
#ifndef CIRCLE_H
#define CIRCLE_H
namespace BasicMath
{
constexpr double pi{ 3.14 };
}
#endif
growth.h:
#ifndef GROWTH_H
#define GROWTH_H
namespace BasicMath
{
// the constant e is also part of namespace BasicMath
constexpr double e{ 2.7 };
}
#endif
main.cpp
#include "circle.h" // for BasicMath::pi
#include "growth.h" // for BasicMath::e
#include <iostream>
int main()
{
std::cout << BasicMath::pi << '\n';
std::cout << BasicMath::e << '\n';
return 0;
}
这完全符合你的预期:

标准库广泛利用了这一特性,因为每个标准库头文件都将其声明封装在该头文件内的命名空间 std 块中。否则整个标准库就必须定义在一个头文件里!
请注意,这种能力也意味着你可以向 std 命名空间添加自己的功能。但这样做通常会导致未定义行为,因为 std 命名空间有特殊规则禁止用户代码进行扩展。
警告
请勿向 std 命名空间添加自定义功能。
嵌套命名空间
命名空间可以嵌套在其他命名空间中。例如:
#include <iostream>
namespace Foo
{
namespace Goo // Goo is a namespace inside the Foo namespace
{
int add(int x, int y)
{
return x + y;
}
}
}
int main()
{
std::cout << Foo::Goo::add(1, 2) << '\n';
return 0;
}
请注意,由于命名空间 Goo 位于命名空间 Foo 之内,因此我们通过 Foo::Goo::add 访问 add 函数。
自 C++17 起,嵌套命名空间也可采用以下方式声明:
#include <iostream>
namespace Foo::Goo // Goo is a namespace inside the Foo namespace (C++17 style)
{
int add(int x, int y)
{
return x + y;
}
}
int main()
{
std::cout << Foo::Goo::add(1, 2) << '\n';
return 0;
}
这相当于之前的示例。
若后续需要向 Foo 命名空间(仅限该命名空间)添加声明,可定义独立的 Foo 命名空间来实现:
#include <iostream>
namespace Foo::Goo // Goo is a namespace inside the Foo namespace (C++17 style)
{
int add(int x, int y)
{
return x + y;
}
}
namespace Foo
{
void someFcn() {} // This function is in Foo only
}
int main()
{
std::cout << Foo::Goo::add(1, 2) << '\n';
return 0;
}
无论您选择保留独立的 Foo::Goo 定义,还是将 Goo 嵌套在 Foo 内部,都属于编程风格的选择。
命名空间别名
由于在嵌套命名空间中输入变量或函数的完整限定名可能相当麻烦,C++允许创建命名空间别名namespace aliases,这使我们能够将冗长的命名空间序列临时缩短为更简洁的形式:
#include <iostream>
namespace Foo::Goo
{
int add(int x, int y)
{
return x + y;
}
}
int main()
{
namespace Active = Foo::Goo; // active now refers to Foo::Goo
std::cout << Active::add(1, 2) << '\n'; // This is really Foo::Goo::add()
return 0;
} // The Active alias ends here
命名空间别名的一个优势在于:若需将 Foo::Goo 中的功能迁移至其他位置,只需更新活动别名指向新目标,而无需逐个查找替换 Foo::Goo 的所有实例。
#include <iostream>
namespace Foo::Goo
{
}
namespace V2
{
int add(int x, int y)
{
return x + y;
}
}
int main()
{
namespace Active = V2; // active now refers to V2
std::cout << Active::add(1, 2) << '\n'; // We don't have to change this
return 0;
}
如何使用命名空间
值得注意的是,C++中的命名空间最初并非为实现信息层次结构而设计——其主要目的是防止命名冲突。作为佐证,请注意标准库的全部内容都位于单一的顶级命名空间std之下。新近引入大量名称的标准库功能(如 std::ranges)已开始采用嵌套命名空间,以避免在 std 命名空间内部发生命名冲突。
- 个人开发的简易应用通常无需使用命名空间。但对于包含大量第三方库的大型个人项目,为代码添加命名空间有助于避免与未正确命名空间化的库发生命名冲突。
作者注
本教程中的示例通常不会使用命名空间,除非我们需要说明命名空间的特定特性,以保持示例的简洁性。
-
任何将分发给他人的代码都应明确使用命名空间,以避免与集成代码发生冲突。通常单个顶级命名空间即可满足需求(例如 Foologger)。额外优势在于,将库代码置于命名空间中还能让用户通过编辑器的自动补全和建议功能查看库内容(例如输入Foologger时,补全功能将显示Foologger下的所有成员)。
-
在多团队组织中,常采用两级甚至三级命名空间来避免不同团队代码间的命名冲突。常见形式如下:
1. 项目或库 :: 模块(例如 Foologger::Lang)
2. 公司或组织 :: 项目或库(例如 Foosoft::Foologger)
3. 公司或组织 :: 项目或库 :: 模块(例如 Foosoft::Foologger::Lang)
使用模块级命名空间有助于将可能被复用的代码与不可复用的应用程序特定代码分离。例如,物理与数学函数可归入一个命名空间(如 Math::),语言与本地化函数归入另一个(如 Lang::)。目录结构也可实现此目的(应用程序专属代码存放于项目目录树,可复用代码存放于独立共享目录树)。
通常应避免深度嵌套的命名空间(超过3层)。
相关内容
C++ 还提供了其他有用的命名空间功能。本章后续内容(第 7.14 节——无名命名空间与内联命名空间)将介绍无名命名空间和内联命名空间。

浙公网安备 33010602011771号