【笔记】《java编程思想(第四版)》第5章-初始化与清理

第5章 初始化与清理

随着计算机革命的发展,“不安全”的编程方式已逐渐成为编程代价高昂的主因之一。

**初始化和清理(cleanup)**正是涉及安全的两个问题。

C++引入了**构造器(constructor)**的概念,这是一个在创建对象时被自动调用的特殊方法。Java中也采用了构造器,并额外提供了“垃圾回收器”。对于不再使用的内存资源,垃圾回收器能自动将其释放。

5.1 用构造器确保初始化

如何命名这个初始化方法。有两个问题:第一,所取的名字都可能与类的某个成员名称相冲突;第二,调用构造器是编译器的责任,所以必须让编译器知道应该调用哪个方法。C++语言中采取的解决方案看来是最简单且更符合逻辑,所以在Java中也采用了这种方案:即构造器采用与类相同的名称。考虑到在初始化期间要自动调用构造器,这种做法就顺利成章了。

现在,在创建带有构造器的类的对象时,将会为对象分配存储空间,并调用相应的构造器。这就确保了在你能操作对象之前,它已经被恰当地初始化了。

请注意,由于构造器的名称必须与类名完全相同,所以“每个方法首字母小写”的编码风格并不适用于构造器。

不接受任何参数的构造器叫做默认构造器,Java文档中通常使用术语无参构造器,但是默认构造器在Java出现之前就已经使用许多年了,所以我仍旧倾向于使用它。但是和其他方法一样,构造器也能带有形式参数,以便指定如何创建对象。

有了构造器形式参数,就可以在初始化对象时提供实际参数。

如果Tree(int)是Tree类中唯一的构造器,那么编译器将不会允许你以其他任何方式创建Tree对象。

构造器有助于减少错误,并使代码更易于阅读。从概念上讲,“初始化”与“创建”是彼此独立的然而在上面的代码中,你却找不到对Initialize()方法的明确调用。在Java中,“初始化”和“创建”捆绑在一起,两者不能分离。

构造器是一种特殊类型的方法,因为它没有返回值。这与返回值为空(void)明显不同。对于空返回值,尽管方法本身不会自动返回什么,但仍可选择让它返回别的东西。构造器则不会返回任何东西,你别无选择(new表达式确实返回了对新建对象的引用,但是构造器本身并没有任何返回值)。假如构造器具有返回值,而且允许人们自行选择返回类型,那么势必得让编译器知道该如何处理此返回值。

5.2 方法重载

任何程序设计语言都具备的一项重要特性就是对名字的运用。当创建一个对象时,也就给次对象分配到的存储空间取了一个名字。所谓方法则是给某个动作取的名字。通过使用名字,你可以引用所有对象和方法。

大多数程序设计语言(尤其是C)要求为每个方法(在这些语言中经常称为函数)都提供一个独一无二的标识符。所以绝不能用名为print()的函数显示了整数之后,又用一个名为print()的函数显示浮点数——每个函数都要有唯一的名称。

在Java(和C++)里构造器是强制重载方法名的另一个原因。既然构造器的名字已经由类名所决定,就只能有一个构造器名。那么要想用多种方式创建一个对象,比如你要创建一个既可以用标准方式进行初始化、也可以从文件里读取信息来初始化的类。这就需要两个构造器:一个默认构造器,另一个取字符串作为形式参数——该字符串表示初始化对象所需的文件名称。由于都是构造器,所以它们必须有相同的名字,即类名。为了让方法名相同而形式参数不同的构造器同时存在,必须用到方法重载。同时,尽管方法重载是构造器所必需的,但它亦可用于其他方法,且用法同样方便。

5.2.1 区分重载方法

区分相同名字的方法,Java的判断规则很简单:每个重载的方法都必须有一个独一无二的参数类型列表。

甚至参数顺序的不同也足以区分两个方法。不过,一般情况下别这么做,因为这会使代码难以维护。

5.2.2 涉及基本类型的重载

如果传入的数据类型(实际参数类型)小于方法中声明的形式参数类型,实际数据类型就会被提升。char型略有不同,如果无法找到恰好接受char参数的方法,就会把char直接提升至int型。

如果传入的实际参数大于重载方法声明的形式参数,就得通过类型转换来执行窄化转换。

5.2.3 以返回值区分重载方法

能否用方法的返回值来区分呢?

只要编译器可以根据语境明确判断出语义,比如在int x=f()中,那么的确可以据此区分重载方法。不过,有时你不关心返回值,只想要方法调用的其他效果(这常被称为“为了副作用而调用”),比如 f();

此时Java如何才能判断该调用哪一个f()呢?因此,根据方法的返回值来区分重载方法是行不通的。

5.3 默认构造器

如前所述,默认构造器(又名“无参”构造器)时没有形式参数的——它的作用是创建一个“默认对象”。如果你写的类中没有构造器,则编译器会自动帮你创建一个默认构造器。

但是,如果已经定义了一个构造器(无论是否有参数),编译器就不会帮你自动创建默认构造器。

5.4 this关键字

如果有同一类型的两个对象,分别是a和b。如何能让这两个对象都能调用peel()方法呢:

