最近一段时间,在公司的项目中,分别写了一些Go与C++代码,在体验了Go所带来的生产力提升后,对于C++越发嫌弃。
《Thinking in C++》作者Bruce Eckel曾经说过他选择一门编程语言的标准:
I have always sought the most powerful languages. For me, the most important aspect of “power” is programmer productivity, and this is predominantly determined by simplicity and clarity. So I seek languages that emphasize simplicity and clarity above all. I am very aware of the cognitive overhead of language features, and the limitations of the human mind in managing complexity. The more of the mind that is used on arbitrary complexity, the less is available to solve the problems at hand. Thus, I seek languages that don’t force the programmer to jump through arbitrary complexity hoops in order to compensate for language decisions that were made for reasons other than “simplest for the programmer.” I’ve found that once I discover the compromises and what they cost – and especially when they cost more than the language benefits, in terms of programmer time and effort – I start losing interest in that language.
其主要观点是:
- 生产力是衡量一门语言的核心标准。
- 生产力主要由语言的简洁性、清晰性决定。
- 在管理复杂性方面,人脑存在局限。语言本身应尽可能少地增加认知负担,开发人员在语言本身上消耗的认知能力越多,其专注于解决问题的认知能力越少。
那么,让我们来看一看,C++都给开发人员增加了哪些认知负担。
1. 隐形学习成本高
C++本身无官方文档、无官方教程,同样是C++程序员,写出来的程序无论从代码风格还是质量上都良莠不齐。除此之外,没有任何一门语言像C++一样,有这多么经典图书,这也从侧面证明了这门语言的艰深晦涩程度之深。类似《Effective C++》系列,内容竟全部是一些C++使用上的最佳实践,都是前人经验的血泪总结。
有经验的C++程序员早已将这些最佳实践内化吸收,融入到自己的日常开发之中,而不觉有任何负担。这显然是一种幸存者偏差,任何C++程序员要想习得这些最佳实践/躲坑指南,要么从自己的经验中学习,靠自己一步一个脚印地踩过,要么从他人的经验里学习,阅读正是第二种方式。
2. 声明与实现分离:服务于编译器,而非人
头文件机制一直是C++被诟病最多的地方,让我们看看.h与.cpp分离给开发人员带来了哪些认知负担:
- 头文件 guard。ifndef/define/endif几乎是每个头文件的标配,然而这些工作只是为了能够编译通过,为编译器扫清障碍。
- 代码冗余。头文件中的函数声明,都要复制一份到cpp文件中。为了实现一个很简单的功能,不得不写一堆boiler plate代码。
- 冗长的include列表。面向对象中,整个程序被分割成一个一个的类,稍微复杂的程序都会同时用到很多类,尤以基础库中的类使用最为频繁,而它们几乎被include进每一个cpp文件。
- 为了优化编译速度,不include头文件,而是使用前置声明。
开发人员背负了这些认知负担,又获得了什么呢?没有任何实质性的产出,不过是为编译器打工罢了。
3. 内存管理
在没有智能指针的时代,C++将内存管理的责任全部委托给了开发人员。有过几年编程经验的人,或多或少都被各种内存问题困扰过,memory leak、segment fault等。
在智能指针出现以后,只要想清楚对象的所有权(具有独占性的对象使用unique_ptr,被多人共享的对象使用shared_ptr),现代C++程序就不再需要手动delete,基本杜绝了memroy leak类问题。
而导致segment fault比较常见的两种bug是:
- 误用野指针
- 底层直接操作内存的代码(即unsafe代码)存在bug
对于第1类bug,智能指针可以解决;而第2类问题,并非智能指针的适用场景,只能交给开发人员解决。
至此,智能指针的引入看起来完美的解决了两类常见的内存错误,然而事实并非如此。在C++中,智能指针以库的形式存在,与语言本身无关。也就是说,开发人员即使写出了明显存在bug的智能指针用法,编译器也会绿灯放行,毕竟没有发现语法错误。等到程序上线运行后,因内存问题导致程序崩溃,此时的调查成本已经成十倍百倍的放大。这主要是因为由内存问题导致的程序崩溃,其崩溃点(coredump)往往非第一现场,很多时候都是崩溃之前写乱的内存导致后面的程序执行出错,进而触发程序崩溃,根据一个滞后的崩溃点反推出第一现场,谈何容易?
如果不幸遇到这么棘手的内存问题,并没有什么容易的解决办法,我一般的解决思路如下:
- Code Review。最朴实的办法,往往有奇效。
- 如果容易重现,用valgrind运行,valgrind的设计理念只会误报,不会漏报。使用valgrind运行问题程序的缺点是严重降低程序运行性能,如果是由一些多线程竞争条件下导致的内存问题,很可能因为在valgrind框架下程序运行变慢而无法复现。
- 如果不易复现,还可以通过试错法。学习进程的内存布局、熟悉业务逻辑,在怀疑的代码路径上加内存保护(mprotect(2)),以期反向追踪到第一现场。
总结
最初,C++将内存管理的责任完全交给开发人员;随后,以库的形式引入智能指针,实现了半自动的内存管理。之所以称为半自动主要是因为智能指针以库的形式存在,并未与语言进行整合,导致本可以在编译期发现的内存问题被隐藏,直到运行时才暴露出来。半自动的表现如:
- 虽然提供智能指针,依然可以使用原始的new/delete管理内存。
- 已经将所有权转移出去的unique_ptr,依然可以被使用。
除了认知负担以外,C++对于软件工程的支持也并不友好
1. 标准库匮乏,第三方库良莠不齐,缺少集中式管理
大家是否观察到一个现象,大公司的招聘启事几乎都有C++岗位,而小公司鲜有C++岗位。
我想,这主要是因为大公司发展久,内部开发&维护着众多C++项目(毕竟C/C++是大学教学语言,容易招人),而这些C++项目中的基础组件会被不断的抽取出来,形成功能丰富的基础组件库,功能丰富的基础库又会吸引新项目使用,形成良性循环。
而小公司是没有人力和时间来维护一份自己的基础库的。生存下来是小公司的头等要事,快速搭建产品原型、快速验证商业模式、快速迭代,可以说,小公司的发展始终围绕一个快字。这就需要小公司选择高生产力的语言,而高生产力的最直观体现就是该语言的标准库是否丰富,能用久经考验地标准库解决的问题,没人愿意试用良莠不齐、无质量保证、有烂尾风险地开源第三方库。
C++的标准库实在太简陋了,在我看来,标准委员会对标准库的定位:一来是给语言打补丁,弥补先天设计不足;二来是尝试开拓语言边界,让C++适合更多开发场景。标准委员会并没有丰富标准库的意思。而一个项目在做技术选型时,必然考虑所选择的技术是否有所需的功能,如果标准库没有,就不得不在第三方库中选择,而选择本身就是成本,更别说选择优秀的第三方开源库本身也是个技术活。我们之所以相信标准库,本质上是选择相信将第三方库纳入标准库的人,相信他们的判断,相信他们的准入标准足够严格。
2. 缺少标准化的工程实践
要想完成一个工程项目,光有编程语言是不够的,还需要各种周边工具及规范:
- 代码结构规范:如何组织内部代码,如何组织第三方代码。
- 编码风格检查工具:避免无意义的风格之争,易于他人阅读和理解。
-
构建工具/依赖管理工具:持续集成,持续交付。
这里还要吐槽一下C++的库依赖关系,对于动态库来说,仅需指定依赖库,无需关心依赖的依赖,这很符合直觉;而对于静态库来说,不仅要指定依赖库,还需要指定依赖的依赖,甚至当库拆分不当,出现循环依赖的时候,还需要通过多次指定依赖库的方式绕过! - 测试框架/Mock框架:便于编写、运行测试用例,统计测试结果,保证质量。
- 代码覆盖率统计工具:衡量质量的基础指标。
- 文档工具:便于他人二次开发。
很遗憾,以上任何内容都没有纳入标准,各个公司、组织、个人都有自己的一套规范与工具。这对开发人员来说并非好事,无论是规范也好、工具也罢,本身并不产生生产力,糟糕的规范和工具甚至会限制生产力,人们只是希望遵守凝聚前人经验智慧的规范,使用标准易用的工具。
后记
上述5点,都是以Go作为对比对象,Go虽然优雅地解决了上述C++所面临的问题,但也并非完美,见Why Go Is Not Good。