Non-Profit, International

Spirit unsterblich.

学习 C++:实践者的方法

字数统计:14856 blog

本文转载自 刘未鹏(pongba) 14 年前所写文章 学习 C++:实践者的方法(Beta1)。作者借用自己的经验来给广大 C++ 学习者指明了一条正确的学习方向,本人也和作者同心合意,有过相似的困惑和经历,因此将文章重新排版转载于此,并加入了一些批注和微小的修改。

pongba 2007-12-11 12:11:00

前言

我的 blog 以前很长一段时间关注的都是 C++ 中的技术 & 细节,乃至于读者和应者都寥寥。然而 5 月份的时候写的一篇 “你应当如何学习 C++” 1 ,阅读量却达到了 3 万多,在 blog 上所有文章中却是最高的(且远远超过了第二位);评论数目也有一百多。为什么独独这篇能够激起这么多的回应,想必是国内的 C++ 社群被 C++ 压抑太久,或者,严格来说,是被 C++ 的教育方式压抑太久。实际不管是在各大国内论坛上,还是在 comp.lang.c++.moderated 这样的国际论坛上,甚至于在豆瓣上的小组内,有心者都会发现,对 C++ 语言的细节的关注一直都没有停止过;同样,对 C++ 语言的细节的抱怨也从来都没有停止过。一个例子就是 comp.lang.c++.moderated 上的一个技术牛人 James Kanze 说的,他说接触 C++ 十年了,到现在还需要不时去翻 C++ 标准。这就难怪 Eric Raymond 老大在《The Art of Unix Programming》中说 “C++ 是反紧凑” 的了。C++ 中的细节太多,就算都看过了,也不可能都记住。更关键的是,就算都记住了,也不能让你成为一个真正的好程序员。

绝大多数人都把细节太多(或者用贬义词来说就是 “阴暗角落太多”)归结为 C++ 的本质问题,认为一切邪恶由此而生。也正因此,大约 9 月份的时候,Linus 在邮件列表上说 “C++ 是一门有思想包袱的语言;仅仅是为了让程序员远离 C++,我也要用 C”。这句短短的话在国内引起了很大的反应,最初是刘江转了 Linus 的话 2 ,然后云风和孟岩都发表了自己的看法;我也写了一篇 “Why C++”(后来发给 Bjarne,Bjarne 对这篇文章做了一个友情评注)。

然而,这一通浑水搅过之后,我相信引起的变化未必很大。大多数原先的反对者能从中找出反对的理由,于是更加反对;大多数原先的赞同者也能从中找到赞同的理由,于是更加赞同;而剩下来的原先没有明确意见的,看双方各有各的道理,可能还是没有头绪。

摆脱自我服务偏见 —— 理性思考的前提

《决策与判断》上提到过一个有趣的真实故事:1980 年的某一天,美国空战司令部的计算机突然发出警报 —— 苏联的一枚核弹正在向美国本土飞来。司令部立即调兵遣将,迅速为一场核战做好了准备,然而 3 分钟之后,工程人员发现是计算机的一个小零部件故障造成的。然而,这场虚惊之后,大众的反应才是真正有意思的:原先支持核武装的,认为现在感觉更加安全了(因为 “事实证明这类的故障是完全可克服的”);而原先反对核武装的则认为更不安全了(因为 “这类错误信号可能导致苏联过度反应,引发真正的核战”)。类似的情况也发生在三里岛核泄露事件之后,同样的,反对者认为(“这表明管理部门没有办法安全管理核能”),支持者认为(“这正表明这样的危险没有想像得那么严重,是可克服的”)。社会心理学把诸如此类的现象总结为 “自我服务偏见”。不幸的是,“真理越辩越明” 其实只适用于理性思考者。

为什么啰嗦这么一大通呢?就是因为,一直以来泛滥于程序员社群的 “语言之争”,背后真正的原因其实并不在于语言实质上的优劣,而在于观察者的眼睛。在观察者的眼睛里面,语言并非一门工具,而是自己花了 N 多时间(其中尤数 C++ 为最)来 “修炼” 的技能,对于这样的技能,被否定无疑等同于自己被否定。所以,从心理学上讲,语言并不是工具(尽管一直有这么一种呼吁),而是信仰。这样的信仰在越是花得时间久的语言上越是激烈。有趣的是,几乎所有的 “热闹” 的社群都有这样的现象,Java、Python、Ruby… 莫不如是;因为就算语言本身不复杂,程序员仍然还是要投入大量的精力去学习各种各样的框架类库(想想 Java 的那些框架?)。因此这些语言社区的信仰未必不比 C++ 社群的强烈。

然而,一旦弄清我们为什么会把语言当成信仰,就非常有助于摆脱在看待语言时的 “自我服务偏见”,从客观的角度去看待问题。——“当你看到的是支持某个意见的证据时,试着去想一想有哪些证据是不支持它的”。

那么为什么要摆脱自我服务偏见?说小了,是为了成为一个更优秀的程序员(谁也不希望因为偏见而去使用一门低效的语言乃至不妥当的语言)。说大了是节省生命(因为偏见可能导致越陷越深,浪费时间)。

所以,如果你能够理性的思考我们将要讨论的问题,避免自我服务偏见(就当你从来没有花时间在 C++ 上一样)。那么我们便可以开始讨论真正的问题了。

前言 2