//:initialization/BananaPeel.java
class Banana(void peel(int i)){/*……*/}
public class BananaPeel{
    public static void main(String[] args){
        Banana a = new Banana(), b = new Banana();
        a.peel(1);
        b.peel(2);
    }
}///:~

如果只有一个peel()方法,它如何知道是被a还是被b所调用的呢?

为了能用简便、面向对象的语法来编写代码——即“发送消息给对象”,编译器做了一些幕后工作。它暗自把“所操作对象的引用”作为第一个参数传递给peel()。所以上述两个方法的调用就变成了这样:

Banana.peel(a,1);

Banana.peel(b,2);

这是内部的表示形式。我们并不能这样书写代码,并试图通过编译。

假如你希望在方法的内部获得对当前对象的引用。由于这个引用是由编译器“偷偷”传入的,所以没有标识符可用。但是,为此有个专门的关键字:this,this关键字只能在方法内部使用,表示对“调用方法的那个对象”的引用。this的用法和其他对象引用并无不同。但要注意,如果在方法内部调用同一个类的另一个方法,就不必使用this,直接调用即可。当前方法中的this引用会自动应用于同一类中额其他方法。所以可以这样写代码:

//:initialization/Apricot.java
public class Apricot{
    void pick(){/*...*/}
    void pit(){pick();/*...*/}
}///:~

在pit()内部,你可以写this.pick(),但无此必要。(有些人执意将this放在每一个方法调用和字段引用前,认为这样“更清楚明确”。但是,千万别这么做。我们使用高级语言的原因就是它们能帮我们做一些事情。人们期望只在必要处使用this。遵循一种一致而直观的编程风格能节省时间和金钱。)编译器能帮你自动添加。只有当需要明确指出对当前对象的引用时,才需要使用this关键字。

5.4.1 在构造器中调用构造器

可能为一个类写了多个构造器,有时可能想在一个构造器中调用另一个构造器,以避免重复代码。可用this关键字做到这一点。

通常写this的时候,都是指“这个对象”或者“当前对象”,而且它本身表示对当前对象的引用。在构造器中,如果为this添加了参数列表,那么就有了不同的含义。这将产生对符合此参数列表的某个构造器的明确引用;这样,调用其他构造器就有了直接的途径。

尽管可以用this调用一个构造器,但不能调用两个。此外,必须将构造器调用置于最初始处,否则编译器会报错。

this的另一个用法:由于参数的名称可以与数据成员的名字相同,所以会产生歧义。(假如都是s) 使用this.s来代表数据成员就能解决这个问题。

除构造器之外,编译器禁止在任何其他方法中调用构造器。

5.4.2 static的含义

static方法就是没有this的方法。在static方法的内部不能调用非静态方法,反过来倒是可以的。而且可以在没有创建任何对象的前提下,仅仅通过类本身来调用static方法。这实际上正式static方法的主要用途。它很像全局方法。Java禁止使用全局方法,但你在类中置入static方法就可以访问其他static方法和static域。

有些人认为static方法不是“面向对象”的,因为它们的确具有全局函数的语义;使用static方法时,由于不存在this,所以不是通过“向对象发送消息”的方式来完成的。的确,要是在代码中出现了大量的static方法,就该重新考虑自己的射击了。然而,static的概念有其实用之处,许多时候都要用到它。至于它是否真的“面向对象”,就留给理论家去讨论把。事实上Smalltalk语言里的“类方法”就是与static方法相对应的。

5.5 清理:终结处理和垃圾回收

把一个对象用完后就“弃之不顾”的做法并非总是安全的。当然,Java有垃圾回收器负责回收无用对象占据的内存资源。但也有特殊情况:假定你的对象(并非使用了new)获得了一块“特殊”的内存区域,由于垃圾回收器只知道释放那些经由new分配的内存,所以它不知道该如何释放该对象的这块“特殊”内存。为了应对这种情况,Java允许在类中定义一个名叫finalize()的方法。它的工作原理“假定”是这样的:一旦垃圾回收器准备好释放对象占用的存储空间,首先调用其finalize()方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。所以要是你打算用finalize(),就能在垃圾回收时刻做一些重要的清理工作。

这里有一个潜在的编程陷阱,因为有些程序员(特别是C++程序员)刚开始可能会误把finalize()当作C++中的析构函数(C++中销毁对象必须用到这个函数)。所以有必要明确区分一下:在C++中,对象一定会被销毁(如果程序中没有缺陷的话);而Java里的对象却并非总是被垃圾回收。换句话说:1.对象可能不被垃圾回收。2.垃圾回收并不等于“析构”。

牢记这些,就能远离困扰。这意味着在你不再需要某个对象之前,如果必须执行某些动作,那么你得自己去做。Java并未提供“析构函数”或类似的概念,要做类似的清理工作,必须自己动手创建一个执行清理工作的普通方法。例如,假设某个对象在创建过程中会将自己绘制到屏幕上,如果不是明确地从屏幕上将其擦除,它可能永远得不到清理。如果在finalize()里加入某种擦除功能,当“垃圾回收”发生时(不能保证一定会发生),finalize()得到调用,图像就会被擦除。要是“垃圾回收”没有发生,图像就会一直保留下来。

