C++的复杂性只是一个必要条件,并非问题的根本症结。根本症结在于人的心理,每个人都喜欢戴着脚镣跳舞,并且以为是“创新”。意识到这一点之后可以帮我们避免被各种各样名目繁多的语言细节和技巧占去不必要的时间。
然而,C++的复杂性始终是一个不可回避的现实。C++中有大量的陷阱和缺陷,后者导致了数目惊人的惯用法和workarounds。不加选择的全盘预先学习,是非常糟糕的做法,不仅低效,而且根本没有必要,实在是浪费生命。爱因斯坦曾经说过,“我只想知道‘他’(宇宙)的设计理念,其它的都是细节”。然而,正如另一些读者指出的,如果对C++中的这些细节事先一点都没有概念的话,那么实际编码中一旦遇到恐怕就变成没头苍蝇了,也许到哪里去RTFM都不知道。这也是为什么那么多C++面试都会不厌其烦地问一些有代表性的语言细节的原因。
把细节全盘装在脑子里固然不好,但对细节一无所知同样也不是个办法。那么对于C++程序员来说,在学习中究竟应该以怎样的态度和学习方法来对付C++的复杂性呢?其实答案也非常简单,首先有一些很重要&必须的语言细节&特性是需要掌握的,然后我们只需知道在C++中大抵有哪些地方有复杂性(陷阱、缺陷),那么遇到问题的时候自然能够知道到哪儿去寻找答案了。具体的建议在后文。
C++的复杂性分类
本来这一节是打算做成一个C++复杂性索引的,然而一来C++的复杂性太多,二来网上其实已经有许多资料(比如Bjarne Stroustrup本人的C++ Technical FAQ就是一个很好的文档),加上市面上的大多数C++书里面也不停的讲语言细节;因此实际上我们不是缺乏资料,而是缺乏一种索引这些资料的办法,以及一种掌控这些复杂性的模块化思维方法。
由于以上原因,这里并不详细罗列C++的复杂性,而是提供一个分类标准。
C++的复杂性有两种分类办法,一是分为非本质复杂性和本质复杂性;其中非本质复杂性分为缺陷和陷阱两类。另一种分类办法是按照场景分类:库开发场景下的复杂性和日常编码的复杂性。从从事日常编码的实践者的角度来说,采用后一种分类可以让我们迅速掌握80%场景下的复杂性。
二八法则
以下通过列举一些常见的例子来解释这种分类标准:
80%场景下的复杂性:
1. 资源管理(C++日常复杂性的最主要来源):深拷贝&浅拷贝;类的四个特殊成员函数;使用STL;RAII惯用法;智能指针等等。
2. 对象生命期:局部&全局对象生存期;临时对象销毁;对象构造&析构顺序等等。
3. 多态
4. 重载决议
5. 异常(除非你不用异常):栈开解(stack-unwinding)的过程;什么时候抛出异常;在什么抽象层面上抛出异常等等。
6. undefined&unspecified&implementation defined三种行为的区别:i++ + ++i是undefined behavior(未定义行为——即“有问题的,坏的行为,理论上什么事情都可能发生”);参数的求值顺序是unspecified(未指定的——即“你不能依赖某个特定顺序,但其行为是良好定义的”);当一个double转换至一个float时,如果double变量的值不能精确表达在一个float中,那么选取下一个接近的离散值还是上一个接近的离散值是implementation defined(实现定义的——即“你可以在实现商的编译器文档中找到说明”)。这些问题会影响到你编写可移植的代码。
(注:以上只是一个不完全列表,用于演示该分类标准的意义——实际上,如果我们只考虑“80%场景下的复杂性”,记忆和学习的负担便会大大减小。)
20%场景下的复杂性:
1. 对象内存布局
2. 模板:偏特化;非类型模板参数;模板参数推导规则;实例化;二段式名字查找;元编程等等。
3. 名字查找&绑定规则
4. 各种缺陷以及缺陷衍生的workarounds(C++书中把这些叫做“技术”):不支持concepts(boost.concept_check库);类型透明的typedef(true-typedef惯用法);弱类型的枚举(强枚举惯用法);隐式bool转换(safe-bool惯用法);自定义类型不支持初始化列表(boost.assign库);孱弱的元编程支持(type-traits惯用法;tag-dispatch惯用法;boost.enable_if库;boost.static_assert库);右值缺陷(loki.mojo库);不支持可变数目的模板参数列表(type-list惯用法);不支持native的alignment指定。
(注:以上只是一个不完全列表。你会发现,这些细节或技术在日常编程中极少用到,尤其是各种语言缺陷衍生出来的workarounds,构成了一个巨大的长尾,在无论是C++的书还是文献中都占有了很大的比重,作者们称它们为技术,然而实际上这些“技术”绝大多数只在库开发当中需要用到。)
非本质复杂性&本质复杂性
此外,考虑另一种分类办法也是有帮助的,即分为非本质复杂性和本质复杂性。
非本质复杂性(不完全列表)
1. 缺陷(指能够克服的问题,但解决方案很笨拙;C++的书里面把克服缺陷的workarounds称作技术,我觉得非常误导):例子在前面已经列了一堆了。
2. 陷阱(指无法克服的问题,只能小心绕过;如果跌进去,那就意味着你不知道这个陷阱,那么很大可能性你也不知道从哪去解决这个问题):一般来说,作为一个合格的程序员(不管是不是C++程序员),80%场景下的语言陷阱是需要记住才行的。比如深拷贝&浅拷贝;基类的析构函数应当为虚;缺省生成的类成员函数;求值顺序&序列点;类成员初始化顺序&声明顺序;导致不可移植代码的实现相关问题等。
本质复杂性(不完全列表)
1. 内存管理
2. 对象生命期
3. 重载决议
4. 名字查找
5. 模板参数推导规则
6. 异常
7. OO(动态)和GP(静态)两种范式的应用场景和交互
总而言之,这一节的目的是要告诉你从一个较高的层次去把握C++中的复杂性。其中最重要的一个指导思想就是在学习的过程中注意你正学习的技术或细节到底是80%场景下的还是20%场景下的(一般来说,读完两本书——后面会提到——之后你就能够很容易的对此进行判断了),如果是20%场景下的(有大量这类复杂性,其中尤数各种各样的workarounds为巨),那么也许最好的做法是只记住一个大概,不去作任何深究。此外,一般来说,不管使用哪门语言,认识语言陷阱对于编程来说都是一个必要的条件,语言陷阱的特点是如果你掉进去了,那么很大可能意味着你本来就不知道这有个陷阱,后者很大可能意味着你不知道如何解决。
然而,C++的复杂性始终是一个不可回避的现实。C++中有大量的陷阱和缺陷,后者导致了数目惊人的惯用法和workarounds。不加选择的全盘预先学习,是非常糟糕的做法,不仅低效,而且根本没有必要,实在是浪费生命。爱因斯坦曾经说过,“我只想知道‘他’(宇宙)的设计理念,其它的都是细节”。然而,正如另一些读者指出的,如果对C++中的这些细节事先一点都没有概念的话,那么实际编码中一旦遇到恐怕就变成没头苍蝇了,也许到哪里去RTFM都不知道。这也是为什么那么多C++面试都会不厌其烦地问一些有代表性的语言细节的原因。
把细节全盘装在脑子里固然不好,但对细节一无所知同样也不是个办法。那么对于C++程序员来说,在学习中究竟应该以怎样的态度和学习方法来对付C++的复杂性呢?其实答案也非常简单,首先有一些很重要&必须的语言细节&特性是需要掌握的,然后我们只需知道在C++中大抵有哪些地方有复杂性(陷阱、缺陷),那么遇到问题的时候自然能够知道到哪儿去寻找答案了。具体的建议在后文。
C++的复杂性分类
本来这一节是打算做成一个C++复杂性索引的,然而一来C++的复杂性太多,二来网上其实已经有许多资料(比如Bjarne Stroustrup本人的C++ Technical FAQ就是一个很好的文档),加上市面上的大多数C++书里面也不停的讲语言细节;因此实际上我们不是缺乏资料,而是缺乏一种索引这些资料的办法,以及一种掌控这些复杂性的模块化思维方法。
由于以上原因,这里并不详细罗列C++的复杂性,而是提供一个分类标准。
C++的复杂性有两种分类办法,一是分为非本质复杂性和本质复杂性;其中非本质复杂性分为缺陷和陷阱两类。另一种分类办法是按照场景分类:库开发场景下的复杂性和日常编码的复杂性。从从事日常编码的实践者的角度来说,采用后一种分类可以让我们迅速掌握80%场景下的复杂性。
二八法则
以下通过列举一些常见的例子来解释这种分类标准:
80%场景下的复杂性:
1. 资源管理(C++日常复杂性的最主要来源):深拷贝&浅拷贝;类的四个特殊成员函数;使用STL;RAII惯用法;智能指针等等。
2. 对象生命期:局部&全局对象生存期;临时对象销毁;对象构造&析构顺序等等。
3. 多态
4. 重载决议
5. 异常(除非你不用异常):栈开解(stack-unwinding)的过程;什么时候抛出异常;在什么抽象层面上抛出异常等等。
6. undefined&unspecified&implementation defined三种行为的区别:i++ + ++i是undefined behavior(未定义行为——即“有问题的,坏的行为,理论上什么事情都可能发生”);参数的求值顺序是unspecified(未指定的——即“你不能依赖某个特定顺序,但其行为是良好定义的”);当一个double转换至一个float时,如果double变量的值不能精确表达在一个float中,那么选取下一个接近的离散值还是上一个接近的离散值是implementation defined(实现定义的——即“你可以在实现商的编译器文档中找到说明”)。这些问题会影响到你编写可移植的代码。
(注:以上只是一个不完全列表,用于演示该分类标准的意义——实际上,如果我们只考虑“80%场景下的复杂性”,记忆和学习的负担便会大大减小。)
20%场景下的复杂性:
1. 对象内存布局
2. 模板:偏特化;非类型模板参数;模板参数推导规则;实例化;二段式名字查找;元编程等等。
3. 名字查找&绑定规则
4. 各种缺陷以及缺陷衍生的workarounds(C++书中把这些叫做“技术”):不支持concepts(boost.concept_check库);类型透明的typedef(true-typedef惯用法);弱类型的枚举(强枚举惯用法);隐式bool转换(safe-bool惯用法);自定义类型不支持初始化列表(boost.assign库);孱弱的元编程支持(type-traits惯用法;tag-dispatch惯用法;boost.enable_if库;boost.static_assert库);右值缺陷(loki.mojo库);不支持可变数目的模板参数列表(type-list惯用法);不支持native的alignment指定。
(注:以上只是一个不完全列表。你会发现,这些细节或技术在日常编程中极少用到,尤其是各种语言缺陷衍生出来的workarounds,构成了一个巨大的长尾,在无论是C++的书还是文献中都占有了很大的比重,作者们称它们为技术,然而实际上这些“技术”绝大多数只在库开发当中需要用到。)
非本质复杂性&本质复杂性
此外,考虑另一种分类办法也是有帮助的,即分为非本质复杂性和本质复杂性。
非本质复杂性(不完全列表)
1. 缺陷(指能够克服的问题,但解决方案很笨拙;C++的书里面把克服缺陷的workarounds称作技术,我觉得非常误导):例子在前面已经列了一堆了。
2. 陷阱(指无法克服的问题,只能小心绕过;如果跌进去,那就意味着你不知道这个陷阱,那么很大可能性你也不知道从哪去解决这个问题):一般来说,作为一个合格的程序员(不管是不是C++程序员),80%场景下的语言陷阱是需要记住才行的。比如深拷贝&浅拷贝;基类的析构函数应当为虚;缺省生成的类成员函数;求值顺序&序列点;类成员初始化顺序&声明顺序;导致不可移植代码的实现相关问题等。
本质复杂性(不完全列表)
1. 内存管理
2. 对象生命期
3. 重载决议
4. 名字查找
5. 模板参数推导规则
6. 异常
7. OO(动态)和GP(静态)两种范式的应用场景和交互
总而言之,这一节的目的是要告诉你从一个较高的层次去把握C++中的复杂性。其中最重要的一个指导思想就是在学习的过程中注意你正学习的技术或细节到底是80%场景下的还是20%场景下的(一般来说,读完两本书——后面会提到——之后你就能够很容易的对此进行判断了),如果是20%场景下的(有大量这类复杂性,其中尤数各种各样的workarounds为巨),那么也许最好的做法是只记住一个大概,不去作任何深究。此外,一般来说,不管使用哪门语言,认识语言陷阱对于编程来说都是一个必要的条件,语言陷阱的特点是如果你掉进去了,那么很大可能意味着你本来就不知道这有个陷阱,后者很大可能意味着你不知道如何解决。