本章导读

前两章我们一直在「一个文件里写所有代码」。本章把视野拉大到多文件大型程序:数据在内存里活多久(存储持续性)、谁能看见它(作用域)、哪些文件能共享它(链接性),以及用名称空间来避免大型项目里的命名冲突。理解本章,你才算真正理解「程序在内存中的样子」。

9.1 分离式编译:把程序拆成多个文件

9.2 存储持续性、作用域与链接性

9.3 静态持续变量与单定义规则

9.4 说明符与 cv 限定符

9.5 函数与链接性

9.6 动态内存与 placement new

9.7 名称空间

9.1 分离式编译:把程序拆成多个文件

C++ 允许(甚至鼓励)把程序的各个函数放在不同的源文件中单独编译,最后再链接成可执行程序。这样改一个文件只需重新编译那一个文件,大型项目管理起来轻松得多。

但直接把第 7 章的坐标转换程序「剪成两半」会出问题:main() 和另两个函数共用同一份结构声明,如果在两个文件里各抄一份,以后改一处忘另一处,程序就悄悄错了。解决方案是第 4 章见过的老朋友——#include 头文件。于是程序被拆成三部分:

  • 头文件:结构声明 + 函数原型;
  • 源文件一:与结构相关的函数定义;
  • 源文件二:调用这些函数的 main()。

这就像一份「接口契约」:头文件告诉所有文件「有哪些类型、有哪些函数」,实现和使用各自独立。

头文件里不该放什么

不要把函数定义或变量声明放进头文件。若头文件里有一个函数定义,又被两个源文件包含,程序里就出现两份定义——链接错误(inline 函数除外)。头文件常放的是:函数原型、#define/const 常量、结构与类声明、模板声明、inline 函数。另外,在 IDE 里不要把头文件加进项目,也不要用 #include 包含源文件。

包含自己的头文件时要用双引号而不是尖括号:"coordin.h" 让编译器先在当前目录找,找不到才去标准头文件目录;<coordin.h> 则直接去标准位置找。

防止头文件被重复包含:头文件可能嵌套包含,同一份内容可能被包含多次。标准做法是用预处理器的 #ifndef(if not defined)「守卫」——第一次包含时宏未定义,处理全部内容并 #define 该宏;第二次包含时宏已定义,直接跳到 #endif。它不是阻止包含,而是让后面的包含内容作废。

Listing 9.1、9.2、9.3 把第 7 章的坐标转换程序拆成了三个文件。

Listing 9.1 coordin.h

// coordin.h -- 结构模板与函数原型
#ifndef COORDIN_H_
#define COORDIN_H_
 
struct polar
{
    double distance;    // 到原点的距离
    double angle;       // 相对原点的方向
};
 
struct rect
{
    double x;   // 水平距离
    double y;   // 垂直距离
};
 
// 原型
polar rect_to_polar(rect xypos);
void show_polar(polar dapos);
 
#endif

Listing 9.2 file1.cpp

// file1.cpp -- 三文件程序示例
#include <iostream>
#include "coordin.h"    // 结构模板、函数原型
using namespace std;
 
int main()
{
    rect rplace;
    polar pplace;
 
    cout << "Enter the x and y values: ";
    while (cin >> rplace.x >> rplace.y)  // 巧妙地用 cin 做循环条件
    {
        pplace = rect_to_polar(rplace);
        show_polar(pplace);
        cout << "Next two numbers (q to quit): ";
    }
    cout << "Bye!\n";
    return 0;
}

Listing 9.3 file2.cpp

// file2.cpp -- 包含 file1.cpp 调用的函数
#include <iostream>
#include <cmath>
#include "coordin.h"
 
// 直角坐标转极坐标
polar rect_to_polar(rect xypos)
{
    using namespace std;
    polar answer;
    answer.distance =
        sqrt(xypos.x * xypos.x + xypos.y * xypos.y);
    answer.angle = atan2(xypos.y, xypos.x);
    return answer;
}
 
// 显示极坐标,角度转为度
void show_polar(polar dapos)
{
    using namespace std;
    const double Rad_to_deg = 57.29577951;
    cout << "distance = " << dapos.distance;
    cout << ", angle = " << dapos.angle * Rad_to_deg;
    cout << " degrees\n";
}

编译链接后的运行示例:

Enter the x and y values: 120 80
distance = 144.222, angle = 33.6901 degrees
Next two numbers (q to quit): q


▲ 图 9.1 在 Unix 系统上编译多文件 C++ 程序的流程:一条命令编译两个源文件,预处理器把头文件内容并入源码,再分别编译、链接。

最后提醒一句:不同编译器对函数名的修饰(name mangling)规则不同,用 A 编译器编译的目标文件很可能无法和 B 编译器的目标文件链接——链接时务必保证所有目标文件来自同一编译器。

9.2 存储持续性、作用域与链接性

