《C++编程规范》七、名字空间与模块
名字空间是管理名字和减少名字冲突的重要工具。模块也是如此,它还是管理发布和版本化的重要工具。我们将模块定义为同一个人或小组维护的紧凑发布单元(见第5条),通常,一个模块也会一致地用同一编译器和开关设置编译。模块有许多级别,尺寸范围变化很大;模块可能小到一个只含一个类的目标文件,也可能大到一个由多个源文件(这些文件的内容可能会构成更大的应用程序的子系统,也可能是独立发布的)生成的共享或者动态的程序库,甚至是由许多小的模块(比如共享库、DLL或者其他程序库)组成、包含成千上万个类型的巨大程序库。虽然 C++标准中并没有直接提到共享库和动态库这样的实体,但是 C++ 程序员构建和使用程序库已经是家常便饭,而且良好的模块化是成功的依赖性管理的基本要素(见第11条)。
很难想像,一个较大的程序能不使用名字空间和模块。本部分中,我们将阐述使用这两种相关的管理和打包工具的基本原则,并讨论它们怎样与 C++ 语言的其他部分和运行时环境交互较好,怎样交互又不好。这些规则和原则将说明如何趋其“利”而避其“害”。
本部分中我们选出的最有价值条款是第58条:应该将类型和函数分别置于不同的名字空间中,除非有意想让它们一起工作。
第57条 将类型及其非成员函数接口置于同一名字空间中
非成员也是函数:如果要将非成员函数(特别是操作符和辅助函数)设计成类X的接口的一部分,那么就必须在与X相同的名字空间中定义它们,以便正确调用。
第58条 应该将类型和函数分别置于不同的名字空间中,除非有意想让它们一起工作
协助防止名字查找问题:通过将类型(以及与其直接相关的非成员函数,见第57条)置于自己单独的名字空间中,可以使类型与无意的 ADL(参数依赖查找,也称 Koenig 查找)隔离开来,促进有意的ADL。要避免将类型和模板化函数或者操作符放在相同的名字空间中。
避免将不属于类型X的接口的非成员函数与X放在同一个名字空间中,尤其是绝对不要将模板化函数或者操作符与用户定义类型放在同一名字空间中。
第59条 不要在头文件中或者#include之前编写名字空间using
名字空间 using 是为了使我们更方便,而不是让我们用来叨扰别人的:绝对不要编写 using声明或者在#include之前编写using指令。
推论:在头文件中,不要编写名字空间级的using指令或者using声明,相反应该显式地用名字空间限定所有的名字。(第二条规则是从第一条直接得出的,因为头文件无法知道以后其他头文件会出现什么样的#include。)
第60条 要避免在不同的模块中分配和释放内存
物归原位:在一个模块中分配内存,而在另一个模块中释放它,会在这两个模块之间产生微妙的远距离依赖,使程序变得脆弱。必须用相同版本的编译器、同样的标志(比较著名的比如用debug 还是NDEBUG)和相同的标准库实现对它们进行编译,实践中,在释放内存时,用来分配内存的模块最好仍在内存中。
确保删除由合适的函数进行,一个很好的方式是使用shared_ptr设施(参阅[C++TR104])。shared_ptr 是一个引用计数的智能指针,可以在构造时捕获其“删除器(deleter)”。删除器是一个执行释放的函数对象(或者是一个直接的函数指针)。 因为这个函数对象或者函数指针是shared_ptr对象状态的一部分,所以分配对象的模块能够同时指定释放函数,而且该函数即使在释放点在另一个模块中的时候,也能够被正确调用——而且是以公认的低代价(正确性是值得用这一代价交换的;另见第5条、第6条和第8条)。当然,原模块必须仍保留在内存中。
第61条 不要在头文件中定义具有链接的实体
重复会导致膨胀:具有链接的实体(entity with linkage),包括名字空间级的变量或函数,都需要分配内存。在头文件中定义这样的实体将导致连接时错误或者内存的浪费。请将所有具有链接的实体放入实现文件。
在开始使用C++时,我们都会很快地知道,这样的头文件:
// 要避免在头文件中定义具有外部链接的实体
int fudgeFactor;
string hello(“Hello, world!”);
void foo() {/* ... */}
只要被一个以上的源文件所包含,就很容易导致连接时错误,编译器会报告存在重复符号错误。原因很简单:每个源文件中,都会定义 fudgeFactor、hello和foo的函数体,并分配空间。当要将它们都链接起来的时候,连接器将面对多个具有相同名字而且互相在竞争可见性的符号。
解决之道非常简单——只在头文件中放置声明即可:
extern int fudgeFactor;
extern string hello;
void foo(); //“extern”对函数声明而言是可有可无的
而实际的定义则放在一个单独的实现文件中:
int fudgeFactor;
string hello(“Hello, world!”);
void foo() {/* ... */ }
第62条 不要允许异常跨越模块边界传播
不要向邻家的花园抛掷石头:C++异常处理没有普遍通用的二进制标准。不要在两段代码之间传播异常,除非能够控制用来构建两段代码的编译器和编译选项;否则模块可能无法支持可兼容地实现异常传播。这通常可以一言以蔽之:不要允许异常跨越模块或子系统边界传播。
C++标准并没有规定异常传播必须实现的方式,甚至也没有大多数系统共同遵守的事实标准。异常传播的机制不仅随着所用的操作系统和编译器的不同而异,而且即使对于给定操作系统上的给定编译器,也会随构建应用程序每个模块所用编译选项的不同而不同。因此,应用程序必须通过保护所有主要模块的边界来防止异常处理的不兼容性,这意味着对于这些模块的每个编译单元,开发人员都要确保一致地使用相同的编译器和编译选项。
最低限度,应用程序必须在以下位置有捕获所有异常的catch(...)兜底语句,其中大多数都直接适用于模块。
-
在main函数的附近。捕获并用日志记录任何将使程序不正常终止而其他地方又没有捕获的异常。
-
在从无法控制的代码中执行回调附近。操作系统和程序库会提供一些框架,可以传递一个指向以后才会调用(例如,发生异步事件时)的函数的指针。不要让异常传播到回调函数之外,因为调用回调函数的代码很有可能使用不同的异常处理机制。其实,调用代码甚至有可能不是用C++编写的。
-
在线程边界的附近。毕竟,线程是在操作系统的内部创建的。要确保线程的 mainline 函数不会向系统传播异常,否则将出乎系统的意料。
-
在模块接口边界的附近。子系统会公开一些公用接口供外部使用。如果子系统要封装为一个库,那么应该使异常仅限于内部,并使用传统的平凡但是可靠的错误代码向外界报告错误(见第72条)。
-
在析构函数内部。析构函数不能抛出异常(见第 51条)。如果析构函数要调用可能会抛出异常的函数,就需要自我保护好,防止这些异常向外泄漏。
确保所有模块在内部一致地使用一种错误处理策略(最好是C++异常,见第72条),而在其接口中也一致地使用另一种错误处理策略(例如,用于C语言的API的错误代码);两种策略可能会碰巧相同,但通常情况下还是不同的。错误处理策略只有在跨越模块边界时才可以改变。应该明确如何处理模块之间策略的接口(例如,如何与COM或CORBA互操作,又如,如何能始终在C语言的API边界捕获异常)。一种好的解决方案是,定义一些中枢性的函数,在异常和子系统返回的错误代码之间进行转换。这样就能够很容易地将来自对应模块的错误转换为内部使用的异常,简化了集成工作。
在本条款所提到的位置之外使用catch(...),经常是一种不良设计的征兆,因为这意味着你想要捕获所有异常,却未必具备处理它们的专门知识(见第74条)。好的程序不应该有很多捕获全部异常的 catch(...),实际上,连 try/catch 语句都不多; 理想情况下,错误可以在模块内部到处顺畅地传播,在跨越模块边界时转换(不得不付出的代价),在按策略设置的边界上进行处理。
第63条 在模块的接口中使用具有良好可移植性的类型
生在(模块的)边缘,必须格外小心:不要让类型出现在模块的外部接口中,除非能够确保所有的客户代码都能正确地理解该类型。应该使用客户代码能够理解的最高层抽象。
程序库发布得越广,对其所有客户代码构建环境的控制就越小,库的外部接口中能够可靠使用的类型就越少。跨模块的接口要涉及二进制数据交换。很遗憾,C++没有指定标准的二进制接口;广为发布的库可能尤其需要依赖于int和char这样的内置类型与外部世界接口。即使在相同的编译器中使用不同的构建选项编译相同的类型,仍然会生成二进制不兼容的版本。
使用的抽象层次越低,可移植性就越好,但复杂性也越高