【C++基础】第一百零四课:[用于大型程序的工具]命名空间

内联命名空间,全局命名空间,未命名的命名空间,命名空间的别名,using声明,using指示

Posted by x-jeff on August 9, 2024

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.命名空间

大型程序往往会使用多个独立开发的库,这些库又会定义大量的全局名字,如类、函数和模板等。当应用程序用到多个供应商提供的库时,不可避免地会发生某些名字相互冲突的情况。多个库将名字放置在全局命名空间中将引发命名空间污染(namespace pollution)。

传统上,程序员通过将其定义的全局实体名字设得很长来避免命名空间污染问题,这样的名字中通常包含表示名字所属库的前缀部分:

1
2
class cplusplus_primer_Query {...};
string cplusplus_primer_make_plural(size_t, string&);

这种解决方案显然不太理想:对于程序员来说,书写和阅读这么长的名字费时费力且过于烦琐。

命名空间(namespace)为防止名字冲突提供了更加可控的机制。命名空间分割了全局命名空间,其中每个命名空间是一个作用域。通过在某个命名空间中定义库的名字,库的作者(以及用户)可以避免全局名字固有的限制。

2.命名空间定义

一个命名空间的定义包含两部分:首先是关键字namespace,随后是命名空间的名字。在命名空间名字后面是一系列由花括号括起来的声明和定义。只要能出现在全局作用域中的声明就能置于命名空间内,主要包括:类、变量(及其初始化操作)、函数(及其定义)、模板和其他命名空间:

1
2
3
4
5
6
namespace cplusplus_primer {
    class Sales_data {/*...*/};
    Sales_data operator+(const Sales_data&, const Sales_data&);
    class Query {/*...*/};
    class Query_base {/*...*/};
} //命名空间结束后无须分号,这一点与块类似

和其他名字一样,命名空间的名字也必须在定义它的作用域内保持唯一。命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。

2.1.每个命名空间都是一个作用域

和其他作用域类似,命名空间中的每个名字都必须表示该空间内的唯一实体。因为不同命名空间的作用域不同,所以在不同命名空间内可以有相同名字的成员。

定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。位于该命名空间之外的代码则必须明确指出所用的名字属于哪个命名空间:

1
cplusplus_primer::Query q = cplusplus_primer::Query("hello");

2.2.命名空间可以是不连续的

命名空间可以定义在几个不同的部分,这一点与其他作用域不太一样。编写如下的命名空间定义:

1
2
3
namespace nsp {
    //相关声明
}

可能是定义了一个名为nsp的新命名空间,也可能是为已经存在的命名空间添加一些新成员。如果之前没有名为nsp的命名空间定义,则上述代码创建一个新的命名空间;否则,上述代码打开已经存在的命名空间定义并为其添加一些新成员的声明。

命名空间的定义可以不连续的特性使得我们可以将几个独立的接口和实现文件组成一个命名空间。此时,命名空间的组织方式类似于我们管理自定义类及函数的方式:

  • 命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件将被包含在使用了这些成员的文件中。
  • 命名空间成员的定义部分则置于另外的源文件中。

在程序中某些实体只能定义一次:如非内联函数、静态数据成员、变量等,命名空间中定义的名字也需要满足这一要求,我们可以通过上面的方式组织命名空间并达到目的。这种接口和实现分离的机制确保我们所需的函数和其他名字只定义一次,而只要是用到这些实体的地方都能看到对于实体名字的声明。

个人注解:内联函数可以在多个文件中定义,但必须保持定义一致。

2.3.定义本书的命名空间

通过使用上述接口与实现分离的机制,我们可以将cplusplus_primer库定义在几个不同的文件中。Sales_data类的声明及其函数将置于Sales_data.h头文件中,Query类将置于Query.h头文件中,以此类推。对应的实现文件将分别是Sales_data.cc和Query.cc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//----Sales_data.h----
//#include应该出现在打开命名空间的操作之前
#include <string>
namespace cplusplus_primer {
    class Sales_data {/*...*/};
    Sales_data operator+(const Sales_data&, const Sales_data&);
    //Sales_data的其他接口函数的声明
}
//----Sales_data.cc----
//确保#include出现在打开命名空间的操作之前
#include "Sales_data.h"

namespace cplusplus_primer {
    //Sales_data成员及重载运算符的定义
}

程序如果想使用我们定义的库,必须包含必要的头文件,这些头文件中的名字定义在命名空间cplusplus_primer内:

1
2
3
4
5
6
7
8
9
10
//----user.cc----
//Sales_data.h头文件的名字位于命名空间cplusplus_primer中
#include "Sales_data.h"
int main()
{
    using cplusplus_primer::Sales_data;
    Sales_data trans1, trans2;
    //...
    return 0;
}

有一点需要注意,在通常情况下,我们不把#include放在命名空间内部。如果我们这么做了,隐含的意思是把头文件中所有的名字定义成该命名空间的成员。例如,如果Sales_data.h在包含string头文件前就已经打开了命名空间cplusplus_primer,则程序将出错,因为这么做意味着我们试图将命名空间std嵌套在命名空间cplusplus_primer中。

2.4.定义命名空间成员

假定作用域中存在合适的声明语句,则命名空间中的代码可以使用同一命名空间定义的名字的简写形式:

1
2
3
4
5
#include "Sales_data.h"
namespace cplusplus_primer { //重新打开命名空间cplusplus_primer
    //命名空间中定义的成员可以直接使用名字,此时无须前缀
    std::istream& operator>>(std::istream& in, Sales_data& s) {/*...*/}
}

也可以在命名空间定义的外部定义该命名空间的成员。命名空间对于名字的声明必须在作用域内,同时该名字的定义需要明确指出其所属的命名空间:

1
2
3
4
5
6
7
//命名空间之外定义的成员必须使用含有前缀的名字
cplusplus_primer::Sales_data;
cplusplus_primer::operator+(const Sales_data& lhs, const Sales_data& rhs)
{
    Sales_data ret(lhs);
    //...
}

2.5.模板特例化

模板特例化必须定义在原始模板所属的命名空间中。和其他命名空间名字类似,只要我们在命名空间中声明了特例化,就能在命名空间外部定义它了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//我们必须将模板特例化声明成std的成员
namespace std {
    template <> struct hash<Sales_data>;
}
//在std中添加了模板特例化的声明后,就可以在命名空间std的外部定义它了
template <> struct std::hash<Sales_data>
{
    size_t operator()(const Sales_data& s) const
    {
        return hash<string>()(s.bookNo) ^
               hash<unsigned>()(s.units_sold) ^
               hash<double>()(s.revenue);
    }
    //其他成员与之前的版本一致
};

2.6.全局命名空间

全局作用域中定义的名字(即在所有类、函数及命名空间之外定义的名字)也就是定义在全局命名空间(global namespace)中。全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式地添加到全局命名空间中。

作用域运算符同样可以用于全局作用域的成员,因为全局作用域是隐式的,所以它并没有名字。下面的形式:

1
::member_name

表示全局命名空间中的一个成员。

2.7.嵌套的命名空间

嵌套的命名空间是指定义在其他命名空间中的命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
namespace cplusplus_primer {
    //第一个嵌套的命名空间:定义了库的Query部分
    namespace QueryLib {
        class Query {/*...*/};
        Query operator&(const Query&, const Query&);
        //...
    }
    //第二个嵌套的命名空间:定义了库的Sales_data部分
    namespace Bookstore {
        class Quote {/*...*/};
        class Disc_quote : public Quote {/*...*/};
        //...
    }
}

嵌套的命名空间同时是一个嵌套的作用域,它嵌套在外层命名空间的作用域中。嵌套的命名空间中的名字遵循的规则与往常类似:内层命名空间声明的名字将隐藏外层命名空间声明的同名成员。在嵌套的命名空间中定义的名字只在内层命名空间中有效,外层命名空间中的代码要想访问它必须在名字前添加限定符。例如,在嵌套的命名空间QueryLib中声明的类名是:

1
cplusplus_primer::QueryLib::Query

2.8.内联命名空间

C++11新标准引入了一种新的嵌套命名空间,称为内联命名空间(inline namespace)。和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。也就是说,我们无须在内联命名空间的名字前添加表示该命名空间的前缀,通过外层命名空间的名字就可以直接访问它。

定义内联命名空间的方式是在关键字namespace前添加关键字inline:

1
2
3
4
5
6
7
inline namespace FifthEd {
    //该命名空间表示本书第5版的代码
}
namespace FifthEd { //隐式内联
    class Query_base {/*...*/};
    //其他与Query有关的声明
}

关键字inline必须出现在命名空间第一次定义的地方,后续再打开命名空间的时候可以写inline,也可以不写。

2.9.未命名的命名空间

