C#和Java的泛型、C++模板、C#的constraints特性以及弱类型化和强类型化的问题

Anders Hejlsberg,C#的主架构师,与Bruce Eckel和Bill Venners 谈论了C#和Java的泛型、C++模板、C#的constraints特性以及弱类型化和强类型化的问题。

Anders Hejlsberg,微软的一位杰出工程师,他领导了C#(发音是C Sharp)编程语言的设计团队。Hejlsberg首次跃上软件业界舞台是源于他在80年代早期为MS-DOS和CP/M写的一个Pascal编译器。不久一个叫做Borland的非常年轻的公司雇佣了他并且买下了他的编译器,从那以后这个编译器就作为Turbo Pascal在市场上推广。在Borland,Hejlsberg继续开发Turbo Pacal并且在后来领导一个团队设计Turbo Pascal的替代品:Delphi。1996年,在Borland工作13年以后,Hejlsberg加入了微软,在那里一开始作为Visual J++和windows基础类库(WFC)的架构师。随后,Hejlsberg担任了C#的主要设计者和.Net框架创建过程中的一个主要参与者。现在,Anders Hejlsberg领导C#编程语言的后续开发。

2003年7月30号,Bruce Eckel(《Thinking in C++》以及《Thinking in Java》的作者)和Bill Venners(Artima.com的主编)与Anders Hejlsberg在他位于华盛顿州Redmond的微软办公室进行了一次面谈。这次访谈的内容将分多次发布在Artima.com以及Bruce Eckel将于今年秋天发布的一张音频光碟上。在这次访谈中,Anders Hejlsberg谈论了C#语言和.Net框架设计上的一些取舍。

·        在 第一部分:C#的设计过程中, Hejlsberg谈论了C#设计团队所采用的流程,以及在语言设计中可用性研究(usability studies)和好的品味(good taste)相对而言的优点。

·        在第二部分:Checked Exceptions的问题中, Hejlsberg谈论了已检测异常(checked exceptions)的版本(versionability)问题和规模扩展(scalability)问题。

·        在第三部分: 委托、组件以及表面上的简单性里,Hejlsberg 谈论了委托(delegates)以及C#对于组件的概念给予的头等待遇。

·        在第四部分:版本,虚函数和覆写里,Hejlsberg解释了谈论了为什么C#的方法默认是非虚函数,以及为什么程序员必须显式指定覆写(override)。

在第五部分:契约和互操作性里,Hejlsberg谈论了DLL hell、接口契约、strong anmes以及互操作的重要性。 
在第六部分:Inappropriate Abstractions里, Hejlsberg以及C#团队的其他成员谈论了试图让网络透明的分布式系统,以及试图屏蔽掉数据库的对象——关系映射。   
在第七部分, Hejlsberg比较了C#和Java的泛型以及C++模板的实现方法,并且介绍了C#的constraints特性以及弱类型化和强类型化的问题。 
泛型概述
Bruce Eckel: 能否就泛型做一个简短的介绍?

Anders Hejlsberg: 泛型的本质就是让你的类型能够拥有类型参数。它们也被成为参数化类型(parameterized types)或者参数的多态(parametric polymorphism)。经典的例子十九一个List集合类。List是一个方便易用的、可增长的数组。它有一个排序方法,你可以通过索引来引用它的元素,等等。现今,如果没有参数化类型,在使用数组或者Lists之间就会有些别扭的地方。如果使用数组,你得到了强类型保证,因为你可以定义一个关于Customer的数组,但是你没有可增长性和那些方便易用的方法。如果你用的是List,虽然你得到了所有这些方便,但是却丧失了强类型保证。你不能指定一个List是关于什么的List。它只是一个关于Object的List。这会给你带来一些问题。类型检测必须在运行时刻做,也就意味着没有在编译时刻对类型进行检测。即便是你塞给List一个Customer对象然后试图取出一个String,编译器也不会有丝毫的抱怨。直到运行时刻你才会发现他会出问题。另外,当把基元类型(primitive type)放入List的时候,还必须对它们进行装箱(box)。基于上述所有这些问题,Lists与Arrays之间的这种不和谐的地方总是存在的。到底选择哪个,会让你一直犹豫不决。

泛型的最大好处就是它让你有了一个两全其美的办法(you can have your cake and eat it too),因为你可以定义一个List<T>[读作:List of T]。当使用一个List的时候,你可以实实在在地知道这个List是关于什么类型的List,并且让编译器为你做强类型检测。这只是它最直接的好处。接下来还有其它各种各样的好处。当然,你不会仅仅想让List拥有泛型。哈希表(Hashtable)或者字典(Dictionary)——随便你怎么叫它——把键(keys)映射到值(values)。你可能会想要把Strings映射到Customrs,或者ints到Orders,而且是以强类型化的方式。

