为什么学习 C/C++
我是用 Python 入门编程的,初始的方向是 Web 后端,大学学习通信工程时也或多或少碰了一些 C 语言,与之对比 Python 简直就像魔法—它的程序量那么少,做的事情却那么多。等到学习了 Coursera 上面的 Programming languages 之后,我才知道 C/C++ 具有很重的历史包袱,设计上有巨大缺陷,便决定以后不碰 C 系语言。
但是我最近却转变了想法,为什么?
因为随着深入系统探索 CS 大海,我发现 C/C++ 是绕不过去的一艘船。现如今从零开始一个高性能计算的新项目,我一定会考虑 Golang、Rust、Elixir 等等,但是我发现 CS 里面有两种宝贵的资源是需要 C 系语言来撬动的:
- 各种高质量的基础公开课。比如斯坦福的计算机网络和编译器设计,MIT 的操作系统,CMU 的数据库和图形学等等。虽然我相信未来这些课程都会用现代语言去重写(比如我之前学过的 MIT 分布式系统设计 就用 Go 替代了 C++,为我们这些学生节省了宝贵的时间和头发),但是最近几年如果想要利用这些公开课打好基础,C 系语言仍然是绕不过去的一道坎。
- 各种高性能计算领域的开源项目。比如 Python 解释器、Redis、PostgreSQL、PyTorch、VSCode、Linux、Firefox 等等,都是使用 C/C++ 编写的。如果想要从中汲取灵感、更好更高效地使用这些软件,终究是要阅读里面的关键源码的。
所以我迅速(并没有)调整了心态,
原则
学习一门新语言,新工具,有以下一些原则
- 优先选择名校的公开课。
- 官方文档和书籍更像是字典,没有人靠字典学会外语;而这个问题在 C/C++ 社区更加明显,我尝试找了一些官方的文档资料,都是大而全且晦涩的,似乎定位是给会 C/C++ 的人当做参考资料。
- 做一门好课需要的成本是很高的,如果你自己尝试写过一篇文章试图讲透一个复杂的编程问题,而且接受读者的反馈,就会发现有多么不容易。你需要把隐性的知识用显性的方式表达出来,把复杂的知识分解成一个个简单的步骤,这是非常耗费精力的。这也是为什么我根本不会随便学一门课,只有看过老师的履历和学生的评价我才会考虑,因为做课的门槛太高了。
- learning by doing
- 编程是一门技能,C/C++ 也不例外,尤其是 debug 的过程,需要一点点打磨和积累。C 系语言是非常容易出运行时 bug 的,而且会随着程序一直往下走,到了最后表现出来的特征千奇百怪。只有从小的 lab 开始一点点积累自己的 debug 数据库,之后面对大型程序才能下得了手。我在用 C++ 实现 TCP 协议时,最后实现 TCP 连接部分就遇到了一个非常奇怪的 bug,整个程序的数据流非常长。于是我索性放弃了打日志,用写 C++ 积累的经验判断出来是 size_t underflow 了(这种类型从零减一之后会变成它的最大值,而且不会报运行时错误),于是我就在目标程序寻找哪里使用了 size_t 类型,而且做了自减,很快定位到了 bug。
- 结合上面,公开课提供了大量的高质量的 lab ,我觉得这些才是一门编程课的精华。考虑设计精良 lab 的精力,这让我更加不会随意选择一门来路不明的课。
浅谈 C/C++
工程知识像是拼图,糟糕的老师喜欢竹筒倒豆子,直接给学生灌一块块拼图,而我更喜欢先建立一个 big picture。先知道整幅拼图是什么样子。所以先从大的方面谈一谈 C 系语言。
C 语言:
C 具有两个大特征:极简与危险。
极简是指 C 语言的语法特性、编译器的静态检查、编译器的运行时检查都非常少,尤其是运行时检查给我一种在高速上骑自行车的感觉(它甚至连垃圾回收也没有)。比如写过 Python、GO、Java、JavaScript 这些现代的高层次语言的读者,初次写 C 会发现常用的各种语法糖(iterator)还有标准库、常用数据结构在 C 里面都是缺乏的,甚至连完整的字符串和动态数组都没有。C 的数组和字符串都是裸指针,指针之上几乎没有任何封装。
前面讲了静态检查和运行时检查很少,也就引出了 C 语言的另一个特性:危险。设计、静态检查、运行时检查、测试是挡住 bug 的四道防线,越往后就越危险,因为时间成本越高、自动化越难。而 C 的静态检查很弱,比如如果你用整形和无符号整形去计算,C 会进行隐式转换把无符号整形转成整形,导致意义完全改变;再比如 C 可以把不同类型的指针来回转换,指针本身也可以和整型互相转换,这些编译器都是允许的。C 的运行时检查也很弱,比如它允许指针运算,一个指针指向整型,完全可以把地址加 100 ,指向一个远远超出整形变量的地址并且进行修改,这导致指针运算出现的 bug 极其难以定位,最危险的是它的数组和字符串的 index 操作默认都是不安全的,不做越界检查。
为什么 C 会设计地这么奇怪,最后还能流行?这一切都要追溯到 C 的来源。
大约 1970 年左右,贝尔实验室的 Dennis MacAlistair Ritchie 和 Kenneth Lane Thompson 开始计划做一个操作系统,因为他们两喜欢玩自己写的游戏,希望有一个操作系统运行自己的游戏[1]。初始他们使用汇编来写,但是汇编的工作量实在是太大了,于是两人决定设计一个专门面向操作系统的语言,这就有了 C。其实当我学完汇编之后,我发现 C 几乎就是在汇编上面简单糊了一层语法,甚至 CSAPP 有很多习题就是给读者一段 C 程序,让读者在大脑中直接把它翻译成汇编。
也是因此,当你真正用 C 写了一个操作系统,才能真正理解为什么 C 要这么设计。各种危险的操作,在写 OS 的时候却变得如此自然。另一方面两人在设计编程语言上很外行,并没有 Rust 团队设计语言特性时的精心考量(他们其实没有团队,只有两个大龄顽童),甚至这个项目本身就是用来娱乐的个人项目。只是后来随着 Unix 流行开来,C 也随之流行,而很多基础软件的特性和 OS 很像,都需要高性能、面对底层,所以 C 也就霸占了各种基础设施。直到 2010 年后才有所好转。
C++:
C++ 兼容了 C,于是继承了它的危险性,同时在另一个特征上走向了反面:变得巨大。
C++ 初始的计划就是 C 加上面向对象编程,到了两千年后,C++ 又逐步开始吸取函数式编程的成果(比如引入了模板和 Option),同时针对内存管理研究新的方案(RAII),具备了这些特性的 C++ 又称为现代 C++。所以到了最后 C++ 简直变成了一个庞然大物。我自己写 C++ 的体会是,和我之前写过的各种语言相比,C++ 是写一段程序需要考量的东西最多的,而且程序里面的元素也非常多(甚至同样的一行程序 token 数量都更多,随机点开一个 STL 函数看它的签名就能感受到):需要考虑类型、内存使用、可变性、作用域,可以说是一个庞然大物了。
除了语法本身之外,C 系语言还需要学习 debuger(GDB)、build 工具(Make 和 CMake,类似于 JavaScript 的 NPM 或者 Rust 的 Cargo,但是因为历史原因功能非常弱小)、内存检查工具(Valgrind)。
课程推荐
C 语言:
1.著名的 CSAPP。这门课其实专注于讲计算机系统,从 CPU 到操作系统的种种特性,C 语言作为其中的工具,讲解篇幅不是很多。但是好处就是将 C 和 Computer System 绑定到一起讲,C 本身知识很少,但是却难以理解(尤其是使用惯了高层级语言之后)。这种讲法会使得 C 更容易理解。我就是在实现 malloc(模仿 C 的内存分配函数) 的时候,才理解了 C 指针操作的意义。脱离了对于计算机架构的了解,比如不了解堆和栈关联与区别,是很难真正理解 C 的。
2.Stanford CS107。与 CSAPP 相比,这门课更像是纯粹讲解 C 语言,虽然名字叫计算机系统,但是绝大部分章节都是在讲 C 语言的,如果把例程过一遍一定能大大加深对于 C 语言的理解。这门课的 slides 很适合作为 CSAPP 的 C 语言补充阅读资料。CSAPP 虽然叫 introduction,但是在 CMU 这门课还有一门前置课,专门讲 C 语言。
3.UCB CS61C。上一门课的美中不足是 lab 没有开放给校外人员,但是这门课前几个 C 语言的 lab 都是开放的,我完成的那一年的课程 lab 是实现链表,很适合作为上门课的补充 lab。
C++:
C 语言我尝试过三门课,其中两门都缺斤短两。而 C++ 就好很多,在 2021 年的版本中老师把 lab 完整放了出来。
1.基础的 Stanford CS106b。这门课很适合作为入门的 C++ 课程,其中还穿插了大量的数据结构。写完这门课大概就能理解为什么说初始的 C++ 是 “C 加上面向对象”。我抽空写了前几个 lab,觉得对于当时的我学不到太多东西了,就放掉了后面的部分。
2.现代 C++ Stanford CS106L。这门课集中讲解了 C++ 的现代特性,我在这门课前写 C++ 17 的 lab 总有种磕磕绊绊的感觉,上完这门课之后总算系统过了一遍 C++ 的现代特性,比如 iterator、move、RAII、模板、可变性、智能指针。其中竟然还颇受启发,尤其是 RAII 的部分。
C++ 委员会的成员在 Stack Overflow 上推荐想要进阶 C++ 的人去学习 Rust,我反而觉得学完现代 C++ 之后入门 Rust 会顺畅很多。现代 C++ 之于 Rust,就像 OS 之于 C,Rust 本身就是一个去掉历史包袱,更加先进和激进的 C++。学完 C++ 更能理解 Rust 想要解决什么问题。
这门课让我体会到了系统学习的好处,之前写 lab 不知道 move 是什么意思,去谷歌搜了之后还是云里雾里。上这门课才发现 move 本身具有上下文,只有结合了 special member function、copy 才能理解,看了老师的例子又写完了 lab 算是得到了比较透彻的理解。这不是写编译器、数据库卡住时,去搜索引擎上能够快速理解的知识。
再比如 C++ 因为没有闭包,所以 Lambda 函数比较复杂,需要在前面用大括号把想要捕获的变量一个个放进去。因为我的 Python 和 Go 的背景,在写 Lambda 函数时下意识地不会这么做,而编译器报的信息又让我毫无头绪,可能临场就放弃使用 Lambda 函数了。
这门课本身很短,只有两个 lab。第一个是实现一个从 Wikipedia A 页面的各种 link 中跳转到 B 页面的算法,算法本身模拟了人的行为,非常有趣,不过因为 Wikipedia 被墙掉了,所以我没有做集成测试;第二个 lab 是模仿 STL 的 map 实现哈希表,阅读老师的框架,并且补全哈希表的功能让我受益匪浅。
此外这个在线的 C/C++ 编译器非常方便,就像 Python 的 REPL 一样可以用来做实验
如果出于和我相似的目的学习 C/C++ ,有这些资料入门便足矣了。大概需要全职投入一到两个月(CSAPP 本身很耗费时间,需要另计)。
注: [1] 后面贝尔实验室的上级叫停了他们的项目,他们两坚持己见,继续上班偷偷摸鱼写这个项目,才有了后来的 Unix。这个插曲令人深思。