第 14 章 命名空间(Namespaces)
目录
14.1 组成问题(Composition Problems)
14.2.1 显式修饰(Explicit Qualification)
14.3.1 显式修饰(Explicit Qualification)
14.4.7 嵌入命名空间 (Nested Namespaces)
14.4.8 无名命名空间 (Unnamed Namespaces)
14.1 组成问题(Composition Problems)
任何实际程序都由许多独立的部分组成。函数(§2.2.1,第 12 章)和类(§3.2,第 16 章)提供了相对细粒度的关注点分离,而“库”,源文件和编译单元(§2.4,第 15 章)提供了较粗粒度的关注点分离。逻辑上的理想是模块化,即保持独立事物的分离,并仅允许通过明确指定的接口访问“模块”。C++ 不提供支持模块概念的单一语言特性;没有模块构造。相反,模块化是通过其他语言功能(如函数、类和命名空间)和源代码组织的组合来表达的。
本章和下一章将讨论程序的粗略结构及其作为源文件的物理表示。也就是说,这两章更关注整体编程,而不是单个类型、算法和数据结构的优雅表达。
考虑一下当人们未能进行模块化设计时可能出现的一些问题。例如,图形库可以提供不同类型的图形形状和功能来帮助使用它们:
// Graph_lib:
class Shape { /* ... */ };
class Line : public Shape { /* ... */ };
class Poly_line: public Shape { /* ... */ }; // connected sequence of lines
class Text : public Shape { /* ... */ }; // text label
Shape operator+(const Shape&, const Shape&); // compose
Graph_reader open(const char∗); //open file of Shapes
现在有人提出了另一个库,提供文本操作功能:
// Te xt_lib:
class Glyph { /* ... */ };
class Word { /* ... */ }; // 字形(Glyphs)
class Line { /* ... */ }; // sequence of Words
class Text { /* ... */ }; // sequence of Lines
File∗ open(const char∗); //open text file
Word operator+(const Line&, const Line&); // 连接
暂时我们先忽略图形和文本处理的具体设计问题,只考虑在程序中同时使用Graph_lib和Text_lib的问题。
假设(足够现实)Graph_lib 的功能在标头(§2.4.1)Graph_lib.h 中定义,而 Text_lib 的功能在另一个标头 Text_lib.h 中定义。现在,我可以“无辜地”#include 两者并尝试使用这两个库中的功能:
#include "Graph_lib.h"
#include "Text_lib.h"
// ...
仅 #include 那些头文件就会导致一连串的错误消息:Line、Text 和 open() 被定义两次,编译器无法消除歧义。尝试使用这些库会给出更多错误消息。
有许多技术可以解决此类名称冲突。例如,可以通过将库的所有功能放在几个类中、使用所谓的不常见的名称(例如,Text_box 而不是 Text)或系统地使用库中名称的前缀(例如,gl_shape 和 gl_line)来解决某些此类问题。这些技术(也称为“变通方法”和“黑客”)在某些情况下都有效,但它们并不通用,使用起来可能不方便。例如,名称往往会变得很长,而使用许多不同的名称会阻碍通用编程(§3.4)。
14.2 命名空间(Namespaces)
命名空间的概念用于直接表示一组直接属于一起的设施的概念,例如库的代码。命名空间的成员处于同一作用域内,可以相互引用而无需特殊符号,而从命名空间外部访问则需要显式符号。特别是,我们可以通过将声明集(例如,库接口)分离到命名空间中来避免名称冲突。例如,我们可以将图库称为Graph_lib:
namespace Graph_lib {
class Shape { /* ... */ };
class Line : public Shape { /* ... */ };
class Poly_line: public Shape { /* ... */ }; // connected sequence of lines
class Text : public Shape { /* ... */ }; // text label
Shape operator+(const Shape&, const Shape&); // compose
Graph_reader open(const char∗); //open file of Shapes
}
类似地,我们的文本库的明显名称是 Text_lib:
namespace Text_lib {
class Glyph { /* ... */ };
class Word { /* ... */ }; // sequence of Glyphs
class Line { /* ... */ }; // sequence of Words
class Text { /* ... */ }; // sequence of Lines
File∗ open(const char∗); //open text file
Word operator+(const Line&, const Line&); // concatenate
}
只要我们设法选择不同的命名空间名称,例如 Graph_lib 和 Text_lib(§14.4.2),我们现在就可以将两组声明一起编译而不会发生名称冲突。
命名空间应该表达某种逻辑结构:命名空间内的声明应该共同提供功能,在用户眼中将它们统一起来,并反映一组共同的设计决策。它们应该被视为一个逻辑单元,例如“图形库”或“文本操作库”,类似于我们考虑类的成员的方式。事实上,在命名空间中声明的实体被称为命名空间的成员。
命名空间是一个(命名的)范围。您可以从后面的声明中访问命名空间中先前定义的成员,但您不能(不经过特别的努力)引用命名空间之外的成员。例如:
class Glyph { /* ... */ };
class Line { /* ... */ };
namespace Text_lib {
class Glyph { /* ... */ };
class Word { /* ... */ }; // sequence of Glyphs
class Line { /* ... */ }; // sequence of Words
class Text { /* ... */ }; // sequence of Lines
File∗ open(const char∗); //open text file
Word operator+(const Line&, const Line&); // concatenate
}
Glyph glyph(Line& ln, int i); // ln[i]
这里,Text_lib::operator+() 声明中的 Word 和 Line 引用 Text_lib::Word 和 Text_lib::Line。该局部名称查找不受全局 Line 的影响。相反,全局 glyph() 声明中的 Glyph 和 Line 引用全局 ::Glyph 和 ::Line。该(非局部)查找不受 Text_lib 的 Glyph 和 Line 的影响。
要引用命名空间的成员,我们可以使用它的完全限定名称。例如,如果我们想要一个使用 Text_lib 定义的 glyph(),我们可以这样写:
Text_lib::Glyph glyph(Text_lib::Line& ln, int i); // ln[i]
从命名空间外部引用成员的其他方式有使用声明(§14.2.2)、使用指令(§14.2.3)和依赖参数的查找(§14.2.4)。
14.2.1 显式修饰(Explicit Qualification)
可以在命名空间定义中声明成员,然后使用命名空间名称::成员名称表示法进行定义。
必须使用以下符号来引入命名空间的成员:
namespace namespace−name {
// declaration and definitions
}
例如:
namespace Parser {
double expr(bool); // declaration
double term(bool);
double prim(bool);
}
double val = Parser::expr(); // use
double Parser::expr(bool b) // definition
{
// ...
}
我们不能使用限定符语法(§iso.7.3.1.2)在命名空间定义之外声明命名空间的新成员。这样做的目的是为了捕获拼写错误和类型不匹配等错误,并且也使得在命名空间声明中找到所有名称变得相当容易。例如:
void Parser::logical(bool); // 错 : Parser 中没有logical()
double Parser::trem(bool); // 错: Parser 中无trem()(误拼)
double Parser::prim(int); // 错: Parser::prim()取bool参数(错误类型)
命名空间是一个范围。通常的作用域规则都适用于命名空间。因此,“命名空间”是一个非常基本且相对简单的概念。程序越大,命名空间就越有用,可以表达其各部分的逻辑分离。全局范围是一个命名空间,可以使用 :: 明确引用。例如:
int f(); // global function
int g()
{
int f; // 局变量; 隐藏了全局函数
f(); // 错: 不能调用一个int 变量
::f(); // OK: 调用全局函数
}
类是命名空间(§16.2)。
14.2.2 使用using 声明
当名称在其命名空间之外频繁使用时,用其命名空间名称反复限定它可能会很麻烦。请考虑:
#include<string>
#include<vector>
#include<sstream>
std::vector<std::string> split(const std::string& s)
// 拆分成空格分隔的子字符串
{
std::vector<std::string> res;
std::istringstream iss(s);
for (std::string buf; iss>>buf;)
res.push_back(buf);
return res;
}
重复限定 std 是乏味且令人分心的。具体来说,我们在这个小例子中重复了四次 std::string。为了缓解这种情况,我们可以使用 using 声明来说明此代码中的 string 表示 std::string:
using std::string; // 使用“string”就意味着“std::string”
std::vector<string> split(const string& s)
// 拆分成空格分隔的子字符串
{
std::vector<string> res;
std::istringstream iss(s);
for (string buf; iss>>buf;)
res.push_back(buf);
return res;
}
使用using声明将同义词引入作用域。通常,最好将局部同义词尽可能地保留在局部,以避免混淆。
当用于重载名称时,using 声明适用于所有重载版本。例如:
namespace N {
void f(int);
void f(string);
};
void g()
{
using N::f;
f(789); //N::f(int)
f("Bruce"); // N::f(string)
}
有关在类层次结构中使用 using声明的信息,请参阅§20.3.5。
14.2.3 using 指令
在 split() 示例(§14.2.2)中,在引入 std::string 的同义词后,我们仍然有三种 std:: 用法。通常,我们喜欢无限制地使用命名空间中的每个名称。这可以通过为命名空间中的每个名称提供 using 声明来实现,但这很繁琐,并且每次在命名空间中添加或删除新名称时都需要额外的工作。或者,我们可以使用 using 指令来请求在我们的作用域内无限制地访问命名空间中的每个名称。例如:
using namespace std; // 使得来自std的每一个名称都可访问
vector<string> split(const string& s)
// 拆分成空格分隔的子字符串
{
vector<string> res;
istringstream iss(s);
for (string buf; iss>>buf;)
res.push_back(buf);
return res;
}
using指令使命名空间中的名称可用,就好像它们是在其命名空间之外声明的一样(另请参阅 §14.4)。使用using指令使经常使用且众所周知的库中的名称无条件可用是一种流行的简化代码的技术。这是本书中用于访问标准库设施的技术。标准库设施在命名空间 std 中定义。
在函数中,可以安全地使用 using 指令来方便使用,但应谨慎使用全局 using 指令,因为过度使用可能会导致名称冲突,而命名空间正是为避免这种情况而引入的。例如:
namespace Graph_lib {
class Shape { /* ... */ };
class Line : Shape { /* ... */ };
class Poly_line: Shape { /* ... */ }; // connected sequence of lines
class Text : Shape { /* ... */ }; // text label
Shape operator+(const Shape&, const Shape&); // compose
Graph_reader open(const char∗); // open file of Shapes
}
namespace Text_lib {
class Glyph { /* ... */ };
class Word { /* ... */ }; // sequence of Glyphs
class Line { /* ... */ }; // sequence of Words
class Text { /* ... */ }; // sequence of Lines
File∗ open(const char∗); // open text file
Word operator+(const Line&, const Line&); // concatenate
}
using namespace Graph_lib;
using namespace Text_lib;
Glyph gl; // Te xt_lib::Glyph
vector<Shape∗> vs; // Graph_lib::Shape
到目前为止,一切顺利。特别是,我们可以使用不冲突的名称,例如 Glyph 和 Shape。然而,现在只要我们使用其中一个冲突的名称,就会发生名称冲突 - 就像我们没有使用命名空间一样。例如:
Text txt; // 错 : 歧义
File∗ fp = open("my_precious_data"); // 错 : 歧义
因此,我们必须小心使用全局范围内的 using 指令。特别是,除非在非常特殊的情况下(例如,为了帮助转换),否则不要将全局范围内的 using 指令放在头文件中,因为您永远不知道头文件可能在哪里被 #included。
14.2.4 参数依赖查询
采用用户定义类型 X 的参数的函数通常与 X 定义在同一个命名空间中。因此,如果在函数的使用上下文中找不到该函数,我们将在其参数的命名空间中查找。例如:
namespace Chrono {
class Date { /* ... */ };
bool operator==(const Date&, const std::string&);
std::string format(const Date&); // 用字符串呈现
// ...
}
void f(Chrono::Date d, int i)
{
std::string s = format(d); // Chrono::format()
std::string t = format(i); // error : no format() in scope
}
与使用显式限定相比,此查找规则(称为参数依赖查找(argument-dependent lookup)或简称 ADL)为程序员节省了大量的输入工作,但它不会像 using 指令(§14.2.3)那样污染命名空间。它对于运算符操作数(§18.2.5)和模板参数(§26.3.5)尤其有用,因为显式限定可能非常麻烦。
请注意,命名空间本身需要在作用域内,并且必须先声明该函数才能找到和使用它。
当然,一个函数可以从多个命名空间中获取参数类型。例如:
void f(Chrono::Date d, std::string s)
{
if (d == s) {
// ...
}
else if (d == "August 4, 1914") {
// ...
}
}
在这种情况下,我们会在调用范围(一如既往)和每个参数的命名空间(包括每个参数的类和基类)中查找该函数,并对找到的所有函数执行通常的重载解析(§12.3)。特别是,对于调用 d==s,我们会在 f() 周围的范围、std 命名空间(其中 == 定义为字符串)和 Chrono 命名空间中查找 operator==。有一个 std::operator==(),但它不接受 Date 参数,因此我们使用接受 Date 参数的 Chrono::operator==()。另请参阅 §18.2.5。
当类成员调用命名函数时,同一类及其基类的其他成员优先于根据参数类型可能找到的函数(运算符遵循不同的规则;§18.2.1、§18.2.5)。例如:
namespace N {
struct S { int i };
void f(S);
void g(S);
void h(int);
}
struct Base {
void f(N::S);
};
struct D : Base {
void mf();
void g(N::S x)
{
f(x); // call Base::f()
mf(x); // call D::mf()
h(1); // 错 : 没找到h(int)函数
}
};
在标准中,参数依赖查找的规则以关联命名空间的形式表述(§iso.3.4.2)。基本上:
• 如果参数是类成员,则关联的命名空间是类本身(包括其基类)和类的封闭命名空间。
• 如果参数是命名空间的成员,则关联的命名空间是封闭的命名空间。
• 如果参数是内置类型,则没有关联的命名空间。
参数依赖查找可以节省大量繁琐且令人分心的输入,但偶尔也会产生令人惊讶的结果。例如,搜索函数 f() 的声明时,不会优先搜索调用 f() 的命名空间中的函数(而搜索调用 f() 的类中的函数时则不会优先搜索):
namespace N {
template<class T>
void f(T, int); // N::f()
class X { };
}
namespace N2 {
N::X x;
void f(N::X, unsigned);
void g()
{
f(x,1); // calls N::f(X,int)
}
}
选择 N2::f() 似乎很明显,但事实并非如此。应用重载解析并找到最佳匹配:N::f() 是 f(x,1) 的最佳匹配,因为 1 是 int 而不是无符号数。相反,已经看到一些示例,其中选择了调用方命名空间中的函数,但程序员希望使用已知命名空间中的更好函数(例如,来自 std 的标准库函数)。这可能最令人困惑。另请参阅 §26.3.6。
14.2.5 命名空间的开放性
命名空间是开放的;也就是说,您可以从多个单独的命名空间声明中向其添加名称。例如:
namespace A {
int f(); // now A has member f()
}
namespace A {
int g(); // now A has two members, f() and g()
}
这样,命名空间的成员就不必连续地放在同一个文件中。当将旧程序转换为使用命名空间时,这一点很重要。例如,考虑一个不使用命名空间编写的头文件:
// my header :
void mf(); // my function
void yf(); // your function
int mg(); // my function
// ...
这里,我们(不明智地)只是添加了所需的声明,而没有考虑模块化。可以重写此代码而无需重新排序声明:
// my header :
namespace Mine {
void mf(); // my function
// ...
}
void yf(); // your function (未放入命名空间)
namespace Mine {
int mg(); // my function
// ...
}
在编写新代码时,我更喜欢使用许多较小的命名空间(参见 §14.4),而不是将真正重要的代码片段放入单个命名空间中。然而,当将软件的主要部分转换为使用命名空间时,这通常是不切实际的。
在几个单独的命名空间声明中定义命名空间成员的另一个原因是,有时我们想要区分用作接口的命名空间部分和用于支持轻松实现的部分;§14.3 提供了一个示例。
命名空间别名(§14.4.2)不能用于重新开放命名空间。
14.3 模块化和接口
任何现实程序都由多个独立部分组成。例如,即使是简单的“Hello, world!”程序也至少涉及两个部分:用户代码请求打印 Hello, world!,然后 I/O 系统执行打印。
考虑第 10.2 节中的桌面计算器示例。它可以看作由五部分组成:
[1] 解析器,进行语法分析:expr()、term() 和 prim();
[2] 词法分析器,用字符组成标记:Kind、Token、Token_stream 和 ts;
[3] 符号表,保存 (string,value) 对:table ;
[4] 驱动程序:main() 和 calculate();
[5] 错误处理程序:error() 和 number_of_errors 。
可以用关系图表示为:
其中箭头表示“使用”。为了简化图示,我没有表示每个部分都依赖于错误处理。事实上,计算器被设想为三个部分,为了完整性,添加了驱动程序和错误处理程序。
当一个模块使用另一个模块时,它不需要了解所用模块的所有信息。理想情况下,模块的大多数细节对其用户来说都是未知的。因此,我们区分了模块和其接口。例如,解析器直接依赖于词法分析器的接口(仅),而不是完整的词法分析器。词法分析器仅实现其接口中宣传的服务。这可以像这样的关系图呈现:
虚线表示“实现”。我认为这是程序的真正结构,我们作为程序员的工作就是在代码中忠实地表示它。这样一来,代码就会变得简单、高效、易懂、可维护等,因为它将直接反映我们的基本设计。
以下小节展示了如何使桌面计算器程序的逻辑结构清晰,而 §15.3 展示了如何物理地组织程序源文本以利用它。计算器是一个很小的程序,所以在“现实生活中”,我不会像在这里一样费心使用命名空间和单独编译(§2.4.1,§15.1)。使计算器的结构明确只是对大型程序有用的技术的一种说明,而不会淹没在代码中。在实际程序中,由单独的命名空间表示的每个“模块”通常都有数百个函数、类、模板等。
错误处理贯穿于程序的结构。将程序分解为模块或(相反)用模块编写程序时,我们必须注意尽量减少由错误处理引起的模块间依赖关系。C++ 提供了异常,以将错误的检测和报告与错误处理分离开来(第 13 章第 2.4.3.1 节)。
除了本章和下一章讨论的概念之外,模块化的概念还有很多。例如,我们可以使用并发执行和通信任务(§5.3,第 41 章)或进程来表示模块化的重要方面。同样,使用单独的地址空间和在地址空间之间通信信息也是这里未讨论的重要主题。我认为这些模块化概念在很大程度上是独立和正交的。有趣的是,在每种情况下,将系统分成模块都很容易。难题是提供跨模块边界的安全、方便和高效的通信。
14.3.1 显式修饰(Explicit Qualification)
命名空间是一种表达逻辑分组的机制。也就是说,如果一些声明根据某些标准在逻辑上属于一起,则可以将它们放在一个公共命名空间中以表达这一事实。因此,我们可以使用命名空间来表达计算器的逻辑结构。例如,来自台式计算器(§10.2.1)的解析器的声明可以放在命名空间Parser中:
namespace Parser {
double expr(bool);
double prim(bool get) { /* ... */ }
double term(bool get) { /* ... */ }
double expr(bool get) { /* ... */ }
}
必须首先声明函数 expr(),然后定义它来打破 §10.2.1 中描述的依赖循环。
桌面计算器的输入部分也可以放在它自己的命名空间中:
namespace Lexer {
enum class Kind : char { /* ... */ };
class Token { /* ... */ };
class Token_stream { /* ... */ };
Token_stream ts;
}
符号表非常简单:
namespace Table {
map<string,double> table;
}
驱动程序不能完全放入命名空间,因为语言规则要求main()是一个全局函数:
namespace Driver {
void calculate() { /* ... */ }
}
int main() { /* ... */ }
错误处理程序也很简单:
namespace Error {
int no_of_errors;
double error(const string& s) { /* ... */ }
}
这种命名空间的使用明确了词法分析器和解析器向用户提供的内容。如果我包含了函数的源代码,这个结构就会被掩盖。如果函数体包含在实际大小的命名空间的声明中,您通常必须浏览大量信息才能找到所提供的服务,即找到接口。
依赖单独指定接口的替代方法是提供一种工具,从包含实现细节的模块中提取接口。我认为这不是一个好的解决方案。指定接口是一项基本的设计活动,模块可以为不同的用户提供不同的接口,并且通常在实现细节具体化之前很久就设计好了接口。
以下是接口与实现分离的Parser版本:
namespace Parser {
double prim(bool);
double term(bool);
double expr(bool);
}
double Parser::prim(bool get) { /* ... */ }
double Parser::term(bool get) { /* ... */ }
double Parser::expr(bool get) { /* ... */ }
请注意,由于将实现与接口分离,每个函数现在只有一个声明和一个定义。用户将只看到包含声明的接口。实现(在本例中为函数体)将放置在用户不需要查看的“别的某处”。
在理想情况下,程序中的每个实体都属于某个可识别的逻辑单元(“模块”)。因此,非平凡程序中的每个声明理想情况下都应位于某个命名空间中,以指示其在程序中的逻辑角色。main() 是个例外,它必须是全局的,以便编译器将其识别为特殊函数(§2.2.1、§15.4)。
14.3.2 实现
代码模块化后会是什么样子?这取决于我们如何决定访问其他命名空间中的代码。我们总是可以从“我们自己的”命名空间访问名称,就像我们在引入命名空间之前所做的一样。但是,对于其他命名空间中的名称,我们必须在显式修饰、使用声明和使用指令之间进行选择。
Parser::prim() 为实现中命名空间的使用提供了一个很好的测试用例,因为它使用了其他每个命名空间(Driver 除外)。如果我们使用显式修饰,我们会得到:
double Parser::prim(bool get) // handle primaries
{
if (get) Lexer::ts.g et();
switch (Lexer::ts.current().kind) {
case Lexer::Kind::number: // floating-point constant
{
double v = Lexer::ts.current().number_value;
Lexer::ts.g et();
return v;
}
case Lexer::Kind::name:
{
double& v = Table::table[Lexer::ts.current().string_value];
if (Lexer::ts.g et().kind == Lexer::Kind::assign) v = expr(true); // ’=’ seen: assignment
return v;
}
case Lexer::Kind::minus: // unar y minus
return −prim(true);
case Lexer::Kind::lp:
{
double e = expr(true);
if (Lexer::ts.current().kind != Lexer::Kind::rp) return Error::error(" ')' expected");
Lexer::ts.g et(); // eat ’)’
return e;
}
default:
return Error::error("primar y expected");
}
}
我数了一下 Lexer:: 出现了 14 次,并且(尽管有理论认为相反)我不认为更明确地使用模块化会提高可读性。我没有使用 Parser::,因为这在命名空间 Parser 中是多余的。
如果我们使用 using 声明,我们会得到:
using Lexer::ts; // 省掉‘‘Lexer::’’的8次出现
using Lexer::Kind; // 省掉‘‘Lexer::’’的6次出现
using Error::error; // 省掉‘‘Error ::’’的2次出现
using Table::table; // 省掉‘‘Table::’’的1次出现
double prim(bool get) // handle primaries
{
if (get) ts.get();
switch (ts.current().kind) {
case Kind::number: // floating-point constant
{
double v = ts.current().number_value;
ts.get();
return v;
}
case Kind::name:
{
double& v = table[ts.current().string_value];
if (ts.get().kind == Kind::assign) v = expr(true); // ’=’ seen: assignment
return v;
}
case Kind::minus: // unar y minus
return −prim(true);
case Kind::lp:
{
double e = expr(true);
if (ts.current().kind != Kind::rp) return error("')' expected");
ts.get(); // eat ’)’
return e;
}
default:
return error("primar y expected");
}
}
我的猜测是 Lexer:: 的使用声明是值得的,但其他的价值则微不足道。
如果我们使用 using 指令,我们会得到:
using namespace Lexer; // 省掉‘‘Lexer::’’的14次出现
using namespace Error; // 省掉‘‘Error ::’’的2次出现
using namespace Table; // 省掉‘‘Table::’’的1次出现
double prim(bool get) // handle primaries
{
// 如前
}
Error 和 Table 的使用声明在符号上没有多大意义,并且可以说它们掩盖了以前修饰名称的起源。
因此,必须根据具体情况在显式修饰、using 声明和 using 指令之间进行权衡。经验法则如下:
[1] 如果某些修饰对于多个名称来说确实很常见,请对该命名空间使用 using 指令。
[2] 如果某些限定对于命名空间中的特定名称很常见,请对该名称使用 using 声明。
[3] 如果名称的修饰不常见,请使用显式修饰来明确说明名称的来源。
[4] 不要对与用户位于同一命名空间中的名称使用显式修饰。
14.3.3 接口和实现
应该清楚的是,我们为 Parser 使用的命名空间定义并不是 Parser 向其用户呈现的理想接口。相反,该 Parser 声明了一组声明,这些声明是方便编写单个Parser函数所需的。Parser 向其用户提供的接口应该简单得多:
namespace Parser { // 用户接口
double expr(bool);
}
我们看到命名空间 Parser 用于提供两件事:
[1] 实现解析器的函数的公共环境。
[2] 解析器向其用户提供的外部接口。
因此,驱动程序代码 main() 应该只看到用户接口。
实现解析器的函数应该将我们决定的接口视为表达这些函数共享环境的最佳接口。即:
namespace Parser { // 实现接口
double prim(bool);
double term(bool);
double expr(bool);
using namespace Lexer; // 使用lexer接供的所有设施
using Error::error;
using Table::table;
}
或者图形化:
箭头表示“依赖于所提供的接口”关系。
我们可以为用户的接口和实现者的接口赋予不同的名称,但(因为命名空间是开放的;§14.2.5)我们不必这样做。缺少单独的名称不一定会导致混淆,因为程序的物理布局(参见§15.3.2)自然提供了单独的(文件)名称。如果我们决定使用单独的实现命名空间,设计在用户看来不会有什么不同:
namespace Parser { // user interface
double expr(bool);
}
namespace Parser_impl { // 实现器接口
using namespace Parser;
double prim(bool);
double term(bool);
double expr(bool);
using namespace Lexer; //由Lexer所提供的所有设施
using Error::error;
using Table::table;
}
或图形化为:
对于较大的程序,我倾向于引入 _impl 接口。
提供给实施者的接口比提供给用户的接口大。如果这个接口是用于实际系统中实际大小的模块,那么它会比用户看到的接口更频繁地发生变化。重要的是模块(在本例中为使用Parser的Driver)的用户不受此类变化的影响。
14.4 使用命名空间的组成
在较大的程序中,我们倾向于使用许多命名空间。本节探讨了使用命名空间编写代码的技术方面。
14.4.1 便捷性对比于安全性
using 声明将名称添加到局部作用域。using 指令则不然,它只是使名称在声明的范围内可访问。例如:
namespace X {
int i, j, k;
}
int k;
void f1()
{
int i = 0;
using namespace X; // 使来自于X 的名称可直接访问
i++; //local i
j++; //X::j
k++; //错: X的 k 还是全局的k?
::k++; //全局的k
X::k++; //X的k
}
void f2()
{
int i = 0;
using X::i; // 错: i 在f2()中声明两次(using在此是声明符)
using X::j;
using X::k; // 隐藏全局k
i++;
j++; //X::j
k++; //X::k
}
局部声明的名称(由普通声明或使用using声明)隐藏了同名的非局部声明,并且在声明点检测到名称的任何无效重载。
请注意 f1() 中 k++ 的歧义错误。全局名称不会优先于全局作用域内可访问的命名空间中的名称。这可以有效防止意外名称冲突,而且更重要的是,可以确保不会因污染全局命名空间而获得任何好处。
当通过using指令可以访问声明许多名称的库时,未使用的名称冲突不会被视为错误,这是一个显著的优势。
14.4.2 命名空间别名
如果用户为其命名空间指定短名称,则不同命名空间的名称将发生冲突:
namespace A {// 知名, 将冲突 (事实上)
// ...
}
A::String s1 = "Grieg";
A::String s2 = "Nielsen";
然而,长命名空间名称在实际代码中可能不切实际:
namespace American_Telephone_and_Telegraph { // 太长
// ...
}
American_Telephone_and_Telegraph::String s3 = "Grieg";
American_Telephone_and_Telegraph::String s4 = "Nielsen";
可以通过为较长的命名空间名称提供短别名来解决此难题:
// use namespace alias to shorten names:
namespace ATT = American_Telephone_and_Telegraph;
ATT::String s3 = "Grieg";
ATT::String s4 = "Nielsen";
命名空间别名还允许用户引用“库”,并有一个声明来定义该库到底是什么。例如:
namespace Lib = Foundation_library_v2r11;
// ...
Lib::set s;
Lib::String s5 = "Sibelius";
这可以极大地简化用另一个版本替换库的任务。通过直接使用 Lib 而不是 Foundation_library_v2r11,您可以通过更改 Lib 别名的初始化并重新编译来更新到版本“v3r02”。重新编译将捕获源级不兼容性。在另一方面,过度使用别名(任何类型的别名)都会导致混乱。
14.4.3 命名空间组成
我们经常想用现有的接口来组成一个接口。例如:
namespace His_string {
class String { /* ... */ };
String operator+(const String&, const String&);
String operator+(const String&, const char∗);
void fill(char);
// ...
}
namespace Her_vector {
template<class T>
class Vector { /* ... */ };
// ...
}
namespace My_lib {
using namespace His_string;
using namespace Her_vector;
void my_fct(String&);
}
鉴于此,我们现在可以根据 My_lib 编写程序:
void f()
{
My_lib::String s = "Byron"; // finds My_lib::His_string::Str ing
// ...
}
using namespace My_lib;
void g(Vector<String>& vs)
{
// ...
my_fct(vs[5]);
// ...
}
如果在提到的命名空间中没有声明显式修饰的名称(例如 My_lib::String),则编译器会在using指令中提到的命名空间中查找(例如 His_string)。
只有当我们需要定义某些东西时,我们才需要知道实体的真实命名空间:
void My_lib::fill(char c) // 错:在My_lib中没有声明的fill()
{
// ...
}
void His_string::fill(char c) // OK: fill() declared in His_string
{
// ...
}
void My_lib::my_fct(String& v)// OK: String is My_lib::String, meaning His_string::Str ing
{
// ...
}
理想情况下,命名空间应该:
[1] 表达一组逻辑上连贯的功能,
[2] 不让用户访问不相关的功能,
[3] 不会给用户带来过多的符号负担。
结合 #include 机制(§15.2.2),这里和以下小节中介绍的组合技术为此提供了强有力的支持。
14.4.4 组成和选择
将组成(通过using指令)与选择(通过using声明)相结合,可实现大多数实际示例所需的灵活性。借助这些机制,我们可以提供对各种设施的访问,从而解决由组合引起的名称冲突和歧义。例如:
namespace His_lib {
class String { /* ... */ };
template<class T>
class Vector { /* ... */ };
// ...
}
namespace Her_lib {
template<class T>
class Vector { /* ... */ };
class String { /* ... */ };
// ...
}
namespace My_lib {
using namespace His_lib; //来自His_lib的一切
using namespace Her_lib; // 来自Her_lib的一切
using His_lib::String; // 解决潜在冲突,以利于 His_lib
using Her_lib::Vector; // 解决潜在冲突,以利于 Her_lib
template<class T>
class List { /* ... */ }; // 额外的stuff
// ...
}
当查看命名空间时,在那里显式声明的名称(包括使用using声明的名称)优先于using指令在另一个范围内可访问的名称(另请参阅 §14.4.1)。因此,My_lib 的用户将看到 String 和 Vector 的名称冲突得到解决,有利于 His_lib::String 和 Her_lib::Vector。此外,默认情况下将使用 My_lib::List,无论 His_lib 或 Her_lib 是否提供 List。
通常,我倾向于在将名称包含到新命名空间时保持不变。这样,我就不必记住同一实体的两个不同名称。然而,有时需要使用新名称,或者只是希望有新名称。例如:
namespace Lib2 {
using namespace His_lib; // everything from His_lib
using namespace Her_lib; // everything from Her_lib
using His_lib::String; // resolve potential clash in favor of His_lib
using Her_lib::Vector; // resolve potential clash in favor of Her_lib
using Her_string = Her_lib::String; // rename
template<class T>
using His_vec = His_lib::Vector<T>; // rename
template<class T>
class List { /* ... */ }; // additional stuff
// ...
}
没有用于重命名的通用语言机制,但对于类型和模板,我们可以通过using(§3.4.5,§6.5)引入别名。
14.4.5 命名空间和重载
函数重载(§12.3)可跨命名空间有效。这对于我们迁移现有库以使用命名空间并尽量减少源代码更改至关重要。例如:
// old A.h:
void f(int);
// ...
// old B.h:
void f(char);
// ...
// old user.c:
#include "A.h"
#include "B.h"
void g()
{
f('a'); // 调用B.h中的f()
}
该程序可以升级到使用命名空间的版本,而无需更改实际代码:
// new A.h:
namespace A {
void f(int);
// ...
}
// new B.h:
namespace B {
void f(char);
// ...
}
// new user.c:
#include "A.h"
#include "B.h"
using namespace A;
using namespace B;
void g()
{
f('a'); // 调用B.h中的 f()
}
如果我们想保持 user.c 完全不变,我们会将using 指令放在头文件中。但是,通常最好避免在头文件中使用 using 指令,因为将它们放在那里会大大增加名称冲突的可能性。
此重载规则还提供了一种扩展库的机制。例如,人们经常想知道为什么他们必须明确提及序列才能使用标准库算法来操作容器。例如:
sort(v.begin(),v.end());
为什么不写成:
sort(v);
原因在于我们需要通用性(§32.2),但操作容器是迄今为止最常见的情况。我们可以像这样去适应这种情况:
#include<algorithm>
namespace Estd {
using namespace std;
template<class C>
void sort(C& c) { std::sort(c.begin(),c.end()); }
template<class C, class P>
void sort(C& c, P p) { std::sort(c.begin(),c.end(),p); }
}
Estd(我的“扩展 std”)提供了经常需要的 sort() 容器版本。这些当然是使用 <algorithm> 中的 std::sort() 实现的。我们可以像这样使用它:
using namespace Estd;
template<class T>
void print(const vector<T>& v)
{
for (auto& x : v)
cout << v << ' ';
cout << '\n';
}
void f()
{
std::vector<int> v {7, 3, 9, 4, 0, 1};
sort(v);
print(v);
sort(v,[](int x, int y) { return x>y; });
print(v);
sort(v.begin(),v.end());
print(v);
sort(v.begin(),v.end(),[](int x, int y) { return x>y; });
print(v);
}
命名空间查找规则和模板的重载规则确保我们找到并调用sort()的正确变体并获得预期的输出:
0 1 3 4 7 9
9 7 4 3 1 0
0 1 3 4 7 9
9 7 4 3 1 0
如果我们从 Estd 中删除 using namespace std;,此示例仍可行,因为 std 的 sort() 可通过参数相关查找找到(§14.2.4)。但是,我们无法找到在 std 之外定义的我们自己的容器的标准 sort()。
14.4.6 版本控制 (Versioning)
对于许多类型的接口来说,最艰难的考验是应对一系列新版本。考虑一个广泛使用的接口,例如 ISO C++ 标准头文件。一段时间后,定义了一个新版本,例如 C++98 头文件的 C++11 版本。可能添加了函数、重命名了类、删除了专有扩展(这些扩展本不应该存在)、更改了类型、修改了模板。为了让实现者的生活“有趣”,数亿行代码“在那里”使用旧头文件,而新版本的实现者永远无法看到或修改它们。不用说,破坏这样的代码会引起公愤,没有新的更好的版本也会引起公愤。到目前为止描述的命名空间设施可用于处理这个问题,只有非常小的例外,但是当涉及大量代码时,“非常小”仍然意味着大量的代码。因此,有一种在两个版本之间进行选择的方法,可以简单而明显地保证用户看到的只有一个特定版本。这称为内联命名空间(inline namespace):
namespace Popular {
inline namespace V3_2 {//V3_2 提供了Popular double f(double)的缺省含义;
int f(int);
template<class T>
class C { /* ... */ };
}
namespace V3_0 {
// ...
}
namespace V2_4_2 {
double f(double);
template<class T>
class C { /* ... */ };
}
}
这里,Popular 包含三个子命名空间,每个子命名空间定义一个版本。内联指定 V3_2 是 Popular 的默认含义。因此我们可以这样写:
using namespace Popular;
void f()
{
f(1); //Popular::V3_2::f(int)
V3_0::f(1); // Popular ::V3_0::f(double)
V2_4_2::f(1); // Popular ::V2_4_2::f(double)
}
template<class T>
Popular::C<T∗> { /* ... */ };
这种inline namespace解决方案具有侵入性;也就是说,要更改默认版本(子命名空间),需要修改标头源代码。此外,天真地使用这种处理版本的方式将涉及大量重复(不同版本中的通用代码)。但是,可以使用 #include 技巧将这种重复最小化。例如:
// file V3_common:
// ... 若干声明 ...
// file V3_2:
namespace V3_2 { // V3_2 提供了 Popular 和double f(double)和缺省含义;
int f(int);
template<class T>
class C { /* ... */ };
#include "V3_common"
}
// file V3_0.h:
namespace V3_0 {
#include "V3_common"
}
// file Popular.h:
namespace Popular {
inline
#include "V3_2.h"
#include "V3_0.h"
#include "V2_4_2.h"
}
除非真的有必要,否则我不建议如此复杂地使用头文件。上面的示例反复违反了禁止包含到非局部作用域以及禁止语法构造跨越文件边界(使用inline)的规则;请参阅§15.2.2。遗憾的是,我见过更糟糕的情况。
在大多数情况下,我们可以通过侵入性较低的方式实现版本控制。我能想到的唯一一个完全不可能通过其他方式实现的例子是使用命名空间名称明确地对模板进行特化(例如,Popular::C<T∗>)。然而,在许多重要情况下,“在大多数情况下”还不够好。此外,基于其他技术组合的解决方案显然不太完全正确。
14.4.7 嵌入命名空间 (Nested Namespaces)
命名空间的一个明显用途是将一整套声明和定义包装在一个单独的命名空间中:
namespace X {
// ... all my declarations ...
}
声明列表通常包含命名空间。因此,允许嵌套命名空间。这是出于实际原因,也出于构造应该嵌套的简单原因,除非有充分理由不嵌套。例如:
void h();
namespace X {
void g();
// ...
namespace Y {
void f();
void ff();
// ...
}
}
适用通常的作用域和修饰规则:
void X::Y::ff()
{
f(); g(); h();
}
void X::g()
{
f(); // 错:在X 中无 f()
Y::f(); // OK
}
void h()
{
f(); //错: 无全局 f()
Y::f(); // 错: 无全局Y
X::f(); // 错: 在X 中无 f()
X::Y::f(); // OK
}
有关标准库中嵌套命名空间的示例,请参阅 chrono (§35.2) 和 rel_ops(§35.5.3)。
14.4.8 无名命名空间 (Unnamed Namespaces)
有时将一组声明包装在命名空间中很有用,只是为了防止名称冲突的可能性。也就是说,目的是保留代码的局部性,而不是向用户呈现接口。例如:
#include "header.h"
namespace Mine {
int a;
void f() { /* ... */ }
int g() { /* ... */ }
}
由于我们不希望 Mine 这个名称在局部上下文之外为人所知,因此发明一个可能意外与其他人的名称冲突的冗余全局名称只会带来麻烦。在这种情况下,我们可以简单地让命名空间不带名称:
#include "header.h"
namespace {
int a;
void f() { /* ... */ }
int g() { /* ... */ }
}
显然,必须有某种方式从无名命名空间外部访问无名命名空间的成员。因此,无名命名空间具有隐含的using指令。前面的声明相当于:
namespace $$$ {
int a;
void f() { /* ... */ }
int g() { /* ... */ }
}
using namespace $$$;
其中 $$$ 是定义命名空间的作用域所特有的某个名称。具体而言,不同编译单元中的无名命名空间是不同的。正如所期望的那样,无法从另一个编译单元命名无名命名空间的成员。
14.4.9 C头文件 (C Headers)
考虑规范的第一个 C 程序:
#include <stdio.h>
int main()
{
printf("Hello, world!\n");
}
破坏这个程序不是一个好主意。将标准库作为特殊情况也不是一个好主意。因此,命名空间的语言规则旨在使将没有命名空间编写的程序转换为使用命名空间的更明确结构的程序变得相对容易。事实上,计算器程序(§10.2)就是一个例子。
在命名空间中提供标准 C 的I/O 设施的一种方法是,将 C 头文件 stdio.h 中的声明放在命名空间 std 中:
// cstdio:
namespace std {
int printf(const char∗ ... );
// ...
}
有了这个<cstdio>,我们可以通过添加 using 指令来提供向后兼容性:
// stdio.h:
#include<cstdio>
using namespace std;
这个 <stdio.h> 使 Hello, world! 程序编译成功。不幸的是,using 指令使得命名空间 std 中的每个名称都可以在全局命名空间中访问。例如:
#include<vector> // 注意避免污染全局命名空间
vector v1; // 错误: 在全局作用域无 ‘‘vector’’
#include<stdio.h> // 含有一个‘‘using namespace std;’’
vector v2; // oops: 现在这个有效
因此,标准要求 <stdio.h> 仅将来自 <cstdio> 的名称放置在全局范围内。这可以通过为 <cstdio> 中的每个声明提供一个 using 声明来实现:
// stdio.h:
#include<cstdio>
using std::printf;
// ...
另一个优点是 printf() 的使用声明可防止用户(意外或故意)在全局范围内定义非标准 printf()。我认为非局部using指令主要是一种过渡工具。我还将它们用于基本基础库,例如 ISO C++ 标准库 (std)。大多数引用其他命名空间中名称的代码都可以使用显式修饰和using声明更清楚地表达。
命名空间和链接之间的关系在§15.2.5 中描述。
14.5 建议
[1] 使用命名空间表达逻辑结构;§14.3.1。
[2] 将除 main() 之外的每个非局部名称放在某个命名空间中;§14.3.1。
[3] 设计一个命名空间,以便您可以方便地使用它,而不会意外访问不相关的命名空间;§14.3.3。
[4] 避免使用非常短的命名空间名称;§14.4.2。
[5] 如有必要,使用命名空间别名缩写长命名空间名称;§14.4.2。
[6] 避免给命名空间的用户带来沉重的符号负担;§14.2.2、§14.2.3。
[7] 对接口和实现使用单独的命名空间;§14.3.3。
[8] 定义命名空间成员时使用 Namespace::member 符号;§14.4。
[9] 使用inline命名空间支持版本控制;§14.4.6。
[10] 使用 using 指令进行过渡、基础库(如 std)或在局部范围内使用;§14.4.9。
[11] 不要将 using 指令放在头文件中;§14.2.3。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup