首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

我还记得当我在Microsoft开发VBA的时候,我们曾经就静态类型检查与动态类型检查的优劣问题争论了很长时间。

“静态类型检查”是指编译器在编译时对所有变量的类型进行正确性检查。例如,如果您写了一个名为log的函数,且参数为数字类型,那么当您按照log("foo")的方式调用该函数时(即传入一个字符串),对于静态类型检查的语言来说,编译器会报错,使程序无法成功编译。

而“动态类型检查”则恰恰相反,变量的类型检查是在程序运行的时候进行的。对于动态类型检查的语言来说,语句log("foo")在编译时不会有问题,但等到执行时就会产生一个错误。动态类型检查的缺点是:在真正执行该行语句之前,特别是对于那些很少用到的功能,这种类型错误的bug可能很长时间都不会被发现。

当初设计VBA的目标是为Excel用户提供一种脚本语言。我当时坚定地站在了支持“弱类型”的阵营中,因为对于那些非专业的程序员而言,连“变量”的概念可能都难于把握,更不用说“类型”的含义了;而“弱类型”显然要容易理解的多。

与我立场相同的还有Smalltalk社区的成员,他们当时的论据有点牵强“反正还会有其他问题出现,只不过出现得稍微迟一些而已……”他们的观点有时是正确的,但并不总是正确。

最终,我在争论中获得了胜利,从而将“Variant”数据类型(一种可以容纳任何类型数据的结构)加入到VBA和COM中,并进一步产生了只支持Variant类型的VBScript。由此看来,这的确是一个很受欢迎的主意。

当然,我很清楚“强类型”是一种非常明智的选择,它可以让编译器来检查各种各样的错误。实际上在编写C++程序时,我会大量地使用类型系统来实现各种错误检查。例如,当需要百分之百确定普通雇员永远都不会获得红利的时候,可以创建一个类型系统,其中包括经理(Manager)和雇员(Employee)两个类,且只有Manager类才拥有PayBonus()函数。如果程序编译成功了,那么就可以非常轻松地认定只有值得嘉奖的、高职位的经理才可以获得红利,而普通雇员连想都不要想。

现在的问题在于,通过类型系统很难在编译时完成更多的程序测试工作。类型检查只能帮助实现一种测试,即“我能够对那个对象做这种操作吗?”而不能检测“当输入1.32时,这个函数的返回值是2.12吗?”

实际上对于广大程序员而言,要想找到一种有效的可以用于检查程序细微方面正确性的类型系统,是非常困难的事情。

实践证明,如果想确保程序的正确性,有一种更直接、更强大的工具:单元测试(Unit Test)。因此,我对于Bruce Eckel提出的用强测试来代替强类型的想法十分感兴趣。

最后,在我把话题交由Bruce之前,我必须警告你们一点:动态类型检查会严重地影响效率。这是因为在程序运行时,所有使用到的数据的类型都要受到检查,因此采用动态类型检查的语言执行程序的速度总是要慢于采用静态类型检查的语言。这可能会被接受,但也可能不会,要看具体应用的需要。由于Python语言只支持动态类型检查,所以用它编写的程序执行起来非常缓慢。我所使用的一个由Python语言编写的垃圾邮件过滤程序常常需要花好几秒的时间才能对一封邮件做出判断。若是需要处理10至20封邮件的话,我将为这个精细入微的“动态类型检查”的特性花掉1至2分钟。如果你掌管的是一个Web服务器集群,那么采用动态类型检查将意味着对于同样数量的用户需要配置5至10倍数量的服务器,这是一笔非常昂贵的支出。

因此,您必须自己做出判断:执行效率对于您的应用是否重要。然而,如果您的单元测试能够较为完整地覆盖所有代码,那么您也不必过分担心放弃静态类型检查的后果。—— 编者

这几年,我的主要兴趣都放在了如何提高程序员工作效率的问题上。程序员的时间贵,而CPU的时间便宜,因此我相信,我们不应该继续以程序员的时间为代价来缩短CPU的时间。

怎样才能最为有效地解决这个问题呢?每当一种新的工具(尤其是一种新的编程语言)出现时,它都会提供某种抽象形式,从而向程序员或多或少地隐藏部分细节。而我一直都在关注的是:是否存在这样一种浮士德式的交易(Faustian bargain),可以让我在获得这种抽象的同时,又不用理会各种限制。Perl就是一个绝佳的例子,它隐藏了很多程序生成时的毫无意义的细节。而它难以阅读的语法(据我所知,是为了反向兼容awk、sed和grep这类Unix工具而造成的)却使工作效率大大降低。