现在,几乎每个学习 C++ 的都知道 C++ 的核心问题是其复杂性;甚至本身不在 C++ 社群的,也知道这是事实。群众的眼睛是雪亮的,何况这还是个太显而易见的事实。

但看了无数篇阐述 C++ 复杂性的文章,和争论 C++ 复杂性的吐沫星子(包括我前段时间写的两篇关于 C++ 的总结)。我始终都有一个感觉 —— 没分析透,就跟盲人摸象一样。正如 “Why C++” 的一位读者批评的,我在文章里面没有写明到底哪些是 C++ 的 “非本质复杂性”。当然,我自己凭感觉就能知道,而接触 C++ 一段时间的人大致也能知道,但新手乃至非新手则对我所谓的 “非本质复杂性” 根本没有一个具体的认识,这就使得那篇 “Why C++” 脱离了原本的意图 —— 面向所有 C++ 使用者和学习者。

同样的原因,在写了 “你应当如何学习 C++” 一文之后,当孟岩先生邀请我给《程序员》写一个系列的文章,介绍一下我在接触 C++ 的过程中的态度和认识转变时,我虽然非常高兴的答应了,但直到现在 3 个月过去了还是颗粒无收。为什么?因为我觉得真正本质的问题没有被清晰的触摸到;所以直到现在我都没有动笔,免得废话说了一大堆,除了能被当成小说读读之外,对真正考虑是否要学习乃至使用 C++ 的人未必有什么实际用处。

然而,这么个念头一直都放在潜意识里面。前一阵子和 Bjarne 通信,谈到了关于 C++ 复杂性的一些想法,在邮件里面总结了一下 C++ 的复杂性来源,感觉思路清晰了许多。而这篇文章要达到的目的,正是传达对 C++ 的复杂性的一个具体而明确的认识,有了这个认识作为支持,我们便可以推导出学习 C++ 的最佳(实践者)的方法。

为什么要学习(并使用)C++

显然,如果找不出要学习 C++ 的理由,那么谈什么 “正确的学习方法” 等于是废话。

首先重复一句 Bjarne 的话:“我们的系统已经是极度复杂的了,为了避开 C++ 的复杂性而干脆不用 C++(Linus 的做法),无异于因噎废食。” 在所有可用 C 和 C++ 的领域,C++ 都是比 C 更好的语言。当我说 “更好的” 时候,我说的是 C++ 拥有比 C 更安全的类型检查、更好的抽象机制、更优秀的库。当然,凡事都有例外,如果你做的项目

  • 1)不大
  • 2)编码中用不到什么抽象机制,甚至 ADT(抽象数据类型,例如 std::complex 这种不含多态和继承的)也用不到,RAII 也用不到,异常也用不到
  • 3)你连基础库(如,简化资源管理的智能指针、智能容器)都用不着

那么也许你用 C 的确没问题;所以如果你的情况如此,不用和我争论,因为我无法反驳你。我们这里说的领域大致是 Bjarne 在 “C++ 应用列表” 里面列出来的那些地方。

底线是:如果把 C++ 中的诸多不必要的复杂性去掉,留下那些本质的,重要的语言特性,简化语言模型,消除历史包袱。即便是 C++ 的反对者也许也很难找到理由说 “我还是不用 C++”。在我看来,一个真正从实践意义上理性反对使用 C++ 的人只有一个理由:C++ 的复杂性带来的混乱抵消乃至超过了 C++ 的抽象机制和库(在他的特定项目中)带来的好处。

值得注意的是,这里需要避免一个陷阱,就是一旦人们认定了 “C++ 不好”,那么这个理由就会 “长出自己的脚来”,即,就算我们拿掉 C++ 的复杂性,他们可能也会坚持还是不用 C++,并为之找一堆理由。我假定你不是这样的人。不过,也许最可能的是他会说:“问题是我们今天用的 C++ 并非如此(简洁),你的假设不成立。” 是的,我的假设不成立。但虽然我们无法消除复杂性,我们实际上是可以容易地避开复杂性,避短扬长的。这也是本文的要点,容我后面再详述。

当然,到现在你可能还是会说。我还是不用 C++ ,因为我可以用 D;或者如果你本来做的项目就不需要 C++,你则可能会说,我用 Python。首先,如果你的项目能用 Java/Python 乃至 Ruby 做,那么用 C++ 是自讨苦吃。因为能用那些语言代表你的项目在效率上本身要求就不高,那么用一门效率上讨不到太大好处,复杂性上却绰绰有余的语言,有什么价值呢?其次,如果你的项目效率是很重要的,你可能会说可以用 D。然而现实是 D 在工业界尤其是国内被运用得非常少,几乎没有。而 C++ 却有大量的既有代码,已经使用 C++ 去做他们的产品的公司,在很长一段时间之内几乎是不可能用别的语言重写代码的,正如 Joel 所说,决定重写一个非平凡的代码基 = 自杀。所以,我们至少要注意以下两个明显的事实:

事实 1:C++ 在工业界仍有稳定的核心市场

这个事实大概不需要多加阐述,很多大公司的核心技术还是要靠 C++ 来支撑的(见 Bjarne 主页上的 C++ 应用列表)。所谓事实,就是未必是大家最愿意承认的情况,但又不得不承认。C++ 积累了庞大的代码基,这个代码基不是一朝一夕能够推翻的。D 从语言角度来说的确优于 C++,但最关键的就是还没有深入工业界(也许根本原因是没有钱支持,但这不是我们讨论的重点)。而 C 呢,根据 Bjarne 本人的说法,他的观察是主流工业界的趋势一直是 “从 C 到 C++” 的,而不是反过来,至少在欧美是如此。在国内我们则可以通过 CSDN 上的招聘情况得到一个大致类似的信息。

事实 2:C++ 程序员往往能享受到有竞争力的薪酬

是的,这不是一篇不食人间烟火的技术文章。这个事实基于的逻辑很简单:物以稀为贵。Andrei Alexandrescu 这次来中国 SD2.0 大会的时候,在接受采访时也说过:“最赚钱的软件(如 MS Office)是 C++ 写的”。孟岩也在 blog 上提到这么个事实,我想他作为 CSDN 的技术总编,业界观察肯定比我清晰深刻。所以我这里就不多废话了。

当然,以上逻辑并不就意味着在怂恿你去学 C++,一切还要看你的兴趣。所以如果你志不在 C++ 身处的那些应用领域,那这篇文章并非为你而写。

“C++ 的复杂性是根本原因”—— 一个有漏洞的推理

一旦我们认识了 C++ 在一些领域是有需求的(值得学习和掌握的)这个问题之后,就可以接下来讨论 “怎样正确学习和掌握 C++” 这个核心问题了。

其实,对于这个问题,Bjarne 已经宣传了十年。早在 99 年的时候 Bjarne 就写了 “Learning C++ as A New Language”,并在好几篇技术访谈里面提到如何正确对待和使用 C++ 中支持的多种抽象机制的问题。Andrew Koenig 也写了一本现代 C++ 教程《Accelerated C++》(这本书后面还会提到)。然而这么多年来,C++ 社群的状况改善了吗?就我所知,就算有改善,也是很小的。学习者还是盲目钻语言细节,只见树木不见森林;网上还是弥漫着各种各样的 “技术” 文章和不靠谱的 “学习 C++ 的 XX 个建议”;一些业界的有身份的专家还是在一本接一本的出语言孔乙己的书(写一些普通程序员八辈子用不着的技巧和碰不着的角落);而业界真正使用 C++ 的公司在面试的时候还总是问一些边边角角的细节问题,而不是考察编程的基本素养(不,掌握所有的语言细节也不能让你成为一个合格的程序员)。这个面试理念是错误的,估计其背后的推理应该是 “如果这个家伙不知道这个细节,那么估计他对语言也熟悉不到哪儿去;而如果他知道,那么虽然他可能并不是好的程序员,但我们还是能够就后一个问题进一步测试的”,这个理念的问题在于,对语言熟悉到一定程度(什么程度后面会具体建议)就已经可以很好的编程了(剩下的只需查查文档);而很多公司在测试 “对语言熟悉程度” 的时候走得明显太远了(比如,问临时对象生命期和析构顺序当然是无可厚非的,但问如何避免一个类被拷贝或者如何避免其构建在堆上?);当然,有些语言知识是必须要提前掌握的,具体有哪些后面会提到,面试的时候并非不能问语言细节,关键是 “问哪些”。

事实 3:C++ 的整个生态圈这么些年来在学习 C++ 的哲学上,实在没有多少改善

为什么?是因为 Bjarne 介绍的学习方法在技术上没有说到点子上?是 Andrew Koenig 的书写得不够好?说了谁也不会相信。因为实际上,这里的原因根本不是技术上的,而是非技术的。

众所周知的一个事实是,从最表层讲,C++ 的最严重问题是在语言学习阶段占用了学习者的太多时间。翻一翻你的 C++ 书架或者电子书目录,绝大多数的 C++“经典” 都是在讲语言。在我们通常的意义上,要 “入门” C++,在语言上需要耗的时间一般要两三年。而要 “精通” C++,则搞不好需要耗上十年八年的。(这跟 Peter Norvig 说的 “十年学习编程” 其实不是一回事,人家那是说一般意义上的编程技能,不是叫你当语言律师。)

那为什么我说 “C++ 的复杂性是根本原因” 是个有漏洞的推理呢?因为,要让人们在使用一门语言去做事情之前耗上大量时间去学习语言中各种复杂性,除了语言本身的复杂性的事实之外,还有一个重要的事实,那就是学习者的态度和(更重要的)方法。而目前大多数 C++ 学习者的态度和方法是什么呢?—— 在真正用 C++ 之前看上一摞语言书(日常编程八辈子都未必用得到)。而为什么会存在这样的学习态度呢?这就是真正需要解释的问题。实际上,有两方面的原因:

事实 4:市面上的绝大多数 C++ 书籍(包括很多被人们广泛称为 “必读经典” 的)实际上都是反面教材

