Why Java Sucks and C# Rocks(2):基础类型与面向对象

原文 - Why Java Sucks and C# Rocks(2):基础类型与面向对象

既然已经谈过这次语言比较的意义与目的,而完整的幻灯片和录音也已经放出,那么接下来自然是详细讨论了。在这篇文章中,我会对两个语言的基本特征进行简单描述,并主要讨论两者对于基础类型的处理方式。在我看来,Java语言对于基础类型的处理方式,并不如C#中值类型般妥当。如果您有任何觉得不妥或是想要补充的意见,请不吝回复。由于C# 1.0发布于2002年,因此本文内容将基于Java 1.4及C# 1.0的情况。

Java语言简单描述#

Java既是一个完整的平台,也是一门语言。Java语言是1995年由James Gosling在Sun Microsystems公司设计,作为Java平台的组成部分之一的语言。Java平台除了语言之外,还有两个组成部分,虚拟机(JVM)和类库。不过在这一系列文章中,我会将关注里放在Java语言这个单独的方面,偶尔会谈到一点JVM,而对于类库方面则几乎不会涉及。

Java语言参考了C语言和C++的设计,因此在代码整体风格上与它们比较类似。不过与C++相比,Java语言设计的更为小巧,简单和可靠。Java的类型分为两种:类(class)和基本类型(primitive type),并没有C++中的struct和union类型。同时,Java还提供了boolean类型,并对布尔类型的定义和使用作出了限制。此外,Java中也不允许开发人员进行运算符重载,但提供如synchronize等进行并发控制的语言特性。

Java语言设计的一大目标是可靠性,因此在Java中,部分元素的抽象级别被提高了,例如数组在Java中是预定义类的实例,在C++中就不是。此外,例如在数组访问中会进行下标范围检测,同时也去除了指针等不安全的语言特性。虽然Java中的“引用”也有些“指针”的意味,但更为安全,例如程序员不可以对指针进行算术运算,这样便避免了一些容易出错的做法。

在面向对象类型系统的设计中,Java不允许C++中的多重继承,因为许多人认为多重继承所带了许多复杂性和混乱,可谓弊大于利。不过Java允许开发人员定义“接口”,即一种“契约”而不包含实现,这在一定程度上也可以带来部分多重继承的优点。

总体而言,Java语言去除了C++中大量的复杂或是不安全的特性,这使的Java成为了一门灵活而强大,同时又更为小巧,简单和可靠的语言。从现在的角度看,Java语言大大降低了C++本身所带来的复杂度,让编程工作变的更为简单,具有很高的历史意义。

C#语言简单描述#

C#语言是Anders Hejlsberg为微软.NET平台设计的一门语言。与Java语言不同的是,C#的定位只是.NET平台上各种语言中的一种——尽管如此,从现在来看,.NET平台和Java平台虽然起步不同,但可谓殊途同归。与Java平台比较类似,.NET平台除了多种语言之外,也有对应的虚拟机(CLR)及基础类库(BCL)。

C#的语言设计同样是基于C和C++的,但是也包括了一些Delphi和VB的思想。许多人觉得Java语言相比C++的优越之处在于“简单”,不过似乎C#的设计人员认为Java语言简化的有些太过了,因此C#将指针(受到一定限制)、struct、enum、运算符重载及goto等语言特性包含在C#语言中。不过,C#的设计人员同时也改进了这些特性,例如C#中的enum类型不能同整数类型进行隐式转换。同样,在C#的struct中,我们也可以为它定义构造函数和成员,并可以实现接口。

C#中还提供了一种数据类型“委托(Delegate)”,它可以被视为是一种类型安全的函数指针。一个委托也是一个对象,它明确定义了函数的签名,可以用来保存一个包含了调用时所需的上下文的函数。在.NET程序中,一个回调函数往往会使用委托进行表示,而在Java中,则一般会使用接口。

C#还提供了一些方便开发的特性,例如foreach,using,以及使用params定义可变长数组等等。此外,C#还提供了事件(event)及属性(property)两种对象的成员类型。属性可以简单理解为带有读写器(也可以只读或只写)的字段。Java语言虽然不包含属性,但很早便形成了一个约定,会将getXyz/setXyz方法作为属性处理,许多开发工具和类库都会以这种方式进行处理。

C#语言的初衷是要成为优越于C++和Java的通用程序设计语言。经过多年的发展,C#在语言设计方面的确遥遥领先于Java语言,这也是我写这一系列文章的事实依据。

Java是个纯面向对象语言吗?#

Java在诞生于面向对象理论在业界愈发普及和流行的时期(著名的GoF设计模式一书便出版于1994年),在当时Java声称自己是个“纯面向对象语言”,但事实果真如此吗?我不这么认为,因为有很明显的一点是,Java语言中的基础类型不是对象。

在Java中定义了一系列基础数据类型,例如int,double,boolean等等。这些类型不是对象,它们不包含成员,也不继承于公共基类Object,因此Java并不是一门纯面向对象语言。

不过,其实我关注的并不是“纯面向对象”这个称号,我更关注Java语言在设计时是如何对待这些类型的。从代码上看,这个特点的确造成了诸多不便:

ArrayList list = new ArrayList();
list.add(5); // cannot compile
int i = (int)list.get(0); // cannot compile
int hash = 3.hashCode(); // cannot compile

上面这行代码有三处会编译不通过。ArrayList是个用于保存Object类型对象的容器,可以存放任何类型的对象。不过由于int类型的数值5不是对象,因此它无法被添加到ArrayList中。同样,在通过get方法获取到一个Object类型的对象后,我们也无法将其转换成int类型。自然,int类型没有成员,因此我们也无法通过它调用定义在Object类型上的hashCode方法。

直至Java 1.4,开发人员都必须编写这样的代码:

ArrayList list = new ArrayList();
list.add(Integer.valueOf(5));
int i = ((Integer)list.get(0)).intValue();
int hash = Integer.valueOf(3).hashCode();

在Java类库中也为每个基础类型分别指定了封装类(wrapper class),当遇到一些需要和Object进行互操作的时候,便可以把基础类型包装为一个对象。这些对象继承于Object类型,自然能够被添加到ArrayList中,也拥有hashCode等定义在基类中的方法。只是,在获取到Object对象并转换成封装对象时,还需要调用对象上的某个方法(如上面的intValue方法)才能重新获取到基础类型。

C#中的值类型#

在此期间微软发布了.NET平台和C#语言。在C#语言中并没有所谓的“基础类型”,或者说C#的“基础类型”是作为.NET中的struct类型统一对待的。在.NET框架中定义了一系列struct类型,如Int32,Boolean,Double等等,在C#语言中使用关键字int,bool,double与之对应,这便有了一些“基础类型”的意味。自然,在C#代码中我们也可以直接使用那些类型的名称,完全等价。

开发人员可以在程序中定义自己的struct类型,并包含构造函数,方法或是属性等等,并统一继承于ValueType类型(值类型),而ValueType也是统一基类Object的子类。因此,在C#中那些“基础类型”也拥有Object类及自己的成员,并可以直接应用在需要Object的地方(即隐式转换)。因此,在C#中我们可以直接编写这样的代码:

ArrayList list = new ArrayList();
list.Add(5);
int i = (int)list[0];
int hash = 3.GetHashCode();

相信看了这几行代码您就能明白了。可以看出,在C#中,值类型和普通的类(也被称为引用类型)在使用上并没有任何区别。

当然,既然被称为“值类型”,它自然和普通的引用类型有所区别。首先,在.NET中值类型在赋值时是“整体拷贝”而引用类型只是复制一个引用。更重要的是,值类型是分配在方法的调用栈上,而引用类型则是分配在托管堆上。这意味着前者在当前方法退出后会被自动释放,而后者则必须等待GC运行时将无用的对象消除。

换句话说,值类型不会对GC造成压力(除非进行了装箱),这点很重要。在某些场景中,例如在并行计算时,假如每个线程临时对象创建地过于频繁,则可能导致GC频率加大。而GC在启动时会暂停运行中的所有线程,因此并行计算最终的瓶颈可能就落在了单线程的GC上——此时您投入再多的CPU等运算资源也无济于事。解决这个问题一般有两种办法,首先是启用并行GC,则为每个CPU分配一个独立的托管堆。在执行时,对象会分配在当前CPU的托管堆上,每个托管堆也有一个线程负责GC操作,这样GC能力也会随着计算能力加大而提高,因此不会成为性能瓶颈。另一种,有时候也可能是更为合适的做法,便是将创建的临时对象设定为值类型,这样一切便是在调用栈上的读写操作,便不会对GC造成压力。

在.NET平台中,一旦将一个值类型的对象用作引用类型时(即转化为Object类型或接口),运行时便会对它进行“装箱”:此时运行时会在堆上创建一个对象,并将值类型的内容复制到对象内部。将一个装箱后的对象转化为值类型时,则会将托管堆上的对象内容复制到方法的调用栈上,这便是所谓的“拆箱”。装箱和拆箱在.NET中是由运行时负责的,不需要特定的封装类,支持任意值类型。

在Java中,开发人员无法自定义值类型,因此所有的对象都是分配在托管堆上。不过这些倒也是和平台密切相关的内容,这里便只作一提吧,毕竟我们的目标主要还是在语言方面。

Java 1.5中的自动装箱/拆箱#

Java语言基础类型和封装类型之间的这种转化方式,从1995年Java语言出现开始,一直保持到2004年Java 1.5出现才有所改变,将近十年时间。那么,Java语言究竟是因为缺少竞争对手而不思进取,还是因为没有比较就体会不到麻烦呢?无论怎样,我相信C#在这方面对Java语言产生的影响是毋庸置疑的。

不管怎么样,在Java 1.5中引入了一个新特性:自动装箱/拆箱(auto boxing/unboxing)。此时,我们便可以编写这样的代码了:

ArrayList list = new ArrayList();
list.add(5); // auto boxing
int i = (Integer)list.get(0); // auto unboxing

在int值5用在Object参数的时候,Java语言的编译器将自动生成创建Integer对象的bytecode。同样,将Integer类型的对象赋值给基础类型int的时候,Java语言的编译器也会自动生成intValue等方法的调用。那么,它和C#中的装箱和拆箱有什么不同呢?自然,区别之一在于C#的装箱和拆箱是.NET平台已有的功能,C#编译器只要直接使用即可,而Java的自动装箱和拆箱完全是编译器的工作。但我倒认为,这个区别并不是我这里特别关注的。

毕竟我现在关注的是语言,也就是通过代码本身所表现出来的,尤其是可以体现出两种语言在设计理念上有所不同的区别。

如果我们仔细观察代码的话,就可以发现,Java编译器其实是将Integer对象与int值互转,例如在上面的第3行代码中,我们先将Object对象转化成Integer类型,然后再隐式地转化至int基本类型。而在C#中,int类型是直接和Object类型相互转化的。我认为,Java的这种做法,表示Java的设计者依然不希望开发人员将int等基本类型看作是一种对象,他们只是让编译器可以帮助开发人员少些一些代码而已。换句话说,Java的设计者认为,int可以看作是Integer对象,boolean是Boolean对象,但是int和boolean仍然是基础类型,而不是对象。

下面的这行代码可能更加能够直接说明这个问题:

int hash = 3.hashCode(); // cannot compile

在Java中,这行代码是无法编译通过的,因为Java编译器并不会将int自动视作Integer对象,基础类型在这里依然是基础类型,不是对象。

在之前的讨论过程中,有朋友说,Java在这里不做自动装箱,是因为要在bytecode层面上保持与之前兼容。我不同意这个说法,因为我想象不到实现如C#这样的自动装箱和拆箱会破坏bytecode的兼容性,Java编译器完全也可以在语言级别将int类型和Integer类型等同起来,所以在这方面我认为完全是语言设计理念上的区别。

说实话,我不喜欢Java的思路,我更认同C#这种更为“面向对象”的设计方式。

本文为 赵劼 发表在 个人博客 的系列文章之一。