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

86797b920df8fa8f75dd4d23d4289efe.gif

C++ 之父 Bjarne Stroustrup:“如果要完成的任务是简单的,那就用简单的方法做;当要完成的任务不是那么简单时,就需要更详细、更复杂的技巧或写法。这就好比你剥下了一层洋葱。剥得越深,流泪就越多。”

作者 | 吴咏炜       责编 | 梦依丹

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

12b46596be89ddf21c69e13b55a15963.png

性能与语言

毋庸置疑,C++ 是一门注重性能的语言。如果你不需要性能,尤其当程序的运行时间远远小于写代码花的时间时,像 Python 这样的脚本语言往往是最佳选择。但是,反过来,如果你的应用程序属于计算密集或者内存密集型,特别是,当你的代码需要部署在多台服务器或者移动设备上的场合,使用 C++ 常常就完全值得了。在很接近底层的场合,如果内存和存储资源比较匮乏,C 也常常会是一个很好的选择;但如果你在资源方面不那么捉襟见肘的话,C++ 提供的零开销抽象,会让你的生产力有一个大幅度的提升。

我们回顾一下 C++ 之父 Bjarne Stroustrup 老爷子对“零开销抽象”的解释:

你不用的东西,你就不需要付出代价。

你使用的东西,你手工写代码也不会更好。

换句话说,我们是既要性能,也要抽象。

当然,抽象从来不是没有任何代价的。对于 C++ 而言,至少语言的复杂性,会是这种抽象的代价。

C++ 里“既要……又要……”的地方并不止前面一处。我们还想要初学者友好。我们还想要向后兼容性——几十年前的代码,仍然应该能够正确编译。

显然,这些目标是有矛盾的,不可兼得——你不可能又支持很多抽象功能,又性能高,又对初学者友好,同时还一直保持向后兼容性……

那我们该怎么办呢?

06e277da6f43dacce0d5cec8e1271480.png

洋葱原则

老爷子对此问题的回答是使用洋葱原则。抽象层次就像一个洋葱,是层层嵌套的。在解决问题时,只要可能,你应该使用尽可能高级的抽象机制,利用比较简单的方式来解决问题。只有在因为性能之类的原因需要进一步优化时,我们才应该使用 C++ 提供的高级功能,在使用抽象机制的同时,进行项目相关的特殊定制。当然,人对抽象和性能的理解通常都是有限的,两者都要的话,复杂度通常会很高——因此,这种深度定制的后果往往就会像切洋葱一样,把自己的眼泪熏出来。

拿老爷子的原话:“如果要完成的任务是简单的,那就用简单的方法做;当要完成的任务不是那么简单时,就需要更详细、更复杂的技巧或写法。这就好比你剥下了一层洋葱。剥得越深,流泪就越多。”

根据洋葱原则,在学习 C++ 时,我们不应该从那些琐碎易错的细节学起,自底向上。相反,学习应当自顶向下,先学习高层的抽象,再层层剥茧、丝丝入扣地一步步进入下层。如果一次走太深的话,挫折可能就难免了。

9496fa942bec856fff369719a4c087fe.png

系统知识

不过,C++ 是一门系统编程语言,写 C++ 我们几乎肯定会和系统底层打交道(否则可能就没有必要使用 C++ 了)。我们只能说,应当从高层开始学起;而不是说,我们不需要了解系统底层的细节。

一般而言,系统的下面几个方面我们需要较早就接触到,否则很难对性能有很好的理解:

  1. 栈,以及栈内存和堆内存的区别

  2. 多级缓存架构

  3. 多线程和锁

  4. 构建过程

以“栈”为例,这是理解 C++ 里对象生存期的一个关键点。函数的调用信息在栈上,本地变量在栈上,函数返回时所有的本地变量都会被销毁,内存被回收。构造和析构以后进先出的“栈”顺序进行,高效而确定。C++ 里最重要的惯用法,RAII(resource acquisition is initialization),也就顺理成章地出现了。同时,理解了这些之后,为什么返回本地变量的引用或指针是未定义行为,也会非常容易理解。

df54e0d49b1d32e2a316465a5864f9ed.png

测试与优化