未命名的命名空间(unnamed namespace)是指关键字namespace后紧跟花括号括起来的一系列声明语句。未命名的命名空间中定义的变量拥有静态生命周期:它们在第一次使用前创建,并且直到程序结束才销毁。

一个未命名的命名空间可以在某个给定的文件内不连续,但是不能跨越多个文件。每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间互相无关。在这两个未命名的命名空间中可以定义相同的名字,并且这些定义表示的是不同实体。如果一个头文件定义了未命名的命名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体。

定义在未命名的命名空间中的名字可以直接使用,毕竟我们找不到什么命名空间的名字来限定它们;同样的,我们也不能对未命名的命名空间的成员使用作用域运算符。

未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同。如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域中的名字所有区别:

1
2
3
4
5
6
int i; //i的全局声明
namespace {
    int i;
}
//二义性:i的定义既出现在全局作用域中,又出现在未嵌套的未命名的命名空间中
i = 10;

其他情况下,未命名的命名空间中的成员都属于正确的程序实体。和所有命名空间类似,一个未命名的命名空间也能嵌套在其他命名空间当中。此时,未命名的命名空间中的成员可以通过外层命名空间的名字来访问:

1
2
3
4
5
6
7
namespace local {
    namespace {
        int i;
    }
}
//正确:定义在嵌套的未命名的命名空间中的i与全局作用域中的i不同
local::i = 42;

未命名的命名空间取代文件中的静态声明

在标准C++引入命名空间的概念之前,程序需要将名字声明成static的以使得其对于整个文件有效。在文件中进行静态声明的做法是从C语言继承而来的。在C语言中,声明为static的全局实体在其所在的文件外不可见。

在文件中进行静态声明的做法已经被C++标准取消了,现在的做法是使用未命名的命名空间。

3.使用命名空间成员

namespace_name::member_name这样使用命名空间的成员显然非常烦琐,特别是当命名空间的名字很长时尤其如此。幸运的是,我们可以通过一些其他更简便的方法使用命名空间的成员。之前的程序已经使用过其中一种方法,即using声明。本部分还将介绍另外几种方法,如命名空间的别名以及using指示等。

3.1.命名空间的别名

命名空间的别名(namespace alias)使得我们可以为命名空间的名字设定一个短得多的同义词。例如,一个很长的命名空间的名字形如:

1
namespace cplusplus_primer {/*...*/};

我们可以为其设定一个短得多的同义词:

1
namespace primer = cplusplus_primer;

命名空间的别名声明以关键字namespace开始,后面是别名所用的名字、=符号、命名空间原来的名字以及一个分号。不能在命名空间还没有定义前就声明别名,否则将产生错误。

命名空间的别名也可以指向一个嵌套的命名空间:

1
2
namespace Qlib = cplusplus_primer::QueryLib;
Qlib::Query q;

一个命名空间可以有好几个同义词或别名,所有别名都与命名空间原来的名字等价。

3.2.using声明:扼要概述

一条using声明(using declaration)语句一次只引入命名空间的一个成员。它使得我们可以清楚地知道程序中所用的到底是哪个名字。

using声明引入的名字遵守与过去一样的作用域规则:它的有效范围从using声明的地方开始,一直到using声明所在的作用域结束为止。在此过程中,外层作用域的同名实体将被隐藏。未加限定的名字只能在using声明所在的作用域以及其内层作用域中使用。在有效作用域结束后,我们就必须使用完整的经过限定的名字了。

一条using声明语句可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域中。在类的作用域中,这样的声明语句只能指向基类成员(参见:访问控制与继承)。

3.3.using指示

using指示(using directive)和using声明类似的地方是,我们可以使用命名空间名字的简写形式:和using声明不同的地方是,我们无法控制哪些名字是可见的,因为所有名字都是可见的。

using指示以关键字using开始,后面是关键字namespace以及命名空间的名字。如果这里所用的名字不是一个已经定义好的命名空间的名字,则程序将发生错误。using指示可以出现在全局作用域、局部作用域和命名空间作用域中,但是不能出现在类的作用域中。

using指示使得某个特定的命名空间中所有的名字都可见,这样我们就无须再为它们添加任何前缀限定符了。简写的名字从using指示开始,一直到using指示所在的作用域结束都能使用。

3.4.using指示与作用域

using指示引入的名字的作用域远比using声明引入的名字的作用域复杂。如我们所知,using声明的名字的作用域与using声明语句本身的作用域一致,从效果上看就好像using声明语句为命名空间的成员在当前作用域内创建了一个别名一样。

