C++之父谈关于C++的五个需要被重新认识的观点

概述:学习和使用过C++的人几乎都曾经听说过下面的五个关于C++的观点,并且对这些话笃信不已,那么真实的情况是怎么样的呢?本文的作者——C++之父Bjarne Stroustrup将会对这些观点作逐一回击。

以下的这五个观点盛行于C++多年:

  1. “要了解C++,你必须先学习C语言。”
  2. “C++是一门面向对象的语言。”
  3. “对于可靠的软件,垃圾回收机制必不可少。”
  4. “为了提高效率,你必须编写底层代码。”
  5. “C++只对大型复杂的项目有用。”

如果你还对这些观点深信不已,那么这篇文章可以给你一些重新认识。这些观点在特定的时间对于某些人、某些工作来说是正确的。但是对于今天的C++,随着ISO C++11标准的编译器和工具的广泛使用,这些观点都需要被重新认识。

接下来,我们将会对这些观点进行逐一反驳。

观点一:“要了解C++,你必须先学习C语言。”


这不对,事实上对于基础编程的学习来说C++比C语言容易的多。

C语言虽然几乎可以认为是C++的子集,但对初学者来说却不是最容易学习的。因为C语言缺乏标记支持和类型安全,并且对于简化简单任务来说C++的标准库更加易于使用。

比如,对于一个非常简单的用于描述邮件地址格式的函数:

C++之父谈C++

它可以被这样使用:

C++之父谈C++

而C语言中需要明确的字符操作和明确的内存管理:

C++之父谈C++

然后,它需要被这样使用:

C++之父谈C++

相比之下,哪种版本更加容易学习?哪种语言更加有效率?很显然是C++了,因为它不需要计算参数字符,不需要为简短的字符串分配动态内存。

对于C++的学习

关于“C语言优先学习”的观点并非来自少部分人的认识。传授这种典型观点的老师主要有以下几个方面原因:

  • 因为这是他们在这方面有丰富经验。
  • 因为这是课程需求。
  • 因为这是老师年轻时的学习方式。
  • 因为C比C++要小,所以更容易学习。
  • 因为学生迟早都必须学习C语言或者C++的C语言子集。

然而,C语言并不是作为优先学习的最简单和有用的C++子集。当你知道足够多的C++知识后学习C语言则会非常容易。这种学习方式可以有效减轻从C到C++学习时在认识和技术上的困难。

对于现代C++的教学方法,可以参见我的著作:Programming: Principles and Practice Using C++。它甚至在有一章的结尾处展示了如何学习使用C语言。这种教学方法在几所大学的数以万计学生中使用,非常的成功。它的第二版是使用C++11和 C++14来让学习变得更加容易。 

C++11标准使C++更容易被初学者接受,例如,这里是一个元素序列已初始化的vector标准库:

C++之父谈C++

在C++98中,我们只能初始化数组和列表。在C++11中,我们可以定义一个包含有{}和需要的任何类型的初始化列表的构造函数。

我们可以通过for循环的范围来遍历vector:

C++之父谈C++

对于v的任何一个元素都会调用一次test()。

for循环的范围可以遍历任何序列,因此我们可以通过直接使用初始化列表来简化示例。

C++之父谈C++

C++11的目的是使简单的事情变得简单。代码的简单化并没有以性能降低为代价。

观点二:“C++是一门面向对象的语言。”


不对。C++支持面向对象和其它编程风格,它并不仅限于“面向对象”这个狭隘的观点。它支持一个综合的编程技术,包括面向对象和泛型编程。通常一个问题的最佳方式需要比较多种类型。最佳,在这里指的是时间最短、最易于理解、最有效率和最易于维护等等。

“C++是一门面向对象的语言”的观点使人们在除非需要拥有许多虚拟(多态运行)函数的巨大类层次结构时才会考虑使用它。而这种用法对于许多问题来说是不合适的。这个观点也会导致另外一些人指责C++的面向对象并不纯粹。毕竟,如果把“好”和“面向对象”划上等号的话,C++还包含了其它被认为是“不好”的非面向对象的东西。这种观点产生的两种认识都会导致人们放弃学习C++。(译者注:作者表达的意思就是把C++比作是一个卖包子和卖米线的餐馆。将C++认作是包子铺会让人产生2种误会,其一,路过的人会以为这里只卖包子,不卖其它的;其二,爱吃包子的人会认为包子铺还卖米线,这包子一定做得不专业)