也许你会发现,只要程序没有濒临存储空间用完的那一刻,对象占用的空间就总也得不到释放。如果程序执行结束,并且垃圾回收器一直都没有释放你创建的任何对象的存储空间,则随着程序的退出,那些资源也会全部交还给操作系统。这个策略是恰当的,因为垃圾回收本身也有开销,要是不使用它,那就不用支付这部分开销了。

5.5.1 finalize()的用途何在

finalize()不该用于通用的清理方法,其真正用途是什么呢?这引出了要记住的第三点:3.垃圾回收只与内存有关。

也就是说,使用垃圾回收器的唯一原因是为了回收程序不再使用的内存。所以对于与垃圾回收有关的任何行为来说(尤其是finalize()方法),它们也必须同内存及其回收有关。

但这是否意味着要是对象中含有其他对象,finalize()就应该明确释放那些对象呢?不,无论对象是如何创建的,垃圾回收器都会负责释放对象占据的所有内存。这就将对finalize()的需求限制到一种特殊情况,即通过某种创建对象方式以外的方式为对象分配了存储空间。

看来之所以要有finalize(),是由于在分配内存时可能采用了类似C语言中的做法。而非Java中的通常做法。这种情况主要发生在使用“本地方法”的情况下。本地方法是一种在Java中调用非Java代码的方式。本地方法目前只支持C和C++,但它们可以调用其他语言写的代码,所以实际上可以调用任何代码。在非Java代码中,也许会调用C的malloc()函数系列来分配存储空间,而且除非调用了free()函数,否则存储空间将得不到释放,从而造成内存泄露。当然,free()是C和C++中的函数,所以需要finalize()中用本地方法调用它。

总之,不要过多地使用finalize()

5.5.2 你必须实施清理

要清理一个对象,用户必须要清理的时刻调用执行清理动作的方法。但却与C++中的“析构函数”的概念稍有抵触。在C++中,所有对象都会被销毁,或者说,应该被销毁。如果在C++中创建了一个局部对象(也就是在堆栈上创建,这在Java中行不通),此时的销毁动作发生在以“右花括号”为边界的、此对象作用域的末尾处。如果对象使用new创建的(类似于Java中),那么当程序员调用C++的delete操作符时(Java没有这个命令),就会调用相应的析构函数。如果程序员忘记调用delete,那么永远不会调用析构函数,这样就会出现内存泄露,对象的其他部分也不会得到清理。这种缺陷很难追踪,这也是C++程序员转向Java的一个主要因素。

相反,Java不允许创建局部对象,必须使用new创建对象。在Java中,也没有用于释放对象的delete,因为垃圾回收器会帮助你释放存储空间。甚至可以肤浅地认为,正是由于垃圾收集机制的存在,使得Java没有析构函数。然而,随着学习的深入,读者会明白垃圾回收器的存在并不能完全代替析构函数。(而且绝对不能直接调用finalize(),所以这也不是一种解决方案。)如果希望进行除释放存储空间之外的清理工作,还是得明确调用某个恰当的Java方法。这就等同于使用析构函数了,只是没有它方便。

记住,无论是“垃圾回收”还是“终结”,都不保证一定会发生。如果Java虚拟机(JVM)并未面临内存耗尽的情形,它是不会浪费时间去执行垃圾回收以恢复内存的。

5.5.3 终结条件

通常,不能指望finalize(),必须创建其他的“清理”方法,并且明确地调用它们。看来,finalize()只能存在于程序员很难用到的一些晦涩用法里了。不过,finalize()还有一个有趣的用法,它并不依赖于每次都要对finalize()进行调用,这就是对象终结条件的验证。

当对某个对象不再感兴趣——也就是它可以被清理了,这个对象应该处于某种状态,使它占用的内存可以被安全地释放。只要对象中存在没有被适当清理的部分,程序就存在很隐晦的缺陷。finalize()可以用来最终发现这种情况——尽管它并不总是会被调用。如果某次finalize()的动作使得缺陷被发现,那么就可据此找出问题所在——这才是人们真正关心的。

System.gc()用于强制进行终结动作。即使不这么做,通过重复地执行程序(假设程序将分配大量的存储空间而导致垃圾回收动作的执行),最终也能找出错误的Book对象。

你应该总是假设基类版本的finalize()也要做某些重要的事情,因此要使用super来调用它。它可能需要进行异常处理,而我们还没有介绍过这部分内容。

5.5.4 垃圾回收器如何工作

在以前所用过的程序语言中,在堆上分配对象的代价十分高昂,因此读者自然会觉得Java中所有对象(基本类型除外)都在堆上分配的方式也非常高昂。然而,垃圾回收器对于提高对象的创建速度,却具有明显的效果。听起来很奇怪——存储空间的释放竟然会影响存储空间的分配,但这确是某些Java虚拟机的工作方式。这也意味着,Java从堆分配空间的速度可以与其他语言从堆栈上分配空间的速度相媲美。

通过垃圾回收器对对象重新排列,实现了一种高速的、有无限空间可供分配的堆模型。