using指示所做的绝非声明别名这么简单。相反,它具有将命名空间成员提升到包含命名空间本身和using指示的最近作用域的能力。

using声明和using指示在作用域上的区别直接决定了它们工作方式的不同。对于using声明来说,我们只是简单地令名字在局部作用域内有效。相反,using指示是令整个命名空间的所有内容变得有效。通常情况下,命名空间中会含有一些不能出现在局部作用域中的定义,因此,using指示一般被看作是出现在最近的外层作用域中。

在最简单的情况下,假定我们有一个命名空间A和一个函数f,它们都定义在全局作用域中。如果f含有一个对A的using指示,则在f看来,A中的名字仿佛是出现在全局作用域中f之前的位置一样:

1
2
3
4
5
6
7
8
9
10
//命名空间A和函数f定义在全局作用域中
namespace A {
    int i, j;
}
void f()
{
    using namespace A; //把A中的名字注入到全局作用域中
    cout << i * j << endl; //使用命名空间A中的i和j
    //...
}

3.5.using指示示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace blip {
    int i = 16, j = 15, k = 23;
    //其他声明
}
int j = 0; //正确:blip的j隐藏在命名空间中
void manip()
{
    //using指示,blip中的名字被“添加”到全局作用域中
    using namespace blip; //如果使用了j,则将在::j和blip::j之间产生冲突
    ++i; //将blip::i设定为17
    ++j; //二义性错误:是全局的j还是blip::j?
    ++::j; //正确:将全局的j设定为1
    ++blip::j; //正确:将blip::j设定为16
    int k = 97; //当前局部的k隐藏了blip::k
    ++k; //将当前局部的k设定为98
}

3.6.头文件与using声明或指示

头文件如果在其顶层作用域中含有using指示或using声明,则会将名字注入到所有包含了该头文件的文件中。

4.类、命名空间与作用域

对命名空间内部名字的查找遵循常规的查找规则:即由内向外依次查找每个外层作用域。外层作用域也可能是一个或多个嵌套的命名空间,直到最外层的全局命名空间查找过程终止。只有位于开放的块中且在使用点之前声明的名字才被考虑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
namespace A {
    int i;
    namespace B {
        int i; //在B中隐藏了A::i
        int j;
        int f1()
        {
            int j; //j是f1的局部变量,隐藏了A::B::j
            return i; //返回B::i
        }
    } //命名空间B结束,此后B中定义的名字不再可见
    int f2() {
        return j; //错误:j没有被定义
    }
    int j = i; //用A::i进行初始化
}

对于位于命名空间中的类来说,常规的查找规则仍然适用:当成员函数使用某个名字时,首先在该成员中进行查找,然后在类中查找(包括基类),接着在外层作用域中查找,这时一个或几个外层作用域可能就是命名空间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace A {
    int i;
    int k;
    class C1 {
    public:
        C1() : i(0), j(0) { } //正确:初始化C1::i和C1::j
        int f1() { return k; } //返回A::k
        int f2() { return h; } //错误:h未定义
        int f3();
    private:
        int i; //在C1中隐藏了A::i
        int j;
    };
    int h = i; //用A::i进行初始化
}
//成员f3定义在C1和命名空间A的外部
int A::C1::f3() { return h; } //正确:返回A::h

4.1.实参相关的查找与类类型形参

考虑下面这个简单的程序:

1
2
std::string s;
std::cin >> s;

该调用等价于(参见:基本概念):

1
operator>>(std::cin, s);

operator>>函数定义在标准库string中,string又定义在命名空间std中。但是我们不用std::限定符和using声明就可以调用operator>>

对于命名空间中名字的隐藏规则来说有一个重要的例外,它使得我们可以直接访问输出运算符。这个例外是,当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效。

在此例中,当编译器发现对operator>>的调用时,首先在当前作用域中寻找合适的函数,接着查找输出语句的外层作用域。随后,因为>>表达式的形参是类类型的,所以编译器还会查找cin和s的类所属的命名空间。也就是说,对于这个调用来说,编译器会查找定义了istream和string的命名空间std。当在std中查找时,编译器找到了string的输出运算符函数。

查找规则的这个例外允许概念上作为类接口一部分的非成员函数无须单独的using声明就能被程序使用。假如该例外不存在,则我们将不得不为输出运算符专门提供一个using声明:

1
using std::operator>>; //要想使用cin>>s就必须有该using声明

或者使用函数调用的形式以把命名空间的信息包含进来:

1
std::operator>>(std::cin, s); //正确:显式地使用std::>>

在没有使用运算符语法的情况下,上述两种声明都显得比较笨拙且无形中增加了使用IO标准库的难度。

4.2.查找与std::movestd::forward

接下来考虑标准库move和forward函数。这两个都是模板函数,在标准库的定义中它们都接受一个右值引用的函数形参。如我们所知,在函数模板中,右值引用形参可以匹配任何类型。如果我们的应用程序也定义了一个接受单一形参的move函数,则不管该形参是什么类型,应用程序的move函数都将与标准库的版本冲突。forward函数也是如此。

4.3.友元声明与实参相关的查找

1
2
3
4
5
6
7
8
namespace A {
    class C {
        //两个友元,在友元声明之外没有其他的声明
        //这些函数隐式地成为命名空间A的成员
        friend void f2(); //除非另有声明,否则不会被找到
        friend void f(const C&); //根据实参相关的查找规则可以被找到
    };
}

此时,f和f2都是命名空间A的成员。即使f不存在其他声明,我们也能通过实参相关的查找规则调用f:

1
2
3
4
5
6
int main()
{
    A::C cobj;
    f(cobj); //正确:通过在A::C中的友元声明找到A::f
    f2(); //错误:A::f2没有被声明
}

因为f接受一个类类型的实参,而且f在C所属的命名空间进行了隐式的声明,所以f能被找到。相反,因为f2没有形参,所以它无法被找到。

5.重载与命名空间

5.1.与实参相关的查找与重载

对于接受类类型实参的函数来说,其名字查找将在实参类所属的命名空间中进行。这条规则对于我们如何确定候选函数集同样也有影响。我们将在每个实参类(以及实参类的基类)所属的命名空间中搜寻候选函数。在这些命名空间中所有与被调用函数同名的函数都将被添加到候选集当中,即使其中某些函数在调用语句处不可见也是如此:

1
2
3
4
5
6
7
8
9
10
11
namespace NS {
    class Quote { /*...*/ };
    void display(const Quote&) { /*...*/ }
}
//Bulk_item的基类声明在命名空间NS中
class Bulk_item : public NS::Quote { /*...*/ };
int main() {
    Bulk_item book1;
    display(book1);
    return 0;
}

我们传递给display的实参属于类类型Bulk_item,因此该调用语句的候选函数不仅应该在调用语句所在的作用域中查找,而且也应该在Bulk_item及其基类Quote所属的命名空间中查找。命名空间NS中声明的函数display(const Quote&)也将被添加到候选函数集当中。

5.2.重载与using声明

using声明语句声明的是一个名字,而非一个特定的函数(参见:继承中的类作用域):

1
2
using NS::print(int); //错误:不能指定形参列表
using NS::print; //正确:using声明只声明一个名字

当我们为函数书写using声明时,该函数的所有版本都被引入到当前作用域中。

一个using声明囊括了重载函数的所有版本以确保不违反命名空间的接口。

一个using声明引入的函数将重载该声明语句所属作用域中已有的其他同名函数。如果using声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明。如果using声明所在的作用域中已经有一个函数与新引入的函数同名且形参列表相同,则该using声明将引发错误。除此之外,using声明将为引入的名字添加额外的重载实例,并最终扩充候选函数集的规模。

5.3.重载与using指示

using指示将命名空间的成员提升到外层作用域中,如果命名空间的某个函数与该命名空间所属作用域的函数同名,则命名空间的函数将被添加到重载集合中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace libs_R_us {
    extern void print(int);
    extern void print(double);
}
//普通的声明
void print(const std::string &);
//这个using指示把名字添加到print调用的候选函数集
using namespace libs_R_us;
//print调用此时的候选函数包括:
//libs_R_us的print(int)
//libs_R_us的print(double)
//显式声明的print(const std::string &)
void fooBar(int ival)
{
    print("Value: "); //调用全局函数print(const string &)
    print(ival); //调用libs_R_us::print(int)
}

与using声明不同的是,对于using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。

5.4.跨越多个using指示的重载

如果存在多个using指示,则来自每个命名空间的名字都会成为候选函数集的一部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
namespace AW {
    int print(int);
}
namespace Primer {
    double print(double);
}
//using指示从不同的命名空间中创建了一个重载函数集合
using namespace AW;
using namespace Primer;
long double print(long double);
int main() {
    print(1); //调用AW::print(int)
    print(3.1); //调用Primer::print(double)
    return 0;
}