C++ 用四种方案(C++11 起是四种加线程)决定数据在内存里活多久:

  1. 自动存储持续性——函数(含形参)内声明的变量,进入其所在函数/块时创建,离开时释放;
  2. 静态存储持续性——在函数外定义或用 static 定义的变量,整个程序运行期间都存在;
  3. 线程存储持续性(C++11)——用 thread_local 声明,生命周期与所属线程相同;
  4. 动态存储持续性——new 分配的内存,直到 delete 或程序结束才释放,俗称自由存储区/堆。

另外两个概念要分清:

  • 作用域(scope):名字在文件内多大的范围可见。局部(块)作用域只在 {} 内可见;全局(文件)作用域从定义点起整个文件可见。函数原型中的作用域只限于参数列表括号内。
  • 链接性(linkage):名字能否跨文件共享。外部链接性可跨文件共享;内部链接性仅限本文件;自动变量没有链接性。

自动变量与栈

自动变量默认是「进块创建、出块销毁」的。块可以嵌套:内层块声明的同名变量会**隐藏(hide)**外层变量,出块后外层变量恢复可见。


▲ 图 9.2 块与作用域:内层块的 websight 只在内层可见,内层的 teledeli 隐藏了外层的同名变量,出块后外层版本恢复。

Listing 9.4 autoscp.cpp(自动变量的作用域演示,输出显示三个 texas 各有各的地址,互不干扰)

// autoscp.cpp -- 自动变量的作用域
#include <iostream>
void oil(int x);
int main()
{
    using namespace std;
    int texas = 31;
    int year = 2011;
    cout << "In main(), texas = " << texas << ", &texas = "
         << &texas << endl;
    oil(texas);
    cout << "In main(), texas = " << texas << ", &texas = "
         << &texas << endl;   // 值与地址都没变
    return 0;
}
 
void oil(int x)
{
    using namespace std;
    int texas = 5;              // 隐藏 main() 里的 texas
    cout << "In oil(), texas = " << texas
         << ", &texas = " << &texas << endl;
    {
        int texas = 113;        // 再隐藏一层
        cout << "In block, texas = " << texas
             << ", &texas = " << &texas << endl;
        cout << "In block, x = " << x << endl;  // 用的是形参 x
    }                           // 这个 texas 出块即销毁
    cout << "Post-block texas = " << texas << endl;  // 回到 5
}

编译器如何管理这些「涨了又缩」的变量?答案是栈:一块按后进先出(LIFO)管理的内存。函数被调用时,它的自动变量和实参被「压」到栈顶;函数结束时,栈顶指针拨回原位,这些变量占的空间就被下一个函数复用。就像食堂里摞盘子:最后放上去的最先被取走。


▲ 图 9.3 用栈传递参数:fib() 被调用时,int 和 long 实参依次压栈,fib() 结束后栈顶指针复位,旧值虽未被擦除但已「无主」,会被下次压栈覆盖。

顺带一提:C++11 把 register 关键字的「寄存器提示」含义废弃了,现在它只是显式声明「这是自动变量」;旧的 auto(自动存储说明符)用法在 C++11 中也失效——auto 已让位给类型推导(第 3、7、8 章)。

9.3 静态持续变量与单定义规则

静态持续变量有三种链接性,都活到程序结束,且未初始化时所有位被置 0(零初始化):

int global = 1000;        // 静态持续,外部链接性(函数外定义)
static int one_file = 50; // 静态持续,内部链接性(函数外 + static)
void funct1(int n)
{
    static int count = 0; // 静态持续,无链接性(块内 + static)
    int llama = 0;        // 自动变量
}

注意 static 的「一词两义」:用在块内声明的是存储持续性(局部作用域但活到程序结束);用在函数外声明的是链接性(内部链接性)。编译器为所有静态变量分配一块固定内存,不需要栈来管理。

存储方式持续性作用域链接性声明方式
自动自动块无块内
寄存器自动块无块内,加 register
静态,无链接性静态块无块内,加 static
静态,外部链接性静态文件外部所有函数外
静态,内部链接性静态文件内部所有函数外,加 static

静态变量的初始化分三档:零初始化(全部置 0)→常量表达式初始化(编译器仅凭文件内容就能算出,如 int y = 5;、long z = 13 * 13;)→动态初始化(要等链接、执行时才算,如 const double pi = 4.0 * atan(1.0); 需要调用 atan())。前两档合称静态初始化。

单定义规则(ODR)与 extern

外部变量(external variable)在每个用到它的文件里都要声明,但整个程序中只能有一处定义——这就是单定义规则(One Definition Rule)。C++ 用两种声明来满足它:

  • 定义声明(definition):分配存储,如 double up;;
  • 引用声明(declaration):用 extern 且不初始化,表示「变量在别处已定义」,如 extern int blem;。
double up;              // 定义,up 被零初始化
extern int blem;        // blem 在别处定义
extern char gr = 'z';   // 带初始化,所以仍是定义!

extern 加了初始化就变成了定义

extern int cats = 20; 因为带初始化,它是定义而不是引用声明。多文件共享一个变量时:只在一个文件里定义(extern 可省略),其余文件用不带初始化的 extern int cats; 来引用。若两个文件都定义了同名外部变量,链接器会报「重复定义」。