C#的泛型
Bill Venners: 泛型在C#中是如何工作的?

Anders Hejlsberg: 没有泛型的C#,基本上你只能写class List {...}。有了泛型,你可以写成class List<T> {...},这里T是类型参数。在List<T>范围内你可以把T当作类型来使用,当真正需要创建一个List对象的时候,写成List<int>或者List<Customer>。新类型是通过List<T>构建的,实际上就像是你的类型参数替换掉了原本的类型参数。所有的T都变成了ints或者Customers,你不需要做类型转换,因为到处都会做强类型检验。

在CLR(Common Language Runtime)环境下,当编译List<T>或者其它任何generic类型的时候,会像其它普通类型一样,先编译成中间语言IL(Intermediate Language)以及元数据。理所当然,IL以及元数据包含了额外的信息,从而可以知道有一个类型参数,但是从原则上来说,generic类型的编译与其它类型并没有什么不同。在运行时刻,当应用程序第一次引用到List<int>的时候,系统会查找看是否有人已经请求过List<int>。如果没有,它会把List<T>的IL和元数据以及类型参数int传递给JIT。而JITer在即时编译IL的过程中,也会替换掉类型参数

Bruce Eckel: 也就是说它是在运行时刻实例化的。

Anders Hejlsberg: 的确如此,它是在运行时刻实例化的。它在需要的时候产生出针对特定类型的原生代码(native code)。从字面上看,当你说List<int>的时候,你会得到一个关于int的List。如果generic类型的代码使用了一个关于T的array,你得到的就是一个关于int的array。

Bruce Eckel: 垃圾回收机制会在某个时候来回收它么?

Anders Hejlsberg: 可以说会,也可以说不会,这是一个正交的问题。这个类在应用程序范围内被创建,然后在这个应用程序范围内就一直存在下去。如果你杀掉这个应用程序,那么这个类也就消失了,这点跟其它类一样。

Bruce Eckel: 如果我有一个应用程序用到了List<int>和List<Cat>,但是它从来没有走到使用List<Cat>的那个分支。。。。。。

Anders Hejlsberg:。。。。。。那么系统就不会实例化一个List<Cat>。现在让我说说一些例外的情况。如果你是使用NGEN在创建一个影像(image),也就是说你在直接产生一个native的映像,你可以提早产生这些实例。但是如果你是在通常的情况下运行程序,是否实例化是完全根据需要来确定的,而且推迟到越晚越好。

这之后,我们针对所有值类型(比如List<int>,List<long>,List<Double>, List<float>)的实例化做进一步的处理,创建可执行的原生代码的唯一拷贝。这样List<int>就有它自己的代码。List<long>也有它自己的代码。List<float>也是如此。对于所有引用类型(reference types),我们共享这些代码,因为它们所代表的东西是相同的。它们只是一些指针罢了。

Bruce Eckel: 你需要进行类型转换吧。

Anders Hejlsberg: 不,实际上并不需要。我们可以共享native image,但实际上它们有各自单独的虚函数表(VTables)。我只是想指出,当共享代码有意义的时候,我们会不遗余力的去做这件事情,但是当你非常需要运行效率的时候,我们对于共享代码会非常谨慎。通常对于值类型,你确实会关心List<int>元素的类型就是int。你不想把它们装箱(box)成Objects。对值类型进行装箱/拆箱,是可以用来进行代码共享的一种方法,但是这种方法代价过于昂贵。

Bill Venners: 对于引用类型,实际上也是完全不同的类。List<Elephant>和List<Orangutan>是不同的,但是它们确实共享所有的类方法的代码。

Anders Hejlsberg: 是的。作为实现上的细节来说,它们确实共享了相同的原生代码(native code)。

C#泛型与Java泛型的比较
Bruce Eckel: C#泛型相比Java泛型有什么特点?

Anders Hejlsberg: Java的泛型实现是基于一个最初叫做Pizza的项目,这个项目是由Martin Odersky和其他一些人完成的。Pizza被重新命名为GJ,然后他成了一个JSR,并且最后被采纳进了Java语言。这个特定的泛型proposal有一个关键的设计目标,就是它应该能够跑在不必经过改动的虚拟机上。不用改动虚拟机当然很棒,但是它也带来了一系列奇奇怪怪的限制。这些限制并不都是显而易见的,但是很快你就会说,“Hmm,这可有点怪。”