要想更好地理解Java中的垃圾回收,先了解其他系统中的垃圾回收机制会很有帮助。引用计数是一种简单但速度很慢的垃圾回收技术。每个对象都含有一个引用计数器,当有引用连接至对象时,引用计数加1.当引用离开作用域或被置为null时,引用计数减1。虽然管理引用计数的开销不大,但这项开销在整个程序生命周期中将持续发生。垃圾回收器会在含有全部对象的列表上遍历,当发现某个对象的引用计数为0时,就释放其占用的空间(但是,引用计数模式经常会在计数值变为0时立即释放对象)。这种方法有个缺陷,如果对象之间存在循环引用,可能会出现“对象应该被回收,但引用计数却不为零”的情况。对垃圾回收器而言,定位这样交互自引用的对象组所需的工作量极大。引用计数常用来说明垃圾收集的工作方式,但似乎从未被应用于任何一种Java虚拟机实现中。

在一些更快的模式中,垃圾回收器并非基于引用计数计数。它们依据的思想是:对任何“活”的对象,一定能最终追溯到其存活在堆栈或静态存储区之中的引用。这个引用链条可能会穿过数个对象层次。由此,如果从堆栈和静态存储区开始,遍历所有引用,就能找到所有“活”的对象。对于发现的每个引用,必须追踪它所用的对象,然后是此对象包含的所有引用,如此反复进行,直到“根源于堆栈和静态存储区的引用”所形成的网络全部被访问为止。你所访问过的对象必须都是“活”的。注意,这就解决了“交互自引用的对象组”的问题——这种现象根本不会被发现,因此也就被自动回收了。

这种方式下,Java虚拟机将采用一种自适应的垃圾回收技术。至于如何处理找到的存活对象,取决于不同的Java虚拟机实现。有一种做法名为停止-复制(stop-and-copy)。显然这意味着,先暂停程序的运行(所以它不属于后台回收模式),然后将所有存活的对象从当前堆复制到另一个堆,没有被复制的全部都是垃圾。当对象被复制到新堆时,它们是一个挨着一个的,所以以新堆保持紧凑排列,然后就可以按前述方法简单、直接地分配新空间了。

当把对象从一处搬到另一处时,所有指向它的那些引用都必须修正。位于堆或静态存储区的引用可以直接被修正,但可能还有其他指向这些对象的引用,它们在遍历的过程中才能被找到(可以想象成有个表格,将旧地址映射至新地址)。

对于这种所谓的“复制式回收器”而言,效率会变低。有这两个原因:首先,得有两个堆,然后得在这两个分离的堆之间来回倒腾,从而得维护比实际需要多一倍的空间。某些Java虚拟机对此问题的处理方式是,按需从堆中分配几块较大的内存,复制动作发生在这些大块内存之间。

第二个问题在于复制。程序进入稳定状态之后,可能只会产生少量垃圾,甚至没有垃圾。尽管如此,复制式回收器仍然会将所有内存自一处复制到另一处,这很浪费。为了避免这种情形,一些Java虚拟机会进行检查:要是没有新垃圾产生,就会转换到另一种工作模式(即“自适应”)。这种模式称为标记-清扫(mark-and-sweep),Sun公司早期版本的Java虚拟机使用了这种技术。对一般用途而言,“标记-清扫”方式速度相当慢,但是当你知道只会产生少量垃圾甚至不会产生垃圾时,它的速度就很快了。

“标记-清扫”所依据的思路同样是从堆栈和静态存储区出发,遍历所有的应用,进而找出所有存活的对象。每当它找到一个存活对象,就会给对象设一个标记,这个过程中不会回收任何对象。只有全部标记工作完成的时候,清理动作才会开始。在清理过程中,没有标记的对象将被释放,不会发生任何复制动作。所以剩下的堆空间是不连续的,垃圾回收器要是希望得到连续空间的话,就得重新整理剩下的对象。

“停止-复制”的意思是这种垃圾回收动作不是在后台进行的;相反,垃圾回收动作发生的同时,程序将会被暂停。同样,“标记-清扫”工作也必须在程序暂停的情况下才能进行。

如前文所述,在这里所讨论的Java虚拟机中,内存分配以较大的“块”为单位。如果对象较大,它会占用单独的块。严格来说“停止-复制”要求在释放旧有对象之前,必须先把所有存活对象从旧堆复制到新堆,这将导致大量内存复制行为。有了块之后,垃圾回收器在回收的时候就可以往废弃的块里拷贝对象了。每个块都用相应的**代数(generation count)**来记录它是否还存活。通常,如果块在某处被引用,其代数会增加;垃圾回收器将对上次回收动作之后新分配的块进行整理。这对处理大量短命的临时对象很有帮助。垃圾回收器会定期进行完整的清理动作——大型对象仍然不会被复制(只是其代数会增加),内含小型对象的那些块则被复制并整理。Java虚拟机会进行监视,如果所有对象都很稳定,垃圾回收器效率降低的话,就切换到“标记-清扫”方式;同样,Java虚拟机会跟踪“标记-清扫”的效果,要是堆空间出现很多碎片,就会切换回“停止-复制”方式。这就是“自适应”技术。

