从零开始 / 一次只解释眼前的一步
多文件编译、
链接与构建。
从一个程序的多个文件出发,先看每一步产生什么。再区分声明和定义、包含和链接,亲手补齐缺失的目标文件,并用CMake保存构建关系。
6小时是包含阅读、推演和编码的设计估算,未经真人试学校准。可分多次学习,遇到不清楚的地方保留预测、实际结果和疑问,之后再回修教材。手机可读图与做预测,编译需要电脑终端。
这一章怎样学
先读一小段,写下预测,再运行程序。每次只改一个条件,最后关掉示例,从空文件独立写一次。遇到错误,把第一条报错和自己的修复记下来;不用赶着把页面滚到底。
每次写下所在目录、输入文件、实际命令和新生成的文件,再检查错误发生在编译还是链接。先完成第16章错误处理与输入解析,再开始本章;此前的M1仍按自己的独立材料核对,打开本页不会替你判定M1或G0通过。
17.1 编译各阶段:一条命令背后发生了什么?
你已经能写函数、类、容器和错误处理代码。先完成16章,这一章把问题换成:代码分在两个文件里时,怎样让它们成为同一个程序?
先暂时保持一个很小的程序。我们要看见每一步接收什么、产出什么,以及错误可能发生在哪一步。这里的“编译”有两个常见含义:日常说“编译这个程序”常指从源码到可执行文件的整个构建;讨论阶段时,编译则指把预处理后的C++转换为目标代码的那部分工作。
clang++、g++和真正的执行程序
终端里的clang++是Clang的C++驱动程序。它根据输入与选项安排预处理、编译、汇编和链接,并为普通C++程序安排相应的标准库链接。g++是GCC的C++驱动程序名称;这组基本阶段选项在两者上都常见,但诊断文字、版本和实现细节不必相同。
macOS上名为g++的命令也可能实际指向Apple Clang。沿用第一章的方法,运行clang++ --version或g++ --version,读取实际输出,不能只看命令名字猜实现。本文命令与本地执行记录使用Apple Clang;选择另一套工具后,记录它自己的身份。
编译器生成程序,./app才是在运行程序。./指当前目录,和第一章相同;目标文件不是已经完成的可执行程序,不能把./phase.o当作最后一步。
五步分别在处理什么
| 阶段 | 输入与工作 | 本章显式保存的结果 |
|---|---|---|
| 预处理 preprocessing | 处理include与预处理指令,形成后续读取的源码 | phase.ii:预处理后的C++文本 |
| 编译 compilation | 检查C++语法和类型,生成目标机器的汇编表示 | phase.s:汇编文本 |
| 汇编 assembly | 把汇编表示转成目标机器的代码及相关信息 | phase.o:目标文件 |
| 链接 linking | 组合目标文件与所需库,为调用找到定义 | app:可执行文件 |
| 运行 execution | 操作系统启动程序,执行main等代码 | 终端输出13,退出状态0 |
这里用Clang的命名约定:.cpp是C++源文件,.ii是已经预处理的C++文本,.s是汇编文本,.o是目标文件。稍后用.hpp保存C++头文件;.h也经常用作头文件后缀。后缀帮助工具判断输入,并不会把任意文本自动变成合法程序。
一条完整构建命令不一定把所有中间文件都留在磁盘上;Clang还可以在内部完成汇编。下表是让驱动程序明确停在哪个阶段的选项,不是要求每次日常构建都手动执行五条命令。
| 选项 | 本次调用在哪里停下 |
|---|---|
| -E | 预处理后停止;不编译、不链接 |
| -S | 生成汇编后停止;不产生最终程序 |
| -c | 生成目标文件后停止;不链接 |
| -o 文件名 | 指定这一次的输出名称;它本身不决定阶段 |
| 不加前三个停止选项 | 驱动程序继续到链接,生成可执行文件 |
-std=c++20沿用本书的语言版本。-Wall -Wextra -Wpedantic -Werror让这组教学程序使用明确的诊断要求;它们不证明逻辑正确。-Werror把相应警告视为错误,也不意味着应该删除一个真正有用的检查来让构建通过。
亲手留下中间文件
保存下面的phase.cpp,在包含它的目录操作。程序本身只使用已授的输出语句,输出13。
#include <iostream>
int main() {
std::cout << 13 << '\n';
}
下面每一行是一条终端命令,按顺序执行。前一行报错时先停下,保留报错,不运行旧的同名产物。
clang++ -std=c++20 -E phase.cpp -o phase.ii
clang++ -std=c++20 -S phase.ii -o phase.s
clang++ -c phase.s -o phase.o
clang++ phase.o -o app
./app
第一行保存预处理后的文本;第二行把该文本编译为汇编;第三行只汇编;第四行链接;第五行才应打印13。这里没有把-E当成“检查全部C++语法”:某个词法上可处理但类型不合法的程序可能预处理成功,随后编译失败。
打开phase.ii会看到标准库头文件带来的大量文本,phase.s则是目标机器的汇编。这一节只检查产物与阶段,不要求逐行预测库内部代码或汇编指令。后面的编译器章节才会教授需要阅读的IR;生成了某种文件不等于已经学会阅读它。
日常从同一源码直接得到程序,可以合并为:
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror phase.cpp -o phase-app
./phase-app
结果仍为13。显式分阶段链说明工具怎样衔接,完整命令减少日常手工步骤;两者都不能仅凭文件还存在就认定“刚刚构建成功”。记录本次命令的退出状态,沿用第一章的echo $?,紧接在要检查的命令后执行。
17.2 真实文件与包含:接口怎样到达另一个文件?
普通函数的声明让编译器知道名字、参数和返回类型;定义还给出函数体。第04章已经用同一文件演示过区别。现在把这条接口放到头文件,让两个.cpp获得同样的声明。
一个源文件连同它通过include取得的内容,经过预处理后组成一个翻译单元(translation unit)。本章的两个.cpp分别形成两个翻译单元。编译main.cpp时,工具不会因为add.cpp恰好躺在同一目录,就自动打开它来寻找函数定义。
先认识预处理条件,再看头文件保护
#define OFFSET 0定义一个名为OFFSET的对象式宏,在后续预处理时把相应名字替换为0。它不是一个具有int类型的C++变量,也不是运行时赋值。这里仅用它作小整数替换,不引入函数式宏。
#ifdef EXTRA表示“这个宏名已定义时保留下面的源码”;#ifndef OFFSET表示“尚未定义时才保留”。#endif结束最近一个这样的条件区域。它们在预处理时选源码;普通if则在已经形成的C++程序里表达条件控制,不能把两者当成同一阶段的分支。
命令行的-DOFFSET=5在本次预处理中提供OFFSET的定义;-DEXTRA定义EXTRA。ifdef检查“是否定义”,不检查其数值是否非零,所以即使写成-DEXTRA=0,对应ifdef区域也仍被保留。每次编译调用独立取得自己的命令行宏,给一次编译加-D不会自动修改源码文件,也不会修改另一个已生成的目标文件。
#include <iostream>
#ifndef OFFSET
#define OFFSET 0
#endif
#define BASE 4
#ifdef EXTRA
#define BONUS 9
#endif
int main() {
std::cout << BASE + OFFSET << '\n';
#ifdef EXTRA
std::cout << BONUS << '\n';
#endif
}
分别构建三个名字不同的程序,避免把先前的输出误当作当前结果:
clang++ -std=c++20 macro-values.cpp -o macro-default
./macro-default
clang++ -std=c++20 -DOFFSET=5 macro-values.cpp -o macro-offset
./macro-offset
clang++ -std=c++20 -DEXTRA macro-values.cpp -o macro-extra
./macro-extra
默认条件采用OFFSET为0,第一份输出4;第二份用命令行的5,输出9;第三份保持默认OFFSET,但额外保留一条输出,依次输出4和9。没有定义EXTRA时,那条额外输出在预处理后的程序里就不存在,不是程序运行后选择不打印。
include guard只管当前翻译单元
接下来头文件使用三条指令:#ifndef TEXTBOOK_BUILD_ADD_HPP先检查这个保护宏尚未定义,#define TEXTBOOK_BUILD_ADD_HPP登记它,最后#endif结束保护区域。保护宏不需要数值;这里仅查询是否已经定义。
第一次include时,保护区域被保留;同一翻译单元第二次include时,保护宏已存在,因此跳过区域。到另一个.cpp的独立预处理过程,宏状态重新开始,它仍然需要得到一次头文件内容。头文件保护防止同一个翻译单元重复展开,不能阻止另一个翻译单元出现不合法的重复定义。
#include "add.hpp"使用双引号查找项目头文件。本章把它和两个.cpp放在同一目录,Clang可以按当前包含文件所在目录找到它。标准库头通常使用<iostream>这样的尖括号形式。两种形式有各自的查找规则;头文件一旦使用某个库名字,就应自行包含提供该名字的头文件,不依赖调用者偶然先include了别的头。
命名空间里的名字,以及从全局开始查找
namespace arithmetic { ... }把区域内的声明放进arithmetic命名空间。访问其中的add写作arithmetic::add,这里的双冒号表示限定名字。你已经读过std::vector等标准库名字;现在由自己的代码创建一个命名空间。
同一个名字的命名空间可以在多个位置继续打开。头文件里声明arithmetic中的add,在add.cpp里重新写namespace arithmetic { ... }并定义add,仍是在定义同一个命名空间里的那个函数。命名空间不是运行时对象,不需要new,也不会自动把文件加入链接。
限定名字最前面的::表示从全局命名空间开始查找。若一个命名空间内也定义了value,无限定的value可以指它,而::value明确指全局的同名对象。全局也指一个名字所在的作用域,不是“任何同名变量都共用一份数值”。后面的header-use例子只用两个小int观察这种选择。
为什么声明放头文件,普通定义放.cpp?
本章的add不是inline函数:头文件给出声明,add.cpp提供唯一函数体。两个翻译单元都能看到声明,最终程序通过链接使用那一个定义。对这里实际调用的普通函数,程序需要有对应的定义;不能只提供一张接口说明就期待运行时自动找到实现。
这与单一定义规则(one-definition rule,ODR)有关。同一翻译单元不能把同一个函数体定义两遍;跨翻译单元也不能随意复制普通函数的定义。某些ODR违反不要求工具给出诊断,所以“没有报链接错误”不是符合全部ODR规则的证明。
头文件里的小函数next另用inline:它允许在满足ODR条件时,把定义放进多个翻译单元。这里直接重复包含同一份头文件,保持定义一致,并且相关名字解析一致;不能在不同.cpp里让它悄悄变成两种函数体。inline并不强制编译器把机器码展开到每个调用点,也不是“写上以后一定更快”。
第15章的模板同样通常把完整定义放在头文件,让需要实例化的翻译单元可以看见它。模板仍需遵守相关定义一致性要求;本章不把所有声明和实现都搬进头文件当作解决链接问题的通用办法。
完整例一:真正的头文件、实现与调用者
保存三份文件,文件名与include中的拼写一致。先预测:哪个文件给出add的函数体,哪个文件定义main,哪些文件只是通过include取得接口?
add.hpp负责接口与可放在头文件里的inline小函数:
#ifndef TEXTBOOK_BUILD_ADD_HPP
#define TEXTBOOK_BUILD_ADD_HPP
namespace arithmetic {
int add(int left, int right);
inline int next(int value) {
return value + 1;
}
}
#endif
add.cpp包含同一接口,再提供普通add的定义;它没有main,不是独立的完整程序:
#include "add.hpp"
namespace arithmetic {
int add(int left, int right) {
return left + right;
}
}
main.cpp是调用者。本例故意连续include同一头文件两次,观察保护区域只在本翻译单元保留一次;日常不需要故意重复include:
#include <iostream>
#include "add.hpp"
#include "add.hpp"
int main() {
std::cout << arithmetic::add(4, 9) << '\n';
}
头文件中的声明让调用通过C++类型检查;add.cpp中的定义才给出4+9的计算。头文件不单独作为这次链接的目标文件输入,也不在其中再定义main。
再看一个很窄的头文件使用者。main通过demo::value读取命名空间中的对象;demo::global_value()则在函数体里用::value读取全局对象。inline next对小整数3返回4。
#include <iostream>
#include "add.hpp"
int value{4};
namespace demo {
int value{9};
int global_value() {
return ::value;
}
}
int main() {
std::cout << arithmetic::next(3) << ' ' << demo::value << ' '
<< demo::global_value() << '\n';
}
把header-use.cpp与add.cpp一起构建时,两个翻译单元都包含add.hpp,inline定义依照上述规则共存。该程序输出4 9 4;它和main.cpp是两个不同的程序入口,后面分别构建,不能把两个main都随意塞进同一个程序。
17.3 链接与构建依赖:有声明,为什么还找不到函数?
把刚才的三文件项目分开编译。以下命令仍在保存add.hpp、add.cpp和main.cpp的同一目录执行:
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -c add.cpp -o add.o
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -c main.cpp -o main.o
clang++ main.o add.o -o add-app
./add-app
第一行的目标文件提供arithmetic::add的定义。第二行的调用者目标文件需要这个定义,但单独生成main.o不需要把整个程序的链接也完成。第三行把两份目标文件交给C++驱动程序进行链接,最后运行输出13。
目标文件里用来标识函数或对象等实体的名字通常称为符号(symbol)。链接器会处理定义及尚待解析的引用。C++工具可能把命名空间、参数类型等信息编码进符号名;错误信息中的拼写不一定逐字等于源码中的add。先找它提到的函数和类型,再对照源文件,不需要在本章背诵某一平台的编码方式。
三个源文件怎样组成一个程序
add.hpp提供共同声明,add.cpp保存普通add定义,main.cpp保存入口和调用。头文件不因此成为第三个可执行程序。
两个cpp各自经过预处理。main的重复include在自己的翻译单元里被guard挡住;另一个翻译单元仍独立读取头文件。
main.o包含调用所需的符号引用,add.o提供实际定义。main.o编译成功不表示完整程序已经链接完成。
完整链接命令同时列出main.o和add.o,得到add-app。本例只有一个main入口。
运行add-app,main调用add(4,9),实现返回13,再由cout显示13。对象文件或源文件本身不是这一步的运行入口。
查看所有步骤的文字与数值
- 1 · 接口、实现和调用分开
add.hpp:声明 / inline定义;add.cpp:add实现;main.cpp:main与调用
add.hpp提供共同声明,add.cpp保存普通add定义,main.cpp保存入口和调用。头文件不因此成为第三个可执行程序。
- 2 · 分别形成翻译单元
main翻译单元:看见add声明;add翻译单元:看见add定义;guard作用范围:各自预处理过程
两个cpp各自经过预处理。main的重复include在自己的翻译单元里被guard挡住;另一个翻译单元仍独立读取头文件。
- 3 · 分别编译对象文件
main.o:引用add;add.o:定义add;程序:尚待链接
main.o包含调用所需的符号引用,add.o提供实际定义。main.o编译成功不表示完整程序已经链接完成。
- 4 · 两个对象交给链接器
main.o:调用者;add.o:实现者;链接结果:add-app
完整链接命令同时列出main.o和add.o,得到add-app。本例只有一个main入口。
- 5 · 程序执行具体调用
调用:add(4,9);返回值:13;终端输出:13
运行add-app,main调用add(4,9),实现返回13,再由cout显示13。对象文件或源文件本身不是这一步的运行入口。
完整例二:只漏掉一个目标文件
保持源码和刚才的两个.o不变,只从链接输入中移除add.o,并换一个输出名:
clang++ main.o -o missing-add
本例应在链接阶段失败:main.o中确实存在需要调用add的代码,这次却没有提供其定义。Clang在macOS上的错误常包含undefined symbols及arithmetic::add;另一些工具可能写undefined reference。具体诊断以本机实际输出为准,记录第一处缺少的符号。
不要运行missing-add,也不要拿刚才已经存在的add-app证明失败命令成功。修复只补回漏掉的目标文件:
clang++ main.o add.o -o recovered-add
./recovered-add
恢复后输出13。没有改算法,没有在main里再抄一份add定义,也没有删掉调用。问题在这一次的构建输入缺了一条依赖。
声明存在,链接仍可能缺定义
main.cpp与add.cpp分别编译成功,得到main.o和add.o。接下来故意遗漏一个链接输入,而不是破坏源码。
调用者仍引用arithmetic::add,命令却没有提供add.o,链接器找不到对应定义。声明不自动代替缺失的实现。
链接命令以失败状态结束,并给出缺定义诊断;没有可供本实验执行的成功产物。不能把存在main.o解释成整个项目成功。
恢复命令同时使用main.o与add.o,源文件和算术合同不变;链接成功生成recovered-app。
执行recovered-app得到13。记录失败命令、恢复命令和实际输出,说明修的是链接依赖。
查看所有步骤的文字与数值
- 1 · 两个对象已成功产生
main.o:已有;add.o:已有;下一步:选择链接输入
main.cpp与add.cpp分别编译成功,得到main.o和add.o。接下来故意遗漏一个链接输入,而不是破坏源码。
- 2 · 错误命令只提供main.o
实际输入:只有main.o;所需定义:arithmetic::add;add.o:未交给链接器
调用者仍引用arithmetic::add,命令却没有提供add.o,链接器找不到对应定义。声明不自动代替缺失的实现。
- 3 · 保留真实失败
链接状态:失败;运行步骤:不执行;诊断:缺少add定义
链接命令以失败状态结束,并给出缺定义诊断;没有可供本实验执行的成功产物。不能把存在main.o解释成整个项目成功。
- 4 · 用相同对象补齐输入
恢复输入:main.o + add.o;源文件:保持;新产物:recovered-app
恢复命令同时使用main.o与add.o,源文件和算术合同不变;链接成功生成recovered-app。
- 5 · 运行确认恢复
实际调用:add(4,9);stdout:13;退出状态:0
执行recovered-app得到13。记录失败命令、恢复命令和实际输出,说明修的是链接依赖。
头文件缺失和目标文件缺失不是同一种错误。找不到add.hpp时,预处理不能取得接口;函数调用没有可见声明时,编译不能按类型检查调用;本例已经生成main.o但遗漏add.o时,失败才发生在链接。先判断阶段,再改对应文件或命令。
两份main应该怎样处理?
此前已准入的stage-01和stage-02各自有main,分别代表一个可独立运行的练习。它们不是同一程序的一半。把stage-01.cpp和stage-02.cpp保存到单独练习目录;先沿用09章的读法回看,正常输出分别是:
- stage-01:第一行count=5 total=14,第二行empty=0 single=5。
- stage-02:第一行raw=14 calibrated=24,第二行original=3 returned=5。
下面先把两份源码分别变成目标文件,然后故意链接进同一个程序:
clang++ -std=c++20 -c stage-01.cpp -o stage-01.o
clang++ -std=c++20 -c stage-02.cpp -o stage-02.o
clang++ stage-01.o stage-02.o -o mixed-stages
两次单独编译可以成功,但最后一行在当前工具链上应因重复main定义而链接失败。一个普通可执行程序只能有一个相应的main入口定义。这里不讨论动态库等其他构建种类;也不通过删除其中一个stage的main去改变原练习。
正确处理是分别链接,保留两个完整程序:
clang++ stage-01.o -o stage-one
clang++ stage-02.o -o stage-two
./stage-one
./stage-two
同理,header-use.cpp应与add.cpp形成自己的程序:
clang++ -std=c++20 header-use.cpp add.cpp -o header-use
./header-use
它输出4 9 4。main.cpp不要一起加进这条命令,因为header-use.cpp已经提供了自己的main。
编译选项、链接选项和库
-std=c++20、头文件搜索目录以及宏定义,主要影响源码怎样变成目标文件。-I目录给编译器增加头文件搜索位置;本例头文件在同目录,不需要额外-I。.o已经生成后,再给最终链接加一个-D,不会让它里面的源码重新经过预处理。
链接则需要目标文件和相应库。静态库可以把一组目标文件组织成归档;动态库在加载与运行时还涉及库文件的查找和装载。仅include一个库的头文件,并不等于已经提供其编译实现。标准库在这里由clang++的正常C++链接安排,第三方库则需要依它的构建说明配置。
命令行上常用-L目录增加库搜索位置,-l名字请求链接相应库。静态库与目标文件的顺序在一些链接器上会影响解析,应遵循实际工具和库的约定;本章不靠盲目调换所有参数排错。接下来的CMake目标依赖可以替我们记录“这个程序依赖哪个库”,无需在源码中手写库文件路径。
有些选项同时影响编译和链接。比如启用ASan/UBSan时,编译要插入对应检查,最终链接也要安排检查运行库,不能只在最后链接时加选项就声称旧目标文件已被检测。第18章再讨论这些检查实际能发现什么。
CMake先描述目标,再生成构建规则
CMake不是另一种C++编译器。它读取CMakeLists.txt,描述目标、源文件及依赖,并生成底层构建规则;随后编译器与链接器仍完成刚才的工作。这里沿用现有工具,不要求下载另一套编译器或安装新的设备软件。
先读懂下一节配置将使用的七种语句,再看完整文件:
| 语句形式 | 在本项目里的意思 |
|---|---|
| cmake_minimum_required(VERSION 3.20) | 声明这个项目要求至少CMake 3.20 |
| project(TextbookBuild LANGUAGES CXX) | 给项目命名,并启用C++语言;CXX是这里的语言标识 |
| add_library(arithmetic STATIC add.cpp) | 用add.cpp构建名为arithmetic的静态库目标 |
| target_compile_features(arithmetic PUBLIC cxx_std_20) | 该库及其使用者需要至少C++20;PUBLIC把使用要求传给依赖者 |
| add_executable(add-app main.cpp) | 用main.cpp建立可执行目标add-app |
| target_link_libraries(add-app PRIVATE arithmetic) | add-app依赖并链接arithmetic;PRIVATE描述当前目标的这条依赖,不作为其对外链接接口 |
| set_target_properties(目标… PROPERTIES CXX_EXTENSIONS OFF) | 为列出的目标设置属性,使用不启用编译器C++语言扩展的标准模式 |
括号中的空白分隔参数,换行也可以分隔参数。这是CMake自己的配置语法,不是C++函数调用;本例不需要分号。目标名称arithmetic只是CMake的构建名称,碰巧也用作C++命名空间名字,两者没有自动关联。
配置还会给stage-07-migrated建立另一个可执行目标,并用PRIVATE cxx_std_20声明它自己的标准要求。cxx_std_20表示至少C++20,不保证强制降到恰好20,也不代替源代码的语义检查。
17.4 可复现构建:换一个目录,还能得到同样结果吗?
“我的电脑上有一个能运行的文件”还不能说明别人能用源码重建它。最低限度要留下源码版本、工作目录、工具版本、完整命令,以及预期和实际结果。构建目录用于保存生成物,源文件则应保留在自己认识的项目目录里。
工程回访:已经读懂的stage-07教材迁移版
下面的stage-07-migrated.cpp是明确命名的教材迁移版。它保留原阶段的五组小整数、五个解析输入和分块求和合同,把两处optional与整数直接比较,改成第16章已经正式教过的“先检查有值,再读取比较”。旧stage-07文件保留原样;两个文件不是同一份源码,也不把教材迁移版记作你的独立实现。
读代码前,把现有合同应用到本文件:
| 本文件里的组合 | 沿用已授规则怎样读 |
|---|---|
| Summary包含count与total | 两个成员分别保存元素个数和long long累加值,不是两个互相影响的引用 |
Summary result{values.size(),0} |
按成员顺序聚合初始化:count取得输入长度,total从0开始;这里的0可保存为long long |
| vector<vector<int>>与两层花括号 | 外层序列有五项;每一项又是一个拥有int元素的序列,空花括号对应空序列 |
| summarize(cases[i]) | 动态长度的只读span借用这组vector元素,沿用09章明确的调用合同;调用期间vector仍存活且不扩容 |
| parse_reading(“3”) | 16章的字面量到string_view参数合同;返回独立optional整数,不把借用带出函数 |
| !three与*three | 前者检查缺失,只有通过有值检查后才读取;逻辑或的短路顺序避免读取空optional |
| const vector<int> left…, right… | 声明两个各自拥有元素的对象;const限制各对象的修改 |
| summarize(left).total | 从这次返回的Summary对象读取成员,用它参与当前表达式;没有保存指向临时对象的引用 |
下面只处理最多五个小整数的固定样本,long long足以保存所列和;不能从这些样本外推任意长度与数值都不会溢出。解析仍先拒绝空输入,检查from_chars错误、完整消费和±1,000,000业务范围。
// Textbook stage-07 migration: check optional state before reading its value.
// Stage 07: independent expected values, always-on checks, and strict input parsing.
#include <charconv>
#include <cstddef>
#include <iostream>
#include <optional>
#include <span>
#include <string_view>
#include <vector>
struct Summary {std::size_t count;long long total;};
Summary summarize(std::span<const int> values) {
Summary result{values.size(),0};
for(const int value:values) result.total+=value;
return result;
}
std::optional<int> parse_reading(std::string_view text) {
if(text.empty()) return std::nullopt;
int value=0;
const auto [end,error]=std::from_chars(text.data(),text.data()+text.size(),value);
if(error!=std::errc{} || end!=text.data()+text.size() || value < -1000000 || value > 1000000)
return std::nullopt;
return value;
}
int main() {
const std::vector<std::vector<int>> cases{{3,1,4,1,5},{},{5},{-3,1},{0,0}};
const std::vector<long long> expected{14,0,5,-2,0};
for(std::size_t i=0;i<cases.size();++i) {
const auto actual=summarize(cases[i]);
if(actual.total!=expected[i] || actual.count!=cases[i].size()) return 1;
}
const auto three = parse_reading("3");
const auto lower = parse_reading("-1000000");
if(!three || *three!=3 || !lower || *lower!=-1000000 ||
parse_reading("3x") || parse_reading("") || parse_reading("1000001")) return 2;
const std::vector<int> left{3,1},right{4,1,5};
if(summarize(left).total+summarize(right).total!=14) return 3;
std::cout<<"batch_cases="<<cases.size()<<" parsing_checks=5 split_total=14\n";
}
五组求和的独立期望依次是14、0、5、−2、0,count必须分别等于输入序列长度。合法解析输入3与−1000000有值;3x、空串、1000001必须拒绝。左右两组读数的和是4与10,加起来14。
这些判断全部通过时,才会输出一行batch_cases=5 parsing_checks=5 split_total=14。固定成功路径执行五次解析调用;失败时程序可以提前退出,不表示后面的检查仍全部执行。这里的parsing_checks=5是与当前五个调用对应的报告文字,不是自动注册计数器;第18章会专门处理测试遗漏时怎样发现数量变化。
同一项目的完整CMake文件
把本章下载的CMakeLists.txt、add.hpp、add.cpp、main.cpp、stage-07-migrated.cpp放进同一源目录。phase、macro-values与header-use是前面的手工支持实验,不会仅因也在目录里就自动进入CMake目标。
cmake_minimum_required(VERSION 3.20)
project(TextbookBuild LANGUAGES CXX)
add_library(arithmetic STATIC add.cpp)
target_compile_features(arithmetic PUBLIC cxx_std_20)
add_executable(add-app main.cpp)
target_link_libraries(add-app PRIVATE arithmetic)
add_executable(stage-07-migrated stage-07-migrated.cpp)
target_compile_features(stage-07-migrated PRIVATE cxx_std_20)
set_target_properties(arithmetic add-app stage-07-migrated
PROPERTIES CXX_EXTENSIONS OFF)
这一配置有三个目标:静态库arithmetic、依赖它的add-app、独立的stage-07-migrated。头文件通过include被源码使用,构建工具记录相应依赖;没有把它当成另一个main。最后一条属性语句同时给这三个目标设置CXX_EXTENSIONS为OFF。
开始前在终端确认工具:
clang++ --version
cmake --version
pwd
pwd输出当前工作目录,沿用第一章的路径读法。若cmake提示command not found,这说明终端尚未找到这个工具,不是C++语法错。先检查自己是否已有CMake;已有安装可用其实际可执行文件路径替换命令开头的cmake。没有安装时,按CMake官方二进制下载说明选择当前系统的安装包,完成后重新检查版本,不需要从源码编译一套CMake。本章验证复用了本地已有的CMake 4.4.3,没有新增下载。
以下使用单配置的Unix Makefiles生成器,它调用系统make执行生成的规则;在本章macOS工具链中make已随开发工具提供。-G "Unix Makefiles"明确选择生成器,引号使含空格的名称作为一个参数。-S .把当前目录选作源码目录,-B build-debug指定单独的构建目录;若该目录已有另一套项目或生成器的缓存,换一个新的名字,不混用旧缓存。
-DCMAKE_BUILD_TYPE=Debug是设置CMake配置变量,不是前面clang++的预处理宏-D;两者属于不同命令。Debug和Release使用不同构建目录。Debug通常保留较多调试信息,Release通常启用优化,具体选项以实际工具输出为准;名字本身不保证程序正确或一定更快。
--build让CMake调用这个构建目录的底层工具;--target后明确列出需要构建的两个程序。
cmake -S . -B build-debug -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Debug
cmake --build build-debug --target add-app stage-07-migrated
./build-debug/add-app
./build-debug/stage-07-migrated
cmake -S . -B build-release -G "Unix Makefiles" -DCMAKE_BUILD_TYPE=Release
cmake --build build-release --target add-app stage-07-migrated
./build-release/add-app
./build-release/stage-07-migrated
每种模式下,add-app输出13,stage-07-migrated输出刚才的一行汇总。配置成功只说明生成规则成功,仍须检查实际构建与运行。
需要看实际编译和链接命令时,在对应的cmake --build命令后加--verbose。这比假定“Release肯定用了某个固定选项”更可靠。这里的单配置流程不等同于Xcode等多配置生成器;不把它们的产物目录和模式选择方式混写在同一组命令里。
保存一份别人能照着做的记录
记录下面五项即可,不需要另建一个仪表盘:
- 这次使用的源文件名称与版本,确认保存过编辑内容。
- 实际工作目录和编译器、CMake版本。
- 完整命令;使用的Debug/Release目录和生成器。
- 一次缺少add.o的真实链接错误,以及只补回该输入后的结果。
- 两个程序在两种模式的实际输出与退出状态。
尚未理解的汇编或IR不作为本章验收材料。需要深入时,从编译器专题继续;本章能说明源文件、目标文件、库、可执行文件和构建规则的职责,就完成了这部分衔接。
四步练习阶梯
先预测。 关闭终端结果,看add.hpp、add.cpp、main.cpp,写下哪个文件提供声明、哪个提供add定义、哪个提供main。预测完整链接输出13,以及只留下main.o会在哪个阶段失败。
补上一步。 自己写出两条独立生成目标文件的命令,再补最后的链接输入。不要用一条全源码命令代替这次分离编译练习;成功后运行并检查13。
找出错误。 两个旧stage各自有main。先预测混合链接为什么失败,再按本章命令亲自观察。修复为两个可执行程序,保留原源文件,分别核对两组已知输出。
独立迁移。 关闭完整CMake文件,在新的练习目录为已读懂的stage-07-migrated.cpp写最小项目,指定C++20并单独构建它。再对照配置,使用Debug与Release两个目录复现那一行汇总。保存自己的配置与错误修复过程;下载了完整答案或运行教材项目,不等于独立完成了迁移。
本章额外两道练习和六道复述题在下面。先留下自己的答案,再展开提示。本章的inline合同限于已讲授的函数定义,不扩展到跨翻译单元的inline变量身份。
先留下自己的答案
两道动手练习
先完成正文的练习阶梯,再用下面两题检查自己的解释。先写预测或代码;需要时展开提示,完成后再对照答案。
练习 1
不修改macro-values源码,分别以-DOFFSET=5、-DEXTRA和-DEXTRA=0构建三个独立程序。预测每个输出,解释最后两个为何相同。
查看提示
- OFFSET没有被外部定义时才取默认0。
- #ifdef检查宏是否已定义,不读取它是否等于0。
对照答案与推理
-DOFFSET=5输出9;-DEXTRA输出4和9;-DEXTRA=0也输出4和9。后两者都定义了EXTRA,保留相同的额外输出语句;这与运行时if(0)的语义不同。
练习 2
已成功生成main.o和add.o,链接时只写main.o而失败。保留三个源文件,写出恢复命令并说明输出;如果把两个各有main的stage对象放在同一命令里,又应怎样修?
查看提示
- 声明已供编译使用,缺少的是实际链接输入。
- 两个独立阶段各有自己的入口,不需要删除它们的main。
对照答案与推理
把main.o与add.o同时交给clang++链接,例如clang++ main.o add.o -o app,运行app输出13。两个stage对象包含重复main,应分别链接为两个可执行程序,再各自验证原固定输出;不要把两个入口合成一个程序或删除原阶段入口。
关掉参考,再做一次
把理解说出来
先完成正文的独立迁移,再回答下面六题。它们只检验第17.1–17.4节已经讲过的内容。打开答案、编译成功或填写用时,都不会自动通过本章,更不代表通过 G0。
阶段与产物
-E、-S、-c分别停在哪里?为什么不能把成功生成.o当作程序已经运行?
对照推理与英文回答
-E输出预处理后的翻译单元;对该C++输入使用-S得到汇编;把汇编交给-c得到对象文件。对象还可能引用别处定义的符号,须正确链接成可执行文件后再运行。本例实际经过cpp、ii、s、o与可执行文件,最终才观察13。
E stops after preprocessing, S emits assembly from the C++ input, and c assembles it into an object. An object may still refer to definitions from other objects. Linking and running are separate steps; thirteen appears only when this executable runs.宏与头文件保护
-DEXTRA=0为何仍保留额外输出?main重复include头文件为何未重复定义next?guard是否会替另一个翻译单元共享状态?
对照推理与英文回答
ifdef检测EXTRA是否被定义,定义为0仍满足。main第一次include定义guard,第二次在同一预处理过程中跳过头文件内容;另一个cpp作为独立翻译单元仍分别预处理,guard不是跨翻译单元的全局登记。
Ifdef tests whether a macro is defined, so defining EXTRA as zero still includes the branch. The guard skips repeated inclusion within one preprocessing run. Another translation unit is processed independently; the guard is not a program-wide registry.命名空间与inline
header-use为何输出4 9 4?头文件里的inline next能出现在多个翻译单元,是否表示编译器必须把调用展开?
对照推理与英文回答
next(3)算出4,demo::value选择命名空间成员9,::value从全局取4。满足相同定义等规则的inline函数可在多个翻译单元定义;这不承诺任何特定优化。include guard解决同一翻译单元重复包含,inline的定义合同解决另一层问题。
Next computes four, demo selects the namespace value nine, and leading scope resolution selects the global four. Inline permits the relevant multiple-definition pattern when its rules are satisfied. It does not require call expansion by the optimizer.链接错误定位
缺add.o与两个main分别属于哪一类失败?两者的cpp都能先编译成功吗?
对照推理与英文回答
缺add.o让最终链接缺少被调用的add定义;两个各有main的对象会在最终链接出现重复入口定义。各翻译单元分别编译可以成功,失败发生在汇总链接输入之后。前者补齐实现对象,后者拆成两个独立可执行目标。
Omitting add.o leaves a required definition unresolved. Combining two entry-point objects supplies main twice. Individual translation units may compile successfully in both cases. Add the missing implementation in the first case and build separate executables in the second.CMake目标关系
为什么add-app不必把add.cpp抄进自己的main?STATIC库、PUBLIC语言要求与PRIVATE链接依赖在本例分别表达什么?
对照推理与英文回答
arithmetic目标从add.cpp生成静态库,add-app链接依赖它来取得add实现。PUBLIC的C++20编译特性作用于库并传给使用该库的目标;PRIVATE链接关系用于当前add-app的构建,不把它变成对外接口。stage迁移版是另一个明确的可执行目标,CXX_EXTENSIONS OFF请求标准语言模式。
The arithmetic target provides the compiled implementation as a static library. Its public C++20 feature requirement also reaches consumers. Add-app uses the library through its private link dependency. The migrated stage is a separate executable, and extensions are disabled for these targets.可复现迁移
stage07迁移版改变了什么、保持了什么?一份构建记录至少应该写哪些环境信息,为什么旧缓存不能证明新项目已通过?
对照推理与英文回答
只把两处optional与int比较换成先判空再取值,五个解析输入、五组batch及split_total=14输出保持,原教学源与旧收据未改。记录工作目录、编译器/CMake版本、源文件身份、配置/编译/链接/运行命令和实际结果;新项目必须在其实际来源上验证,旧缓存可能包含不同源码或选项。
The migration replaces two optional-to-int comparisons with checked value access while preserving the inputs and output. The old source stays frozen. Record directories, tool versions, source identities, commands and results. An old cache may contain different inputs or options and cannot attest to this new project.和正文是同一份源码
示例文件
先自己输入和预测,卡住时再下载对照。文件名相同不代表内容相同;把它们放在单独的练习目录中,避免覆盖自己的作品。
- phase.cpp从一个已知程序观察五个阶段
- macro-values.cpp先读宏:存在与数值是不同问题
- add.hpp公共声明与允许跨翻译单元出现的定义 · 项目组件,按正文组合构建
- add.cpp完整例二:补回缺失的实现对象 · 项目组件,按正文组合构建
- main.cpp完整例一:三个文件组成输出13的程序 · 项目组件,按正文组合构建
- header-use.cpp命名空间、全局限定与头文件inline · 项目组件,按正文组合构建
- stage-07-migrated.cpp独立迁移:给已知阶段建立工程目标
- CMakeLists.txt用明确目标记录项目依赖 · 构建配置,不能直接运行
下载清单来自本章元数据,正文代码与下载同源。本章的头文件、实现文件、main与CMakeLists.txt需要按正文放在同一项目目录;头文件和构建配置不是独立可运行程序。先读各例的命令与文件组合,再区分成功构建、故意缺定义及修复后的结果,不把单个文件下载成功当作整个项目已通过。
可选的学习反馈
记下你真正花的时间
每完成一个学习时段,再填实际分钟。环境准备、阅读推演、独立编码和卡点排查分别记录,避免同一段时间重复计算。离开吃饭或做其他事情的时间不算进去。
记录只保存在你的浏览器,可导出给我复盘。留空表示尚未记录,不等于零耗时;页面停留时间不会自动计为学习。不要把开发者检查时间填进来。
尚无真实试学用时。
换设备:导入记录,或取回损坏的旧记录
导入会合并时段,相同编号不重复累加;发生冲突会保留现有记录。
阅读记录与课程验收分别保存。
本章资料与查证
- WG21 N4861:C++20工作草案 ↗
17.1查[lex.phases];17.2查[cpp.include]、[cpp.replace]、[cpp.cond]、[namespace.def]、[namespace.qual]、[basic.def.odr]与[dcl.inline];17.3查[basic.start.main]。标准描述语言合同,具体Clang命令与诊断由本机实际验证。
- CMake官方:cmake-buildsystem ↗
17.3/17.4的Binary Targets、Static Libraries及Build Specification and Usage Requirements;本例仅明确STATIC库、两个可执行目标、PUBLIC编译特性和PRIVATE链接依赖。
- Clang官方:Command Guide ↗
17.1的Stage Selection Options:-E、-S、-c以及无阶段停止选项时的链接;17.4记录实际编译器版本,不要求读取LLVM IR。
本章独立解释所需读法;资料用于核对与补充。工具版本、操作系统和实际执行状态见自己的运行记录。