文 / Peter Seibel 译 / 郝培强
本文是Common Lisp专家Peter Seibel对Google公司首席Java架构师Joshua Bloch的访谈,谈到他所遇到的最糟糕的Bug以及Java的命运。
最糟糕的Bug
Seibel:我们聊聊调试吧。你遇到的最糟糕的Bug是什么?
Bloch:提起Bug我立马就想到了一个,这个Bug很严重,而且很搞笑。那是90年代初,我在匹兹堡的Transarc公司工作时。我在很紧的工期下提交了一个事务共享内存的实现。我在限期内完成了设计和实现,甚至还在过程中做出了几个可重用的组件。但是这么匆忙地写了很多新代码,我还是挺担心的。
为了测试这些代码,我写了一个叫做“乱撞”的很长的程序出来。它运行了大量的事务,每个事务又包含了嵌套的事务,嵌套到可以嵌套的最大深度。每个嵌套事务都可能会加锁,以递增的顺序读取共享数组里面的几个元素,对每个元素都加入点东西,保持数组中所有元素的和为0,还是不变量。这些事务要么提交,要么取消,如90%的提交,10%的取消,其他比例也可以。多个线程同步运行于这些事务之上,长时间地访问数组。因为我测试的是一个共享内存机制,所以我同时运行多个有多个线程的“乱撞”程序,每个有自己的进程。
在一般的并发级别下,“乱撞”轻松过关。但是当我真正调高并发级别时,我发现“乱撞”偶尔,仅仅是偶尔,无法通过一致性检查。我不知道这是怎么搞的。这只能是我的错,因为新代码都是我一个人写的。
我花了大约一个星期,痛苦地为每个组件写了彻底的单元测试,所有的单元测试都通过了。然后我为每个内部数据结构写了详细的一致性检查,这样我就可以在每次变化后调用这些一致性检查,直到测试失败为止。最后,我终于发现一个底层的一致性检查失败了,这个问题无法重现,但是某种程度上可以帮助我分析问题出在哪里。最后,我得出了确实的结论——我的锁根本不工作。两个事务锁定、读写同一个值的时候,产生了并发的读—修改—写回操作,而后一次写入毁掉了第一次的写入。
我编写了自己的锁管理器,所以我怀疑是它出了问题。但是锁管理器轻松地通过了测试。最后,我觉得问题不在锁管理器,而是它依赖的互斥体的实现!那时候操作系统还不支持多线程,我们需要写自己的多线程包。原来负责互斥体代码的工程师,不小心把我们的Solaris的线程实现中的lock和try-lock的汇编代码的标签弄混了。所以,每次你以为你在调用lock的时候,其实调用的是try-lock,反之亦然。也就是说当真的有争用发生的时候——在当年其实是很罕见的——第二个线程直接就进入了第一个线程的临界区,因为第一个线程也没有锁住。搞笑的是,这也就是说,整个公司几个星期都在运行没有互斥体的程序,而且谁都不知道。
Knuth有句关于测试的名言,Bentley和Mcllroy的精彩论文“Engineering a Sort Function”中曾经引用过,大概意思是说,做测试时,要不惮以最大的恶意来推测所要测试的代码的错误。做这些测试的时候,我就是这么做的。但是这样会把所有东西纠结在一起,更难找到Bug。首先,并发的时候很难这么做,往往完全无法复现场景。其次,到最后可能会发现你的核心假设是错的。喜欢喊“耶,这语言出错了”或者“系统出问题了”是新手干的事儿。但是在这里,我依靠的基石——互斥体,确实出问题了。
Seibel:也就是说Bug不在你的代码中,但是同时你只能对你的代码进行彻底的单元测试,因为你没有别的办法,只能去检查自己的代码。你觉得这些测试是不是可以,或者说应该让互斥体代码的作者来写,这样你就不用浪费一个星期,节省了一半的测试量,而且也可以找到这个Bug。
Bloch:给互斥体代码加一个好的自动化单元测试肯定可以避免让我遭受那些痛苦,不过注意那可是90年代初。我想都没想过要抱怨那个工程师没写个好的单元测试。即使是今天,为并发工具写单元测试还是一种艺术形式。
Java的命运
当你改进一个成熟语言的时候,你必须更加仔细地考虑能力和复杂度之间的平衡。
Seibel:既然你说到这里,Java是否逃脱了灭亡的命运了?它变复杂的速度是不是比变好的速度更快呢?
Bloch:这个问题不好回答。具体说来,Java5加入了比我们设想的更多的复杂度。将泛型特别是通配符加到语言中到底有多复杂我也说不好。我得为有功劳的人说句话,GrahamHamilton真是了不起,那时候他就想明白了一切,而我不明白。
有趣的是,他抗争多年,希望阻止泛型进入Java语言中。但是在泛型被成功地阻挡在Java外的这些年里变体的概念,也就是通配符的隐含意义流行了起来。如果它们来得更早,没有变体,也许我们现在可以有一个更简单的、更容易跟踪的语言。
引入通配符有实际的好处。子类化和泛型之间根本就是阻抗不匹配的,通配符尽力在弥合这种不匹配。但是这么做又显著地增加了复杂度。有些人认为在声明空间,而不是用户空间,变体是更好的解决方案,但我不太相信这一点。
这仍旧悬而未决,因为它们都还没经过在真实世界里海量的程序员们的测试呢。一些语言经常只在小范围内获得成功,人们会说:“噢,这些语言很不错,只是可惜没有成为世界范围成功的语言。”但是这往往是有原因的。希望使用Scala或者C#4.0,这样的声明空间变体的语言可以彻底解决这一疑问。
Seibel:那么是什么推动Java引入泛型呢?
Bloch:没看起来那么精彩啦,我们的新闻报道是可信的。我的思维模式是,“嗨,集合多半都应该是同质的——一组字符串,一个从字符串到数字的映射,等等。而现在默认情况下集合是异质的:它们都是对象的集合,取出时都需要类型转换,这简直是胡闹。”如果我可以告诉系统,这是一个从字符串到数字的映射,它会帮我做类型转换,而且会在编译期间帮我盯着,防止我做错什么,那不是挺好的吗?它可以抓到更多的错误——它可以包含高层的类型信息,看起来是件好事儿。
我认为泛型和其他加入到Java5的语言特性一样,我们只是让语言去做以前我们要手工去做的事情而已。某些情况下我坚信:foreach就是好。它所做的就是对你隐藏遍历器和索引变量带来的复杂性。代码更短,概念也不复杂。从某种意义上说,它的概念更简单,因为我们为数组和其他的集合创建了这种伪多态机制,你可以遍历一个ArrayList或者一个数组,而无需关心你遍历的是什么类型。
这种思想不能适用于泛型的主要原因是,它是对已经很复杂的类型系统的大扩展。类型系统是很微妙的,修改它们可能对语言带来深远的、难以预期的影响。
我认为得到的教训是,当你改进一个成熟语言的时候,你必须更加仔细地考虑能力和复杂度之间的平衡。而且,实际上,复杂度跟语言的功能数量间至少是平方级关系。为一门老语言加上了一个新的功能,通常就意味着为它加入了一大堆复杂度。当一种语言已经达到或接近程序员理解能力的极限时,那么你加入任何复杂性进来都会加剧理解的难度。
语言更复杂后就会消失吗?不会。我认为C++早已超越了它的复杂度极限,但还是有很多人用它编程。可这实际上是逼人们只使用其中一个子集。所以我认识的每个用C++的公司都说:“对,我们用C++,但是用的是多继承,不用操作符重载。”有很多功能你完全不用,因为使用它们会造成代码太复杂。即使不得不用那些功能,我认为也实在没什么好处。那样的话,程序员就读不懂别人的代码,也就不存在“程序员的可移植性”了。
Seibel:如果去掉泛型,现在Java会变得更好用吗?
Bloch:我不知道。我还是喜欢泛型。泛型能帮我找到代码中的Bug。泛型可以让编译器强制做一些限制,之前这些限制我只能放在注释中。另一方面来说,当我看到那些疯狂的参数类型相关的错误信息,当我看到像classEnum
我们总是太乐观,然后搬起石头砸自己的脚。所以我们说:“耶!我们当然可以把泛型放到Java中。在CLU的时候我们就知道泛型了。这技术25年前就有了。”最近我听到关于闭包的类似言论,不过那是50年前的技术了。“噢,闭包很简单,不会给语言加入任何新的复杂性。”
嗯,没错。但是我觉得我们从泛型这件事儿得到了教训。在你懂得这个改动会对概念层面带来什么影响之前,在你可以确保软件行业从业人员可以高效地使用新特性,而且这一新特性会让他们活得更好之前,你不应该给语言加入这一特性。
如果早知道程序员们对泛型是这个反应,我们肯定不会把它加到Java里。这是不是说我们就完全不会搞泛型?不,我不这么认为。我认为泛型确实很好。主要是因为大多数集合是同质的,而不是异质的,同质的集合处理起来是比较方便的。多数情况下类型转换都不合适。转换可能会失败,而且让你的程序不再优雅。我想你知道这是什么集合,它应该自动符合你的这些需求。但是,是不是这就意味着你应该承受我们现在承受的这种复杂度?不,我想我们只是没有处理好泛型。
Seibel:关于泛型有来自于用户的压力吗?有人抱怨泛型的缺点干扰他们写程序了吗?
Bloch:有没有工程师大骂泛型的缺点?不,没有,他们没有抱怨过。如果因为泛型简洁就把它们加进来,那我会内疚的。因为当时我们以为这么做是对的。
有人说,很多工程都是扯淡。有人要求我们加入foreach吗?没有。他们没有要求我加入。但是我就知道这是应该做的。我对了——每个人都喜欢它。但是我觉得我们行业内的一大问题就是,在工程领域,做一个东西,仅仅因为它简洁,仅仅因为它是一个好的工程项目,等等。如果你不能解决真实用户——在这里就是Java程序员——的真实问题,那么你就不应该加入新的特性。
James Gosling曾做过一个非常了不起的演讲——“Java的感觉”。他说,给Java加入任何东西之前,都需要三个真实的用户。不应该因为一个东西简洁就把它放进来。
但是人们就是想把什么东西都放进去。工程师是做什么的?他们就是写代码的。而当他们写一个库,或者一个语言的时候,他们就是想放各种东西进去。你需要他人的参与,需要指导的声音,需要这些东西来帮助你完成产品,帮你在放与不放之间做出最好的权衡。因为你可以放进去的东西总比你应该放进去的东西多。那么是不是说所有的这些东西都不好呢?那也不是。只是你需要做出决定,某些东西是不应该放进去的。
思考Java带来的编程经验
Seibel:思考Java的设计并实现它,是否让你学到了什么跟编程有关系的东西?
Bloch:我学到的东西太多了。比如我知道了即使是想把一个很小的程序写对也是非常难的。我把这个想法发表在了博客里,题目是“几乎所有的二分搜索和归并排序都是错的”。认为自己程序是对的就是在愚弄自己。程序里有大量没解决的Bug,当然是不对的。多数情况下,程序里的Bug都不少,它们只能免费完成任务。
我知道,既然写正确的程序那么难,我们就应该尽力去帮助大家。所以能减少Bug的所有东西都是好的。这就是我是静态类型和静态分析的信徒的原因,任何可以减少某个特定类别Bug的东西都是非常好的,任何可以让程序员的工作更轻松的东西都是好的。
我更加确信有好的API文档是很重要的。人们很少提及Javadoc对这个平台的成功所起的作用。好的API文档永远都是Java文化的一部分,也许是因为Javadoc从一开始就存在吧(译者注:所以人们低估了Javadoc的作用)。
我一直信奉“简单就是美”这句话,现在更是如此。我不断看到更复杂的东西最终被证实是有害的,只是有的时间长点儿,有的时间短点儿。我设计的时候,会仔细看着我的“复杂度计”,一旦复杂度要到红线了,就需要重新设计了。
偶尔我会遇到不相信这些的人们,他们会说:“Josh你太傻了,你怎么就是不明白;这才是应该做的,可惜你就是搞不懂。”我就是不信这些。我觉得事情一旦复杂起来,那么一定有什么地方错了,也许到了寻找更简单的方法的时候了。
Tony Hoare的图灵奖获奖感言中有一句充满了大智慧的话,讲的是设计一个系统的两种方式:“一种是尽量简单,这样显然不会有什么问题;另外一种是,尽量复杂,这样没什么问题会很显然。”
后面的内容同样饱含智慧,但是知道的人不多:“第一种方法其实更难。它需要从复杂的自然现象发现简单物理规律的那种技能、投入、洞察力,甚至是那种灵感,同时还需要你能接受你的目标受限于物理、逻辑和科技的约束,以及在目标间有冲突的时候可以妥协。委员会无法做到这些,除非已经完全来不及了。”
Seibel:你是否想过在职业生涯中再次更换你的主要语言,还是准备退休前一直做Java?
Bloch:我自己也不知道。我从C语言转向Java有点突然。从研究生毕业时起,到1996年,我主要使用C语言编程,然后一直使用Java直到现在。我已经预见到我可能要更换到其他语言了。但是我不知道是什么语言。也许它还不存在。我觉得产生一个新编程语言的时机已经成熟,但是同时我又觉得平台的惯性也比以前更大了。现代的平台不仅仅是一个语言和一些库,它包括很多工具,是一个虚拟机,一个庞然大物。创建一个完整的新平台的前景比以前更不乐观。
我不知道将出现什么。但是我认为如果改变我的主要语言是对的,那么我就会这么做。我想尽力保持开放的心态。我想尝试更多的语言。我最近没时间做,但是以后还是会做的。
Seibel:列出几个你想尝试的语言吧?
Bloch:我想试试Scala,虽然我怀疑它是否能成为未来的新宠。我很崇敬MartinOdersky。我觉得他写的语言中有很多精妙的想法。但是我同时也认为他加入了太多复杂的东西,太学术化了,所以很难取得世界范围内的大成功。当然我还没权利去评价,因为我还没学习过。
我还想用用Python。Scheme不是新生物,不过我也想试试。我想花几个月,跟我儿子一起过一遍《Structure and Interpretation of Computer Programs》一定很有意思。每个人都说这是一本伟大的书。我已经买了,算是开了个头。看完它需要一些时间。我想这就是我目前想学的。
Seibel:现在很多人在讨论我们写程序的时候,如何能把未来的多核CPU的优势利用起来。Java显然是第一个内建多线程机制的主流语言。你觉得Java的逻辑在多核的世界是否仍然可用?
Bloch:我想说得更深入一些。我认为Java是现有语言中最好的。但有趣的是,现在很流行谈Java是否即将死去。我觉得这基本上是扯淡。我认为现在最好的多线程构件就在Java里。我认为Java将迎来复兴。我不是说它是未来20年内最先进的,也不是说它是处理多核的最好方式。但是我认为从现有的东西来看,我们是足以傲视同侪的。
(本文来源于《程序员》杂志11年01期,内容节选自人民邮电出版社北京图灵文化发展有限公司出版的《编程人生》一书。特此感谢图灵公司授权。)