比如说,使用Java泛型,实际上你就得不到任何刚才我所说得程序执行上的效率,因为当你在Java里编译一个泛型类的时候,编译器拿掉了类型参数并到处代之以Object。List<T>编译好的影像文件(image)就像是一个到处使用Object(作为类型参数)的List。当然,如果你试图创建一个List<int>,那就的对所有用到的int对象进行装箱(boxing)。这就产生了很大的负担。此外,为了与老的虚拟机兼容,编译器实际上会插入各种各样的转换代码,而这些转换代码并不是由你来写的。如果是一个关于Object的List,而你试图把这些Objects当作Customers来对待,这些Objects必须在某些地方被转换成Customers,以便让verifier的验证能够通过。实际上它们的实现所做的就是自动为你插入那些类型转换。也就是说你得到了语法上的甜头,或者至少是一部分语法上的甜头,但是你并没有得到任何程序执行上的效率。这是我认为Java泛型解决方案的第一个问题。

第二个问题是,我认为这可能是更大的一个问题,因为Java的泛型实现依赖于去处掉类型参数,当到了运行时刻,你实际上并没有一个相对于运行时刻的可靠的泛型表示。当你在Java里针对一个泛型List使用反射(reflection)的时候,你并不知道这个List到底是关于什么的List。它只是一个List。因为你已经丢失了类型信息,对于任何动态代码生成(dynamic code-generation)的应用或者基于反射的应用,就没法工作了。这种趋势对我来说已经很明了了,(丢失类型信息的)情况越来越多。它根本没办法工作,因为你丢失了类型信息。而在我们的实现里,所有这些信息都是可获得的。你可以通过反射得到List<T>对象的System.Type表示。但这时候你还不能创建它的实例,因为你还不知道T是什么。但是你可以使用反射得到int的System.Type表示。然后你可以请求反射机制把这两个东西放在一起创建一个List<int>,这样你就得到了另外一个用以表示List<int>的System.Type。也就是说,从表示方法来说,任何你可以在编译时刻做到的事情,你也可以在运行时刻做到。

C#泛型与C++模板的比较
Bruce Eckel: C#泛型相比C++模板有哪些特点?

Anders Hejlsberg: 在我看来,理解C#泛型与C++模板之间的差异最重要的一点就是:C#泛型实际上就像是类,除了它们有类型参数。而C++模板实际上就像是宏(macros),除了它们看起来像是类。

C#泛型与C++模板最大的不同之处在于类型检验发生的时间以及实例化的方式。首先,C#是在运行时刻实例化的,而C++ 是在编译时刻或者可能是在link的时候。但是不管怎样,C++模板实例化发生在程序运行之前。这是第一个不同之处。第二个不同之处在于,当你编译generic类型的时候,C#对它进行强类型检验。对于像List<T>这样未加限制的类型参数(unconstrained type parameter),类型T的值所能使用的方法仅限于Object类型所包含的方法,因为只有这些方法才是通常我们保证能够存在的方法。也就是说,在C#泛型里,我们保证你所实施于类型参数的任何操作都会成功。

C++正好与此相反。在C++里,你可以对一个类型参数做任何你想做的事情。但是当你对它进行实例化的时候,它有可能通不过,而你会得到一些非常难懂的错误信息。比如,你有一个类型参数T以及两个T类型的变量,x和y,如果你写成x+y,那你最好事先定义了用于两个T型变量相加的+运算符,否则你会得到一些古怪的错误信息。所以从某种意义上说,C++模板实际上是非类型化的,或者说是弱类型化的。而C#泛型则是强类型化的。

C#泛型的constraints特性
Bruce Eckel: constraints在C#泛型里是如何工作的?

Anders Hejlsberg: 在C#泛型里,我们可以针对类型参数加一些限制条件(constraints)。还以List<T>为例,你可以写成,class List<T> where T: IComparable。意思是T必须实现IComparable接口。

Bruce Eckel: 有意思的是在C++里限制条件是隐含的。

Anders Hejlsberg: 是的。在C#里,你也可以让限制条件是隐含的。比如说我们有一个Dictionary<K,V>,它有一个add方法,以K为键(key)V为值(value)。Add方法的实现很可能需要把传入的键与Dictionary已有的键进行比较,而且它可能通过一个叫做IComparable的接口来做这个比较。一种方法是把key参数转换成IComparable,然后调用compareTo方法。当然,当你这么做的时候,你就已经针对K类型和key参数创建了一个隐式的限制条件。如果传入的key没有实现IComparable接口,你就会得到一个运行时错误。但是实际上你并没有在你的哪个方法里或者约定里明确表明key必须实现IComparable。而且你当然还得付出运行时刻类型检测的代价,因为实际上你所做的是运行时刻的动态类型检验。