举个例子:

C++之父谈C++

它面向对象吗?当然,它严重依赖包含虚函数的类层次结构。它是泛型编程吗?当然是,它严重依赖于参数化容器(vector)和泛型函数for_each。它是函数式编程吗?在一定程度上是,它使用了匿名函数(由[]构造)。那么它到底是什么?它是现代C++:C++11。

我同时使用了for循环和标准库算法for_each只是为了展示其特性。在实际代码中,我只会使用其中的一个循环。

泛型编程

你想让上面那段代码更通用吗?因为毕竟它只适用于vector指针的Shape基类。那么对于列表和内置数组呢?对于象shared_ptr和unique_ptr这样的“智能指针”(资源管理指针)呢?对于没有调用Shape类的对象能够使用draw()和rotate()么?可以这样来做:

C++之父谈C++

你可以使用这段程序对任何序列从头到尾进行遍历。这是一个C++风格的标准库算法。我使用了auto来避免必须为“象Shape类这样的对象”的接口类型命名。这是C++11的特性,它的含义是“使用被用于初始化的表达式的类型”。所以由for循环中p的类型就能决定这是什么类型的对象。这种使用auto表示匿名函数参数类型的方法是现已广泛使用的一个C++14新特性。

如下图所示:

C++之父谈C++

在这里我假定Blob是包含了操作函数draw()和rotate()的图形化类型,而Container是容器类型。标准库list(std::list)拥有成员函数begin()和end(),用于帮助用户遍历元素的序列。这是很好很经典的面向对象编程。但是,假如容器不支持C++标准关于遍历半开序列[b:e)的概念呢?假如库里面没有begin()和end()成员函数呢?或者,由于没有容器一类的东西因此无法遍历。对于这些情况,我们可以用适当的语义来定义独立的begin()和end()。标准库提供了C语言风格的数组,因此如果容器是C语言风格的数组,问题就迎刃而解了——而C语言风格的数组非常常见。

改写

来看看一个更难点的例子,假如容器保留了对象的指针,并且有一个用于访问和遍历的不同模型呢?比如,你会访问到象下面的这个容器:

C++之父谈C++

这种风格并不少见,我们可以将其映射到[b,e)这样的一个序列:

C++之父谈C++

注意,这种修改是无关紧要的:我并没有修改容器或者某些由C++标准库支持的将容器映射到模型进行遍历的容器类的层次结构。这是改写的一种形式而不是重构。

我选择这个例子是为了说明这些泛型编程技术并不局限于流行的标准库。它们也符合常见的“面向对象”的定义,但是它们却不是面向对象的。

关于C++的代码一定是面向对象(意味着在每个地方都会使用层次结构和虚函数)的观点深深地影响了人们对C++性能的评价。还有一些人认为当需要解决多种类型的运行的问题只有面向对象才是最好的。在以前,我也是这么想的。但是事实上,它也有死板的一面(比如并不是所有相关类型都属于同一层次结构)并且虚函数无法作为内联函数(这就使得处理许多简单而重要的任务时会多花费大量的时间)。

观点三:“对于可靠的软件,垃圾回收机制必不可少。”

对于回收未使用的内存这份工作,垃圾回收做得不错但却不够完美。它并非灵丹妙药。内存可以被间接引用并且许多资源并非单纯的内存。来看这个例子:

C++内存回收

这里Filter的构造函数会开启两个用于数据存储的文件(file)。完成这项工作以后,Filter从输入文件执行输入任务并将产生的输出结果保存到输出文件里。 这些任务包括硬连接到Filter,作为匿名(lambda)函数,提供一个可能具有覆盖虚函数派生类的函数。在谈及资源管理时这些细节并不重要。我们可以这样创建Filter:

C++内存回收

从资源管理的角度来看,这里的问题是如何关闭文件以及对与输入输出流相关联的对象资源进行回收重用。

在许多种依托于垃圾回收的语言和系统里,常见解决方案是放弃使用delete(它很容易在编程过程中被人遗忘,从而导致内存泄漏)和析构函数(被垃圾回收后的语言中尽量少用析构函数和不用finalizer,因为它们在逻辑上令人捉摸不透并经常破坏性能)。垃圾回收器可以回收所有的内存资源,但是我们还需要使用手动操作(通过编写代码的方式)来关闭文件并释放任何与数据流相关的非内存资源(比如锁)。因此虽然内存被自动完全回收了,但是由于其它资源是手动管理的,内存的错误和泄漏仍有可能发生。