Java虚拟机中有许多附加技术用以提升速度。尤其是与加载器操作有关的,被称为“即时”(Just-In-Time, JIT)编译器的技术。这种技术可以把程序全部或部分翻译成本地机器码(这本来是Java虚拟机的工作),程序运行速度因此得以提升。当需要装载某个类(通常是在为这个类创建第一个对象)时,编译器会先找到其.class文件,然后将该类的字节码装入内存,此时,有两种方案可供选择。一种是就让即时编译器编译所有代码。但这种方法有两个缺陷:这种加载动作散落在整个程序生命周期内,累加起来要花更多时间;并且会增加可执行代码的长度(字节码要比即时编译器展开后的本地机器码小很多),这将导致页面调度,从而降低程序速度。另一种做法称为惰性评估(lazy evaluation),意思是即时编译器只要在必要的时候才编译代码。这样,从不会被执行的代码也许就压根不会被JIT所编译。新版JDK中的Java HotSpot技术就采用了类似方法,代码每次被执行的时候都会做一些优化,所以执行的次数越多,它的速度就越快。

5.6 成员初始化

Java尽力保证:所有变量在使用前都能得到恰当的初始化。对于方法的局部变量,Java以编译时错误的形式来贯彻这种保证。

要是类的数据成员(即字段)是基本类型,情况就会变得有些不同。正如在“一切都是对象”一章所看到的,类的每个基本类型数据成员保证都会有一个初始值。

数据类型 初始值
boolean false
char [ ]
byte 0
short 0
int 0
long 0
float 0.0
double 0.0
reference null

尽管数据成员的初值没有给出,但它们确实有初值(char值为0,所以显示为空白)。这样至少不会冒“未初始化变量”的风险了。

在类里定义一个对象引用时,如果不将其初始化,此引用就会获得一个特殊值null。

5.6.1 指定初始化

有一种很直接的办法,就是在定义类成员变量的地方为其赋值(注意在C++里不能这样做,尽管C++新手们总想这样做)。

也可以用同样的方法初始化非基本类型的对象。如果Depth是一个类,那么可以像下面这样创建一个对象并初始化它:

//:initialization/Measurement.java
class Depth{}
public class Measurement{
    Depth d = new Depth();
    //...
}///:~

如果没为d指定初始值就尝试使用它,就会出现运行时错误,告诉你产生了一个异常(这在第12章中详述)

甚至可以通过调用某个方法来提供初值。这个方法可以是无参的,也可以带有参数,但这些参数必须是被初始化了的。

//:initialization/MethodInit3.java
public class MethodInit3 {
    //! int j=g(i)//Illegal forward reference
    int i=f();
    int j=g(i);
    int f(){return 11;}
    int g(int n){return n*10;}
}///:~

5.7 构造器初始化

可以用构造器来进行初始化。在运行时刻,可以调用方法或执行某些动作来确定初值,这为编程带来了更大的灵活性。但要牢记:无法阻止自动初始化的进行,它将在构造器被调用之前发生。

5.7.1 初始化顺序

在类的内部,变量定义的先后顺序决定了初始化的顺序。即使变量定义散布于方法定义之间,它们仍旧会在任何方法(包括构造器)被调用之前得到初始化。

5.7.2 静态数据的初始化

无论创建多少个对象,静态数据都只占用一份存储区域。static关键字不能应用于局部变量,因此它只能作用于域。如果一个域是静态的基本类型域,且也没有对它进行初始化,那么它就会获得基本类型的标准初值;如果它是一个对象引用,那么它的默认初始化值就是null。

如果想在定义处进行初始化,采取的方法与非静态数据没什么不同。

静态初始化只有在必要时刻才会进行。如果不创建Table对象,也不引用Table.b1或Table.b2,那么静态的Bowl b1和b2永远都不会被创建。只有第一个Table对象被创建(或者第一次访问静态数据)的时候,它们才会被初始化。此后,静态对象不会再次被初始化。

初始化的顺序是先静态对象(如果它们尚未因前面的对象创建过程而被初始化),而后是“非静态”对象。从输出结果中可以观察到这一点。要执行main()(静态方法),必须加载StaticInitialization类,然后其静态域table和cupboard被初始化,这将导致它们对应的类也被加载,并且由于它们也都包含静态的Bowl对象,因此Bowl随后也被加载。这样,在这个特殊的程序中的所有类在main()开始之前就都被加载了。实际情况通常并非如此,因为在典型的程序中,不会像在本例中所做的那样,将所有的事物都通过static联系起来。

总结一下对象的创建过程,假设有个名为Dog的类:

  1. 即使没有显式地使用static关键字,构造器实际上也是静态方法。因此,当首次创建类型为Dog的对象时(构造器可以看成静态方法),或者Dog类的静态方法/静态域首次被访问时,Java解释器必须查找类路径,以定位Dog.class文件。
  2. 然后载入Dog.class(后面会学到,这将创建一个Class对象),有关静态初始化的所有动作都会执行。因此,静态初始化只在Class对象首次加载的时候进行一次。
  3. 当用new Dog()创建对象的时候,首先将在堆上为Dog对象分配足够的存储空间。
  4. 这块存储空间会被清零,这就自动地将Dog对象中所有基本类型数据都设置成了默认值(对数字来说就是0,对布尔型和字符型也相同),而引用则被设置成了null。
  5. 执行所有出现于字段定义处的初始化动作。
  6. 执行构造器,正如将在第7章所看到的,这可能会牵扯到很多动作,尤其是涉及继承的时候。

