1.13 弱类型机制够不够强
程序员的呐喊
你觉得……一个动态类型系统能有多大?静态类型系统到底重不重要?我非常想知道答案。
亚马逊有很多很大的系统,绝大多数采用了静态强类型,至少我知道的情况如此。那么有没有这样一种需求,或者说我们能不能用Perl、Ruby、Lisp、Smalltalk完成同样的工作?
其实,我感兴趣的是更宽泛一点的话题,也就是编程语言里的类型系统。例如,强类型之于关系数据建模和编程语言是一样的。你可以为每种可能性建模,也可以用键值对的方法快速建立原型。XML数据建模也一样。
静态类型的优点
下面列出了静态类型的主要优点。
(1)静态类型可以在程序运行之前,依赖其与生俱来的限制来及早发现一些类型错误。(或是在插入/更新记录,解析XML文档等情况下进行检测。)
(2)静态类型有更多机会(或者说更容易)优化性能。
例如,只要数据模型完整丰富,那么实现智能化的数据库索引就会更容易一些。编译器在拥有更精确的变量和表达式类型信息的情况下,可以做出更优的决策。
(3)在C++和Java这样拥有复杂类型系统的语言里,你可以直接通过查看代码来确定变量、表达式、操作符和函数的静态类型。
这种优势或许在ML和Haskell这样的类型推导语言里并不明显,他们显然认为到哪里都要带着类型标签是缺点。不过你还是可以在有助阅读理解的情况下标明类型——而这些在绝大多数动态语言里是根本做不到的。
(4)静态类型标注可以简化特定类型的代码自动化处理。
比如说自动化文档生成、语法高亮和对齐、依赖分析、风格检查等各种“让代码去解读代码”的工作。换句话说,静态类型标签让那些类似编译器的工具更容易施展拳脚:词法工具会有更多明确的语法元素,语义分析时也比较少要用猜的。
(5)只要看到API或是数据库结构(而不用去看代码实现或数据库表)就能大致把握到它的结构和用法。
还有其他要补充的吗?
静态类型的缺点
静态类型的缺点如下。
(1)它们人为地限制了你的表达能力。
比如,Java的类型系统里没有操作符重载、多重继承、mix-in、引用参数、函数也不是一等公民。原本利用这些技术可以做出很自然的设计,现在却不得不去迁就Java的类型系统。
无论是Ada还是C++,或是OCaml等任何一种静态类型系统都有这样的问题。差不多半数的设计模式(不光是GoF的那些)都是扭曲原本自然直观的设计,好将它们塞进某种静态类型系统:这根本就是方枘圆凿嘛。
(2)它们会拖慢开发进度。
事先要创建很多静态模型(自顶向下的设计),然后还要依据需求变化不断修改。这些类型标注还会让源代码规模膨胀,导致代码难以理解,维护成本上升。(这个问题只在Java里比较严重,因为它不支持给类型取别名。)还有就是我上面已经提到过的,你得花更多的时间来调整设计,以适应静态类型系统。
(3)学习曲线比较陡。
动态类型语言比较好学。静态类型系统则相对挑剔,你必须花很多时间去学习它们建模的方式,外加静态类型的语法规则。
另外,静态类型错误(也可以叫编译器错误)对于初学者来说很难懂,因为那时程序根本还没跑起来呢。你连用printf来调试的机会都没有,只能撞大运似的调整代码,祈求能让编译器满意。
因此学习C++比C和Smalltalk难,OCaml比Lisp难,Nice语言比Java难。而Perl所具备的一系列静态复杂性——各种诡异的规则,怎么用,什么时候用等——让它的难度比Ruby和Python都要高。我从来没见过有哪门静态类型语言是很好学的。
(4)它们会带来虚幻的安全感。
静态类型系统确实能减少运行时的错误,提升数据的完整性,所以很容易误导人们觉得只要能通过编译让程序跑起来,那它基本上就没什么bug了。人们在用强静态类型系统的语言写程序时似乎很少依赖单元测试,当然这也可能只是我的想象罢了。
(5)它们会导致文档质量下滑。
很多人觉得自动生成的javadoc就足够了,哪怕不注释代码也没关系。SourceForge上充斥着这样的项目,甚至连Sun JDK也常常有这个问题。(比如,Sun很多时候都没有给static final常量添加javadoc注释。)
(6)很难用它们写出兼具高度动态和反射特点的系统。
绝大多数静态类型语言(大概)都出于追求性能的目的,在运行时丢弃了几乎所有编译器生成的元数据。可是这样一来,这些系统通常也就很难在运行时作出修改(甚至连内省都做不到)。比如,若要想给模块加一个新函数,或是在类里加一个方法,除了重新编译,关闭程序然后重启之外别无他法。
受此影响的不单是开发流程,整个设计理念也难逃波及。你可能需要搭建一个复杂的架构来支持动态功能,而这些东西会无可避免地和你的业务代码混在一起。
我还漏掉了其他什么缺点吗?
只要把上面的列表对调一下,你基本上就可以列出动态类型语言的优缺点了。动态语言的表达能力更强,设计灵活度也更大;易学易用,开发速度快;通常运行时的灵活性也更高。相对地,动态语言无法及时给出类型错误(至少编译器做不到),性能调优的难度也比较高,很难做自动化静态分析,另外,变量和表达式的类型在代码里很不直观,没办法一眼看出来。
静态语言最终会向用户屈服,开始添加一些动态特性,而动态语言常常也会尝试引入一下可选的静态类型系统(或是静态分析工具),此外它们还会设法改善性能,增加错误检测,以便及早发现问题。很遗憾,除非一开始设计语言的时候就考虑到可选的静态类型,否则强扭的瓜怎么也不会甜的。
到底正确的方法是什么?
强弱类型之争真的会让人肾上腺素飙升。选择任何一种都会影响到项目周期、架构以及开发实践。
假设你(暂时)只能选一个的话,下面哪个更符合贵公司的实际情况?
1.总的来说,稳定性更重要。公司规模巨大,业务的复杂度也很高,所以只有为代码和数据建立严格的模型才能拨乱反正。若不能在一开始就正确地建好模型和架构,将来必定被反咬一口,所以最好在前期设计阶段多下点工夫。健壮的接口肯定是少不了的——这基本上就是静态类型的意思了,否则用户怎么知道如何使用它们。另外还必须追求性能最优,这也离不开静态类型和细致的数据模型。我们在业务上最重要的优势是系统和接口能保持稳定、可靠、预期可控以及性能卓越。可选技术有SOAP(或者CORBA)、UML和严格的ERD,所有XML都要有的DTD或者schema定义,以及C++、Java、C#、Ocaml、Haskell和Ada。
2.总的来说,灵活性更重要。我们的业务需求会不断产生不可预期的变化,死板的数据模型几乎不可能合理地预见这些变化。小团队需要迅速完成自己的目标,同时还要适应业务的快速变化。因此我们需要灵活性大、表达能力强的语言和数据模型,哪怕它增加了所需的性能成本也在所不惜。可靠性可以通过严格的单元测试以及敏捷开发实践来保证。我们在业务上最大的优势就是可以快速发布新特性。可选技术有XML/RPC和HTTP,必不可少的敏捷编程,XML和关系数据都采用不那么严格的键值对模型,以及Python、Ruby、Lisp、Smalltalk和Erlang。
下面我会以稍微有点戏谑的方式解释这两种理念的工作流程,尽可能将它们的本质区别展现出来。
强类型阵营基本是这样工作的:首先是按照当前的需求进行设计;制定出文档,哪怕只是初稿也没关系;然后定义接口和数据模型。假设系统要承受巨大流量,因此每个地方都要考虑性能。避免采用垃圾收集和正则表达式这类抽象。(注意:即便是Java程序员,通常也会努力避免触发垃圾收集,他们总是在开始写程序之前讨论对象池的问题。)
他们只有在无计可施的情况下才会考虑动态类型。例如,一支采用CORBA的团队只有在极端情况下才会在每个接口调用上添加一个XML字符串参数,这样他们就能绕开当初选择的死板的类型系统了。
第二个阵营基本是这样工作的:先搭建原型。只要你写代码的速度比写同等详细程度的文档快,你就可以更早地从用户那里获得反馈。按照当下的需求定义合理的接口和数据模型,但是别在上面浪费太多时间。一切以能跑起来为准,怎么方便怎么来。假设自己肯定要面对大量的需求变化,所以每个地方首先考虑的是尽快让系统运行起来。能用抽象的地方就尽量用(比如每次都去收集数据而先不考虑缓冲,能用正则的地方就先不用字符串比较),就算明知是牛刀也没关系,因为你换回的是更大的灵活性。代码量比较少,通常bug的数量也会更少。
他们只有在被逼无奈的情况下才会进行性能调优以及禁止修改接口和数据定义。例如,一支Perl团队可能会将一些关键的核心模块用C重写,然后创建XS绑定。时间一长,这些抽象就渐渐变成了既定标准,它们被包裹在数据定义和细致的OO接口里,再也无法修改。(就算是Perl程序员也常常会忍不住祭出银弹,为常用的抽象编写OO接口。)
那你觉得最终采用这些策略的结果会怎么样?
案例大分析
多年来我目睹了亚马逊客服应用的(各种形式的)强弱之争。一开始我是这样被归类的:
语言上我属于“强类型”阵营,最爱的语言是Java;
协议(如在SOAP上的XML/RPC)和XML建模(完全不要DTD和schema)上我倾向的是“弱类型”阵营;
关系建模上则不属于任何一方(谦虚低调向专家学习)。
我观察下来的一个结论就是喜欢Perl的那些家伙总是能很快把东西做出来,速度快得吓人,连经验丰富的Java程序员也比不过。而且活儿干得又好又快,绝不是Java程序员想象中的那种hack一大堆的东西。他们的代码通常整洁有序,就算一开始有点乱,他们也会定期保养修复。有时候他们会顺手写一点hack味道很重的脚本,然而事实一再证明,这种能力往往是非常关键的。不过总的来说,Perl和Java绝对可以分庭抗礼。就算性能出现瓶颈,他们也能发挥聪明才智想办法解决问题。
客服应用曾经为客户联络方式建立过一个关系数据模型,不过由于采用的是无类型的属性系统(说白了就是键值对),它并不完善。关系模型变化的频率很低,其中一个原因是当时流行的是集中控制的数据库,就算我们的软件工程师里有不少数据建模天才,修改数据结构仍然很麻烦。另一个原因是就算可以改,也赶不上需求变化的速度。所以灵活的联络属性就是我们的救命稻草了,就算静态语言阵营也不得不同意这一点。
好吧,数据完备性的确是个问题。键值对模型则更容易发生名字错误(笔误)、无效值、无效依赖等,因为没有办法依赖数据库来保证约束。不过也正是因为这样,我们变得更加谨慎小心。每次只要发现数据不完备,我们就会设法写程序把丢失的数据填回去。这活儿有时候并不简单。但要知道就算是强类型的表格,也不可能彻底消灭数据完备性错误。所以不管是不是强类型,从错误中恢复过来的能力都是必不可少的。
好吧,有时候我们也会受到性能的困扰——可是Java也要面对这个问题,C++代码同样不能幸免(没办法,客服应用是个大杂烩,我们差不多要和公司里所有的系统打交道)。语言和性能其实没有必然联系,你要做的就是找到并且修复“瓶颈”。
不管怎么样,多年来我们一直都同时在Java和Perl下进行开发,这纯粹是当初相互博弈妥协后的结果。我们在讨论怎么实现Arizona(客服应用的内部Web版)的时候,倾向Perl和Java的意见基本上是对半开。
不过真正开始开发的时候,Perl的那部分完成任务的速度可谓惊人。有一段时间,Arizona的Perl代码要比Java来得多,就是因为Perl程序员干完自己的活儿以后,把Java的那部分也拿过来顺手实现了。后来公司的“风向”逐渐倾向Java。这个说来话长,这里先按下不表,总之几年以后,Arizona里大部分的Perl代码都用Java重写了。(我听说我离开以后,风向又再次改变,Java又渐渐被C++和Perl赶了上来,不过那个基本上和语言本身没什么关系。)
不管这么说,多年来我见证了Perl和Java程序员一起开发同一个系统的情况,有时候甚至要分别实现同一份逻辑。我承认这种Perl和Java协同开发的方式确实效率不高,但当时选择那么做是有正当理由的,所以,我有幸亲眼目睹了一场强弱类型阵营之间长达数年的较量,而且是在同一个生产环境里,可谓针尖对麦芒。
总的来说,感觉很震撼。我当时是Java的死忠,不过不得不承认Perl确实比Java更精悍、更简单。Perl的代码谈不上“干净”,反正它本来在这方面就名声不佳,不过模块化做得还可以。它的架构很好,能出活,表现稳定。
尽管我读Java已经没有任何障碍,但相比之下它(对我来说)确实复杂得多。我觉得Java程序员总是有一种过度工程化的倾向,我自己都不能例外。我想大概很多Java程序员会觉得我们的Perl代码写得太粗糙吧。可是如果它真的那么粗糙的话,早就应该出问题了。其实Perl代码里的大多数问题都是和外部服务(或者数据库,假如没有提供服务的话)交互的问题。大多数服务都没有为接口提供Perl绑定,结果我们的Perl程序员只好自己动手想办法绕开限制。
在数据建模方面,我学到了在关系模型下创建灵活属性系统的技巧。DBA和(特别是)数据建模工程师通常不喜欢这种技巧,但是软件工程师却正好相反,因为它绕过了“真正”的对象关系映射里的限制,在出现新需求的时候也不用去修改数据模型。我很怀疑要是没有这套灵活的系统,我们还能不能跟上变化的速度。
我为什么支持弱类型
我现在觉得要解决亚马逊所面对的大多数问题,其实方法二(具备一定限制的弱类型系统)比方法一(没那么严格的静态强类型)更好。我们的业务一直在快速变化,所以要不断地调整接口和数据模型。要做的事情永远都比我们料想得多。当然啦,我们可以仰赖摩尔定律……除了它已经失效了3年之外。不过谁知道呢……
尽管我觉得客服应用的例子已经相当能说明问题了(这种长期大规模在同一个系统下比较两种不同理念的类型系统的事情可不常见),但它并不是我的唯一论据。
我更喜欢弱类型的另一个主要原因是我见过很多采用强类型的团队最终都放弃了。我见过一支团队彻底放弃“硬性规定”的接口,给一个CORBA接口加上一个XML字符串参数,我觉得这个决定实在是太英明了。他们被CORBA的类型系统折腾得死去活来:一个客户的细微改动就会影响所有客户。这个“后门”让他们能多喘一口气。
这种事情我见得太多了。Sun的JMX接口看起来是强类型的,其实它通过一个字符串参数(“ObjectID”)嵌了一个弱类型的查询语言进去,编译器、接口生成器什么的对这个参数都一无所知。强类型的好日子可谓到头了。
有一次我们给某个大型体育厂商做了一个网站,他们希望让客户能在运动衫等商品上印上他们的名字缩写(或者客户号码等随便什么东西)。可是我们的订单模型(以及相应的强类型接口)都是定死的,根本没办法把自定义的缩写字段包含在发货请求里传给后台。(这个其实还不算发货请求,不过这不是重点……)我到现在都还记得当时那种好几个星期对不知道要怎么解决这个问题的焦虑。
而客户名缩写这种事情和无线设备(手机)所带来的模型变化相比就是小巫见大巫了。那种焦虑持续了好几个月。而无线设备和我们最近的项目比起来(至少从数据模型和接口设计的角度相比)又更加微不足道。
我还能举出更多第一手的资料。基本上,静态强类型一而再再而三地给我们带来麻烦,而弱类型却不会造成更多“糟糕透顶”的结果。毕竟不管用什么方法,糟糕透顶的事情总是避免不了的。
完全以我个人的观点来看,设计优秀的弱类型系统比同样优秀的强类型系统更有竞争力(用起来也让人心情更愉悦)。Emacs就是一个例子。虽然我用Java比Lisp用得更好,但是我还是宁可给Emacs写插件,也不想给Eclipse写。简单程度至少差一个数量级。好吧,前提是你会Lisp,而Lisp并不容易学。但是从长远来看,我还是宁可先多花点时间在学习上,这样的效率提升是永久性的。
其实Emacs的设计算不上一流,至少以现代的标准来看如此。要是当初写的时候用了新版本的Lisp的话,那就可以用到OO接口、名字空间、多线程的好处,少依赖一点动态作用域这些东西了。可即便是Emacs这样的庞然大物,为它写插件仍然要简单一个量级。要是你把Emacs和Eclipse的比较范围严格限定在用户扩展机制上,那么这个强弱类型的比较是极具说服力的,弱类型再次获胜。
回过头来看Java:就算是Java的拥护者也认为Java的反射、动态代理、动态类加载、可变参数等无法进行静态检查的特性至关重要。他们虽然会警告你可能存在的性能缺陷,也会告诫你不要太过依赖这些动态特性——但我很怀疑Java社区会愿意放弃这些东西。这些特性被认为是采用Java的主要优势。
我曾经是强类型的大粉丝,不过随着阅历增长,我开始觉得其实我错了,至少对于我们搭建的那些系统来说如此。假如你在银行工作,强类型当然可以胜任。假如你的业务几乎不怎么变化,稳定的数据模型自然没什么问题。假如你所在的行业对性能和安全有异常严格的要求,那么强类型系统是自然的选择。
上周末我看了新版的《银河系漫游指南》,里面有一幅非常好笑的讽刺沃根人英国政府式的官僚的漫画。看完后我告诉自己:我讨厌官僚主义。而静态类型系统基本上就是官僚主义。我不希望被它们束缚住手脚,不用填一大堆表格什么的就能把工作做完。假如非要和一个笨到死的编译器和严格的类型系统打交道才能获取所谓的静态类型安全的话,那还是谢谢你吧,我自己就能处理好类型错误这种事情。
弱类型的能力够不够强?
要是说疑惑,我心里还是有的。弱类型系统是不是天生就扩展性不足?是不是真的像静态类型阵营宣称的那样,达到一定规模后,弱类型系统就是一个巨大的坑?运行时类型错误率会不会失控,就算有详尽的单元测试和软件工程规范也无济于事?
另外,性能的代价真的昂贵到不可接受吗?举个例子,你知不知道有哪些弱类型的大型系统最后用静态类型语言重写,来达到所需的性能目标的?(要求是同一个团队,这样才能表明重写并非是因为语言喜好。)我对那种分布式系统的失败案例特别感兴趣,不是嵌入式系统或是给终端用户使用的桌面程序。
我觉得这会是个问题,理论上,我觉得在亚马逊,用Ruby或Lisp(或Smalltalk、Python等其他支持代码模块化和面向对象抽象机制的动态语言)来构建直接面对客户的大型服务是完全可以做到的。不过我还是希望先看到一些成功的先例。
时至此刻,我觉得强制性的静态类型(例如,Java、C++、Ocaml、Ada这种语言)是妨碍进步和灵活性的。当然我也觉得完全没有(比如Ruby和今天的Python)也是有问题的,因为等到系统的运用模式稳定下来以后,就没有办法有选择地收紧了。我觉得Lisp的方法还蛮接近理想状态的,即静态类型可按需添加。
由于Ruby的性能以及缺乏原生线程的支持,我仍然对于用Ruby(我选择的弱类型语言)编写任何大型应用有所犹豫。对于Common Lisp我也一样有所保留,主要是因为Cliki上的软件包实在是不够看,语言本身的趋势也没有好到让我有信心选择它。对所有其他选项的态度也都差不多(比如Python、Erlang、Scheme、Lua)。
可是我也不想再写Java和C++了。
这个问题有点棘手。
注:出于两个原因,这篇文章里我故意误用了“强”和“弱”这两个词。首先是为了强调我要表达的东西不仅仅是编程语言,数据和接口建模里也有这个问题。其次是这样诗意一点。
在谈到编程语言的时候,我其实是在说静态类型和动态类型。我知道其实最好应该用二维表格来表达静态/动态和强/弱的组合,比如本杰明·皮尔斯在《类型和程序设计语言》的第1章里所采用的方法。
之所以专门强调一下,是因为有一些Python的粉丝实在是不可理喻,他们选择性地无视了我文章里后Python(或者Smalltalk等)时代的观点,因为他们拒绝承认Python的动态类型属于“弱类型”。显然Python和我的诗不怎么合拍。