单元三 · 那 C 的底层知识怎么办

专栏《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++ 里都仍然活着,只是换了长相:

  1. 内存布局与指针算术。 对象在内存里怎么排布,指针加一为什么跨过一整个元素。C++ 里 v.data() 返回的就是一个 int*
  2. 预处理器与头文件。 #include#define、头文件守卫。C++ 仍然用这套,只是多了 constexprinlinemodule 这些替代品。
  3. 链接与符号。 多个源文件怎么拼到一起、函数名在目标文件里长什么样。C++ 因为支持重载和命名空间,这一步比 C 复杂。
  4. 位运算与字节序。 掩码、移位、大小端。写协议解析、文件格式、嵌入式代码时绕不开。
  5. 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)

它不是随手垫的,规则有两条:

  1. 每个成员按自身大小对齐------它的起始偏移必须是自身大小的整数倍。
  2. 整个结构体的大小是最大成员的整数倍,不够就在末尾补齐。

按这两条推 Packedchar 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

AB 装的是同样的三个成员。B 把两个 char 挨在一起,int b 就只需要在偏移 2、3 垫两个字节,末尾补到 8。

所以「把成员大小加起来估算结构体大小」是不成立的。sizeofalignof 随时可以问,不用心算。

第二个是指针算术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* pp + 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 把这个当作入门的前提。

相关推荐
MetaLite1 小时前
Java 时间处理的两个坑:单位误判,半秒算成一秒
java·开发语言·前端
abc light1 小时前
单片机(1)
单片机·嵌入式硬件
汉克老师2 小时前
GESP 六级明日冲刺:最后一晚只抓两个大方向
c++·gesp·小学生·学c++编程
云泽8082 小时前
STM32 USART 详解(四):数据帧及标志位的收发机制与寄存器深度剖析
stm32·单片机·嵌入式硬件
萧鼎2 小时前
Python 数据验证神器 Pydantic:数据解析与验证、设置管理、JSONSche、与ORM集成全搞定
开发语言·python
神仙别闹2 小时前
基于 QT(C++)开发的地铁换乘系统
数据库·c++·qt
haon11222 小时前
海外信贷的策略全生命周期:从准入到还款的本地化布防
大数据·开发语言·深度学习·面试·职场和发展·产品经理
zander2582 小时前
LeetCode 128. 最长连续序列
数据结构·算法
linx2952 小时前
单元四 · 对称认知·上:内存与指针
c语言·开发语言·数据结构·嵌入式硬件·算法