从 C++之父的视角来解锁性能与抽象的关系

当我们谈及编程语言时,往往会想到那些能够快速搭建应用程序的高级语言,它们以其易用性和高效性吸引着众多开发者。然而,在某些特定的场景下,性能成为了至关重要的因素。而 C++,这门以性能著称的语言,正是为了满足这一需求而生。

从 C++之父的视角来解锁性能与抽象的关系

性能与语言
毋庸置疑,C++ 是一门注重性能的语言。如果你不需要性能,尤其当程序的运行时间远远小于写代码花的时间时,像 Python 这样的脚本语言往往是最佳选择。但是,反过来,如果你的应用程序属于计算密集或者内存密集型,特别是,当你的代码需要部署在多台服务器或者移动设备上的场合,使用 C++ 常常就完全值得了。在很接近底层的场合,如果内存和存储资源比较匮乏,C 也常常会是一个很好的选择;但如果你在资源方面不那么捉襟见肘的话,C++ 提供的零开销抽象,会让你的生产力有一个大幅度的提升。
我们回顾一下 C++ 之父 Bjarne Stroustrup 老爷子对“零开销抽象”的解释:
你不用的东西,你就不需要付出代价。
你使用的东西,你手工写代码也不会更好。
换句话说,我们是既要性能,也要抽象。
当然,抽象从来不是没有任何代价的。对于 C++ 而言,至少语言的复杂性,会是这种抽象的代价。
C++ 里“既要……又要……”的地方并不止前面一处。我们还想要初学者友好。我们还想要向后兼容性——几十年前的代码,仍然应该能够正确编译。
显然,这些目标是有矛盾的,不可兼得——你不可能又支持很多抽象功能,又性能高,又对初学者友好,同时还一直保持向后兼容性……
那我们该怎么办呢?

从 C++之父的视角来解锁性能与抽象的关系

洋葱原则
老爷子对此问题的回答是使用洋葱原则。抽象层次就像一个洋葱,是层层嵌套的。在解决问题时,只要可能,你应该使用尽可能高级的抽象机制,利用比较简单的方式来解决问题。只有在因为性能之类的原因需要进一步优化时,我们才应该使用 C++ 提供的高级功能,在使用抽象机制的同时,进行项目相关的特殊定制。当然,人对抽象和性能的理解通常都是有限的,两者都要的话,复杂度通常会很高——因此,这种深度定制的后果往往就会像切洋葱一样,把自己的眼泪熏出来。
拿老爷子的原话:“如果要完成的任务是简单的,那就用简单的方法做;当要完成的任务不是那么简单时,就需要更详细、更复杂的技巧或写法。这就好比你剥下了一层洋葱。剥得越深,流泪就越多。”
根据洋葱原则,在学习 C++ 时,我们不应该从那些琐碎易错的细节学起,自底向上。相反,学习应当自顶向下,先学习高层的抽象,再层层剥茧、丝丝入扣地一步步进入下层。如果一次走太深的话,挫折可能就难免了。

从 C++之父的视角来解锁性能与抽象的关系

系统知识
不过,C++ 是一门系统编程语言,写 C++ 我们几乎肯定会和系统底层打交道(否则可能就没有必要使用 C++ 了)。我们只能说,应当从高层开始学起;而不是说,我们不需要了解系统底层的细节。
一般而言,系统的下面几个方面我们需要较早就接触到,否则很难对性能有很好的理解:
  1. 栈,以及栈内存和堆内存的区别
  2. 多级缓存架构
  3. 多线程和锁
  4. 构建过程
以“栈”为例,这是理解 C++ 里对象生存期的一个关键点。函数的调用信息在栈上,本地变量在栈上,函数返回时所有的本地变量都会被销毁,内存被回收。构造和析构以后进先出的“栈”顺序进行,高效而确定。C++ 里最重要的惯用法,RAII(resource acquisition is initialization),也就顺理成章地出现了。同时,理解了这些之后,为什么返回本地变量的引用或指针是未定义行为,也会非常容易理解。

从 C++之父的视角来解锁性能与抽象的关系

测试与优化
一般而言,指令执行少的代码更快,我们分析算法使用的大 O 表示法也是从这个角度考虑性能的。但实际的项目里,使用这种方式来分析性能可能存在困难。比如:
  • 某些性能相关部分不是我们自己写的(像操作系统提供的接口),没法直接“分析”它的性能,或者分析会很难
  • 缓存架构对性能会有很大的扭曲
  • 系统比较复杂时,我们只关心程序的“热点”在哪里,而“热点”难以预测
  • 某个语言机制的开销很大,超过了大 O 的影响
  • ……
在这个时候,我们就需要自己来进行性能测试,而测试……则非常容易有陷阱。
为了测试性能,我们需要打开优化,而优化本身就可能会影响测试。这有点像量子力学的测不准原理——有没有观察者效果是不同的。如果没有观察者的话, 编译器就可以大胆地做非常激进的优化;但如果有观察者需要查看结果的话,编译器就不能那么肆意妄为了。通过合理安排观察机制,我们才能做到,既能观测到性能 结果、又不对性能产生负面影响。
在很久很久以前,我曾经测到过手工循环对内存清零比使用 memset 函数更快(当前的编译器上你通常不会得到这样的结果了)。这个结果就是我的测试方法有问题造成的。而背后的实际原因是,在缺乏观察者的情况下,C++ 编译器把我的手工循环完全优化没了,而对 memset 则没有优化得那么彻底……
在我的培训课上,在我强调了这些陷阱之后,还是有相当比例的学员,在写测试代码时仍然犯了该类型的错误,导致测试的结果存在各种问题。可见,这是一种非常常见的错误了。编译器能做什么样的优化,我们该如何来避免某些不该发生的优化,这是一个需要持续学习的问题。

从 C++之父的视角来解锁性能与抽象的关系

“学”与“习”
有一种说法是“学编程”没什么用,要“做项目”才有用。——这种说法,有点像学英语的人说,上课学习没什么用,要跟老外多混多说才有用。
这看似有点道理,但其实并不然。跟人说话,只要对方理解了,那就算成功了,你也很容易验证对方是不是真正明白了。一般而言,即使你表达的方式存在问题,真出现大的理解偏差的概率并不那么高。在缺乏直接反馈的场合,比如写作时,上面这种依赖反馈的做法就不可行了。而当你跟计算机沟通时,精确很重要,错一点点都不行。虽然我们也能部分依赖计算机系统的反馈,但要命的是,即使编译通过了,执行结果正确了,都不能说明你的代码没有问题。如果你只使用试错法来写代码的话,那很有可能,只要你修改了一个编译选项,或者增/删了一行代码,执行结果就出问题了。
如果能问题立即暴露出来的话,实际也还好。最怕的就是写出了未定义行为,只在小概率下呈现出来——那调试时真会让人发疯的。
因此,只通过项目实践来写代码完全不可取。这就跟没经过适当的基本学习和训练就去摸武器一样,很可能你把自己炸飞了,还不知道自己是怎么死的。
不过,反过来,只通过书本学、而不进行练习也是完全不可取的。对于任何一种语言,练习都是必需的。学英语需要“听说读写”,学编程语言虽不需要“听说”,但“读写”仍然必不可少。
模仿孔老夫子说一句,习而不学则惘,学而不习则怠。

注:文章内容来源于网络,仅为表达观点所用,如有侵权请后台联系删除。

原创文章,作者:北大青鸟,如若转载,请注明出处:http://news.yy-accp.com/archives/17497