C++ 语言基础完整学习手册 / Complete C++ Language Basics Guide
1. 这本手册怎么使用
这不是一张“看完就算学会”的打卡表。
每个阶段都要完成四件事:
理解一个问题
|
v
写一个最小程序
|
v
制造或观察一个现象
|
v
用自己的话解释现象如果只看文章不运行代码,你可能记住了词,却没有建立模型。
如果只运行代码不解释现象,你可能得到了输出,却不知道条件改变后会发生什么。
2. 阶段 0:程序如何被翻译
阅读:
01-compile-link-odr.md目标:
- 区分源码、目标文件和可执行文件。
- 区分编译和链接。
- 理解声明和定义的分工。
- 看懂最基本的
undefined reference。
最小实验:
// math.cpp
int add(int a, int b) {
return a + b;
}// main.cpp
#include <iostream>
int add(int, int);
int main() {
std::cout << add(20, 22) << '\n';
}c++ -std=c++17 -Wall -Wextra -c math.cpp -o math.o
c++ -std=c++17 -Wall -Wextra -c main.cpp -o main.o
c++ main.o -o app第一次看到这种命令时,不要求你立刻背下来。
先把它当作一个有结构的句子:
c++
+-- 使用 C++ 编译器
-std=c++17
+-- 选择 C++17 规则
-Wall -Wextra
+-- 打开常见警告
-c
+-- 只生成目标文件,不生成最终程序
math.cpp / main.cpp
+-- 要处理的源码文件
-o math.o
+-- 指定输出文件名第三条命令应该暴露链接问题。
命令中的 main.o 和 math.o 不是两个“可以直接运行的程序”,而是两个目标文件。
把它们组合成最终程序时,才会进入链接阶段。
然后执行:
c++ main.o math.o -o app
./app这里:
-o app
+-- 把最终输出命名为 app
./app
+-- 运行当前目录中的 app复习:
为什么两个 .cpp 都能编译?
为什么只有 main.o 不能生成程序?
add 的声明和定义分别解决什么问题?3. 阶段 1:值、类型和对象
阅读:
02-language-fundamentals.md目标:
- 区分名字、对象、类型和存储。
- 区分初始化和赋值。
- 理解
int、float、double的表示和限制。 - 理解
const、转换、范围和未初始化。
从这段程序开始:
#include <iostream>
int main() {
int count{3};
const int limit{10};
float ratio{0.1f};
std::cout << count << ' '
<< limit << ' '
<< ratio << '\n';
}观察:
count 是什么?
int 决定了什么?
ratio 为什么不一定精确表示 0.1?
limit 为什么不能被赋值?实验:
- 比较
int、long、float和double的sizeof。 - 比较
int和unsigned的混合比较。 - 用高精度输出观察
0.1f。 - 比较
int x = 3.9和int x{3.9}。 - 打开
-Wall -Wextra检查未初始化。
4. 阶段 2:表达式和控制流
阅读:
03-expressions-operators-control-flow.md目标:
- 解释表达式的类型和值。
- 识别副作用。
- 理解短路求值。
- 解释
if、for、while和return如何改变控制流。
实验:
int calls = 0;
bool ready() {
++calls;
return true;
}
if (false && ready()) {
}运行后观察 calls,再把 false 改成 true。
复习:
右侧为什么有时不会执行?
表达式除了返回值,还可能改变什么?
隐式类型转换在哪里发生?5. 阶段 3:函数、作用域和调用栈
阅读:
04-functions-scope-overloading.md目标:
- 区分函数声明、定义和调用。
- 理解按值、指针和引用传参。
- 区分作用域与生命周期。
- 画出简单函数调用的栈帧。
观察这个危险例子:
int* bad_pointer() {
int local{42};
return &local;
}不要把“某次运行还能打印 42”当作它正确。
回答:
local 什么时候存在?
返回的地址保存了什么?
函数返回后哪个条件失效了?6. 阶段 4:数组、指针、字符串和引用
阅读:
05-arrays-pointers-strings.md
06-references-const-type-deduction.md目标:
- 画出连续数组的地址布局。
- 解释数组退化和指针加法。
- 区分指针对象与被指向对象。
- 识别越界、悬空和失效。
- 理解引用、
const和auto的推导结果。
int values[3]{10, 20, 30};
for (int* current = values; current != values + 3; ++current) {
std::cout << *current << '\n';
}复习:
values 为什么能参与指针运算?
current + 1 为什么按元素移动?
values + 3 为什么是尾后位置?7. 阶段 5:结构化数据和多文件程序
阅读:
07-enums-structs-unions-aliases.md
08-preprocessor-headers-namespaces.md目标:
- 理解结构体字段和 padding。
- 区分枚举的语义与底层表示。
- 理解
#include是怎样影响翻译单元的。 - 能把声明、定义和命名空间组织起来。
实验:
#include <cstddef>
#include <iostream>
struct Record {
char tag;
int value;
};
int main() {
std::cout << sizeof(Record) << '\n';
std::cout << offsetof(Record, value) << '\n';
}解释:
为什么结构体大小可能大于字段大小之和?
padding 对齐解决了什么问题?8. 阶段 6:输入、错误和调试
阅读:
09-basic-io-errors-debugging.md目标:
- 理解输入流的状态机。
- 区分 EOF、格式错误和设备错误。
- 区分编译、链接和运行时诊断。
- 学会使用最小复现、调试器和 Sanitizer。
#include <iostream>
int main() {
int value{};
if (!(std::cin >> value)) {
std::cerr << "input is not an integer\n";
return 1;
}
std::cout << value << '\n';
}分别输入:
42
hello
42abc解释每种输入对流状态的影响。
9. 每个阶段的实验记录格式
问题:
我想知道什么?
假设:
我认为程序会怎样?
最小代码:
哪些内容可以删掉?
命令:
使用什么编译器和参数?
观察:
实际输出是什么?
解释:
哪条语言规则或实现行为解释了它?
边界:
换平台、输入或优化级别后还成立吗?10. 阶段验收方式
每个阶段都要完成四种输出:
一句话解释
|
v
一张 ASCII 图
|
v
一个最小程序
|
v
一个故意失败的实验例如学指针,不能只说“指针保存地址”。
还应该能够画出:
pointer object
|
+-- stores a location
|
v
target object
|
+-- has its own type, value, and lifetime11. 模块完成验收
完成这个模块后,你应该能够:
- 解释源码到可执行程序的主要阶段。
- 区分编译错误、链接错误和运行时错误。
- 解释类型的用途、表示、范围和限制。
- 区分名字、对象、存储和生命周期。
- 解释初始化与赋值的区别。
- 画出数组、地址和指针关系。
- 解释函数调用和局部变量生命周期。
- 说明引用、指针和借用视图的差异。
- 解释结构体 padding。
- 组织一个简单的多文件程序。
- 处理基本输入错误。
- 用实验验证而不是猜测。
12. 进入下一个模块之前
下一个模块是对象模型和内存管理。
不要把智能指针当作本模块的替代品。
智能指针表达所有权。
要理解所有权,必须先能解释:
对象什么时候开始存在?
谁能访问它?
谁负责让资源失效?
地址什么时候不再有效?如果这些问题还模糊,就回到阶段 1、阶段 3 和阶段 4 重新做实验。
13. 推荐的每周节奏
如果每天只有一小时,可以采用:
10 分钟:回顾上一阶段的图
20 分钟:阅读新概念
20 分钟:运行最小实验
10 分钟:写下自己的解释不要把一小时全部用来阅读。
底层知识需要观察错误和修改条件,才能变成自己的模型。
14. 编译器输出也是学习材料
学习基础阶段不要只看程序的最终输出。
还要看:
编译器警告
链接器错误
调试器中的局部变量
调用栈
Sanitizer 报告
目标文件符号同一段代码可以从多个观察窗口理解:
源码窗口 -> 你写了什么
编译器窗口 -> 类型和语法是否接受
链接器窗口 -> 跨文件实体是否能组合
调试器窗口 -> 运行时对象和控制流怎样变化15. 遇到不会的问题先分层
程序崩溃时,不要立刻归因于“指针问题”。
先问:
程序是否成功编译?
程序是否成功链接?
程序是否成功启动?
崩溃发生在哪个函数?
对象生命周期是否结束?
输入和索引是否经过验证?这种分层比背更多 API 更重要。
16. 贯穿练习的版本记录
建议保留:
text-counter/
+-- stage-00-build/
+-- stage-01-values/
+-- stage-02-expressions/
+-- stage-03-functions/
+-- stage-04-pointers/
+-- stage-05-headers/
+-- stage-06-errors/每个阶段保存:
源代码
编译命令
一次成功输出
一次失败输出
自己的解释这样学习内存管理时,可以回到早期代码观察生命周期问题是怎样出现的。
17. 不要用“运行成功”代替正确性
下面这些现象都不能单独证明程序正确:
程序打印了预期数字
一次运行没有崩溃
优化打开后仍然工作
换一组输入仍然工作更可靠的验收要结合:
语言规则
+-- 类型和生命周期是否成立
工具诊断
+-- 警告、Sanitizer、调试器
边界实验
+-- 空输入、极值、错误输入、重复调用18. 进入内存管理之前
先画出:
名字
|
v
对象
|
v
存储
|
v
生命周期
|
v
访问路径然后再问:
如果存储来自 new,谁负责释放?
如果多个对象引用同一资源,谁拥有它?
如果函数保存了借用地址,地址何时失效?这四个问题就是 RAII 和所有权设计的入口。