▲ 图 9.4 定义声明与引用声明:file01.cpp 里定义 cats 和 dogs,file02.cpp/file98.cpp 用 extern 引用它们;fleas 没被 extern 引用,其他文件就看不见它。

Listing 9.5 + 9.6 external.cpp / support.cpp(跨文件共享外部变量 + 作用域解析运算符)

// external.cpp -- 外部变量(与 support.cpp 一起编译)
#include <iostream>
using namespace std;
double warming = 0.3;          // 定义外部变量
void update(double dt);
void local();
 
int main()
{
    cout << "Global warming is " << warming << " degrees.\n";
    update(0.1);               // 修改全局变量
    cout << "Global warming is " << warming << " degrees.\n";
    local();                   // 用了局部同名变量的函数
    cout << "Global warming is " << warming << " degrees.\n";
    return 0;
}
// support.cpp -- 使用另一文件里的外部变量
#include <iostream>
extern double warming;         // 引用声明:warming 在别处定义
 
void update(double dt)
{
    extern double warming;     // 可选的再次声明,起文档作用
    warming += dt;             // 使用全局 warming
    std::cout << "Updating global warming to "
              << warming << " degrees.\n";
}
 
void local()                   // 局部变量隐藏全局变量
{
    double warming = 0.8;      // 新变量,遮住外部变量
    std::cout << "Local warming = " << warming << " degrees.\n";
    std::cout << "But global warming = " << ::warming
              << " degrees.\n";// 用 :: 访问全局版本
}

输出:update() 把全局 warming 改成 0.4;local() 里同名局部变量让它显示 0.8,但 ::warming 仍能访问全局的 0.4——作用域解析运算符 :: 放在变量名前,明确表示「用全局版本」。

内部链接性与 static

想让两个文件用同名的不同变量怎么办?直接在两边都写 int errors = 20; 会违反单定义规则;正确做法是在其中一个文件加 static:

// file1
int errors = 20;          // 外部链接性
// file2
static int errors = 5;    // 内部链接性,仅 file2 可见
void froobish() { cout << errors; }  // 用的是 file2 的 errors

static 使 file2 的 errors 只在本文件有效,不试图引入外部定义,因此不违反 ODR。

Listing 9.7 + 9.8 twofile1.cpp / twofile2.cpp(通过地址验证:tom 两文件共享,dick 和 harry 各是独立变量)

// twofile1.cpp -- 外部与内部链接性(与 twofile2.cpp 一起编译)
#include <iostream>
int tom = 3;              // 外部变量定义
int dick = 30;            // 外部变量定义
static int harry = 300;   // static:内部链接性
 
void remote_access();
 
int main()
{
    using namespace std;
    cout << "main() reports the following addresses:\n";
    cout << &tom << " = &tom, " << &dick << " = &dick, "
         << &harry << " = &harry\n";
    remote_access();
    return 0;
}
// twofile2.cpp -- 内部与外部链接性
#include <iostream>
extern int tom;           // tom 在别处定义
static int dick = 10;     // 覆盖外部 dick,仅本文件可见
int harry = 200;          // 外部定义,不与 twofile1 的 harry 冲突
 
void remote_access()
{
    using namespace std;
    cout << "remote_access() reports the following addresses:\n";
    cout << &tom << " = &tom, " << &dick << " = &dick, "
         << &harry << " = &harry\n";
}

运行结果显示:tom 的地址在两个文件中相同(共享),dick 和 harry 的地址不同(各是各的变量)。

静态局部变量

块内加 static 的变量虽只在块内可见,却在程序整个运行期存在——它的值在两次函数调用之间保持,且只在程序启动时初始化一次。

Listing 9.9 static.cpp(static 局部变量实现累计计数)

// static.cpp -- 使用静态局部变量
#include <iostream>
const int ArSize = 10;
void strcount(const char * str);
 
int main()
{
    using namespace std;
    char input[ArSize];
    char next;
    cout << "Enter a line:\n";
    cin.get(input, ArSize);
    while (cin)
    {
        cin.get(next);
        while (next != '\n')     // 一行没读完就丢弃余下字符
            cin.get(next);
        strcount(input);
        cout << "Enter next line (empty line to quit):\n";
        cin.get(input, ArSize);
    }
    cout << "Bye\n";
    return 0;
}
 
void strcount(const char * str)
{
    using namespace std;
    static int total = 0;        // 静态局部变量
    int count = 0;               // 自动局部变量
    cout << "\"" << str << "\" contains ";
    while (*str++)               // 数到字符串末尾
        count++;
    total += count;
    cout << count << " characters\n";
    cout << total << " characters total\n";
}

输出节选:每行报自己的字符数(count 每次重置为 0),而 total 一路累加(9 → 15 → 24 → 26)。这就是静态局部变量「记得住过去」的本事。

用 static 局部变量做「记忆」

需要函数记住调用历史(计数器、累计值、只初始化一次的查找表)时,块内 static 是最轻量的方案——不用全局变量,也不会污染其他文件。书上那句玩笑话很形象:静态变量就像「转世投胎还带着前世记忆」。

9.4 说明符与 cv 限定符