被C++推荐和使用的方法是依靠析构函数来处理资源回收的问题。值得一提的是,这些被构造函数获取的资源是通过RAII(“资源获取即初始化”)这一简单而通用的技术来处理的。在user()中,用于flt的析构函数隐式调用了用于输入输出流(IS及OS)的析构函数。这些析构函数依次关闭文件并释放与数据流相关的资源。而delete对*p会做同样的操作。

拥有丰富的现代C++开发经验的程序员会注意到user()非常笨拙且容易产生错误,而采用下面的编写方式会更好:

C++内存回收

现在当user()退出后*p需要被隐式释放。程序员不能忘记这项操作。与内置的“裸”指针不同的是,智能指针unique_ptr是一个用于确保资源释放掉后就不再需要运行时间和内存空间等系统开销的标准库类。

然而,我们仍然能够看到new。这个解决方案有点冗长(Filter类型重复了),并且由于结构被普通指针(使用的new)和智能指针(在这里是unique_ptr)分拆开而使某些重要的优化丢失。我们可以使用一个C++14的帮助函数make_unique来进行改善,它能够构造一个指定类型的对象并返回一个指向它的unique_ptr指针:

C++内存回收

除非出现需要第二个具有指针语义的Filter的情况(不太可能),否则这段代码将会更好:

C++内存回收

最后的一个版本比原来的更加简短、清晰和快速。

Filter的析构函数做了什么呢?它释放了属于Filter的资源。也就是说,它关闭了文件(通过调用它们的析构函数)。事实上,这项工作是通过隐式的方式完成的,所以除了Filter需要的一些东西,我们可以去掉Filter析构函数的显式声明并让编译器来处理这一切。因此,我只需要这样编写:

C++内存回收