近几年来,随着传统编程语言的广泛应用,并且从它们向静态类型检查发展的趋势上看,上述“浮士德式交易”的问题已经逐渐明朗化。最初,我与Perl一起度过了两个月的“蜜月期”,其快速开发的特点使我提高了工作效率(由于Perl对于引用和类的处理很不好,所以“蜜月期”很快就结束了;后来我才发现,原来真正的问题出在语法上)。对于Perl而言,静态类型检查与动态类型检查孰优孰劣的问题根本无从争辩,因为您无法使用Perl来建立大的项目,也就完全没有机会看到这些问题;况且,在较为短小的程序中,Perl糟糕的语法把其他所有问题都掩盖了。

在转而使用Python(一种可以用于建立大型、复杂的系统的编程语言。www.Python.org提供免费下载)之后,我开始注意到:尽管Python并不注重类型检查,但是使用该语言不仅能够方便、快捷地生成程序,而且这些程序似乎还都能运行得很顺利。虽然我们都“清楚”:静态类型检查才是唯一正确的解决编程问题的途径,但是不采用静态类型检查的Python语言也并没有像我们所预想的那样碰到种种问题。

这个现象让我十分困惑:如果静态类型检查真的这么重要,那人们为何还能使用Python编写出大型、复杂的程序(与那些静态类型检查的语言相比,只需较少的时间与精力),而且连我原本断定会发生的故障也没有出现呢?

这动摇了我原先对于静态类型检查的坚定不移的信念(这种信念来源于从非ANSI C转向C++时所表现出的巨大的改进),并促使我再一次分析了Java中checked exception2的问题。这一次,我对其产生了疑问,并最终导致了一场激烈的争论3。在争论中我被告知,如果我继续鼓吹unchecked exception的话,那么人类就会陷入黑暗,文明就会不复存在。在《Java编程思想》第3版(原书名为“Thinking in Java, 3rd Edition”,由Prentice Hall PTR于2002年出版)一书中,我曾经演示了如何将RuntimeException作为一个包装类使用以屏蔽checked exception。尽管我每次这么做时似乎都是可行的(我注意到,Martin Fowler几乎是在同一时间也提出了相同的想法),但偶尔我还是能够收到警告我的电子邮件,说我有悖正义和真理,甚至说我违反了美国的爱国者法案。(Hi,你们这些FBI的家伙,欢迎访问我的博客网站!)

使用checked exception所带来的麻烦似乎要比它的价值更大(关键在于检查,而不是异常—— 我相信一个单纯、一致的错误报告机制才是最根本的)。但是,确定这一点并没有解答“为什么Python并没有像传统的看法所认为的那样产生很多故障,反而工作得很好”这个问题。Python和其他类似的采用动态类型检查的语言一样,对于检查对象类型一事都很不积极。与Java尽早在对象上施加尽可能严格的约束条件的做法正好相反,Ruby、Smalltalk和Python这些语言在对象上仅仅施加最低限度的约束条件,并且只有在必要的情况下才对类型进行判断。

这就是“隐式类型”(latent typing)或“结构式类型”(structural typing)的构想,常常也被俗称为“duck typing”(它源于习惯用语“If it walks like a duck, and talks like a duck, we can just treat it like a duck”(如果像鸭子一样走路并且像鸭子一样说话,那么它就是鸭子了))。它意味着,您可以向任何对象发送任何消息,而语言只关心该对象能否接受该消息。它并不像Java那样强求该对象必须是某一种特定的类型。例如,若您用Java编写一个能够说话的宠物,那么该程序的代码可能会是这样:

// Speaking pets in Java:

interface Pet {

void speak();

}

class Cat implements Pet {

public void speak() { System.out.println("meow!"); }

}

class Dog implements Pet {

public void speak() { System.out.println("woof!"); }

}

public class PetSpeak {

static void command(Pet p) { p.speak(); }

public static void main(String[] args) {

Pet[] pets = { new Cat(), new Dog() };

for(int i = 0; i < pets.length; i++)

command(pets[i]);

}

}

请注意,函数command()必须明确地知道它所能接受的参数的类型—— Pet,而不是其他任何类型。因此,必须创建一个由接口Pet与其派生类Dog和Cat组成的层次结构,这样才能将它们向上转型(upcast)到通用函数command()的参数中。

在很长的一段时间内,我一直都认为向上转型(upcasting)是面向对象编程语言的一个固有组成部分,并且对在那些无知的、使用Smalltalk的人身上也发现了同样的问题而烦恼。但当我开始使用Python时,我发现了一些有趣的事情。上面的那段Java代码可以直接改写成如下的Python代码:

# Speaking pets in Python:

class Pet:

def speak(self): pass

class Cat(Pet):

def speak(self):

print "meow!"

class Dog(Pet):

def speak(self):

print "woof!"

def command(pet):

pet.speak()

pets = [ Cat(), Dog() ]

for pet in pets:

command(pet)

如果您之前从未接触过Python,那么就会注意到它通过一种很好的方式重新定义了“简洁编程语言”的含义,您认为C/C++才叫简洁?抛弃那些花括号吧—— 缩进对于我们的意义已经十分明显,因此可以用缩进来定义作用域。至于函数的参数类型和返回类型,那就让语言自己去处理吧!在创建类时,基类只需要通过放在圆括号中来表示。def用于创建一个函数或方法定义,此外,Python在定义方法时,需要明确地声明this参数(参数名约定为self)。

关键字pass表示“稍后定义”,因此可以将其视为关键字abstract的一个变体。

注意,函数command(pet)仅仅表明了它能够接受一个名为pet的对象作为参数,但它并未提供任何有关该对象的类型的信息。这是因为Python根本就不关心这个问题,只要能够调用speak()函数或是完成其他任何所需的操作即可。这就是隐式duck类型,我们稍后还会作详细介绍。

另外,command(pet)只是一个普通的函数,这在Python中也是允许的。Python并不强制所有东西都必须是对象,而有时您所需的可能就只是一个函数。

在Python中,list和dictionary(又名map或associative array)都是非常重要的数据类型,从而被直接放入语言的内核来实现。因此,在使用它们时并不需要引用任何额外的库。请看以下的代码:

pets = [ Cat(), Dog() ]

这个语句创建了一个list,其中包含了两个类型分别为Cat和Dog的对象。对象的构造函数会被自动调用,而无需使用“new”(您会发现Java其实也无需使用“new”—— 它只是一个从C++继承而来的冗余的部分)。

由于顺序遍历的操作十分重要,因此在Python中该操作也非常直观:

for pet in pets:

该语句从list中依次选出每一个对象并放入变量pet中,这比Java的方式更清楚明了。我认为即使与J2SE5中的“foreach”语法相比,它也毫不逊色。

上述Python程序的输出与Java版的输出并无二致。看到这里,您一定明白了为什么Python经常被称为“可执行的伪代码”:因为它不仅像伪代码那样简单易用,而且真的可以执行。这意味着您能够快速地通过Python来试验您的想法,然后等程序正常运行之后再用Java、C++、C#或其他任何您挑选的语言来重写。您也许会认为既然问题已经用Python解决了,那么为什么还要花精力重写呢(我就经常产生这样的想法)?在我教授的课程中,我已经开始采用Python代码来给出练习的提示,因为这使得我无需给出解决方案的全貌,学生们就能够理解我的意图,从而继续向前摸索。另外,我还可以通过执行来验证伪代码的正确性。

上述代码中还有一个有趣的地方:由于函数command(pet)不关心其参数的类型,因此就不需要向上转型(upcast)了。所以我可以重写这段Python程序且无需定义基类:

# Speaking pets in Python, but without base classes:

class Cat:

def speak(self):

print "meow!"

class Dog:

def speak(self):

print "woof!"

class Bob:

def bow(self):

print "thank you, thank you!"

def speak(self):

print "hello, welcome to the neighborhood!"

def drive(self):

print "beep, beep!"

def command(pet):

pet.speak()

pets = [ Cat(), Dog(), Bob() ]

for pet in pets:

command(pet)

由于command(pet)只关心它能否向其参数发送speak()的消息,因此我干脆将基类Pet去掉,而且还增加了一个名为Bob的、根本就不是宠物的类,它只是碰巧也有一个speak()函数,所以该类也能与函数command(pet)合作得很好。

看到这里,那些静态类型检查的拥护者们一定会暴跳如雷,他们认为像Python这种懒散的处理方式必将导致极大的错误和严重的混乱。毫无疑问,函数command()有可能会接收到类型错误的对象,而且类似的错误还可能潜在于系统的其他地方。使语句简单明了所带来的益处甚至不如所面临的危险—— 即使其开发效率是Java或C++的5到10倍。

当Python程序碰到诸如“对象类型不正确”这样的问题时会如何处理呢?与Java和C#一样(其实C++也应该这样),Python会把所有错误都作为异常报出来。这样,您的确能够发现问题,但却总是出现在运行的时候。您一定会说“啊!这就是问题的关键:由于在编译时缺少必要的类型检查,所以无法确保程序的正确性。”