5.7.3 显式的静态初始化

Java允许将多个静态初始化动作组织成一个特殊的“静态子句”(有时也叫做“静态块”)。就像下面代码一样:

//:initialization/Spoon.java
public class Spoon{
    static int i;
    static{
        i=47;
    }
}///:~

尽管上面的代码看起来像个方法,但它实际只是一段跟在static关键字后面的代码。与其他静态初始化动作一样,这段代码仅执行一次:当首次生成这个类的一个对象时,或者首次访问属于那个类的静态数据成员时(即便从未生成过那个类的对象)

5.7.4 非静态实例初始化

Java中也有被称为实例初始化的类似语法,用来初始化每一个对象的非静态变量。

看起来它与静态初始化子句一模一样,只不过少了static关键字。这种语法对于支持“匿名内部类”的初始化是必须的,但是它也使得你可以保证无论调用了哪个显式构造器,某些操作都会发生。从输出中可以看到实例初始化子句是在两个构造器之前执行的。

5.8 数组初始化

数组只是相同类型的、用一个标识符名称封装到一起的一个对象序列或基本类型数据序列。数组是通过方括号下标操作符[ ]来定义和使用的。要定义一个数组,只需在类型名后加上一对方括号即可:

int[] a1;

方括号也可以置于标识符后面:

int a1[];

两种格式的含义是一样的,后一种格式符合C和C++程序员的习惯。不过,前一种格式或许更合理,毕竟它表明类型是“一个int型数组”。本书将采用这种格式。

编译器不允许指定数组的大小。现在拥有的只是对数组的一个引用(你已经为该引用分配了足够的内存空间),而且也没给数组对象本身分配任何空间。为了给数组创建相应的存储空间,必须写初始化表达式。对于数组,初始化动作可以出现在代码的任何地方,但也可以使用一种特殊的初始化表达式,它必须在创建数组的地方出现。这种特殊的初始化是由一对花括号括起来的值组成的。这种情况下,存储空间的分配(等价于使用new)将由编译器负责。例如:

int[] a1 = {1, 2, 3, 4, 5};

那么为什么还要在没有数组的时候定义一个数组引用呢?

在Java中可以将一个数组赋值给另一个数组,所以可以这样:

a2 = a1;

其实真正做的只是复制了一个引用。

所有数组(无论它们的元素是对象还是基本类型)都有一个固定成员,可以通过它获知数组内包含了多少个元素,但不能对其修改。这个成员就是length。与C和C++类似,Java数组计数也是从第0个元素开始,所以能使用的最大下标数是length-1。要是超过这个边界,C和C++会“默默”地接受,并允许你访问所有内存,许多声名狼藉的程序错误由此而生。Java则能保护你免受这一问题的困扰,一旦访问下标过界,就会出现运行时错误(即异常)。

Arrays.toString()方法属于java.util标准类库,它将产生一维数组的可打印版本。

数组也可以在定义的同时进行初始化:

Random rand = new Random(47);
int[] a = new int[rand.nextInt(20)];

如果你创建了一个非基本类型的数组,那么你就创建了一个引用数组。以整形的包装器类Integer为例,它是一个类而不是基本类型

这里即使使用new创建数组之后:

Integer[] a = new Integer[rand.nextInt(20)];

它还只是一个引用数组,并且直到通过创建新的Integer对象(在本例中是通过自动包装机制创建的),并把对象赋值给引用,初始化进程才算结束:

a[i]=rand.nextInt(500);

如果忘记了创建对象,并且试图使用数组中的空引用,就会在运行时产生异常。

也可以用花括号括起来的列表来初始化对象数组。有两种形式:

//:initialization/ArrayInit.java
//Array initialization
import java.util.*;
public class ArrayInit{
    public static void main(String[] args){
        Integer[] a = {
            new Integer(1),
            new Integer(2),
            3, //Autoboxing
        };
        Integer[] b = new Integer[]{
            new Integer(1),
            new Integer(2),
            3, //Autoboxing
        };
        System.out.println(Arrays.toString(a));
        System.out.println(Arrays.toString(b));
    }
}/* Output:
[1, 2, 3]
[1, 2, 3]
*///:~

在这两种形式中,初始化列表的最后一个逗号都是可选的(这一特性使维护长列表变得更容易)。

尽管第一种形式很有用,但是它也更加受限,因为它只能用于数组被定义之处。你可以在任何地方使用第二种和第三种形式,甚至是在方法调用的内部。例如,你可以创建一个String对象数组,将其传递给另一个main()方法,以提供参数,用来替换传递给该main()方法的命令行参数。

5.8.1 可变参数列表

第二种形式提供了一种方便的语法来创建并调用方法,以获得与C的可变参数列表(C通过把它简称为varargs)一样的效果。这可以应用于参数个数或类型未知的场合。由于所有的类都直接或间接继承于Object类(随着本书的进展,读者会对此有更深入的认识),所以可以创建以Object数组为参数的方法,并像下面这样调用:

