专栏《Zero to C++》· 单元 03
阅读时间约 10 分钟。本篇回答:不先学 C,那 C 传下来的那些东西放在哪、什么时候补。
前两篇留下了一个缺口
单元 01 讲了先学 C 的代价,单元 02 讲了 C++ 自己的骨架。两篇连起来看,容易推出一个结论:C 的东西可以不管了。
这个推论是错的,要就地拦住它。
内存怎么摆、指针是什么、编译器怎么把源码变成可执行文件------这些知识在 C++ 里一样重要。不知道它们的人,能写出跑得通的程序,写不出能调、能优化、能和别人的库对接的程序。
本篇要处理的是排序问题:这些内容放在学习的哪个位置,以及不同起点的读者该从哪里进来。
单元 00 给过一句结论「可以边学边补」,也提了一句依据「C++ 的三层结构」。这一篇把它展开。
三层结构
《C++ Primer》在序言里是这么切分这门语言的:
Modern C++ can be thought of as comprising three parts:
- The low-level language, much of which is inherited from C
- More advanced language features that allow us to define our own types and to organize large-scale programs and systems
- The standard library, which uses these advanced features to provide useful data structures and algorithms
译过来:
| 层 | 内容 | 与 C 的关系 |
|---|---|---|
| 第一层 · 低层语言 | 内存、指针、数组、位运算、预处理器 | 大部分从 C 继承 |
| 第二层 · 自定义类型 | 类、模板、继承、运算符重载 | C 里没有 |
| 第三层 · 标准库 | 容器、算法、字符串、智能指针、文件流 | C 里没有 |
这张表的作用是给 C 那一层定位置 :它是三层中的一层,位置固定,删不掉。教材里先教 C 子集的问题出在顺序上------把第一层提到最前面讲透再讲后两层,读者会在最枯燥的部分放弃,撑下来的人也会先养成第一层的思维定式。
本专栏的主张是把这个顺序倒过来:先站上第二、三层(单元 02 那六件套),再回头把第一层补全。补的内容不只是「C 里是什么」,还有「它在 C++ 里对应什么」。
第一层里到底有什么
第一层不是一个笼统的「底层」,它可以拆成几块。每块在 C++ 里都仍然活着,只是换了长相:
- 内存布局与指针算术。 对象在内存里怎么排布,指针加一为什么跨过一整个元素。C++ 里
v.data()返回的就是一个int*。 - 预处理器与头文件。
#include、#define、头文件守卫。C++ 仍然用这套,只是多了constexpr、inline、module这些替代品。 - 链接与符号。 多个源文件怎么拼到一起、函数名在目标文件里长什么样。C++ 因为支持重载和命名空间,这一步比 C 复杂。
- 位运算与字节序。 掩码、移位、大小端。写协议解析、文件格式、嵌入式代码时绕不开。
- C 链接约定。 让 C++ 代码能被 C 调用,或者反过来,靠
extern "C"。
这五块里,第 2、3 两块属于「每个 C++ 程序都在用,只是你可能没注意」;第 1、4、5 三块属于「用到的时候必须懂」。第 1、3、5 三块的补课位置在单元 04 和 05。
第 4 块(位运算与字节序)本专栏不铺开讲。它在 C 和 C++ 之间几乎没有差异,C++ 原样继承了这套操作,所以它不构成「对称认知」的问题------等到你要解析文件格式、写协议、做嵌入式的时候,找一份位运算的材料现学即可。
一个例子:第一层什么时候会找上门
不用等到写底层代码。下面这段是教科书式的 C++,没有一个「底层」的意图:
cpp
#include <iostream>
#include <vector>
struct Packed { char a; int b; char c; };
int main() {
std::cout << "sizeof(Packed) = " << sizeof(Packed) << '\n'
<< "sizeof(char)+sizeof(int)+sizeof(char) = "
<< sizeof(char) + sizeof(int) + sizeof(char) << '\n';
std::vector<int> v{10, 20, 30};
int* p = v.data(); // 容器交出的是一块连续内存的地址
std::cout << "v.data()+1 -> " << *(p + 1) << '\n';
}
输出:
sizeof(Packed) = 12
sizeof(char)+sizeof(int)+sizeof(char) = 6
v.data()+1 -> 20
两个第一层的概念在这段代码里露了头。
第一个是内存对齐 。三个成员加起来 6 字节,sizeof 却是 12。多出来的那些字节是编译器垫进去的,叫填充(padding)。
它不是随手垫的,规则有两条:
- 每个成员按自身大小对齐------它的起始偏移必须是自身大小的整数倍。
- 整个结构体的大小是最大成员的整数倍,不够就在末尾补齐。
按这两条推 Packed:char a 放在偏移 0;int b 要落在 4 的倍数上,于是偏移 1、2、3 被垫掉,b 占 4--7;char c 放在 8。此时长度是 9,但最大成员是 4 字节的 int,总长必须是 4 的倍数,所以 9、10、11 再垫三个------得到 12。
规则对不对,可以用一个实验验证:成员相同、顺序不同,大小就不一样。
cpp
struct A { char a; int b; char c; };
struct B { char a; char c; int b; };
std::cout << "sizeof(A) = " << sizeof(A) << " alignof(A) = " << alignof(A) << '\n';
std::cout << "sizeof(B) = " << sizeof(B) << " alignof(B) = " << alignof(B) << '\n';
实测输出:
sizeof(A) = 12 alignof(A) = 4
sizeof(B) = 8 alignof(B) = 4
A 和 B 装的是同样的三个成员。B 把两个 char 挨在一起,int b 就只需要在偏移 2、3 垫两个字节,末尾补到 8。
所以「把成员大小加起来估算结构体大小」是不成立的。sizeof 和 alignof 随时可以问,不用心算。
第二个是指针算术 。v.data() 让你以为自己在用容器,拿到的却是裸 int*;p + 1 前进的是一个 int 的距离。C 的指针规则原封不动地在这里生效。
对齐这件事在 C 里一模一样------它是硬件和 ABI 的要求,和用什么语言无关。区别在于撞上它的时机:写 C 的时候更早(手工算偏移、按字节读文件),写 C++ 时它通常藏在 sizeof 后面,等到你需要设计二进制格式、对接网络协议、或者觉得内存占用不对劲的时候才找上门。
从这两处可以看到,第一层的内容不会因为你从 C++ 入门就消失。它只是在你需要它的时候出现 ------上面这段是因为打印了 sizeof 才看见,如果你写的是 for (int x : v),它就藏在背后。
三层各自在路线图的哪一步
本专栏剩下的篇目按这张表分工:
| 层 | 对应的篇目 | 读者在哪一步学 |
|---|---|---|
| 第三层 · 标准库 | 单元 02、10 | 入门即用;vector / string 第一天就上 |
| 第二层 · 自定义类型 | 单元 02、09 | 会用别人写的类之后,学写自己的类 |
| 第一层 · 低层语言 | 单元 04、05 | 有了一小段 C++ 经验之后,回头补齐 |
顺序上有个取舍要说清楚。先学第三层、后补第一层,代价是早期写的代码可能不够快、不够省内存。对入门阶段这是划得来的:先让程序跑起来、先建立对类型和抽象的直觉,等你能判断「这里为什么慢」的时候,再回头对付内存布局,学的效率更高。
单元 04 讲第一层里「内存与指针」的那一半:malloc/free 对应什么、裸指针怎么变成智能指针、char* 怎么变成 string。单元 05 讲「编译与链接」那一半。
两类读者的入口
到这里可以给入口了。读者的起点不同,走法也不同。
| 读者 A · 零基础 | 读者 B · 已学 C | |
|---|---|---|
| 起点 | 没写过代码,或只写过脚本 | 会指针、数组、malloc,想转现代 C++ |
| 从哪进 | 单元 06(装环境)→ 07 → 08 → 09 → 10 | 单元 04 → 05,再按需回看 09、10 |
| 可以先跳过 | 04、05(等有 C++ 经验再回来) | 06--08 的大部分 |
| 最值钱的部分 | 06--10 这条路线图 | 04、05 两张对照表 |
读者 B 的入口放在 04、05。他会写程序,卡住他的是手上那套 C 的默认动作。单元 01 已经把三种倒灌的习惯摆出来,04、05 负责给每一处一个对称物。
读者 A 的入口放在 06,原因是路线图要先把「写程序」这件事本身立起来:装编译器、跑通第一个程序、读懂报错。这一段和语言是不是 C++ 关系不大,任何一门语言都要过。
一条必须说清的边界
读者 A 的路线图不能直接照着《C++ Primer》走。这本书在序言里写明:
Although you do not need to know C in order to understand this book, we assume you know enough about programming to write, compile, and run a program in at least one modern block-structured language. In particular, we assume you have used variables, written and called functions, and used a compiler.
它只承诺「不必懂 C」,同时假定你已经会一门结构化语言------用过变量、写过函数、用过编译器。
这条边界决定了单元 06 的存在。零基础读者在拿到 C++ Primer 之前,先要跨过「会用一门语言写程序」这道坎。越过之后,上面那些经典教材才适用。本专栏的路线图会按这个前提设计每一步。
下一个单元的起点是读者 B 最关心的部分:C 的内存与指针,在 C++ 里各自对应什么。
附录 · 术语表
本单元出现的词。不要求记住,读的时候遇到不懂的回来查一眼。
本单元的核心概念
C++ 的三层结构 --- C++ Primer 序言给出的划分:从 C 继承来的低层语言、用来定义自己类型的高级特性、标准库。本单元用它给 C 的那部分知识定位置。
低层语言(low-level language) --- 三层里的第一层。指内存、指针、数组、位运算、预处理器这些贴近机器的内容。
内存对齐(alignment) --- 编译器为了访问效率,让变量起始地址落在 2、4、8 这类边界上,中间空出的字节叫填充(padding)。本单元里 sizeof(Packed) 是 12 而不是 6,就是它的结果。
填充(padding) --- 对齐在结构体里留下的空隙。它不存任何数据,但会计入 sizeof。
alignof --- 求一个类型的对齐要求,也就是它的起始偏移必须是几的倍数。alignof(int) 是 4。
指针算术 --- 指针加一前进的距离是一个元素的大小,不是一个字节。int* p 上 p + 1 前进 4 个字节。
二进制接口(ABI) --- 编译好的代码之间约定的规则:函数怎么调用、参数怎么传、对象怎么排布。两个模块的 ABI 要一致才能链接到一起。
缓存局部性(cache locality) --- 数据在内存里挨得越近,CPU 取用时越快。这是选 vector 而不是链表的一个常用理由。
本单元的代码里出现的
sizeof --- 求一个类型或对象占多少字节。
struct --- 把几个不同类型的数据打包在一起的自定义类型。C++ 里它和 class 只差默认访问权限。
#include --- 预处理指令,把另一个文件的内容插到当前位置。
#define --- 预处理指令,做文本替换。C++ 里多数场合可以用 constexpr 或模板替代。
constexpr --- 让一个值或函数在编译期就能算出来。
inline --- 允许一个函数或变量在多个源文件里出现而不违反「只能定义一次」的规则。
module --- C++20 引入的替代头文件的机制。
extern "C" --- 让 C++ 按 C 的链接约定导出符号,用于和 C 代码互相调用。
v.data() --- 返回容器内部那块连续内存的首地址,类型是 T*。
std::vector<int> v{10, 20, 30}; --- 用三个元素初始化一个 vector。
范围 for(range-based for) --- for (int x : v) 这种写法,依次取出容器里的每个元素。它把指针和下标都藏在背后。
关于教材
C++ Primer --- 主流 C++ 入门教材之一。它假定读者已会一门结构化语言,所以适合读者 B,不适合读者 A 的第一步。
结构化语言(block-structured language) --- 用花括号之类的块来组织控制流的语言,例如 C、Java、Python。C++ Primer 把这个当作入门的前提。