当我写《C++编程思想》第1版(原书名为Thinking in C++, 1st Edition,由Prentice Hall PTR于1998年出版)的时候,采用了一种非常粗略的测试方法:我先写了一个程序把书中的所有代码自动提取出来(程序利用放置在每段程序开头和结尾处的注释符号来进行识别),然后再建立相应的makefile把代码全部编译。通过这种方式,就能确保书中的所有代码都可以成功编译,从而实现“只要是书中的程序,就都是正确的。”尽管编译成功并不表示执行结果正确,但我并不在乎,因为我已经向“自动程序验证”的目标迈进了一大步(正如每一位编程书籍的读者所知,很多作者仍然不愿意把精力花在代码正确性的验证上)。然而在我书中的一些例子里,程序的执行结果确实存在问题。这些年来随着越来越多的错误被发现,我逐渐意识到不能再继续忽视测试的问题了。在写Thinking in Java第3版的时候,我更是特别强烈地体会到了这一点,因此我写道:

If it’s not tested ,it’s broken(如果没有经过测试,那么程序就是不可用的)

更确切地说,即使程序是用一种静态类型检查的语言编写的,并且编译成功,那么这也只是表明程序通过了一部分的测试。编译只能保证语法的正确性(Python在编译时也检查语法—— 只是它没有那么多语法方面的限制罢了),但它不能保证程序的正确性。如果您的代码貌似可以执行,它并不一定就能获得正确的结果。

无论语言是静态类型检查还是动态类型检查,能够保证正确性的唯一方式就是确认程序通过了所有的测试。正是这些测试定义了程序的正确性。当然,有些测试程序必须由您自己编写,就像单元测试(unit test)、验收测试(acceptance test)等等。在《Java编程思想》第3版中,我就是用程序来进行单元测试的,并且反复执行了一遍又一遍。要知道一旦“测试成瘾”,就很难戒掉了。

这种情况与当年从非ANSI C转向C++时非常类似。编译器突然之间就能够执行更多的检查,从而使得代码可以更加快捷地排除错误。但是,那些语法测试也只能达到这个地步而已。编译器不可能知道您期望程序如何运行,所以您必须进行单元测试(无论使用哪种语言)。只要有充分的单元测试,您就可以迅速地对程序进行大规模的改动(例如重构代码或者修改设计方案),因为测试将会成为您的坚实后盾,只要修改后的程序有问题,测试时就会立即发现—— 正如编译器能够立即发现语法错误一样。

如果没有一套完整的单元测试(这是最起码的要求),程序的正确性就无从得到保证。如果认为C++、Java或C#里的静态类型检查能够防止写出错误的程序,那显然是在痴人说梦(如果实践的话,您也可以证明这点)。事实上,我们需要的是:

强测试,而不是强类型

因此,我坚信强测试就是Python为什么能管用的原因之一。C++的测试发生在编译时(在少数特殊的情况下);Java的测试一部分发生在编译时(语法检查),另外一部分发生在运行时(例如数组边界检查);对于Python而言,绝大多数测试都发生在运行时,而不是在编译时,但测试的确是实行了,这才是关键。由于编写一个Python程序并让其运行起来所花费的时间比对应的C++/Java/C#版本要少得多,所以我可以更早地开始真正的测试:单元测试、根据我的假设进行测试、换用其他方法进行测试,等等。只要一个Python程序能得到充分的单元测试,那么它就可以和那些进行了充分的单元测试的C++、Java或C#程序一样稳健(而且用Python编写测试程序要更加快捷)。

Robert Martin是一位长期活跃在C++社区中的积极分子,他撰写了不少图书、文章、参考资料、教材等等。他应该算是一位支持静态类型检查的中坚分子,至少我是这么认为的,直到有一天我读到了他在自己博客上发表的一篇文章(请访问http://www.artima.com/weblogs/viewpost.jsp?thread=4639。—— 编者)。Robert也得出了与我大致相同的结论,只是他首先变得“测试成瘾”,然后才意识到编译器仅仅是一种(而不是全部)形式的测试。他还明白了一种动态类型检查的语言不仅能够提高工作效率,而且同样能够编写出与静态类型检查的语言一样稳健的程序——前提是要进行充分的测试。

当然,Martin也遭到了质疑他的想法的批评。这个问题当初也使得我在静态/动态类型检查这两种观念之间苦苦挣扎。我们两人原本都是坚定的静态类型检查的支持者,有趣的是,导致我们的信念被颠覆的起因都是一次深刻的经历——例如变得“测试成瘾”或学习另一种完全不同的语言。