//:initialization/VarArgs.java
//Using array syntax to create variable argument lists.
class A{}
public class VarArgs{
    static void printArray(Object[] args){
        for(Object obj:args)
            System.out.print(obj+" ");
        System.out.println();
    }
    public static void main(String[] args){
    	printArray(new Object[]{
       		new Integer(47), new Float(3.14), new Double(11.11) 
    	});
    	printArray(new Object[]{"one","two","three"});
    	printArray(new Object[]{new A(), new A(), new A()});
	}
}/*Output:(Sample)
47 3.14 11.11
one two three
A@la46e30 A@3e25a5 A@19821f
*///:~

默认行为(如果没有定义toString()方法的话,后面会讲这个方法的)就是打印类的名字和对象的地址。

你可能看到过像上面这样编写的Java SE5之前的代码,它们可以产生可变的参数列表。然而,在Java SE5中,这种盼望已久的特性终于添加了进来,因此你现在可以使用它们来定义可变参数列表了,就像在printArray()中看到的那样:

//:initialization/NewVarArgs.java
//Using array syntax to create variable argument lists.

public class NewVarArgs{
    static void printArray(Object... args){
        for(Object obj:args)
            System.out.print(obj+" ");
        System.out.println();
    }
    public static void main(String[] args){
        //Can take individual elements:
    	printArray(new Integer(47), new Float(3.14), new Double(11.11) );
        printArray(47,3.14F,11.11);
    	printArray("one","two","three");
    	printArray(new A(), new A(), new A());
        //Or an array:
        printArray((Object[])new Integer[]{1,2,3,4});
        printArray();//Empty list is OK
	}
}/*Output:(75% match)
47 3.14 11.11
47 3.14 11.11
one two three
A@la46e30 A@3e25a5 A@19821f
1 2 3 4
*///:~

有了可变参数,就再也不用显式地编写数组语法了,当你指定参数时,编译器实际上会为你去填充数组。你获取的仍旧是一个数组,这就是为什么print()可以使用foreach来迭代该数组的原因。但是,这不仅仅是从元素列表到数组的自动转换,请注意程序中倒数第二行,一个Integer数组(通过使用自动包装而创建的)被转型为一个Object数组(以便移除编译器警告信息),并且传递给了printArray(),很明显,编译器会发现它已经是一个数组了,所以不会在其上执行任何转换。因此,如果你有一组事物,可以把它们当成列表传递,而如果你已经有了一个数组,该方法可以把它们当作可变参数列表来接受。

该程序的最后一行表明将0个参数传递给可变参数是可行的,当具有可选的尾随参数时,这一特性就会很有用:

//:initialization/OptionTrailingArguments.java
public class OptionalTrailingArguments{
    static void f(int required, String... trailing){
        System.out.print("required: "+required+" ");
        for(String s:trailing)
            System.out.print(s+" ");
        System.out.println();
    }
    public static void main(String[] args){
        f(1, "one");
        f(2, "two", "three");
        f(0);
    }
}/* Output:
required: 1 one
required: 2 two three
required: 0
*///:~

这个程序还展示了你如何使用具有Object之外类型的可变参数列表。这里所有的可变参数都必须是String对象。在可变参数列表中可以使用任何类型的参数,包括基本类型。下面的例子也展示了可变参数列表变为数组的情形,并且如果在该列表中没有任何元素,那么转变成数据的尺寸0:

//:initialization/VarargType.java
public class VarargType{
    static void f(Character... args){
        System.out.print(args.getClass());
        System.out.println(" length "+args.length);
    }
    static void g(int... args){
        System.out.print(args.getClass());
        System.out.println(" length "+args.length);
    }
    public static void main(String[] args){
        f('a');
        f();
        g(1);
        g();
        System.out.println("int[]: "+new int[0].getClass());
    }
}/*Output:
Class [Ljava.lang.Character; length 1
Class [Ljava.lang.Character; length 0
Class [I length 1
Class [I length 0
int[]: class [I
*///:~

