跳至内容
Topos Uranos

摘要

到目前为止,本课程的每个程序都能放进一个文件。一个上千行的程序已无法一口气读完,而它的函数也值得为其他程序所用。本课教你如何拆分程序:一个头文件声明模块提供什么,一个实现文件给出定义,一个 main.cpp 使用它。本课介绍头文件保护;单一定义规则,它解释链接器的两种经典错误以及 inline 的现代含义;分离编译,它只重新编译发生变化的部分;最后以一个由前面各课的函数组成的自建库收尾。

学习目标

  1. 把程序拆分为一个含声明的头文件、一个实现文件和一个 main.cpp,并编译它。
  2. 编写头文件保护并解释它解决什么问题;认识到 #pragma once 是一种所有编译器都接受、但标准并未收录的扩展。
  3. 解释单一定义规则,并根据链接器的消息(“multiple definition”“undefined reference”)诊断对它的违反。
  4. 用 -c 分离编译并链接目标文件,说明每个文件改变时需要重新编译什么。

接口与实现

模块化

带契约的函数,如 用函数进行设计 一课中的那些函数,已经把它承诺的内容(写在声明和契约中)与它如何兑现承诺(写在函数体中)分开了;调用者只需要前者。既然如此,就没有什么妨碍把这两部分存放在不同的文件里。模块是一组具有共同目的的函数(以及它们所用的类型);它的接口是它向使用者提供的东西,它的实现则是它做到这一点的方式。在 C++ 中,接口写在头文件里(扩展名 .hpp,或继承自 C 的 .h),实现写在同名的 .cpp 文件里。

由此得出三个好处。可以分部分理解:要使用模块,读它的头文件就够了。可以分部分编译:实现的改动不会迫使只使用接口的文件重新编译。还可以分部分测试,对照每一份契约进行,正如 程序测试 一课将要讲授的那样。《LaTeX 学术写作》课程在它的“长文档”一课中,也以同样的方式拆分一篇长文档。

三个文件

本例中的平面几何模块提供三个函数,其中两个来自第 5.3 课。头文件只声明它们,每个都附有契约。以 # 开头的三行将在后面一节解释;目前只需知道每个头文件都有这三行。

geometry.hpp
// geometry.hpp:平面几何模块的接口。#ifndef GEOMETRY_HPP#define GEOMETRY_HPP​// 半径为 radius 的圆的面积。// 前置条件:radius >= 0。// 后置条件:返回半径为 radius 的圆的面积。double circleArea(double radius);​// 三个长度能否构成三角形的三边。// 前置条件:无。// 后置条件:当且仅当存在边长为 a、b、c 的三角形时返回 true。bool isTriangle(double a, double b, double c);​// 边长为 a、b、c 的三角形的面积。// 前置条件:isTriangle(a, b, c)。// 后置条件:返回边长为 a、b、c 的三角形的面积(海伦公式)。double triangleArea(double a, double b, double c);​#endif

实现文件包含它自己的头文件并定义这些函数。它所需的标准库头文件(求平方根用的 <cmath>,以及提供 π\pi 的 <numbers>)在这里包含,而不在头文件中包含,因为使用该模块的人并不需要它们。

geometry.cpp
// geometry.cpp:平面几何模块的实现。#include "geometry.hpp"​#include <cmath>#include <numbers>​double circleArea(double radius){    return std::numbers::pi * radius * radius;}​bool isTriangle(double a, double b, double c){    return a > 0 && b > 0 && c > 0 && a < b + c && b < a + c && c < a + b;}​double triangleArea(double a, double b, double c){    const double s{(a + b + c) / 2};    return std::sqrt(s * (s - a) * (s - b) * (s - c));}

最后,main.cpp 包含头文件并使用该模块,却看不到它实现中的任何一行:

C++ 程序
#include <print>​#include "geometry.hpp"​int main(){    std::println("半径为 2 的圆:{:.4f}", circleArea(2.0));    std::println("是否存在边长为 3、4、5 的三角形?{}", isTriangle(3.0, 4.0, 5.0));    std::println("它的面积:{}", triangleArea(3.0, 4.0, 5.0));    std::println("边长为 1、2、3 的呢?{}", isTriangle(1.0, 2.0, 3.0));}
输出
半径为 2 的圆:12.5664是否存在边长为 3、4、5 的三角形?true它的面积:6边长为 1、2、3 的呢?false

#include "geometry.hpp" 中的引号首先在包含它的那个文件所在的文件夹中查找文件,而 #include <print> 中的尖括号则在系统文件夹中查找:引号用于自己的文件。文件 geometry.cpp 首先包含它自己的头文件,这样,如果头文件缺少一个它自身需要的包含,错误就会在编译该模块时出现,而不会出现在别人的文件里。边长为 1、2、3 的三角形并不存在(3=1+23 = 1 + 2),因此 triangleArea 的前置条件要求事先询问这一点。

编译时,命令接收两个 .cpp 文件;头文件不必写出,因为它通过 #include 进入每个文件。本课程的选项保存在一个终端变量 FLAGS 中,以免在每条命令中重复;$FLAGS 取回它们。这是 Linux 和 macOS 中的写法;在 PowerShell 中变量的写法不同,最好写出完整的选项,或者在编辑器中编译(“分离编译”一节)。

终端
$ FLAGS="-std=c++23 -Wall -Wextra -Wpedantic -Wconversion -Wshadow -g -fsanitize=address,undefined -fno-sanitize-recover=undefined"$ g++ $FLAGS main.cpp geometry.cpp -o main$ ./main半径为 2 的圆:12.5664是否存在边长为 3、4、5 的三角形?true它的面积:6边长为 1、2、3 的呢?false
例题三个文件的历程

此图沿用 程序、编译器与 C++ 一课中的图,用于这个项目。

证明

  1. 程序员编写三个文件:一个头文件和两个 .cpp 文件。只有 .cpp 文件交给编译器。

  2. 预处理器把 geometry.hpp 复制到每个包含它的文件中。因此,声明有两份副本,每个翻译单元一份,这是正确的:一个函数想声明多少次都可以。

  3. 每个单元分别编译成各自的目标文件。编译 main.cpp 时,编译器不看 geometry.cpp:声明就足以让它检查每次调用的类型。

  4. 链接器把两个目标文件和标准库连接起来,并把 main.o 中对 circleArea 的每次调用与 geometry.o 中的定义对应起来。

一个常见错误是包含实现文件:#include "geometry.cpp"。如果它同时又出现在命令中,它的函数就被定义了两次,链接失败,原因是“单一定义规则”一节将要解释的那条规则;如果它没有出现在命令中,main.cpp 又变回一整段文本,每次改动都要整体重新编译。.cpp 文件用来编译;头文件用来包含。