原文地址:http://www.artima.com/cppsource/top_cpp_aha_moments.html。译文发表于《程序员》2007.3。
<shapetype id="_x0000_t75" stroked="f" filled="f" path="m@4@5l@4@11@9@11@9@5xe" o:preferrelative="t" o:spt="75" coordsize="21600,21600"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:connecttype="rect" gradientshapeok="t" o:extrusionok="f"></path><lock aspectratio="t" v:ext="edit"></lock></shapetype><shape id="_x0000_s1031" style="MARGIN-TOP: 8.35pt; Z-INDEX: 6; LEFT: 0px; MARGIN-LEFT: 0px; WIDTH: 75pt; POSITION: absolute; HEIGHT: 99pt; TEXT-ALIGN: left; mso-wrap-edited: f" wrapcoords="-216 0 -216 21436 21600 21436 21600 0 -216 0" type="#_x0000_t75"><font size="6"><imagedata o:title="scottMeyers" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msoclip1/01/clip_image001.jpg"></imagedata><wrap type="tight"></wrap></font></shape>Scott Meyers,C++顶级权威之一,为世界各地客户提供培训和咨询服务。发表有畅销的Effective C++系列图书(《Effective C++》、《More Effective C++》和《Effective STL》),设计了创新型的Effective C++ CD,Addison Wesley的Effective Software Development Series顾问编辑,The C++ Source (http://www.artima.com/cppsource/)咨询板块专家。布朗大学计算机科学博士,他的网站是www.aristeia.com。
在本系列第五也是最后一篇文章里,我将选出让自己拍案惊奇、豁然开朗的五个时刻。
如果你从事某项工作的时间足够长,必然有几次在疑窦丛生的同时,忽感豁然开朗的经历(如果没有过,那你肯定是入错行了)。每当这些时刻来临的时候,我都惊得呆了,禁不住大口吸气;好像原来只见黑白二色,突然穿越了时空,来到一个五光十色的世界。最后我慢慢回过味来,面带微笑。如此时刻让人激动。疑窦烟消云散,洞明取代了它的位置。
这样的事情在1978年就出现过一次。经过长期的煎熬,有一天我突然明白了指针的工作原理——如果说软件学习之路上也有成年礼的话,那它就是吧。但我那时还在使用Pascal编程,因此不能将它列入这个和C++相关的名单。现在公开我的选择吧:
认识到C++中特殊成员函数可以声明为private[注释1],1988年。和很多朋友一样,那时我正在自学C++。某天,刚毕业的同事John Shewchuk跑到我的办公室问我,“如果得到一个不可拷贝对象?”在场的有好几个人,但都不知如何回答。我们知道,如果不定义拷贝构造函数和拷贝赋值操作符,那么编译器会自动加入,最后得到的对象是可拷贝的。若要阻止编译器自动生成,我们就必须手工定义,但这样一来,对象还是可拷贝的。就像Grinch[注释2]一样,我们个个迷惑不解,没有人能找到解决办法。
后来(可能是当天或第二天,我记不清了),John宣称自己有了解决办法:将拷贝构造函数声明为private就搞定了。现在看来,这个问题是多么简单啊!但当时对于我们来说,不蒂于发现了新大陆;这是我们对C++知识融会贯通的重要一步。三年后,我出《Effective C++》第一版时,将这个简单的发现供奉在一个独立的条款(不到一页,大概是这本书中最短的条款)里。再后来,我愈加意识到这个发现的重要性,因此在《Effective C++》的后面两版中都写了进去。1988年,我不觉得这种用private阻止编译器隐式生成函数的方法显而易见;现在是2006年,我还是这么认为。
理解Barton 和Nackman在单位分析(译者注:Dimensional Analysis。更学术化的叫法是量纲分析)法中提出的无类型模板参数(non-type template parameters)的用法,1995年。1988年5月,我在《IEEE Software》上读到Robert F. Cmelik和Narain H. Gehani合著的一篇文章——《Dimensional Analysis with C++》。他们提出了一种在物理单位(如长度、速度和时间等)的计算过程中检测单位错误的方法。比如,用长度除以时间,再将结果和一个速度量比较是正确的,但和加速度量(它由长度除以时间的平方得来)比较就错了。Cmelik和Gehani提出,可以将单位信息存储到对象中,然后在运行时进行错误检测。这种方法将使对象变大,而且耗费运行时间。我觉得应该有更好的办法,但折腾再三也没有结果,后来就不了了之。
John J. Barton和Lee R. Nackman在他们1994年出版的《Scientific and Engineering C++》(Addison-Wesley出版社)中提出了一个很好的单位问题解决方案。不过,虽然我当时也拿到了这本书,却没有注意到该项成果——老实说,这本书写得太糟糕了点,我开了个头就扔到一边。直到1995年,我通读了Barton和Nackman发表在《C++ Report》上的专栏文章,他们这次用通俗易懂的语言描述了自己的方案。结果给我留下了三方面深刻印象。第一,它涵盖了单位的所有可能组合,而不仅仅以命名为依据的组合,因为命名是不完全的。例如,我们将长度除以时间的结果命名为速度,还将压力除以长度的平方的结果命名为压强,但却没有给长度乘以时间的平方再除以角速度的立方的结果一个名份。至少我不知道。即便计算中产生了迄今为止还用不到的单位组合,B&N方案也会确保单位分析的正确性。
第二是B&N方案的运行时消耗:没有。对象没有变大,程序也没有变慢。因此可以说B&N方案是无本而万利[注释3] 。这才是我真正感兴趣的组合方式。
不过最让连连称奇的,还是他们对无类型模板参数(代表各种基本单位的指数式)及其上算术指令(计算结果单位类型)的使用 [注释4]。这样,他们不仅解决了多年前搞得我兴趣索然的实际问题,而且使用的还是一项C++特性(即无类型模板参数。它在那以前引发了我无穷的好奇心)。
直到今天,我还为Barton和Nackman的成果激动。原本打算将他们在《C++ Report》上的文章列入我的“C++历史上最重要文献”名单,但后来我发现它影响甚微——很少有人像我那样认为他们的成果具有重大意义。现在,我觉得自己有点可耻,因为我只顾自己满足,却没有将好东西与更多人分享。
理解Visitor模式的涵义,1996或1997年。命名恰当,是软件工程的一个基本原则。这儿就有一个例子,充分说明了糟糕的命名会多么折磨人。我没觉得Visitor模式的设计机理有什么特别问题,但就是一直弄不明白它的意义。我无法将支离破碎的认识融会贯通。直到后来有一天,我终于明白:Visitor模式和“访问”毫无关系。其实,它是一种体系设计方法——要求引入新的虚拟行为函数时不必改变原体系的结构。抓住这点后,我一下就理解了这个模式的含义。但其命名于我造成了巨大理解障碍,甚至看了《Design Patterns》(http://www.artima.com/cppsource/top_cpp_books.html#dp)如下的描述后:
Visitor使你不用改变行为操作的元素的类,就可以定义新行为。
这个解释清楚而直接,现在看来很好理解,但我当时就是盯住了模式的名字,总觉得“Visitor”应该和“访问”、“遍历”啥的发生点关系。
出现这种结果,我想有两个可能原因。一是我死心眼,见识短浅,鼠目寸光。再有就可能是这个名字选得过于随意。如果名字本身指的张三,而使用文档上说的却是李四,那么至少一些人——比较执拗的那种——肯定要搞糊涂了。我倾向于后一种解释。
理解“remove”为什么实际上并没有删除任何东西,1998年?我与STL的remove算法相遇得不是时候。当我期望Visitor设计模式访问个啥的时候,我也认定remove算法就应该删除某个东西。但结果让我非常震惊,我发现在容器上执行remove[注释5]时,容器内元素数目根本不会改变!我有一种被出卖的感觉——我是正儿八经要求删除啊!骗子!谎言!无聊的广告!
后来我读到一篇文章——可能是Andrew Koenig的《C++ Containers are Not Their Elements》(发表于《C++ Report》1998年11-12月刊)——它才让我明白STL内部的真相:算法不能改变容器内元素的数目,因为算法根本不知道容器的类型。容器还可能是一个数组呢,显然数组的大小是不可改变的[注释6]。自然,算法应该和容器彼此独立,互不影响。我认识到,“remove”不会改变容器内元素数目,因为它不能。直到那时,我才算真正理解了STL的内部结构,知道迭代器(iterator)虽然通常由容器成员函数提供,但就像容器和算法一样,其实它也是完全独立的实体。后来,我把这篇文章读了很多次。类似上面的解释,可能别人都说过很多回了,但别怪我鹦鹉学舌,这可是我第一次真正理解remove。
自此以后,我就能与remove和睦相处了。再后来,当发现remove不仅将份内事情做得很好,而且效率超过绝大多数程序员自己编写的循环(remove的运行时间是线性的,而普通循环是二次的)时,我甚至对它有点另眼相看了。虽然我仍然不太喜欢这个命名,但也说不清到底哪个名字既能准确描述其行为,又便于记忆。
理解Boost库里shared_ptr的deleter如何工作,2004年。Boost的引用计数智能指针shared_ptr很有趣——你可以向其构造器传递一个函数或者仿函数(function object,或functor),当引用计数归零的时候,它将在被引用对象上调用删除器(deleter)[译注7]。乍一看,似乎没啥了不起啊,但请看代码:
template<typename T>
class shared_ptr {
public:
template<typename U, typename D>
explicit shared_ptr(U* ptr, D deleter);
...
};
注意shared_ptr<T>必然在析构时调用类型为D的删除器,然而它根本不知道D为何物。这个对象不能包含类型为D的数据成员,也不能指向类型为D的对象,因为声明其数据成员时,D对它而言还是未知的。那么,shared_ptr对象如何跟踪删除器(它在构造阶段传入;当T对象将被销毁时,还得使用它)呢?更通俗地说,构造器如何将未知类型的信息传递给它正在构造的对象,而这个对象本身对信息类型完全无知?
答案很简单:让此对象包含一个指向已知类型基类的指针(Boost叫它sp_counted_base),然后让构造器以D为参数实例化一个派生于上述基类的模板(Boost中叫sp_counted_impl_p和sp_counted_impl_pd),最后用声明于基类、实现于派生类的虚函数(Boost中使用dispose)去调用删除器。用图表示更为直观:
<shapetype id="_x0000_t75" stroked="f" filled="f" path="m@4@5l@4@11@9@11@9@5xe" o:preferrelative="t" o:spt="75" coordsize="21600,21600"><stroke joinstyle="miter"></stroke><formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f></formulas><path o:connecttype="rect" gradientshapeok="t" o:extrusionok="f"></path><lock aspectratio="t" v:ext="edit"></lock></shapetype><shape id="_x0000_i1025" style="WIDTH: 257.25pt; HEIGHT: 117pt" type="#_x0000_t75"><imagedata o:title="sharedPtr" src="file:///F:%5CDOCUME~1%5Cluoxp%5CLOCALS~1%5CTemp%5Cmsohtml1%5C01%5Cclip_image001.gif"></imagedata></shape>
完全明白了——只要你看过这个图[译注8、9]。而且,看过此图后,我想你马上就会意识到它可以应用在很多领域;它为模板设计拓宽了思路,比如,模板化类使用很少的模板参数(例如shared_ptr只有一个),就可以跟踪无限个先前未知类型的信息)。当我想到这些的时候,我禁不住面露赞许的微笑,难抑钦佩之情[译注10]。
好了,文章结束,这就是我的5×5系列的末篇。简单总结一下本系列全部文章:C++历史上最重要的图书、文献、软件、人物,最后是对我来说最难以忘怀的五个神奇时刻。我还将再次和大家讨论这类有趣的话题,不过那至少应该是又一个18年后了。
注释:
1. 可能不一定非叫“特殊”成员函数不可——不过《Standard》如此称呼——具体包括缺省构造器、拷贝构造器、拷贝赋值操作符和析构函数。之所以“特殊”,是因为如果使用了它们而又未显式声明,编译器一般会隐式生成。
2. “Grinch两腿冰冷站在雪地里,想了又想:‘怎么会这样呢?’”参见Dr. Seuss的《Grinch如何偷走圣诞节》(Random House出版社,1957年)。不过在网上也能看到哦(http://www.kraftmstr.com/christmas/books/grinch.html,别告诉Random House)。
3. 我指的是不消耗更多运行时时间。他们大量使用了模板,当然会增加编译时间。
4. 你将两个数相乘时,就等于增加了指数次数,没忘记吧?
5. 我指的是STL的通用算法,而非list类的成员函数。
6. 重新分配内存不能算,不是所有的数组都是动态分配的。
7. 不要吃惊,TR1的shared_ptr就是以Boost的shared_ptr为基础并提供了相同功能。我正讨论的是Boost的shared_ptr,因为它有一个实现,我们这里说的也是实现问题。TR1仅仅是一个规范,如果你书生意气,问我TR1里讲的东西如何实现,那就没意思了。
8. 更准确地说,你以前应该看到过相关解释。譬如我,我得到的解释(我经常要别人给我讲解C++方面的问题)来自于Usenet新闻组的一个免费讨论(http://tinyurl.com/r66ql)。
9.我相信它也是外部多态(External Polymorphism,http://www.cs.wustl.edu/~schmidt/PDF/External-Polymorphism.pdf)设计模式的一个应用。自从我读过Chris Cleeland 和Douglas C. Schmidt于1998年9月发表在《C++ Report》上的有关此模式的文章后,我就喜欢上它了。不过直到现在,我仍然没看到这个模式的广泛应用。
10. 我觉得有两点需要说明。第一,它是Boost中大量成功创新的一个典范,Boost的创新性正是我将其列入“最重要C++软件”的原因之一。第二,很可惜的是,Boost中的这些创新未被整理并在C++社区广泛传播,很多有趣的东西,都掩藏在Boost库的盖子下不为人知。