getClass()方法属于Object的一部分,我们将在第14章做全面介绍。它将产生对象的类,并且在打印该类时,可以看到表示该类类型的编码字符串。前导的“[”表示这是一个后面紧随的类型的数组,而紧随的“I”表示基本类型int。

然而,可变参数列表与自动包装机制可以和谐共处。例如:

//:initialization/AutoboxingVarargs.java
public class AutoboxingVarargs{
    public static void f(Integer... args){
        for(Integer i:args)
            System.out.print(i + " ");
        System.out.println();
    }
    public static void main(String[] args){
        f(new Integer(1), new Integer(2));
        f(4,5,6,7,8,9);
        f(10, new Integer(11),12);
    }
}/*Output:
1 2
4 5 6 7 8 9
10 11 12
*///:~

请注意,你可以在单一的参数列表中将类型混合在一起,而自动包装机制将有选择地将int参数提升为Integer。

可变参数列表使得重载过程变得复杂了,尽管乍一看会显得足够安全:

//:initialization/OverloadingVarargs.java
public class OverloadingVarargs{
    static void f(Character... args){
        System.out.print("first");
        for(Character c:args)
            System.out.print(" "+c);
        System.out.println();
    }
    static void f(Integer... args){
        System.out.print("second");
        for(Integer i:args)
            System.out.print(" "+i);
        System.out.println();
    }
    static void f(Long... args){
        System.out.println("third");
    }
    public static void main(String[] args){
        f('a', 'b', 'c');
        f(1);
        f(2,1);
        f(0);
        f(0L);
        //! f(); //Won't compile -- ambiguous
    }
}/*Output:
first a b c
second 1
second 2 1
second 0
third
*///:~

在每一种情况中,编译器都会使用自动包装机制来匹配重载的方法,然后调用最明确匹配的方法。

但是在不使用参数调用f()时,编译器就无法知道应该调用哪一个方法了。尽管这个错误可以弄清楚,但是它可能会使客户端程序员大感意外.

你可能会通过在某个方法中增加一个非可变参数来解决该问题:

//:initialization/OverloadingVarargs2.java
//(CompileTimeError){won't compile}
public class OverloadingVarargs2{
    static void f(float i, Character... args){
        System.out.println("first");
    }
    static void f(Character... args){
        System.out.print("second")
    }
    public static void main(String[] args){
        f(1, 'a');
        f('a','b');
    }
}///:~

{CompileTimeError}注释标签把文件排除在了本书的Ant构建之外。如果你手动编译它,就会得到下面的错误信息:

reference to f is ambiguous, both method f(float, java.lang.Character...)
in OverloadingVarargs2 and method f(java.lang.Character...) in
OverloadingVarargs2 match

如果你给两个方法都天际一个非可变参数,就可以解决问题了:

//:initialization/OverloadingVarargs3.java

public class OverloadingVarargs3{
    static void f(float i, Character... args){
        System.out.println("first");
    }
    static void f(char c, Character... args){
        System.out.println("second")
    }
    public static void main(String[] args){
        f(1, 'a');
        f('a','b');
    }
}/*Output:
first
second
*///:~

你应该总是只在重载方法的一个版本上使用可变参数列表,或者压根就不是用它。

5.9 枚举类型

在Java SE5 中添加了一个看似很小的特性,即enum关键字,它使得我们在需要群组并使用枚举类型集时,可以很方便地处理。在此之前,你需要创建一个整型常量集,但是这些枚举值并不会必然地将其自身的取值限制在这个常量集的范围之内,因此它们显得更有风险,且更难以使用。枚举类型属于非常普遍的需求,C、C++和其他语言都已经拥有它了。在Java SE5之前,Java程序员在需要使用枚举类型时,必须了解很多细节并需要格外仔细,以正确地产生enum的效果。现在Java也有了enum,并且它的功能比C/C++中的枚举类型要完备得多。下面是一个简单的例子:

//:initialization/Spiciness.java
public enum Spiciness{
    NOT, MILD, MEDIUM, HOT, FLAMING
}///:~

这里创建了一个名为Spiciness的枚举类型,它具有5个具名值。由于枚举类型的实例是常量,因此按照命名惯例它们都用大写字母表示(如果在一个名字中有多个单词,用下划线将它们隔开)。

为了使用enum,需要创建一个该类型的引用,并将其赋值给某个实例:

//:initialization/SimpleEnumUse.java
public class SimpleEnumUse{
    public static void main(String[] args){
        Spiciness howHot = Spiciness.MEDIUM;
        System.out.println(howHot);
    }
}/* Output:
MEDIUM
*///:~

在你创建enum时,编译器会自动添加一些有用的特性。例如,它会创建toString()方法,以便你可以很方便地显示某个enum实例的名字,这正是上面的打印语句如何产生其输出的答案。编译器还会创建ordinal()方法,用来表示某个特定enum常量的声明顺序,以及static values()方法,用来按照enum常量的声明顺序,产生由这些常量值构成的数组:

//:initialization/EnumOrder.java
public class EnumOrder{
    public static void main(String[] args){
        for(Spiciness s : Spiciness.values())
            System.out.println(s+", ordinal "+s.ordinal());
    }
}/*Output:
NOT, ordinal 0
MILD, ordinal 1
MEDIUM, ordinal 2
HOT, ordinal 3
FLAMING, ordinal 5
*///:~

尽管enum看起来像是一种新的数据类型,但是这个关键字只是为enum生成对应的类时,产生了某些编译器行为,因此在很大程度上,你可以将enum当做其他任何类来处理。事实上,enum确实是类,并且具有自己的方法。

enum有一个特别使用的特性,即它可以在switch语句内使用。由于switch是要在优先的可能值集合中进行选择,因此它与enum正是绝佳的组合。请注意enum的名字是如何能够倍加清楚地表明程序意欲何为的。

大体上,你可以将enum用做另外一种创建数据类型的方式,然后直接将所得到的类型拿来使用。这正是关键所在,因此你不必过多地考虑它们。在Java SE5引进enum之前,你必须花费大量的经历去保证与其等价的枚举类型是安全可用的。

我们将在第19章更加深入地讨论enum。

猜你喜欢

转载自blog.csdn.net/ZeromaXHe/article/details/89343505
今日推荐