也就是说,随便你拿起哪本 C++ 书籍(包括很多被人们广泛称为 “必读经典” 的),那么有很大的可能这本书中的内容不是你应该学的,而是你不应该学的。我之所以这么说有两个原因,因为一,我曾经是受害者。二,也是更实质性的原因,这些所谓的必读经典,充斥的是介绍 C++ 中的陷阱和对于 C++ 的缺陷的各种 workarounds(好听一点叫 Idioms(惯用法)或 techniques(技术));又因为 C++ 中的这类陷阱和缺陷实在数不胜数,所以就拉出了一个 “长尾”;这类书籍在所有语言中都存在(“C 缺陷和陷阱”、“Effective Java”、“Effective C#” 等等),然而在 C++ 里面这个尾巴特别长,导致这类书数不胜数。三,这些书中列出来的缺陷和陷阱根本不区分常见程度,对于一个用本程序员来说,应该希望看到 “从最常见的问题到最不常见的问题” 这样的顺序来罗列内容,然而这些书里面要么全部混在一起,要么按照 “资源管理、类设计、泛型” 这样的技术分类来介绍内容,这根本毫无帮助(如果我看到一个章节的内容,我当然知道它讲的是类设计还是资源管理,还用废话么?),使得一个学习者无法辨别并将最重要的时间花在最常见的问题之上。

最最关键的是:这些书当中介绍的内容与成为一个好程序员根本毫无关系,它们顶多只能告诉你 —— 嗨,小心跌入这个陷阱。或者告诉你 —— 嗨,你知道当你(八辈子都不一定遇到)遇到这个需求的时候,可以通过这个技巧来得以解决吗?结果读了一本又一本之后,你脑袋里除了塞满了 “禁止”、“警戒”、“灯泡” 符号之外,真正的编程素质却是一无长进。又或者有这样一类书,热衷于解释语言实现背后的机制,然而语言特性本质上是干嘛用的?是用来在实际编码中进行抽象的(说得好听一点就是 “设计”),不是用来告诉你这个特性是怎么支持的。比如我就见过以下的情景:面试官问:“你知道虚函数吗?” 得到的回答是一堆关于虚函数表机制的解释。面试官又问:“那虚函数的好处是什么呢?” 到底为什么要虚函数呢?得到的回答是:“恩… 啊… 就是… 多态吧”(这时已经觉得回答不够深刻了)。再问:“那多态是干嘛的呢?” 哑口无言。

事实 5:就算记住一门语言的所有细节也不能让你成为一个合格的程序员

萧叶轩注:

此处标题下作者并没有写内容,不过这几天遇到一个例子我觉得比较适合在这里写出来丰富一下内容,好的代码规范和设计能避免编写 C++ 代码遇到 “细节错误” 而去误以为这个细节需要被记住:

对于大部分静态面向对象程序设计语言(如 C++,C#,Java),都存在构造函数这种设计,因为指针或者引用类型的对象必须绑定到数据上才能保证安全(C++ 的内置类型也需要初始化)。这类语言大部分在设计之初让对象的成员仅允许在构造函数内初始化,但是在语言发展的过程中逐步开放了这个限制,可以在类的定义中对成员进行初始化(C++ 甚至还有属于构造函数但是有别于函数体内的初始化列表这种语法)。

但是许多初学者由于并不了解代码编写设计哲学,混用不同的初始化方式,导致成员的初始化存在逻辑上的冲突导致解决冲突的方式变为 “细节”,而这种错误是完全可以避免的: 我在帮助初学者解决这个问题的时候并不知道这个细节:“成员定义包含初始化先于构造函数还是晚于构造函数”,但是我知道 “对于复杂对象的构造要尽量写为构造函数”,甚至这句 “真理” 也可以简化为更高级别的源头:编写代码要保持封装优良和逻辑清晰。

显然错误的使用方式两者都不满足:多种初始化方式以一种神秘的 “细节” 组合到一起破坏了构造函数的封装性;成员定义包含初始化使得代码逻辑不清晰(编写构造函数的时候需要知道成员到底有没有被初始化)。

由这个问题还牵扯到了另外的编写习惯:二段式构造或者在构造函数内调用其他函数进行构造。 显而易见的,这两种编写习惯都不是最好的,因为都没有做到封装优良和逻辑清晰。

对于其他高级语言来讲,类似的问题同样存在,比如广而周知的 “将警告视为错误”。

事实 6:了解语言实现固然有其实践意义(在极端场合的 hack 手法,以及出现底层 bug 的时候迅速定位问题),然而如果为了了解语言机制而去了解语言机制便脱离了学习语言的本意了

在 C++ 里面这样的情况很多见:知道了语言实现的底层机制,却不知道语言特性本身的意义在什么地方。本末倒置。为什么?书害的。二,这类书当中介绍的所有情景加起来其实只属于那 20%(二八法则),甚至 20% 都不到的场景(究竟是哪些书,后面会介绍,我不便直接列出书名,打击面太大,但我会把我认为 essential 的书列出来)。这就是为什么我说 “八辈子都用不着” 的原因。

事实 7:80% 的 C++ 书籍(包括一些 “经典”)只涉及到 20%(或者更少)的场景

你可能会说,那难道这些书就根本不值得看了吗?

我的回答是,对。根本不值得看。—— 但是值得放在旁边作为必要的时候的参考(记住从索引或目录翻起,只看严格必要的部分),如果你是个严肃的程序员的话。因为不管承认与否,墨菲法则的强大力量是不可忽视的 —— 如果有一个可能遇到的陷阱,那么总会遇到的。而同样,C++ 的那些奇技淫巧也并非空穴来风,总有时候会需要用到的。但是你不需要预先把 C++ 的所有细节和技巧存在脑子里才能够去编程,即:

建议 1:有辨别力地阅读(包括那些被广泛称为 “经典” 的)C++ 书籍

如果书中介绍的某块内容你认为在日常编程中基本不会用到(属于 20% 场景),那么也许最好的做法是非常大概的浏览一下,留个印象,而不是顺着这条线深究下去。关于在初学的时候应该读哪些书,后面还会提到。

萧叶轩注:最明显的莫过于一个广泛使用的复杂的库中各种对象的方法,由于许多方法是为了特殊需求优化的,在学习过程中显然只需要将方法分类,记住类别就可以了,需要的时候查阅文档即可。

实际上,除了语言无关的编程修养之外(需要阅读什么书后面会提到),对于 C++ 这门特定的语言,要开始用它来编程,你只需知道一些基础但重要的语言知识(需要阅读哪些书后面会提到)以及 “C++ 里面有许多缺陷和陷阱” 的事实,并且 ——

建议 2:养成随时查阅资料和文档的习惯

“查文档” 几乎可以说是作为一个程序员最重要的能力(是的,能力)了;它是如此重要,以至于在英文里面有一个专门的缩写 ——RTFM。为什么这个能力如此重要,原因很简单:编程领域的知识太鸡零狗碎了。不仅知识量巨大,而且知识的细节性简直是任何学科都无与伦比的(随便找一个框架类库看看它的 API 文档吧)。所以,把如此巨量的信息预先放在脑子里不仅不实际,而且简直是自作孽。你需要的是 “元能力”,也就是查文档的能力 —— 从你手头遇到的问题开始,进行正确合理的分析,预测问题的解决方案可能在什么地方,找到关于后者的资料,阅读理解,运用。

同样,在 C++ 中也是如此,如果你从学习 C++ 一开始就抱着这种态度的话,那么即便等到面试的时候被问到某个语言细节,你也可以胸有成竹的说你虽然并不知道这个细节,但在实际编码中遇到相应问题的时候肯定会找到合适的参考资料并很快解决问题(解决问题,才是最终目的)。当然,更大的可能性是,你在平常编码中已经接触过了最常见的那 80% 的陷阱和技巧了,由于你用的是实践指导性的学习方式,所以你遇到的需要去学习的陷阱和技巧几乎肯定都是常见场景下的,比没头苍蝇似的逮住一本 C++“经典” 就 “细细研读” 的办法要高效 N 倍,因为在没有实践经验的情况下,你很可能会认为其中的每个技巧,每个陷阱,都是同样概率发作的。

为什么市面上的 C++ 书热衷于那些细节和技巧呢?

你用一个天生用来开啤酒瓶的工具开了啤酒瓶,不但啥成就感也没有,而且谁也不会觉得你牛 13。然而,如果你发明了一种用两根筷子也能打开啤酒瓶的办法,或者你干脆生就一口好牙可以把瓶盖啃开,那也许就大不一样了。人家就会觉得你很好很强大。

事实 8:每个人都喜欢戴着脚镣跳舞

也就是说,如果你用一个天生为某个目的的工具来做他该做的事情,没有人会喝彩,你也不会觉得了不起。但如果你用两个本身不是为某个目的的工具组合出新功能的话,你就是 “创新” 者(尽管也许本来就有某个现成的工具可用)。

而 C++ 则是这些 “创新” 的土壤,是的,我说的就是无穷无尽的 workarounds 和惯用法。但问题是,这些 “创新” 其实根本不是创新,你必须认识到的是,他们都只不过是在没有 first-class 解决方案的前提下不得已折腾出来的替补方案。是的,它们某种程度上的确可以叫创新,甚至研究可行的解决方案本身也是一件非常有意思的事情,但 ——

事实 9:我知道它们很有趣,但实际上它们只是补丁方案

是的,不要因为这些 “创新” 方案有趣就忍不住一头钻进去。你之所以觉得有趣是因为当你一定程度上熟悉了 C++ 之后,C++ 的所有一切,包括缺陷,对你来说就成了一个 “既定事实”,一个背景,一个习以为常的东西(人是有很强的适应性的)。因此,当你发现在这个习以为常的环境下居然出现了新的可能性时,你当然是会欢呼雀跃的(比如我当年读《Modern C++ Design》的时候就有一次从早读到晚,午饭都没吃),然而实际上呢?其它语言中也许早就有 first-class 的支持了,其它语言也许根本不需要这个惯用法,因为它们就没有这些缺陷。此外,从实践的角度来说,更重要的是,这些 “解决方案” 也许你平时编程根本就用不到。

不,我当然不是说这些补丁方案不重要。正如前面所说,C++ 中繁杂的技巧并非空穴来风,总有实际问题在背后驱动的。但问题是,对于我们日常编程来说,这些 “实际问题” 简直是八杆子打不着的。犯不着先费上 80% 的劲儿把 20% 时候才用到的东西揣在脑子里,用的时候查文档或书就行了。

看到这里,塑造 C++ 中特定的心态哲学的另一个原因想必你也已经知道了。实际上,这个原因才是真正根本的。前面说的一个原因是 C++ 书籍市场(教育)造就的,然而为什么人们喜欢写这些书呢?进一步说,为什么人们喜欢读这些书呢?(我承认,我也曾经读得津津有味。)答案很简单:心理。每个人都喜欢戴着脚镣跳舞(事实 8)。认识到这一点不是为了提倡它,而是只有当我们认识到自己为什么会津津有味地去钻研一堆补丁解决方案的时候,我们才真正能够摆脱它们的吸引。

总而言之,C++ 的复杂性只是一个必要条件,并非问题的根本症结。根本症结在于人的心理,每个人都喜欢戴着脚镣跳舞,并且以为是 “创新”。意识到这一点之后可以帮我们避免被各种各样名目繁多的语言细节和技巧占去不必要的时间。

然而,C++ 的复杂性始终是一个不可回避的现实。C++ 中有大量的陷阱和缺陷,后者导致了数目惊人的惯用法和 workarounds。不加选择的全盘预先学习,是非常糟糕的做法,不仅低效,而且根本没有必要,实在是浪费生命。爱因斯坦曾经说过,“我只想知道‘他’(宇宙)的设计理念,其它的都是细节”。然而,正如另一些读者指出的,如果对 C++ 中的这些细节事先一点都没有概念的话,那么实际编码中一旦遇到恐怕就变成没头苍蝇了,也许到哪里去 RTFM 都不知道。这也是为什么那么多 C++ 面试都会不厌其烦地问一些有代表性的语言细节的原因。

萧叶轩注:需要学习的 “细节” 往往都是 C++ 的基础 “组件”,我将 C++ 的 STL 中的模板分为元件和组件,元件 “不可再分”,组件由元件构成。C++ 标准提供了许多元件和组件,但是 C++ STL 的具体实现由于局限性需要额外提供一些 “元件”,这些元件自然是不必学习的。

把细节全盘装在脑子里固然不好,但对细节一无所知同样也不是个办法。那么对于 C++ 程序员来说,在学习中究竟应该以怎样的态度和学习方法来对付 C++ 的复杂性呢?其实答案也非常简单,首先有一些很重要 & 必须的语言细节 & 特性是需要掌握的,然后我们只需知道在 C++ 中大抵有哪些地方有复杂性(陷阱、缺陷),那么遇到问题的时候自然能够知道到哪儿去寻找答案了。具体的建议在后文。

萧叶轩注:如果你观察 C++ 的正式标准,会发现 C++ 标准中并不存在所谓 “奇淫巧计”,虽然 C++ 标准偶尔也有缺陷,但是这种缺陷绝不会引导你写出 “奇淫巧计” 来修补它。即使是 C++ 中一些设计不好的地方,对它的补充也可以满足 best practices,并不会引入额外的不稳定因素。

C++ 的复杂性分类

本来这一节是打算做成一个 C++ 复杂性索引的,然而一来 C++ 的复杂性太多,二来网上其实已经有许多资料(比如 Bjarne Stroustrup 本人的 C++ Technical FAQ 就是一个很好的文档),加上市面上的大多数 C++ 书里面也不停的讲语言细节;因此实际上我们不是缺乏资料,而是缺乏一种索引这些资料的办法,以及一种掌控这些复杂性的模块化思维方法。

由于以上原因,这里并不详细罗列 C++ 的复杂性,而是提供一个分类标准。

C++ 的复杂性有两种分类办法,一是分为非本质复杂性和本质复杂性;其中非本质复杂性分为缺陷和陷阱两类。另一种分类办法是按照场景分类:库开发场景下的复杂性和日常编码的复杂性。从从事日常编码的实践者的角度来说,采用后一种分类可以让我们迅速掌握 80% 场景下的复杂性。

二八法则

萧叶轩注:由于文章年代久远,一些问题在最新标准中已经不再存在,所以此处列举的内容有一定修改。

以下通过列举一些常见的例子来解释这种分类标准:

80%

  1. 资源管理(最常见的复杂性来源):RAII,内存回收,容器访问
  2. 生命周期:RAII,静态成员,构造 / 析构开销,const
  3. 多态(类设计)
  4. 重载决议:普通类的重载决议和模板的重载决议
  5. 异常:异常设计,异常处理
  6. 语法规范:未定义行为,内置类型长度,隐式转换

20%

  1. 内存对齐 / 内存布局
  2. 模板:特化 / 偏特化,类型萃取,模板约束
  3. 名称(标识符)查找规则
  4. 设计友好,隐式 bool 转换

非本质复杂性 & 本质复杂性

此外,考虑另一种分类办法也是有帮助的,即分为非本质复杂性和本质复杂性。

非本质复杂性(不完全列表)

缺陷(指能够克服的问题,但解决方案很笨拙;C++ 的书里面把克服缺陷的 workarounds 称作技术,我觉得非常误导):例子在前面已经列了一堆了。

陷阱(指无法克服的问题,只能小心绕过;如果跌进去,那就意味着你不知道这个陷阱,那么很大可能性你也不知道从哪去解决这个问题):一般来说,作为一个合格的程序员(不管是不是 C++ 程序员),80% 场景下的语言陷阱是需要记住才行的。比如深拷贝 & 浅拷贝;基类的析构函数应当为虚;缺省生成的类成员函数;求值顺序 & 序列点;类成员初始化顺序 & 声明顺序;导致不可移植代码的实现相关问题等。

萧叶轩注:本文写于 2007 年,由于当时 C++11 并没有被确定并且 C++11 对于旧版本的 C++ 做出了很多修正,我认为目前 C++ 没有太过于明显的缺陷,至于陷阱,就如前文注释所写,如果编写代码的时候使用良好的设计哲学并且遵从最佳实践,大部分的陷阱都可以避免。

本质复杂性(不完全列表)

  1. 内存管理
  2. 对象生命期
  3. 重载决议
  4. 名字查找
  5. 模板参数推导规则
  6. 异常
  7. OO(动态)和 GP(静态)两种范式的应用场景和交互

总而言之,这一节的目的是要告诉你从一个较高的层次去把握 C++ 中的复杂性。其中最重要的一个指导思想就是在学习的过程中注意你正学习的技术或细节到底是 80% 场景下的还是 20% 场景下的(一般来说,读完两本书 —— 后面会提到 —— 之后你就能够很容易的对此进行判断了),如果是 20% 场景下的(有大量这类复杂性,其中尤数各种各样的 workarounds 为巨),那么也许最好的做法是只记住一个大概,不去作任何深究。此外,一般来说,不管使用哪门语言,认识语言陷阱对于编程来说都是一个必要的条件,语言陷阱的特点是如果你掉进去了,那么很大可能意味着你本来就不知道这有个陷阱,后者很大可能意味着你不知道如何解决。

萧叶轩注:作为一个理想主义者,我认为 80% 的陷阱都可以避免,正如我从来不会滥用成员声明包括初始化,因而我从来不需要记忆初始化顺序。本文原作者认为陷阱需要被记忆,但是我认为陷阱可以被分析出来,因为如果你一直坚守设计哲学和最佳实践,质量较差的部分会很容易辨别。如果这个陷阱过于隐蔽,那么我相信不常见的陷阱记住了也会忘掉,只要能及时找到解决方案即可(无论是通过询问大佬还是查阅文档)。

学习 C++:实践者的方法

在上面写了那么多之后,如何学习 C++ 这个问题的答案其实已经很明显了。我们所欠缺的是一个书单。

第一本:

如果你是一个 C++ 程序员,那么很大的可能性你会需要用到底层知识(硬件平台架构、缓存、指令流水线、硬件优化、内存、整数 & 浮点数运算等);这是因为两个主要原因:一,了解底层知识有助于写出高效的代码。二,C++ 这样的接近硬件的语言为了降低语言抽象的效率惩罚,在语言设计上作了很多折衷,比如内建的有限精度整型和浮点型,比如指针。这就意味着,用这类语言编程容易掉进 Joel 所谓的 “抽象漏洞”,需要你在语言提供的抽象层面之下去思考并解决遇到的问题,此时的底层知识便能帮上大忙。因此,一本从程序员(而不是电子工程师)的角度去介绍底层知识的书会非常有帮助 —— 这就是推荐《Computer Systems:A Programmers Perspective》(以下简称 CSAPP)(中译本《深入理解计算机系统》)的原因。

萧叶轩注:C++11 乃至更新的版本都在不断规范 C++ 的底层设计元件使得 C++ 代码的跨平台编译 / 移植变得更为轻松,如果你不是专注于面向底层硬件的效率优化,本书(了解硬件)仅需要粗读,需要时精读某一节即可。

第三本(是的,第三本)

另一方面,C++ 不同于 C 的一个关键地方就在于,C++ 在保留有 C 的高效的基础上,增添了抽象机制。而所谓的 “现代 C++ 风格” 便是倡导正确利用 C++ 的抽象机制和这些机制构建出来的现代 C++ 库(以 STL 为代表)的,Bjarne 也很早就倡导将 C++ 当作一门不同于 C 的新语言来学习(就拿内存管理来说,使用现代 C++ 的内存管理技术,几乎可以完全避免 new 和 delete),因此,一本从这个思路来介绍 C++ 的入门书籍是非常必要的 —— 这就是推荐《Accelerated C++》的原因(以下简称 AC++)。《Accelerated C++》的作者 Andrew Koenig 是 C++ 标准化过程中的核心人物之一。

萧叶轩注:《Accelerated C++》这本书成书于 2008 年,由于 C++ 的不断发展,虽然思想是亘古不变的,但是实现却已经过时了,作为 C++ 入门书更推荐 C++ Primer,如果想学习如何设计类,可以购买使用新标准语法的设计模式或者面向对象的书。

第二本

C++ 是在 C 语言大行其道的历史背景下发展起来的,在一开始以及后来的相当长一段时间内,C++ 是 C 的超集,所有 C 的特性在 C++ 里面都有,因此导致了大量后来的 C++ 入门书籍都从 C 讲起,实际上,这是一个误导,因为 C++ 虽然是 C 的超集,然而用抽象机制扩展 C 语言的重大意义就在于用抽象去覆盖 C 当中裸露的种种语言特性,让程序员能够在一个更自然的抽象层面上编程,比如你不是用 int* 加一个数组大小 n 来表示一个数组,而是用可自动增长的 vector;比如你不是用 malloc/free,而是用智能指针和 RAII 技术来管理资源;比如你不是用一个只包含数据的结构体加上一组函数来做一个暴露的类,而是使用真正的 ADT。比如你不是使用 second-class 的返回值来表达错误,而是利用 first-class 的语言级异常机制等等。然而,C 毕竟是 C++ 的源头,剥开 C++ 的抽象外衣,底层仍然还是 C;而且,更关键的是,在实际编码当中,有时候还的确要 “C” 一把,比如在模块级的二进制接口封装上。Bjarne 也说过,OO/GP 这些抽象机制只有用在合适的地方才是合适的。当人们手头有的是锤子的时候,很容易把所有的目标都当成钉子,有时候 C 的确能够提供简洁高效的解决方案,比如 C 标准库里面的 printf 和 fopen(此例受云风的启发)的使用界面就是典型的例子。简而言之,理解 C 语言的精神不仅有助于更好地理解 C++,更理性地使用 C++,而且也有其实践意义 —— 这就是推荐《The C Programming Language》(以下简称 TCPL)的原因。此外,建议在阅读《Accelerated C++》之前先阅读《The C Programming Language》。因为,一,《The C Programming Language》非常薄。二,如果你带着比较的眼光去看问题,看完《The C Programming Language》再看《Accelerated C++》,你便会更深刻的理解 C++ 语言引入抽象机制的意义和实际作用。

萧叶轩注:由于 C++ 基本兼容 C 的全部语法(除了少部分如 C99 的 VLA),所以 《The C Programming Language》可以作为 C++ 的幼儿园书籍学习,至于 printf 等,现代编程语言通常有更好的实现,而 C++20 也加入了 std::format 来用于格式化输出。

建议 3:CSAPP & TCPL & AC++ & TC++PL

是的,在 C++ 方面登堂入室并不需要阅读多得恐怖的所谓 “经典”,至于为什么这些 “经典” 无需阅读,前面已经讲的很详细了。其实你只需要这四本书,就可以奠定一个深厚的基础,以及对 C++ 的成熟理性的现代运用理念。其余的书都可以当成参考资料,用到的时候再去翻阅,即:

建议 4:实践驱动地学习

实践驱动当然不代表什么基础都不打,直接捋起袖管就上。不管运用哪种工具,首先都需要知道关于它的一定程度的基本知识(包括应该怎么用,和不应该怎么用)。知道应该怎么用可以帮你发挥出它的正确和最大效用,知道不应该怎么用则可以帮你避免用的过程中伤及自身的危险。这就是为什么我建议你看四本书,以及建议你要了解 C++ 中的陷阱(大部分来自 C,因此你可以阅读《C 缺陷和陷阱》)的原因。

实践驱动代表着一旦一个扎实的基础具备了之后获得延伸知识的方式。出于环境和心理的原因,C++ 学习者们在这条路上走错的几率非常大,许多人乃至以上来就拿 Effective C++ & More Effective C++、Inside C++ Object Model 这类书去读(是的,我也是,所以我才会在这里写下这篇文章),结果读了一本又一本,出现知道虚函数实现机制的每个细节却不知道虚函数作用的情况。

实践驱动其实很简单:实践 + 查文档。知识便在这样一个简单的循环中积累起来。实践驱动的最大好处就是你学到的都是实践当中真正需要的,属于那 “80%” 最有用的。而查文档的重要性前面已经说过了,但对于 C++ 实践者来说,哪些 “文档” 是非常重要的呢?

萧叶轩注:这部分也如同上文所述,不必深究。由于 C++11 修正了很多缺陷,所以新版的书籍大部分也都转为讲述最佳实践(best practices),而不是如何避免缺陷。

其它

所有优秀的技术书籍都是资料来源。一旦养成了查文档的习惯,所有的电子书、纸书、网络上的资源实际上都是你的财富。不过,查文档的前提是你要从手边的问题分析出应该到什么地方去查资料,这里,分析问题的能力很重要,因此:

建议 5:思考

这个建议就把我们带到了第四本书:

第四本

《你的灯亮着吗?》。不作介绍,自己阅读,这本书只有一百多页,但精彩非常,妙趣横生。

最后,要想理性地运用一门语言,不仅需要看到这门语言的特点,还要能够从另一个角度去看这门语言 —— 即看到它的缺点,因为从心理上 ——

事实 10:一旦我们熟悉了一门语言之后,就容易不知不觉地在其框架下思考,受到语言特性的细节的影响,作出 second-class 的设计

对于像 C++ 这样的在抽象机制上作了折衷的语言,尤其如此,思考容易受到语言机制本身细节的影响,往往在心里头还没想好怎么抽象,就已经确定了使用什么语言机制乃至技巧;更有甚者是为了使用某个特性而去使用某个特性。然而,实际上,我们应该 ——

建议 6:脱离语言思考,使用语言实现

关于设计的一般理念,Eric Raymond 在《The Art of Unix Programming》的第二部分有非常精彩的阐述。

此外,除了脱离语言的具体抽象机制来思考设计之外,学习其它语言对同类抽象机制的支持也是非常有益的,正如老话所说,“兼听则明”。前一阵子 reddit 上也常出现 “How Learning XXX help me become a Better YYY programmer”(其中 XXX 和 YYY 指代编程语言)的帖子,正是这个道理,这就把我们带到了最后一个建议:学习其它语言。

建议 7:学习其它语言

如果你是一个系统程序员,你可能会觉得没有必要学习其它语言,然而未必如此,你未必需要精通其它语言,而是可以去试着了解其它语言的设计理念,是如何支持日常编程中的设计的。这一招非常有利于在使用你自己的语言编程时心理上脱离语言机制细节的影响,作出更好的抽象设计。

尾声

建议 8(可选):重读本文

注:这篇文章的目的是给国内的 C++ 学习者(尤其是初学者)一个可操作的建议。我打算不断修订并完善它;因为这是根据我个人的经验来写的,而基于我对 C++ 的熟悉程度,可能会有地方并不能完完全全站到初学者的视角来看问题。我估计会有这样的地方,所以,如果有任何建议,请发邮件给我:[email protected]

  1. 萧叶轩注:由于本文成文与 2007 年,本篇具有一定前瞻性的思考都被 C++ 新标准解决了,例如 foreach, variadic templates, concepts 甚至 decltype。虽然 foreach 和 decltype 是 C++11 才加入,vt 是 17 加入,concepts 是 20 加入,但是必须承认这些问题已经被解决了,所以不在正文里放入 链接 

  2. 此处是经典的 Linus 炮轰 C++ 的话,不过那时候 C++ 没有智能指针,内存回收确实说不上高明,但是作为面向对象倒是没太大问题。 


若无特殊声明,本文以 CC BY-SA 3.0 许可协议 提供。