C++编程语言:基础设施:源文件和程序(Bjarne Stroustrup)
C++源文件和程序
第15章 源文件和程序
(Source Files and Programs)
目录
15.1 单独编译(Separate Compilation)
15.2.1 文件局部名(File-Local Names)
15.2.3 一次定义原则(The One-Definition Rule)
15.1 单独编译(Separate Compilation)
任何实际程序都由许多逻辑上独立的组件组成(例如,命名空间;第 14 章)。为了更好地管理这些组件,我们可以将程序表示为一组(源代码)文件,其中每个文件包含一个或多个逻辑组件。我们的任务是为程序设计一个物理结构(文件集),以一致、易懂和灵活的方式表示逻辑组件。特别是,我们的目标是接口(例如,函数声明)和实现(例如,函数定义)的清晰分离。文件是传统的存储单元(在文件系统中)和传统的编译单元。有些系统不将 C++ 程序存储、编译和呈现为文件集。但是,这里的讨论将集中在采用传统文件用法的系统上。
通常不可能将完整的程序放在一个文件中。特别是,标准库和操作系统的代码通常不以源代码形式作为用户程序的一部分提供。对于实际大小的应用程序,即使将用户自己的所有代码放在一个文件中也是不切实际和不方便的。将程序组织成文件的方式可以帮助强调其逻辑结构,帮助人类读者理解程序,并帮助编译器强制执行该逻辑结构。如果编译单位是文件,则每当对文件或它所依赖的内容进行更改(无论多小)时,都必须重新编译整个文件。即使对于中等大小的程序,通过将程序划分为合适大小的文件,也可以显著减少重新编译所花费的时间。
用户向编译器提供源文件。然后编译器对文件进行预处理;即进行宏指令处理(§12.6)并且 #include指令引入头文件(§2.4.1,§15.2.2)。预处理的结果称为翻译单元。此单元是编译器正常工作的单元,也是 C++ 语言规则描述的内容。在本书中,我仅在必要时区分源文件和翻译单元,以区分程序员看到的内容和编译器考虑的内容。
为了启用单独编译,程序员必须提供声明,提供分析与程序其余部分隔离的编译单元所需的类型信息。由许多单独编译的部分组成的程序中的声明必须与由单个源文件组成的程序中的声明完全一致。您的系统有工具来帮助确保这一点。特别是,链接器可以检测到多种不一致性。链接器是将单独编译的部分绑定在一起的程序。链接器有时(令人困惑地)被称为加载器。链接可以在程序开始运行之前完全完成。或者,可以稍后将新代码添加到正在运行的程序(“动态链接”)。
将程序组织成源文件的过程通常称为程序的物理化结构过程。将程序在物理上分离成单独的文件应由程序的逻辑结构指导。指导程序在命名空间之外组合的依赖关系与指导程序在源文件中组合的依赖关系相同。但是,程序的逻辑结构和物理结构不必完全相同。例如,使用多个源文件来存储来自单个命名空间的函数、将命名空间定义集合存储在单个文件中或将命名空间的定义分散到多个文件中(§14.3.3)可能会有所帮助。
在这里,我们首先考虑一些与链接相关的技术细节,然后讨论将桌面计算器(§10.2,§14.3.1)分解为文件的两种方法。
15.2 链接(Linkage)
函数、类、模板、变量、命名空间、枚举和枚举器的名称必须在所有编译单元中一致使用,除非明确指定为局部的。
程序员的任务是确保每个命名空间、类、函数等在其出现的每个编译单元中都得到正确声明,并且引用同一实体的所有声明都是一致的。例如,考虑两个文件:
// file1.cpp:
int x = 1;
int f() { /* do something */ }
// file2.cpp:
extern int x;
int f();
void g() { x = f(); }
file2.cpp 中 g() 使用的 x 和 f() 是在 file1.cpp 中定义的。关键字 extern 表示 file2.cpp 中 x 的声明(只是)一个声明,而不是一个定义(§6.3)。如果 x 被初始化,extern 将被忽略,因为带有初始化器的声明始终是一个定义。对象在程序中必须定义一次。它可以被声明多次,但类型必须完全一致。例如:
// file1.cpp:
int x = 1;
int b = 1;
extern int c;
// file2.cpp:
int x; // means ‘‘int x = 0;’’
extern double b;
extern int c;
这里有三个错误:x 被定义两次,b 被声明两次但类型不同,c 被声明两次但未定义。这些类型的错误(链接错误)无法被一次只查看一个文件的编译器检测到。然而,许多错误可以被链接器检测到。例如,我知道的所有实现都正确诊断了 x 的双重定义。然而,在流行的实现中,b 的不一致声明未被捕获,而 c 的缺失定义通常只有在使用 c 时才会被捕获。
请注意,在全局或命名空间范围内定义的未使用初始化器的变量会默认初始化(§6.3.5.1)。非static局部变量或在自由存储中创建的对象则不是这种情况(§11.2)。
在类主体之外,实体必须在使用前声明(§6.3.4)。例如:
// file1.cpp:
int g() { return f()+7; } // 错 : f() (还)没有声明
int f() { return x; } // 错 : x (还)没有声明
int x;
如果名称可以在定义它的编译单元之外的编译单元中使用,则称该名称具有外部链接。前面示例中的所有名称都具有外部链接。如果名称只能在定义它的编译单元中引用,则称该名称具有内部链接。例如:
static int x1 = 1; // 内部链接: 不能从其它编译单元访问
const char x2 = 'a'; // 内部链接: 不能从其它编译单元访问
当在命名空间作用域(包括全局范围;§14.2.1)中使用时,关键字 static(有点不合逻辑)表示“无法从其他源文件访问”(即内部链接)。如果您希望 x1 可以从其他源文件访问(“具有外部链接”),则应删除 static。关键字 const 意味着默认内部链接,因此如果您希望 x2 具有外部链接,则需要在其定义前面加上extern:
int x1 = 1; // 外部链接: 从其它编译单元访问
extern const char x2 = 'a'; //外部连接: 从其它编译单元访问
链接器看不到的名称(例如局部变量的名称)被认为没有链接。
内联函数(§12.1.3,§16.2.8)必须在使用它的每个编译单元中以相同的方式定义(§15.2.3)。因此,以下示例不仅品味低下,而且是无效的:
// file1.cpp:
inline int f(int i) { return i; }
// file2.cpp:
inline int f(int i) { return i+1; }
不幸的是,这个错误很难被实现捕获,并且以下——否则完全合乎逻辑的——外部链接和内联的组合被禁止,以简化编译器编写者的工作:
// file1.cpp:
extern inline int g(int i);
int h(int i) { return g(i); } // 错: g() 在这个编译单元中未定义
// file2.cpp:
extern inline int g(int i) { return i+1; }
// ...
我们通过使用头文件(§15.2.2)保持内联函数定义的一致性。例如:
// h.h:
inline int next(int i) { return i+1; }
// file1.cpp:
#include "h.h"
int h(int i) { return next(i); } // fine
// file2.cpp:
#include "h.h"
// ...
默认情况下,const 对象(§7.5)、constexpr 对象(§10.4)、类型别名(§6.5)以及在命名空间范围内声明为static的任何内容(§6.3.4)都具有内部链接。因此,此示例是有效的(尽管可能会造成混淆):
// file1.cpp:
using T = int;
const int x = 7;
constexpr T c2 = x+1;
// file2.cpp:
using T = double;
const int x = 8;
constexpr T c2 = x+9;
为了确保一致性,将别名、const、constexpr 和 inline 放在头文件中(§15.2.2)。
可以通过显式声明为 const 提供外部链接:
// file1.cpp:
extern const int a = 77;
// file2.cpp:
extern const int a;
void g()
{
cout << a << '\n';
}
这里,g() 将打印 77。
管理模板定义的技术在§23.7.15.2.1中描述。
15.2.1 文件局部名(File-Local Names)
最好避免使用全局变量,因为它们会导致维护问题。特别是,很难知道它们在程序中的哪个位置使用,并且它们可能是多线程程序中数据竞争的来源(§41.2.4),从而导致非常隐蔽的错误。
将变量放在命名空间中会有点帮助,但是这样的变量仍然容易受到数据竞争的影响。
如果必须使用全局变量,至少应将其使用限制在单个源文件中。此限制可通过以下两种方式之一实现:
[1] 将声明放在无名命名空间中。
[2] 将实体声明为static。
无名命名空间 (§14.4.8) 可用于将名称设为编译单元的局部名称。无名命名空间的效果与内部链接的效果非常相似。例如:
// file 1.cpp:
namespace {
class X { /* ... */ };
void f();
int i;
// ...
}
// file2.cpp:
class X { /* ... */ };
void f();
int i;
// ...
file1.cpp 中的函数 f() 与 file2.cpp 中的函数 f()不同。如果编译单元使用局部名称,而其他具有外部链接的实体也使用相同的名称,那么就会带来麻烦。
关键字 static(令人困惑)表示“使用内部链接”(§44.2.3)。这是 C 语言早期遗留下来的不幸产物。
15.2.2 头文件(Header Files)
同一对象、函数、类等的所有声明中的类型必须一致。因此,提交给编译器并随后链接在一起的源代码也必须一致。实现不同编译单元中声明一致性的一个不完善但简单的方法是在包含可执行代码和/或数据定义的源文件中 #include 包含接口信息的头文件。
#include 机制是一种文本操作工具,用于将源程序片段集中到一个单元(文件)中以供编译。考虑:
#include "被包含文件"
#include 指令将出现 #include 的行替换为被包含文件的内容。被包含文件的内容应为 C++ 源文本,因为编译器将继续读取它。
要包含标准库头文件,请使用尖括号 < 和 > 括住名称,而不是使用引号。例如:
#include <iostream> // 来自标准库包含目录
#include "myheader.h" // 来自当前目录
遗憾的是,include 指令中的 < > 或 " " 内的空格很重要:
#include < iostream > // 会找不到<iostream>
每次将源文件包含在某个地方时都重新编译它似乎有些奢侈,但文本可以是程序接口信息的合理密集编码,并且编译器只需要分析实际使用的细节(例如,模板主体通常直到实例化时才完全分析;§26.3)。此外,大多数现代 C++ 实现都提供了某种形式的(隐式或显式的)头文件预编译,以最大限度地减少处理相同头文件的重复编译所需的工作。
根据经验,头文件可能包含:
对于可以在头文件中放置什么内容的这个经验法则并不是语言要求。它只是使用 #include 机制来表达程序物理结构的一种合理方式。相反,头文件不应包含:
包含此类定义的头文件将导致错误或(在使用指令的情况下)混淆。头文件通常以 .h 为后缀,而包含函数或数据定义的文件以 .cpp 为后缀。因此,它们通常分别称为“.h 文件”和“.cpp 文件”。还有其他约定,例如 .c、.C、.cxx、.cc、.hh 和 hpp。编译器手册将非常具体地说明这个问题。
建议将简单常量的定义(而不是聚合的定义)放在头文件中的原因是,实现很难避免在多个翻译单元中重复出现聚合。此外,简单情况更为常见,因此对于生成良好的代码更为重要。
明智的做法是不要太聪明地使用 #include。我的建议是:
• 仅将 #include 用作头文件(不要将“包含变量定义和非内联函数的普通源代码”作为 #include)。
• 仅将完整的声明和定义作为 #include。
• 转换旧代码时,仅在全局作用域、链接规范块和命名空间定义中 #include(§15.2.4)。
• 将所有 #include 放在其他代码之前,以尽量减少意外依赖。
• 避免使用宏指令魔法(macro magic)。
• 尽量减少在头文件中使用非头文件局部名称(尤其是别名)。
我最不喜欢的活动之一是追踪由于一个名称被宏指令替换为完全不同的东西而导致的错误,这个宏指令是在间接 #included 头文件中定义的,而我从未听说过。
15.2.3 一次定义原则(The One-Definition Rule)
给定的类、枚举和模板等必须在程序中定义一次。
从实际角度来看,这意味着必须只有一个定义,比如说,一个类的定义位于某个文件中。不幸的是,语言规则不能那么简单。例如,类的定义可以通过宏指令扩展来组合(呃!),类的定义可以通过 #include 指令以文本形式包含在两个源文件中(§15.2.2)。更糟糕的是,“文件”不是 C++ 语言定义中的概念;存在不将程序存储在源文件中的实现。
因此,标准中关于类、模板等必须具有唯一定义规则的表述方式更为复杂和微妙。此规则通常称为单一定义规则(“ODR”)。也就是说,当且仅当满足以下述条件时,类、模板或内联函数的两个定义才被接受为同一唯一定义的示例:
[1] 它们出现在不同的翻译单元中,并且
[2] 它们每个标记都相同,并且
[3] 这些标记的含义在两个翻译单元中都相同。
例如:
// file1.cpp:
struct S { int a; char b; };
void f(S∗);
// file2.cpp:
struct S { int a; char b; };
void f(S∗ p) { /* ... */ }
ODR 表示此示例有效,并且 S 在两个源文件中引用同一个类。但是,像这样写出两次定义是不明智的。维护 file2.cpp 的人会自然而然地认为 file2.cpp 中 S 的定义是 S 的唯一定义,因此可以随意更改它。这可能会引入难以检测的错误。
ODR 的目的是允许将类定义包含在来自通用源文件的不同编译单元中。例如:
// s.h:
struct S { int a; char b; };
void f(S∗);
// file1.cpp:
#include "s.h"
// use f() here
// file2.cpp:
#include "s.h"
void f(S∗ p) { /* ... */ }
或图形化为:
以下是违反 ODR 的三种方式的示例:
// file1.cpp:
struct S1 { int a; char b; };
struct S1 { int a; char b; }; // 错: 两次定义
这是一个错误,因为一个结构不能在单个翻译单元中定义两次。
// file1.cpp:
struct S2 { int a; char b; };
// file2.cpp:
struct S2 { int a; char bb; }; // 错误
这是一个错误,因为 S2 用于命名成员名称不同的类。
// file1.cpp:
typedef int X;
struct S3 { X a; char b; };
// file2.cpp:
typedef char X;
struct S3 { X a; char b; }; // 错误
这里,S3 的两个定义是每个 token 都相同的,但这个例子是错误的,因为名称 X 的含义在两个文件中被偷偷地弄得不同。
检查单独的翻译单元中不一致的类定义超出了大多数 C++ 实现的能力。因此,违反 ODR 的声明可能成为细微错误的根源。不幸的是,将共享定义放在头文件中并用 #include包含它们的技术无法防止最后一种形式的 ODR 违规。本地类型别名和宏可以改变被 #include 声明的含义:
// s.h:
struct S { Point a; char b; };
// file1.cpp:
#define Point int
#include "s.h"
// ...
// file2.cpp:
class Point { /* ... */ };
#include "s.h"
// ...
防范此类黑客行为的最佳方法是使头文件尽可能独立。例如,如果在 s.h 头文件中声明了 Point 类,则将检测到错误。
只要遵守 ODR,模板定义就可以被 #include 到多个编译单元中。这甚至适用于函数模板定义和包含成员函数定义的类模板。
15.2.4 标准库头文件
标准库的功能通过一组头文件(§4.1.2,§30.2)呈现。标准库头文件不需要后缀;它们被称为头文件,因为它们是使用 #include<...> 语法而不是 #include"..." 包含的。没有 .h 后缀并不意味着头文件如何存储。诸如 <map> 之类的头文件通常存储为某个标准目录中名为 map.h 的文本文件。另一方面,标准头文件不需要以常规方式存储。实现可以利用标准库定义的知识来优化标准库实现和标准头文件处理方式。例如,实现可能具有内置标准数学库(§40.3)的知识,并将 #include<cmath> 视为一个开关,使标准数学函数可用而无需实际读取任何文件。
对于每个 C 标准库头文件 <X.h>,都有一个对应的标准 C++ 头文件 <cX>。例如,#include<cstdio> 提供 #include<stdio.h> 的功能。典型的 stdio.h 如下所示:
#ifdef __cplusplus // for C++ compilers only (§15.2.5)
namespace std { // the standard library is defined in namespace std (§4.1.2)
extern "C" { // stdio functions have C linkage (§15.2.5)
#endif
/* ... */
int printf(const char∗, ...);
/* ... */
#ifdef __cplusplus
}
}
// ...
using std::printf; // make printf available in global namespace
// ...
#endif
也就是说,实际声明(很可能)是共享的,但必须解决链接和命名空间问题,以允许 C 和 C++ 共享头文件。宏指令 __cplusplus 由 C++ 编译器定义(§12.6.2),可用于区分 C++ 代码和用于 C 编译器的代码。
15.2.5 链接到非C++ 代码
通常,C++ 程序包含用其他语言(例如 C 或 Fortran)编写的部分。同样,C++ 代码片段通常用作主要由其他语言(例如 Python 或 Matlab)编写的程序的一部分。用不同语言编写的程序片段之间,甚至用同一语言编写但使用不同编译器编译的片段之间的协作可能很困难。例如,不同语言和同一语言的不同实现可能在使用机器寄存器保存参数、放在栈上的参数布局、内置类型(如字符串和整数)的布局、编译器传递给链接器的名称形式以及链接器所需的类型检查量方面有所不同。为了提供帮助,可以指定要在 extern 声明中使用的链接约定。例如,这声明了 C 和 C++ 标准库函数 strcpy(),并指定应根据(系统特定的)C 链接约定进行链接:
extern "C" char∗ strcpy(char∗, const char∗);
此声明的效果与“简单”声明的效果不同:
extern char∗ strcpy(char∗, const char∗);
仅在用于调用 strcpy() 的链接约定中。
由于 C 和 C++ 之间关系密切,extern "C" 指令特别有用。请注意,extern "C" 中的 C 指的是链接约定,而不是语言。通常,extern "C" 用于链接到恰好符合 C 实现约定的 Fortran 和汇编程序例程。
extern "C" 指令仅指定链接约定,不会影响对函数调用的语义。特别是,声明为 extern "C" 的函数仍然遵循 C++ 类型检查和参数转换规则,而不是较弱的 C 规则。例如:
extern "C" int f();
int g()
{
return f(1); // 错 : 无预期的参数
}
在许多声明中添加 extern "C" 可能会很麻烦。因此,有一种机制可以指定与一组声明的链接。例如:
extern "C" {
char∗ strcpy(char∗, const char∗);
int strcmp(const char∗, const char∗);
int strlen(const char∗);
// ...
}
此构造通常称为链接块,可用于封装完整的 C 头文件,以使头文件适合 C++ 使用。例如:
extern "C" {
#include <string.h>
}
此技术通常用于从 C 头文件生成 C++ 头文件。或者,可以使用条件编译(§12.6.1)来创建通用的 C 和 C++ 头文件:
#ifdef __cplusplus
extern "C" {
#endif
char∗ strcpy(char∗, const char∗);
int strcmp(const char∗, const char∗);
int strlen(const char∗);
// ...
#ifdef __cplusplus
}
#endif
(译注:说明在C++环境中编译时,原来的C函数使用C语言的连接规则进行链接。)
预定义宏名 __cplusplus (§12.6.2) 用于确保当文件用作 C 头文件时,C++ 构造被编辑掉(译注:即表明在C++环境中时,C函数使用C的链接原则,不使用C++原则)。
任何声明都可以出现在链接块中:
extern "C" { // any declaration here, for example:
int g1; // 定义
extern int g2; // 声明, 非定义
}
具体来说,变量的作用域和存储类(§6.3.4,§6.4.2)不受影响,因此 g1 仍然是全局变量 ——并且仍然是定义的,而不仅仅是声明的。要声明但不定义变量,必须在声明中直接应用关键字 extern。例如:
extern "C" int g3; // 声明,非定义(直接应用extern)
extern "C" { int g4; } // 定义(非直接应用extern)
乍一看这似乎很奇怪。然而,这是在将“C”添加到外部声明时保持含义不变以及在将文件包含在链接块中时保持文件含义不变的简单结果。
具有 C 链接的名称可以在命名空间中声明。命名空间将影响在 C++ 程序中访问名称的方式,但不会影响链接器查看它的方式。std 中的 printf() 是一个典型示例:
#include<cstdio>
void f()
{
std::printf("Hello, "); // OK
printf("world!\n"); // 错: 无全局 printf()
}
即使调用 std::printf,它仍然是相同的旧 C printf() (§43.3)。
请注意,这允许我们将具有 C 链接的库包含到我们选择的命名空间中,而不是污染全局命名空间。不幸的是,对于在全局命名空间中定义具有 C++ 链接的函数的头文件,我们无法获得相同的灵活性。原因是 C++ 实体的链接必须考虑命名空间,以便生成的对象文件能够反映命名空间的使用或未使用。
15.2.6 链接和函数指针
当在一个程序中混合使用 C 和 C++ 代码片段时,我们有时希望将一种语言中定义的函数指针传递给另一种语言中定义的函数。如果两种语言的实现共享链接约定和函数调用机制,则将指针传递给函数是微不足道的。但是,这种共性通常不能假定,因此必须小心确保函数的调用方式符合预期。
当为声明指定链接时,指定的链接将应用于声明引入的所有函数类型、函数名称和变量名称。这使得各种奇怪的(有时是必要的)链接组合成为可能。例如:
typedef int (∗FT)(const void∗, const void∗); //FT 有 C++ 链接规则
extern "C" {
typedef int (∗CFT)(const void∗, const void∗); //CFT 有 C 链接规则
void qsort(void∗ p, size_t n, size_t sz, CFT cmp); // cmp has C linkage
}
void isort(void∗ p, size_t n, size_t sz, FT cmp); // cmp has C++ linkage
void xsort(void∗ p, size_t n, size_t sz, CFT cmp); // cmp has C linkage
extern "C" void ysort(void∗ p, size_t n, size_t sz, FT cmp);//cmp具有 C++ 链接//规则
int compare(const void∗, const void∗); //compare() has C++ linkage
extern "C" int ccmp(const void∗, const void∗); //ccmp() has C linkage
void f(char∗ v, int sz)
{
qsort(v,sz,1,&compare); // 错误
qsort(v,sz,1,&ccmp); // OK
isort(v,sz,1,&compare); // OK
isort(v,sz,1,&ccmp); // 错误
}
C 和 C++ 使用相同调用约定的实现可能会接受将“错误”标记为语言扩展的声明。但是,即使对于兼容的 C 和 C++ 实现,std::function(§33.5.3)或具有任何形式捕获的 lambda(§11.4.3)也无法跨越语言障碍。
15.3 使用头文件
为了说明头文件的用法,我介绍了几种表达计算器程序物理结构的替代方法(§10.2,§14.3.1)。
15.3.1 单头文件组织结构
将程序划分为多个文件的问题的最简单解决方案是将定义放在适当数量的 .cpp 文件中,并在每个 .cpp 文件 #include 的单个 .h 文件中声明它们协作所需的类型、函数、类等。这是我为自己使用的简单程序使用的初始组织;如果需要更复杂的东西,我会稍后重新组织。
对于计算器程序,我们可能会使用五个 .cpp 文件(lexer.cpp、parser.cpp、table.cpp、error.cpp 和 main.cpp)来保存函数和数据定义。头文件 dc.h 保存在多个 .cpp 文件中使用的每个名称的声明:
// dc.h:
#include <map>
#include<string>
#include<iostream>
namespace Parser {
double expr(bool);
double term(bool);
double prim(bool);
}
namespace Lexer {
enum class Kind : char {
name, number, end,
plus='+', minus='−', mul='∗', div='/’, print=';', assign='=', lp='(', rp=')'
};
struct Token {
Kind kind;
string string_value;
double number_value;
};
class Token_stream {
public:
Token(istream& s) : ip{&s}, owns(false}, ct{Kind::end} { }
Token(istream∗ p) : ip{p}, owns{true}, ct{Kind::end} { }
˜Token() { close(); }
Token get(); // read and return next token
Token& current(); // most recently read token
void set_input(istream& s) { close(); ip = &s; owns=false; }
void set_input(istream∗ p) { close(); ip = p; owns = true; }
private:
void close() { if (owns) delete ip; }
istream∗ ip; //pointer to an input stream
bool owns; // does the Token_stream own the istream?
Token ct {Kind::end}; // current_token
};
extern Token_stream ts;
}
namespace Table {
extern map<string,double> table;
}
namespace Error {
extern int no_of_errors;
double error(const string& s);
}
namespace Driver {
void calculate();
}
每个变量声明都使用关键字 extern,以确保在各个 .cpp 文件中 #include dc.h 时不会出现多重定义。相应的定义可以在相应的 .cpp 文件中找到。
我根据 dc.h 中声明的需要添加了标准库头文件,但没有添加仅为方便单个 .cpp 文件而需要的声明(例如 using声明)。
除去实际的代码,lexer.cpp 看起来会像这样:
// lexer.cpp:
#include "dc.h"
#include <cctype>
#include <iostream> // 冗余: 在 dc.h 中已有包含
Lexer::Token_stream ts;
Lexer::Token Lexer::Token_stream::g et() { /* ... */ }
Lexer::Token& Lexer::Token_stream::current() { /* ... */ }
我使用显式限定 Lexer:: 来进行定义,而不是简单地将它们全部括在:
namespace Lexer { /* ... */ }
这避免了意外向 Lexer 添加新成员的可能性。另一方面,如果我想向 Lexer 添加不属于其接口的成员,我必须重新打开命名空间(§14.2.5)。
以这种方式使用头文件可确保头文件中的每个声明在某个时候都会包含在包含其定义的文件中。例如,在编译 lexer.cpp 时,编译器将显示以下内容:
namespace Lexer { // from dc.h
// ...
class Token_stream {
public:
Token get();
// ...
};
}
// ...
Lexer::Token Lexer::Token_stream::get() { /* ... */ }
这可确保编译器能够检测到名称所指定类型的任何不一致之处。例如,如果 get() 被声明为返回 Token,但被定义为返回 int,则 lexer.cpp 的编译将因类型不匹配错误而失败。如果缺少定义,链接器将捕获该问题。如果缺少声明,某些 .cpp 文件将无法编译。
文件 parser.cpp 将如下所示:
// parser.cpp:
#include "dc.h"
double Parser::prim(bool get) { /* ... */ }
double Parser::term(bool get) { /* ... */ }
double Parser::expr(bool get) { /* ... */ }
文件表.cpp 将如下所示:
// table.cpp:
#include "dc.h"
std::map<std::string,double> Table::table;
符号表是标准库map。
文件 error.cpp 成为:
// error.cpp:
#include "dg.h"
// any more #includes or declarations
int Error::no_of_errors;
double Error::error(const string& s) { /* ... */ }
最后,文件 main.cpp 将如下所示:
// main.cpp:
#include "dc.h"
#include <sstream>
#include <iostream> // redundant: in dc.h
void Driver::calculate() { /* ... */ }
int main(int argc, char∗ argv[]) { /* ... */ }
要被识别为程序的 main(),main() 必须是一个全局函数(§2.2.1,§15.4),因此这里不使用命名空间。
系统的物理结构可以这样呈现:
顶部的头文件都是标准库工具的头文件。对于许多形式的程序分析,这些库可以忽略,因为它们众所周知且稳定。对于小型程序,可以通过将所有 #include 指令移至公共头文件来简化结构。同样,对于小型程序,将 error.cpp 和 table.cpp 从 main.cpp 中分离出来通常是多余的。
当程序较小且其各部分不打算单独使用时,这种单头样式的物理分区最有用。请注意,当使用命名空间时,程序的逻辑结构仍然在 dc.h 中表示。如果不使用命名空间,结构就会变得模糊,尽管注释可能会有所帮助。
对于较大的程序,单头文件方法在传统的基于文件的开发环境中是不可行的。对公共头文件的更改会迫使重新编译整个程序,并且多个程序员对该单个头文件的更新很容易出错。除非特别强调严重依赖命名空间和类的编程风格,否则逻辑结构会随着程序的增长而恶化。
15.3.2 多头文件组织结构
另一种物理组织方式是让每个逻辑模块都有自己的头文件来定义它所提供的功能。然后,每个 .cpp 文件都有一个对应的 .h 头文件来指定它提供的内容(其接口)。每个 .cpp 文件都包含自己的 .h 文件,通常还包括其他 .h 文件,这些文件指定它需要从其他模块获取什么,以实现接口中宣传的服务。这种物理组织对应于模块的逻辑组织。用户的接口放入其 .h 文件中,实现者的接口放入后缀为 _impl.h 的文件中,模块的函数、变量等定义放在 .cpp 文件中。这样,解析器由三个文件表示。解析器的用户接口由 parser.h 提供:
// parser.h:
namespace Parser { // interface for users
double expr(bool get);
}
parser_impl.h 提供了实现解析器的函数 expr()、prim() 和 term() 的共享环境:
// parser_impl.h:
#include "parser.h"
#include "error.h"
#include "lexer.h"
using Error::error;
using namespace Lexer;
namespace Parser { // 实现器接口
double prim(bool get);
double term(bool get);
double expr(bool get);
}
如果我们使用了 Parser_impl 命名空间(§14.3.3),那么用户接口和实现接口之间的区别会更加清晰。
头文件 parser.h 中的用户接口被 #include,以便编译器有机会检查一致性(§15.3.1)。
实现解析器的函数与解析器函数所需的头文件的 #include 指令一起存储在 parser.cpp 中:
// parser.cpp:
#include "parser_impl.h"
#include "table .h"
using Table::table;
double Parser::prim(bool get) { /* ... */ }
double Parser::term(bool get) { /* ... */ }
double Parser::expr(bool get) { /* ... */ }
从图形上看,解析器和驱动程序对它的使用如下所示:
正如预期的那样,这与 §14.3.1 中描述的逻辑结构非常接近。为了简化此结构,我们可以在 parser_impl.h 中而不是在 parser.cpp 中 #included 头文件table.h。但是,table.h 是一个示例,它不是表达解析器函数共享上下文所必需的;只有它们的实现才需要它。事实上,它只被一个函数 prim() 使用,所以如果我们真的希望最小化依赖关系,我们可以将 prim() 放在它自己的 .cpp 文件中,并且只在那里 #include 头文件table.h:
除了较大的模块外,这种详细说明并不合适。对于实际大小的模块,通常会在需要单个函数的地方 #include 额外的文件。此外,拥有多个 _impl.h 并不罕见,因为模块函数的不同子集需要不同的共享上下文。
请注意,_impl.h 符号不是一个标准,甚至不是一个常见的惯例;它只是我喜欢命名事物的方式。
为什么要费心采用这种更复杂的多个头文件方案?显然,只需将每个声明放入单个头文件即可,就像对 dc.h 所做的那样,这需要少得多的思考。
多头文件组织结构可以扩展到比我们的玩具解析器大几个数量级的模块和比我们的计算器大几个数量级的程序。使用这种组织类型的根本原因是它提供了更好的关注点的局部化。在分析和修改大型程序时,程序员必须专注于相对较小的代码块。多头文件组织可以轻松确定解析器代码所依赖的内容并忽略程序的其余部分。单头文件方法迫使我们查看任何模块使用的每个声明并决定它是否相关。简单的事实是,代码的维护总是在信息不完整的情况下从局部角度完成的。多头文件组织使我们能够仅从局部角度“从内到外”地成功工作。单头文件方法——就像其他以全局信息存储库为中心的组织一样——需要自上而下的方法,并且会永远让我们想知道到底什么依赖于什么。
更好的局部化可以减少编译模块所需的信息,从而加快编译速度。效果可能非常显著。我曾看到,由于简单的依赖性分析可以更好地利用头文件,编译时间减少了 1000 倍。
15.3.2.1 其它计算模块
其余计算器模块的组织方式与解析器类似。但是,这些模块非常小,因此不需要自己的 _impl.h 文件。只有当逻辑模块的实现包含许多需要共享上下文的函数(除了提供给用户的函数)时,才需要此类文件。
错误处理程序在 error.h 中提供了其接口:
// error.h:
#include<string>
namespace Error {
int Error::number_of_errors;
double Error::error(const std::string&);
}
在error.cpp中可以找到实现:
// error.cpp:
#include "error.h"
int Error::number_of_errors;
double Error::error(const std::string&) { /* ... */ }
词法分析器提供了一个相当庞大和混乱的接口:
// lexer.h:
#include<string>
#include<iostream>
namespace Lexer {
enum class Kind : char {/* ... */ };
class Token { /* ... */ };
class Token_stream { /* ... */ };
extern Token_stream is;
}
除了 lexer.h 之外,词法分析器的实现还依赖于 error.h 以及 <cctype> (§36.2) 中的字符分类函数:
// lexer.cpp:
#include "lexer.h"
#include "error.h"
#include <iostream> // redundant: in lexer.h
#include <cctype>
Lexer::Token_stream is; // defaults to ‘‘read from cin’’
Lexer::Token Lexer::Token_stream::g et() { /* ... */ };
Lexer::Token& Lexer::Token_stream::current() { /* ... */ };
我们可以将 error.h 的 #include 指令分解为 Lexer 的 _impl.h 文件。但是,我认为对于这个小程序来说,这有点多余。
像往常一样,我们在模块的实现中 #include 模块提供的接口(在本例中为 lexer.h),以便编译器有机会检查一致性。
符号表本质上是自包含的,尽管标准库头 <map> 可以拖入各种有趣的东西来实现高效的 map 模板类:
// table.h:
#include <map>
#include <string>
namespace Table {
extern std::map<std::string,double> table;
}
因为我们假设每个头文件可能被 #included在多个.cpp文件中,所以我们必须将表的声明与其定义分开:
// table.cpp:
#include "table .h"
std::map<std::string,double> Table::table;
我只是将驱动程序插入到main.cpp中:
// main.cpp:
#include "parser.h"
#include "lexer.h" // to be able to set ts
#include "error.h"
#include "table .h" // 能够预定义名
#include <sstream> // 能够将 main()的参数放进string 流
namespace Driver {
void calculate() { /* ... */ }
}
int main(int argc, char∗ argv[]) { /* ... */ }
对于较大的系统,通常值得将驱动程序分离出来并尽量减少 main() 中执行的操作。这样,main() 就会调用放置在单独源文件中的驱动程序函数。这对于打算用作库的代码尤其重要。然后,我们不能依赖 main() 中的代码,并且必须准备好从各种函数调用驱动程序。
15.3.2.2 使用头文件
程序使用的头文件数量取决于许多因素。其中许多因素与系统处理文件的方式有关,而与 C++ 无关。例如,如果您的编辑器/IDE 无法方便地同时查看多个文件,那么使用许多头文件就不那么有吸引力了。
需要注意的是:几十个头文件加上程序执行环境的标准头文件(通常可以数以百计)通常是可以管理的。但是,如果你将大型程序的声明划分为逻辑上最小大小的头文件(将每个结构声明放在自己的文件中等),即使是小型项目,也很容易得到数百个文件组成的难以管理的混乱局面。我觉得这太过分了。
对于大型项目,多个头文件是不可避免的。在这样的项目中,数百个文件(不包括标准头文件)是常态。当它们开始以数千个为单位计数时,真正的混乱就开始了。在这种规模下,这里讨论的基本技术仍然适用,但它们的管理变成了一项艰巨的任务。依赖分析器等工具可以提供很大的帮助,但如果程序是无结构的混乱,它们对编译器和链接器的性能几乎没有作用。请记住,对于实际大小的程序,单头文件样式不是一种选择。这样的程序将有多个头文件。对于组成程序的部分,两种组织样式之间的选择会(反复)发生。
单头文件模式和多头头文件模式并不是真正的替代品。它们是互补的技术,在设计重要模块时必须考虑,并且随着系统的发展必须重新考虑。重要的是要记住,一个接口并不能同样好地服务于所有人。通常值得区分实施者的接口和用户的接口。此外,许多大型系统的结构使得为大多数用户提供一个简单的接口,为专家用户提供更广泛的接口是一个好主意。专家用户的接口(“完整接口”)往往包含比普通用户想要了解的更多的功能。事实上,普通用户的接口通常可以通过消除需要包含定义普通用户不知道的功能的头文件的功能来识别。术语“普通用户”不是贬义的。在那些我不需要成为专家的领域,我强烈希望成为一名普通用户。这样,我就能够减少麻烦。
15.3.3 包含保护(Include Guards)
多头文件方法的思想是将每个逻辑模块表示为一个一致的、独立的单元。从整个程序来看,使每个逻辑模块完整所需的许多声明都是多余的。对于较大的程序,这种冗余可能会导致错误,因为包含类定义或内联函数的头文件在同一个编译单元中 #include 两次(§15.2.3)。
我们有两个选择。我们可以:
[1] 重新组织我们的程序,消除冗余,或者
[2] 找到一种允许重复包含头文件的方法。
第一种方法(导致了计算器的最终版本)对于实际大小的程序来说既繁琐又不切实际。我们还需要这种冗余,以使程序的各个部分在单独的情况下易于理解。
分析冗余 #include 以及由此产生的程序简化的好处在逻辑角度和减少编译时间方面都十分显著。但是,分析很少能够做到全面,因此必须采用某种方法允许冗余 #include。最好是系统地应用分析,因为无法知道用户会觉得分析有多彻底。
传统解决方案是在头文件中插入包含保护。例如:
// error.h:
#ifndef CALC_ERROR_H
#define CALC_ERROR_H
namespace Error {
// ...
}
#endif // CALC_ERROR_H
如果定义了 CALC_ERROR_H,编译器将忽略 #ifndef 和 #endif 之间的文件内容。因此,在编译过程中第一次看到 error.h 时,会读取其内容并为 CALC_ERROR_H 赋值。如果在编译过程中再次向编译器显示 error.h,则会忽略其内容。这是一个宏指令黑客行为,但它有效,并且在 C 和 C++ 世界中普遍存在。标准头文件都具有包含保护。
在本质上头文件包含于任意的上下文中,并且没有针对宏指令名称冲突的命名空间保护。因此,我为包含保护选择了相当长且丑陋的名称。
一旦人们习惯了头文件并包含保护,他们就会倾向于直接或间接地包含大量头文件。即使使用优化了头文件处理的 C++ 实现,这也可能是不可取的。它可能会导致不必要的编译时间过长,并且会将大量声明和宏指令带入作用域。后者可能会以不可预测和不利的方式影响程序的含义。头文件应仅在必要时才包含。
15.4 程序
程序是通过链接器组合起来的单独编译单元的集合。此集合中使用的每个函数、对象、类型等都必须具有唯一的定义(§6.3,§15.2.3)。程序必须只包含一个名为 main() 的函数(§2.2.1)。程序执行的主要计算从全局函数 main() 的调用开始,到 main() 的返回结束。main() 的返回类型为 int,所有实现都支持以下两个版本的 main():
int main() { /* ... */ }
int main(int argc, char∗ argv[]) { /* ... */ }
程序只能提供上述两种选择中的一种。此外,实现可以允许 main() 的其他版本。argc、argv 版本用于从程序环境传输参数;请参阅 §10.2.7。
main() 返回的 int 将作为程序的结果传递给调用 main() 的任何系统。main() 返回的非零值表示出现错误。
对于包含全局变量(§15.4.1)或抛出未捕获异常(§13.5.2.5)的程序,必须详细说明这个简单的故事。
15.4.1 非局部变量的初始化
在原则上,在任何函数之外定义的变量(即全局变量、命名空间变量和类静态变量)在调用 main() 之前初始化。编译单元中的此类非局部变量按其定义顺序初始化。如果此类变量没有显式初始化器,则默认将其初始化为其类型的默认值(§17.3.3)。内置类型和枚举的默认初始化器值为 0。例如:
double x = 2; // nonlocal var iables
double y;
double sqx = sqrt(x+y);
这里,x 和 y 在 sqx 之前初始化,因此调用 sqrt(2)。
不同编译单元中全局变量的初始化顺序没有保证。因此,在不同的编译单元中创建全局变量初始化器之间的顺序依赖关系是不明智的。此外,无法捕获全局变量初始化器抛出的异常(§13.5.2.5)。通常最好尽量减少全局变量的使用,特别是限制需要复杂初始化的全局变量的使用。
有几种技术可以强制执行不同编译单元中全局变量的初始化顺序。但是,没有一种既可移植又高效。特别是,动态链接库不能与具有复杂依赖关系的全局变量愉快地共存。
通常,返回引用的函数是全局变量的良好替代方案。例如:
int& use_count()
{
static int uc = 0;
return uc;
}
现在,调用 use_count() 可充当全局变量,只不过它在第一次使用时会被初始化(§7.7)。例如:
void f()
{
cout << ++use_count(); // 读取并递增
// ...
}
与static的其他用法一样,此技术不是线程安全的。局部静态的初始化是线程安全的(§42.3.3)。在这种情况下,初始化甚至使用常量表达式(§10.4),因此它是在链接时完成的,并且不受数据争用的影响(§42.3.3)。但是,++ 可能导致数据争用。
非局部(静态分配)变量的初始化由实现用于启动 C++ 程序的任何机制控制。只有在执行 main() 时,此机制才能保证正常工作。因此,应避免在旨在作为非 C++ 程序片段执行的 C++ 代码中使用需要运行时初始化的非局部变量。
请注意,由常量表达式 (§10.4) 初始化的变量不能依赖于来自其他编译单元的对象的值,并且不需要运行时初始化。因此,此类变量在所有情况下都可以安全使用。
15.4.2 初始化和并发
考虑:
int x = 3;
int y = sqrt(++x);
x 和 y 的值可能是什么?答案显然是“3 和 2!”为什么?使用常量表达式对静态分配对象进行初始化是在链接时完成的,因此 x 变为 3。但是,y 的初始化器不是常量表达式(sqrt() 不是 constexpr),因此 y 直到运行时才初始化。但是,单个编译单元中静态分配对象的初始化顺序定义明确:它们按定义顺序初始化(§15.4.1)。因此,y 变为 2。
这个参数的缺陷在于,如果使用多个线程(§5.3.1,§42.2),每个线程都会进行运行时初始化。没有隐式提供互斥来防止数据争用。然后,一个线程中的 sqrt(++x) 可能在另一个线程设法增加 x 之前或之后发生。因此,y 的值可能是 sqrt(4) 或 sqrt(5)。
为了避免此类问题,我们应该(像往常一样):
• 尽量减少使用静态分配的对象,并尽可能简化它们的初始化。
• 避免依赖其他编译单元中的动态初始化对象(§15.4.1)。
此外,为了避免初始化过程中的数据竞争,请按顺序尝试以下技术:
[1] 使用常量表达式进行初始化(请注意,没有初始化器的内置类型将被初始化为零,而标准容器和字符串在链接时初始化时将被初始化为空)。
[2] 使用没有副作用的表达式进行初始化。
[3] 在已知的单线程计算“启动阶段”进行初始化。
[4] 使用某种形式的互斥(§5.3.4,§42.3)。
15.4.3 程序终止
程序可以通过多种方式终止:
[1] 通过从 main() 返回;
[2] 通过调用 exit();
[3] 通过调用 abort();
[4] 通过抛出未捕获的异常;
[5] 通过违反 noexcept;
[6] 通过调用 quick_exit();
此外,还有多种导致程序崩溃的不良行为和依赖于实现的方式(例如,将双精度数除以零)。
如果使用标准库函数 exit() 终止程序,则会调用构造的静态对象的析构函数(§15.4.1,§16.2.12)。但是,如果使用标准库函数 abort() 终止程序,则不会调用。请注意,这意味着 exit() 不会立即终止程序。在析构函数中调用 exit() 可能会导致无限递归。exit() 的类型为:
void exit(int);
与 main() (§2.2.1) 的返回值一样,exit() 的参数作为程序的值返回给“系统”。零表示成功完成。
调用 exit() 意味着调用函数及其调用者的局部变量将不会调用其析构函数。抛出异常并捕获它可确保正确销毁局部对象(§13.5.1)。此外,调用 exit() 会终止程序,而不会给调用 exit() 的函数的调用者处理问题的机会。因此,通常最好通过抛出异常并让处理程序决定下一步做什么来离开上下文。例如,main() 可能会捕获每个异常(§13.5.2.2)。
C(和 C++)标准库函数 atexit() 提供了在程序终止时执行代码的可能性。例如:
void my_cleanup();
void somewhere()
{
if (atexit(&my_cleanup)==0) {
// my_cleanup will be called at normal termination
}
else {
// oops: too many atexit functions
}
}
这与程序终止时自动调用全局变量的析构函数非常相似(§15.4.1,§16.2.12)。atexit() 的参数不能接受参数或返回结果,并且 atexit 函数的数量存在实现定义的限制。atexit() 返回的非零值表示已达到限制。这些限制使得 atexit() 的用处不如乍一看那么大。基本上,atexit() 是 C 语言中缺少析构函数的解决方法。
在调用 atexit(f) 之前创建的构造静态分配对象 (§6.4.2) 的析构函数将在调用 f 之后调用。在调用 atexit(f) 之后创建的此类对象的析构函数将在调用 f 之前调用。
quick_exit() 函数与 exit() 类似,只不过它不调用任何析构函数。您可以使用 at_quick_exit() 注册要由 quick_exit() 调用的函数。
exit()、abort()、quick_exit()、atexit() 和 at_quick_exit() 函数在 <cstdlib> 中声明。
15.5 建议
[1] 使用头文件表示接口并强调逻辑结构;§15.1,§15.3.2。
[2] 在源文件中 #include 一个实现其功能的头文件;§15.3.1。
[3] 不要在不同的编译单元中定义具有相同名称和相似但不同含义的全局实体;§15.2。
[4] 避免在头文件中定义非内联函数;§15.2.2。
[5] 仅在全局范围和命名空间中使用 #include;§15.2.2。
[6] 仅 #include 完整声明;§15.2.2。
[7] 使用包含保护;§15.3.3。
[8] 在命名空间中 #include C 头文件以避免全局名称;§14.4.9,§15.2.4。
[9] 使头文件自包含(译注:只包含自己需要的头文件,避免循环包含);§15.2.3。
[10] 区分用户接口和实现者接口;§15.3.2。
[11] 区分普通用户接口和专家用户接口;§15.3.2。
[12] 避免在用于非 C++ 程序一部分的代码中使用需要运行时初始化的非局部对象;§15.4.1。
内容来源:
<<The C++ Programming Language >> 第4版,作者 Bjarne Stroustrup
更多推荐
所有评论(0)