这样比大多数拥有垃圾回收机制的语言(如Java或者C#)的编写都要简单,而且也不会因为程序员的健忘而导致内存泄漏。它比其它的替代方案也要快速的多(无需模拟自由/动态内存的使用且不需要运行垃圾回收器)。值得一提的是,相对于手动操作的方法RAII还降低了资源的滞留时间。

这是理想的资源管理方法。它处理的不仅是内存,还包括一般(非内存)资源,比如文件句柄、线程句柄以及锁等。但这样就够了么?对于那些需要从一个函数传递到另外一个函数的对象又该怎么办呢?对于那些没有明显的单一所有者的对象又该怎么办呢?

转移所有权:move

让我们首先来考虑将对象(所包含的信息)从一个作用域转移到另一个的问题。这个问题的关键在于在不使用copy或易错指针等需要影响系统性能的情况下如何从作用域之外获得大量关于所需对象的信息。传统的方法是使用一个指针:

C++内存回收

现在负责删除对象的是谁?在这个简单的例子中,很明显是make_X()的调用者,但在通常情况下这个答案是不明确的。假如make_X()为了将系统开销降低最小而保留了对象缓存呢?假如user()将指针传递给了一些other_user()呢?这种方法产生混乱的可能性很大并且也容易产生内存泄漏。

我可以使用shared_ptr或者unique_ptr来明确所创建对象的所有权。例如:

C++内存回收

但是为什么非要使用一个指针(智能指针或者一般指针)呢?我通常都不希望使用指针,因为指针的使用与常规的对象引用不合拍。例如,一个Matrix加法函数创建了一个包含2个参数的新对象(求和),但如果返回一个指针则会导致代码变得非常奇怪: 

C++内存回收

那个*的位置应该是需要的求和结果,而不是一个指向这个结果的指针。在很多时候,我真正想获取的是一个对象,而不是指向对象的指针。而多数情况下,获取对象都会很简单,特别是对于那些小型对象,只需要简单的copy就可以了,根本不需要考虑使用指针:

C++内存回收

另一方面,一个包含大量数据信息的对象通常会处理大部分那样的数据。比如istream,string,vector,list和thread。它们只是使用了几句关于数据的简单命令就可以确保潜在的大量数据的合理访问。让我们再来看看Matrix加法,我们希望的是

C++内存回收

我们可以很容易用这种实现(创建临时对象函数):

C++内存回收

在默认的情况下,程序会把res(临时对象)的元素copy到r,但随后res会被销毁,持有这些元素所占用的内存也会被释放,我们考虑到了一种无需copy(C++的设计目标就是尽量少分配内存)的方法:直接“窃取”这些元素。从第一天学习C++的初学者到老手,每一个人都想过要这么做,但这种方法很难实现且技术还没有得到广泛理解。C++11的出现使这种构想成为了现实。它支持“窃取对象信息(steal the representation)”的理念——通过move句柄的形式转移对象所有权(即转移对象所包含信息)。来看看下面这个简单的2维双重Matrix函数:

C++内存回收

copy操作可通过引用(&)参数来识别的,同样的,move操作可通过右值引用(&&) 参数来识别。move操作可以用来“窃取”对象的信息并遗留下一个“空对象”。对于Matrix来说,这就意味着是这样的:

C++内存回收

它的机制是这样的:当编译器看到了return res,它就明白可以把res销毁了。也就是说,res在返回之后就不会再使用了。因此,编译器会立刻应用一个move构造函数而不是copy构造函数来转移返回的值。通过以下的形式:

C++内存回收

在operator+()中的res会成为空对象,然后交由析构函数来善后,而res中的元素现在已经归r所有。将对象包含的信息从函数operator+()提取出来放进调用的变量中,我们已经达成了获取元素(可能是上百万字节的内存)的结果,并且我们只使用了最小的成本(也就是差不多四行用于分配的代码)。

老道的C++用户会指出,在某些情况下,好的编译器能够完全清除掉return上所copy的信息(在本例中会保存关于move的四行代码和调用的析构函数)。然而,这是对实现的依赖,我不希望基础编程技术的性能还要由每个独立编译器的聪明程度来决定。此外,能够清除掉copy信息的编译器也能够很轻松的把move给抹掉。我们这里的就有一个用于减小把大量信息从一个作用域copy到另外一个的复杂性和所产生花费的简单、可靠、通用的方法。

通常情况下,我们甚至不需要定义所有的这些copy和move操作。如果一个类中缺乏所需的成员,我们可以依靠编译器所生成的默认操作,比如:

C++内存回收

这个版本的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的指针:

C++内存回收

我假定task1、task2、iqueue和oqueue已经在其它地方被定义了,在这里我使用了detatch()来让线程的生存周期比创建线程的作用域更长。你可能会想到多任务管道和sync_queues。然而,在这里我感兴趣的只有一个问题:“是谁删除了startup()中所创建的sync_queue?”以书面文字来说,这问题这么提会更好:“最后使用sync_queue的是谁?”这是经典的垃圾回收调用案例。垃圾回收的原型就是计算指针:持续对使用对象计数,当计数归零则删除该对象。(当有一个指针指向自己时计数值加1;当删除一个指向自己的指针时,计数值减1,如果计数值减为0,说明已经不存在指向该对象的指针了,则可以安全销毁)。现在许多语言的垃圾回收机制都是以此为蓝本发展的而在C++11里shared_ptr就是使用的这种机制。上面的例子可变成:

C++内存回收

用于task1和task2的析构函数可以销毁它们的shared_ptrs(在大多数优秀的设计当中都会非常隐蔽的干这项工作),两者中较晚完成的会同时对sync_queue进行销毁。

这个方法简单且合理高效。它意味着一个运行复杂的系统并一定需要垃圾回收器。重要的是,它不仅可以回收与sync_queue相关的内存资源,还能够回收sync_queue中用于管理不同任务的多线程同步性的同步对象(互斥对象、锁等)。这种方法不仅适用于内存管理,还适合一般的资源管理。“隐藏”的同步对象准确处理前面例子中文件句柄和数据流缓冲器所处理的工作。

我们可以尝试通过在某些封装任务的作用域中引入一个唯一所有者来替代使用shared_ptr,当这样做起来并不一定简单,因此C++11提供了unique_ptr(用于唯一所有权)和shared_ptr(用于共享所有权)。

类型安全

前面,我只谈论了垃圾回收与资源管理的关系。在类型安全方面,垃圾回收也影响重大。只要我们有一个明确的delete操作,它就有可能被误用。例如:

C++内存回收

不要这样做,在一般的用户代码上使用“裸指针”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 

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值