C++之父谈关于C++的五个需要被重新认识的观点(中)
概述:学习和使用过C++的人几乎都曾经听说过下面的五个关于C++的描述,并且对这些话笃信不已,那么现在的情况是怎么样的呢?本文的作者——C++之父Bjarne Stroustrup将会对这些观点作逐一回击。本篇为中篇,探讨其中的第三个观点。</blockquote> </div>
- C++之父谈关于C++的五个需要被重新认识的观点(上)
- C++之父谈关于C++的五个需要被重新认识的观点(中)
学习和使用过C++的人几乎都曾经听说过下面的五个关于C++的描述,并且对这些话笃信不已,那么现在的情况是怎么样的呢?本文的作者——C++之父Bjarne Stroustrup将会对这些观点作逐一回击。
以下的这五个观点盛行于C++多年:
- “要了解C++,你必须先学习C语言。”
- “C++是一门面向对象的语言。”
- “对于可靠的软件,垃圾回收机制必不可少。”
- “为了提高效率,你必须编写底层代码。”
- “C++只对大型复杂的项目有用。”
如果你还对这些观点深信不已,那么这篇文章可以给你一些重新认识。这些观点在特定的时间对于某些人、某些工作来说是正确的。但是对于今天的C++,随着ISO C++11标准的编译器和工具的广泛使用,这些观点都需要被重新认识。
接上一篇,这一篇里我们将围绕“对于可靠的软件,垃圾回收机制必不可少。”的观点进行探讨。
观点三:“对于可靠的软件,垃圾回收机制必不可少。”
对于回收未使用的内存这份工作,垃圾回收做得不错但却不够完美。它并非灵丹妙药。内存可以被间接引用并且许多资源并非单纯的内存。来看这个例子:
这里Filter的构造函数会开启两个用于数据存储的文件(file)。完成这项工作以后,Filter从输入文件执行输入任务并将产生的输出结果 保存到输出文件里。 这些任务包括硬连接到Filter,作为匿名(lambda)函数,提供一个可能具有覆盖虚函数派生类的函数。在谈及资源管理时这些细节并不重要。我们可 以这样创建Filter:
从资源管理的角度来看,这里的问题是如何关闭文件以及对与输入输出流相关联的对象资源进行回收重用。
在许多种依托于垃圾回收的语言和系统里,常见解决方案是放弃使用delete(它很容易在编程过程中被人遗忘,从而导致内存泄漏)和析构函数(被垃 圾回收后的语言中尽量少用析构函数和不用finalizer,因为它们在逻辑上令人捉摸不透并经常破坏性能)。垃圾回收器可以回收所有的内存资源,但是我 们还需要使用手动操作(通过编写代码的方式)来关闭文件并释放任何与数据流相关的非内存资源(比如锁)。因此虽然内存被自动完全回收了,但是由于其它资源 是手动管理的,内存的错误和泄漏仍有可能发生。
被C++推荐和使用的方法是依靠析构函数来处理资源回收的问题。值得一提的是,这些被构造函数获取的资源是通过RAII(“资源获取即初始化”)这 一简单而通用的技术来处理的。在user()中,用于flt的析构函数隐式调用了用于输入输出流(IS及OS)的析构函数。这些析构函数依次关闭文件并释 放与数据流相关的资源。而delete对*p会做同样的操作。
拥有丰富的现代C++开发经验的程序员会注意到user()非常笨拙且容易产生错误,而采用下面的编写方式会更好:
现在当user()退出后*p需要被隐式释放。程序员不能忘记这项操作。与内置的“裸”指针不同的是,智能指针unique_ptr是一个用于确保资源释放掉后就不再需要运行时间和内存空间等系统开销的标准库类。
然而,我们仍然能够看到new。这个解决方案有点冗长(Filter类型重复了),并且由于结构被普通指针(使用的new)和智能指针(在这里是 unique_ptr)分拆开而使某些重要的优化丢失。我们可以使用一个C++14的帮助函数make_unique来进行改善,它能够构造一个指定类型 的对象并返回一个指向它的unique_ptr指针:
除非出现需要第二个具有指针语义的Filter的情况(不太可能),否则这段代码将会更好:
最后的一个版本比原来的更加简短、清晰和快速。
Filter的析构函数做了什么呢?它释放了属于Filter的资源。也就是说,它关闭了文件(通过调用它们的析构函数)。事实上,这项工作是通过 隐式的方式完成的,所以除了Filter需要的一些东西,我们可以去掉Filter析构函数的显式声明并让编译器来处理这一切。因此,我只需要这样编写:
这样比大多数拥有垃圾回收机制的语言(如Java或者C#)的编写都要简单,而且也不会因为程序员的健忘而导致内存泄漏。它比其它的替代方案也要快 速的多(无需模拟自由/动态内存的使用且不需要运行垃圾回收器)。值得一提的是,相对于手动操作的方法RAII还降低了资源的滞留时间。
这是理想的资源管理方法。它处理的不仅是内存,还包括一般(非内存)资源,比如文件句柄、线程句柄以及锁等。但这样就够了么?对于那些需要从一个函数传递到另外一个函数的对象又该怎么办呢?对于那些没有明显的单一所有者的对象又该怎么办呢?
转移所有权:move让我们首先来考虑将对象(所包含的信息)从一个作用域转移到另一个的问题。这个问题的关键在于在不使用copy或易错指针等需要影响系统性能的情况下如何从作用域之外获得大量关于所需对象的信息。传统的方法是使用一个指针:
现在负责删除对象的是谁?在这个简单的例子中,很明显是make_X()的调用者,但在通常情况下这个答案是不明确的。假如make_X()为了将 系统开销降低最小而保留了对象缓存呢?假如user()将指针传递给了一些other_user()呢?这种方法产生混乱的可能性很大并且也容易产生内存 泄漏。
我可以使用shared_ptr或者unique_ptr来明确所创建对象的所有权。例如:
但是为什么非要使用一个指针(智能指针或者一般指针)呢?我通常都不希望使用指针,因为指针的使用与常规的对象引用不合拍。例如,一个Matrix加法函数创建了一个包含2个参数的新对象(求和),但如果返回一个指针则会导致代码变得非常奇怪:
那个*的位置应该是需要的求和结果,而不是一个指向这个结果的指针。在很多时候,我真正想获取的是一个对象,而不是指向对象的指针。而多数情况下,获取对象都会很简单,特别是对于那些小型对象,只需要简单的copy就可以了,根本不需要考虑使用指针:
另一方面,一个包含大量数据信息的对象通常会处理大部分那样的数据。比如istream,string,vector,list和thread。它们只是使用了几句关于数据的简单命令就可以确保潜在的大量数据的合理访问。让我们再来看看Matrix加法,我们希望的是
我们可以很容易用这种实现(创建临时对象函数):
在默认的情况下,程序会把res(临时对象)的元素copy到r,但随后res会被销毁,持有这些元素所占用的内存也会被释放,我们考虑到了一种无 需copy(C++的设计目标就是尽量少分配内存)的方法:直接“窃取”这些元素。从第一天学习C++的初学者到老手,每一个人都想过要这么做,但这种方 法很难实现且技术还没有得到广泛理解。C++11的出现使这种构想成为了现实。它支持“窃取对象信息(steal the representation)”的理念——通过move句柄的形式转移对象所有权(即转移对象所包含信息)。来看看下面这个简单的2维双重Matrix 函数:
copy操作可通过引用(&)参数来识别的,同样的,move操作可通过右值引用(&&) 参数来识别。move操作可以用来“窃取”对象的信息并遗留下一个“空对象”。对于Matrix来说,这就意味着是这样的:
它的机制是这样的:当编译器看到了return res,它就明白可以把res销毁了。也就是说,res在返回之后就不会再使用了。因此,编译器会立刻应用一个move构造函数而不是copy构造函数来转移返回的值。通过以下的形式:
在operator+()中的res会成为空对象,然后交由析构函数来善后,而res中的元素现在已经归r所有。将对象包含的信息从函数 operator+()提取出来放进调用的变量中,我们已经达成了获取元素(可能是上百万字节的内存)的结果,并且我们只使用了最小的成本(也就是差不多 四行用于分配的代码)。
老道的C++用户会指出,在某些情况下,好的编译器能够完全清除掉return上所copy的信息(在本例中会保存关于move的四行代码和调用的 析构函数)。然而,这是对实现的依赖,我不希望基础编程技术的性能还要由每个独立编译器的聪明程度来决定。此外,能够清除掉copy信息的编译器也能够很 轻松的把move给抹掉。我们这里的就有一个用于减小把大量信息从一个作用域copy到另外一个的复杂性和所产生花费的简单、可靠、通用的方法。
通常情况下,我们甚至不需要定义所有的这些copy和move操作。如果一个类中缺乏所需的成员,我们可以依靠编译器所生成的默认操作,比如:
这个版本的Matrix运行起来与上个版本很相似,除了稍微提升了对错误的处理和有一个更多一些的陈述(vector通常只有3行代码)
对于那些不是句柄的对象呢?假如它们很小,就象一个int或者一个双double类型complex那样,则无须担心。否则,需要使用nique_ptr或shared_ptr这样的智能指针来处理它们并进行返回操作。注意,不要加入“裸”指针new和delete。
不幸的是,就象我举例的Matrix类一样,某些类并不是ISO C++标准库的一部分,但是它的其中一部分还是可用的(开源和面向商业的)。例如,在网上搜索“Origin Matrix Sutton”,你可以看见在我的书The C++ Programming Language (Fourth Edition)的第29章在讨论如何设计这样的一个矩阵。
共享所有权:shared_ptr在关于垃圾回收的讨论中,经常会看到并不是每一个对象都对应唯一的所有者。这意味着我们必须确保当对象的最后一个引用消失后,该对象是否已经被销毁 /释放。在这个模型里,我们必须使用一个机制来确保当最后一个所有者被销毁后这个对象也会随之被销毁。也就是说,我们需要一个共享所有权的形式。例如,我 们有一个同步队列sync_queue,用于任务之间的通信。提供者(producer)和使用者(consumer)都被赋予了一个指向 sync_queue的指针:
我假定task1、task2、iqueue和oqueue已经在其它地方被定义了,在这里我使用了detatch()来让线程的生存周期比创建线 程的作用域更长。你可能会想到多任务管道和sync_queues。然而,在这里我感兴趣的只有一个问题:“是谁删除了startup()中所创建的 sync_queue?”以书面文字来说,这问题这么提会更好:“最后使用sync_queue的是谁?”这是经典的垃圾回收调用案例。垃圾回收的原型就 是计算指针:持续对使用对象计数,当计数归零则删除该对象。(当有一个指针指向自己时计数值加1;当删除一个指向自己的指针时,计数值减1,如果计数值减 为0,说明已经不存在指向该对象的指针了,则可以安全销毁)。现在许多语言的垃圾回收机制都是以此为蓝本发展的而在C++11里shared_ptr就是 使用的这种机制。上面的例子可变成:
用于task1和task2的析构函数可以销毁它们的shared_ptrs(在大多数优秀的设计当中都会非常隐蔽的干这项工作),两者中较晚完成的会同时对sync_queue进行销毁。
这个方法简单且合理高效。它意味着一个运行复杂的系统并一定需要垃圾回收器。重要的是,它不仅可以回收与sync_queue相关的内存资源,还能 够回收sync_queue中用于管理不同任务的多线程同步性的同步对象(互斥对象、锁等)。这种方法不仅适用于内存管理,还适合一般的资源管理。“隐 藏”的同步对象准确处理前面例子中文件句柄和数据流缓冲器所处理的工作。
我们可以尝试通过在某些封装任务的作用域中引入一个唯一所有者来替代使用shared_ptr,当这样做起来并不一定简单,因此C++11提供了unique_ptr(用于唯一所有权)和shared_ptr(用于共享所有权)。
类型安全前面,我只谈论了垃圾回收与资源管理的关系。在类型安全方面,垃圾回收也影响重大。只要我们有一个明确的delete操作,它就有可能被误用。例如:
不要这样做,在一般的用户代码上使用“裸指针”delete是危险且多余的。让delete远离字符串、输出流、线程、unique_ptr和shared_ptr这样的资源管理类。在这些地方,delete需要与new谨慎配用来以确保无害。
摘要:资源管理理念对于资源管理,我认为垃圾回收应该作为最后的选择,而不是作为“解决方案”或者理念:
- 使用递归和隐式的占用抽象来处理自己的资源,对于这种作用域变量的对象来说是更好的选择。
- 当你需要指针/引用语义时,使用如unique_ptr或者shared_ptr这样的智能指针来表示所有权。
- 如果所有都失败了(比如,因为你的代码是一段包含缺乏内存管理和错误处理的语言特性支持的混乱指针的程序),请尝试“手动”处理非内存资源并嵌入一个保守的垃圾回收器来处理几乎不可能避免的内存泄漏。
这样的策略很完美么?不,但是至少它是简单适用的。基于传统垃圾回收的策略并不完美,它并不能直接解决非内存资源的问题。
前一篇我们探讨了“要了解C++,你必须先学习C语言。”和“C++是一门面向对象的语言。”的观点,在下一篇我们将探讨最后两个观点“为了提高效率,你必须编写底层代码。”和“C++只对大型复杂的项目有用。”
本文翻译自Five Popular Myths about C++, Part 2,作者为:C++之父Bjarne Stroustrup
本文译者为慧都控件网——回忆和感动,转载请注明:本文转载自慧都控件网