使用constraint,你可以把代码里的动态检验提前,在编译时刻或者加载的时候对它进行验证。当你指定K必须实现IComparable,这就隐含了一系列的东西。对于任何K类型的值,你都可以直接访问接口方法,而不需要进行转换,因为从语义上来说,在整个程序里K类型要实现这个接口,这一点是得到保证的。无论什么时候你想要创建该类型的一个实例,编译器都会针对你给出的任何作为K参数的类型进行检验,看它是否实现了IComparable。如果没有实现,你会得到一个编译时错误。或者如果你是利用反射来做的话,会得到一个异常

Bruce Eckel: 你说到了编译器以及运行时刻。

Anders Hejlsberg: 编译器会做检验,但是你也可能是在运行时刻通过反射来做的,这时候就由系统来做检验。如前所述,任何你在编译时刻可以做的事情,你都可以在运行时刻通过反射来做。

Bruce Eckel: 我是否可以写一个模板函数,或者换句话说,一个参数类型未知的函数?你们是在所做的是给容器加上更强的类型检验,但是我是否可以像在C++模板里那样得到弱类型化的东西呢?比如说,我是否可以写一个函数,它以A a和B b作为参数,然后我在代码里就可以写a+b?我是否可以不关心A和B是什么,只要它们有一个“+”运算符就可以了,因为我想要的是弱类型化。

Anders Hejlsberg: 你实际上问的是,通过constraints你到底能做到什么程度?与其它特性类似,如果把constraints发挥到极致,他可以变得异常复杂。仔细想想,其实constraints是一种模式匹配(pattern matching)的机制。你想要能指定,“该类型参数必须有一个接受两个参数的构造函数,并且实现了+运算符,要有某个静态方法,以及其它两个非静态方法,等等。”问题是,你想要这种模式匹配的机制复杂到哪种程度?

从什么也不做到功能全面的模式匹配,这是很大的一个范围。我们认为什么也不做太说不过去了,而全面的模式匹配又会变得非常复杂,所以我们选择了折衷的方式。我们允许你指定一个constraint,它可以是一个类、零个或者多个接口、以及叫做constructor constraint的东西。比如说,你可以指定“该类型必须实现IFoo和IBar接口,”或者“该类型必须继承自基类X。”一旦你这么做了,我们会在所有地方做类型检验以确认该constraint是否为真,包括编译时刻和运行时刻。任何由这个constraint所暗含的方法都可以通过类型参数的实例直接访问。

另外,在C#里,运算符都是静态成员函数。也就是说,一个运算符永远不可能成为一个接口的成员函数,因此一个接口限制条件(interface constraint)永远不可能让你指定一个“+”运算符。要指定一个“+”运算符,唯一的方法就是通过一个类限制条件(class constraint),这个类限制条件指定说必须继承自某个类,比如说Number类,因为Number有一个“+”运算符。但是你不可能把它抽象成:“必须有一个+运算符”,然后由我们来以多态的方式解析它的实际含义。

Bill Venners: 你是通过类型,而不是签名(signature)来实现限制条件的。

Anders Hejlsberg: 是的。

Bill Venners: 也就是说指定类型必须扩展某个类或者实现某些接口。

Anders Hejlsberg: 是的。本来我们可以走得更远。我们确实考虑过走得更远一些,但是那会非常复杂。并且我们不知道添加这些复杂性相对于你所获得的微不足道的好处,是否值得。如果你想做的事情没有被constraint系统直接支持,你可以借助于工厂模式(factory pattern)来完成。比如说,你有一个矩阵类Matrix<T>,在这个Matrix里你想定义一个标量积(dot product)方法。这当然意味着你最终需要理解如何把两个T相乘,但你不能把它表达成一个constraint,至少如果T是int、double或者float的时候这样做不行。但是你可以这么做:让Matrix接受一个Calculator<T>这样的参数,然后在Calculator<T>里声明一个叫做multiply的方法。你实现这个方法并把它传给Matrix。

Bruce Eckel: Calculator也是个参数化类型。

Anders Hejlsberg: 是的,它有点像factory模式。总之,是有办法来做这些事情的。可能不如你想要的那么棒,但是任何事情都是有代价的。

Bruce Eckel: 嗯,我感觉C++模板像是一种弱类型化(weak typing)的机制。当你开始在它上面添加constraints的时候,你是在从弱类型化转向强类型化(strong typing)。通常加入强类型化都会让事情更加复杂。这像是一个频谱。

Anders Hejlsberg: 你所意识到的类型化(typing)的问题,其实是一个拨盘(dial)。你把它拨的越高,程序员越觉得难受,但同时代码更安全了。但是在两个方向上你都有可能把它拨过头。

你可能感兴趣的:(constraints)