一般而言,指令执行少的代码更快,我们分析算法使用的大 O 表示法也是从这个角度考虑性能的。但实际的项目里,使用这种方式来分析性能可能存在困难。比如:

  • 某些性能相关部分不是我们自己写的(像操作系统提供的接口),没法直接“分析”它的性能,或者分析会很难

  • 缓存架构对性能会有很大的扭曲

  • 系统比较复杂时,我们只关心程序的“热点”在哪里,而“热点”难以预测

  • 某个语言机制的开销很大,超过了大 O 的影响

  • ……

在这个时候,我们就需要自己来进行性能测试,而测试……则非常容易有陷阱。

为了测试性能,我们需要打开优化,而优化本身就可能会影响测试。这有点像量子力学的测不准原理——有没有观察者效果是不同的。如果没有观察者的话, 编译器就可以大胆地做非常激进的优化;但如果有观察者需要查看结果的话,编译器就不能那么肆意妄为了。通过合理安排观察机制,我们才能做到,既能观测到性能 结果、又不对性能产生负面影响。

在很久很久以前,我曾经测到过手工循环对内存清零比使用 memset 函数更快(当前的编译器上你通常不会得到这样的结果了)。这个结果就是我的测试方法有问题造成的。而背后的实际原因是,在缺乏观察者的情况下,C++ 编译器把我的手工循环完全优化没了,而对 memset 则没有优化得那么彻底……

在我的培训课上,在我强调了这些陷阱之后,还是有相当比例的学员,在写测试代码时仍然犯了该类型的错误,导致测试的结果存在各种问题。可见,这是一种非常常见的错误了。编译器能做什么样的优化,我们该如何来避免某些不该发生的优化,这是一个需要持续学习的问题。

4f22a45021e637b48744b24e9ec702c7.png

“学”与“习”

有一种说法是“学编程”没什么用,要“做项目”才有用。——这种说法,有点像学英语的人说,上课学习没什么用,要跟老外多混多说才有用。

这看似有点道理,但其实并不然。跟人说话,只要对方理解了,那就算成功了,你也很容易验证对方是不是真正明白了。一般而言,即使你表达的方式存在问题,真出现大的理解偏差的概率并不那么高。在缺乏直接反馈的场合,比如写作时,上面这种依赖反馈的做法就不可行了。而当你跟计算机沟通时,精确很重要,错一点点都不行。虽然我们也能部分依赖计算机系统的反馈,但要命的是,即使编译通过了,执行结果正确了,都不能说明你的代码没有问题。如果你只使用试错法来写代码的话,那很有可能,只要你修改了一个编译选项,或者增/删了一行代码,执行结果就出问题了。

如果能问题立即暴露出来的话,实际也还好。最怕的就是写出了未定义行为,只在小概率下呈现出来——那调试时真会让人发疯的。

因此,只通过项目实践来写代码完全不可取。这就跟没经过适当的基本学习和训练就去摸武器一样,很可能你把自己炸飞了,还不知道自己是怎么死的。

不过,反过来,只通过书本学、而不进行练习也是完全不可取的。对于任何一种语言,练习都是必需的。学英语需要“听说读写”,学编程语言虽不需要“听说”,但“读写”仍然必不可少。

模仿孔老夫子说一句,习而不学则惘,学而不习则怠。

d824d6a31a7be296765f0f8989a12344.png

我的培训课程

《C++ 性能优化》是一个我讲过了很多次的课程,重点在 C++ 语言提供的抽象机制上,但也会在必要的地方讨论一些语言外的东西,尤其是内存架构和性能测试——要理解性能,那绝对不可不提。从目前学员的反馈来看,大家对这门课程还是非常欢迎的。

在课程里,我会讨论:

  • 性能相关的基本概念,包括软件和硬件

  • C++ 程序的性能测试

  • C++ 跟性能相关的特性和高级技巧

  • C++ 程序的性能调优

  • ……

当然,课程是死的,课程里的交流、课后你自己的练习和拓展才是成长的关键。我希望我的课程能带给你一个看待 C++ 和性能的新视角;我希望你多多提出问题,由我来为你答疑解惑;我更希望你学完不是就那么结束了,而是牢牢记住一定要“学而时习之”,把课程的结束当成一个新的学习阶段的开始。只有这样,我的授课才不是白费力气。

是为记。

489cbe3a52512b47f9527d933010b320.png

b27bb22fe0991402bebe01255631e302.gif

7ec37baf632a86aedb9b7fe1f8e3f741.jpeg

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值