《Android程序设计》一2.2 Java类型系统-阿里云开发者社区

开发者社区> 华章出版社> 正文
登录阅读全文

《Android程序设计》一2.2 Java类型系统

简介: 本节书摘来自华章出版社《Android程序设计》一 书中的第2章,第2.2节,作者:G. Blake Meike, Masumi Nakamura,更多章节内容可以访问云栖社区“华章计算机”公众号查看。

2.2 Java类型系统

Java语言基础数据类型有两种:对象和基本类型(primitives)。Java通过强制使用静态类型来确保类型安全,要求每个变量在使用之前必须先声明。举个例子,变量i的类型声明是int(原始32位整数),代码如下:
int i;
这种机制和非静态类型的语言有很大差别,非静态语言不要求对变量进行声明。虽然显式类型声明看起来较烦琐,但其有助于编译器对很多编程错误的预防,例如,由于变量名拼写错误导致创建了没有用的变量,调用了不存在的方法等,显式声明可以彻底防止这些错误被生成到运行代码中。关于Java类型系统的详细说明可以在Java语言规范(Java Language Specification)中找到。

2.2.1 基本类型

Java的基本类型不是对象,它们不支持本章稍后将会描述的对象相关的操作。基本数据类型只能通过一些预定义的操作符来修改它们,例如,“+”、“-”、“&”、“|”及“=”等。Java中的基本类型如下所示:
boolean(布尔型)
值为真或假
byte(字节)
8位二进制整数
short(短整型)
16位二进制整数
int(整型)
32位二进制整数
long(长整型)
64位二进制整数
char(字符型)
16位无符号整数,表示一个UTF-16编码单元
float(浮点型)
32位IEEE 754标准的浮点数
double(双精度浮点型)
64位的IEEE 754标准的浮点数

2.2.2 对象和类

Java是一种面向对象的语言,其重点不是基础数据类型,而是对象(数据的组合及对这些数据的操作)。类(class)定义了成员变量(数据)和方法(程序),它们一起组成一个对象。在Java中,该定义(构建对象所用的模板)本身就是一种特定类型的对象,即类。在Java中,类是类型系统的基础,开发人员可以用它来描述任意复杂的对象,包括复杂的、专门的对象和行为。
与绝大多数面向对象的语言一样,在Java语言中,某些类型可以从其他类型继承而来。如果一个类是从另一个类中继承来的,那么可以说这个类是其父类的子类(subtype或subclass),而其父类被称为超类(supertype或superclass)。有多个子类的类可以称为这些子类的基类(base type)。
在一个类中,方法和成员变量的作用域都可以是全局的,在对象外可以通过对这个类的实例的引用来访问它们。以下给出了一个非常简单的类的例子,它只有一个成员变量ctr和一个方法 incr():
screenshot

2.2.3 对象的创建

使用关键字new创建一个新的对象,即某个类的实例,如:
Trivial trivial = new Trivial();
在赋值运算符“=”的左边定义了一个变量,名为Trivial。该变量的类型是Trivial,因此只能赋给它类型为Trivial的对象。赋值符右边为新创建的Trivial类的实例分配内存,并对该实例进行实体化。赋值操作符为新创建的对象变量分配引用。
在Trivial这个类中,变量ctr的定义是绝对安全的,虽然没有对它进行显式初始化,这可能会让你很吃惊。Java会保证给ctr的初始值赋为0。Java会确保所有的字段在对象创建时自动进行初始化。布尔值初始化为false,基本数值类型初始化为0,所有的对象类型(包括String)初始化为null。
警告: 上述的初始化赋值只适用于对象的成员变量。局部变量在被引用前必须进行初始化!
可以在定义类时,通过构造函数更好地控制对象的初始化。构造函数的定义看起来很像一个方法,区别在于构造函数没有返回类型且名字必须和类的完全相同:
screenshot

事实上,Java中的每个类都会有一个构造函数。如果没有显式定义的构造函数,Java编译器会自动创建一个不带参数的构造函数。此外,如果子类的构造函数没有显式调用超类的构造函数,那么Java编译器会自动隐式调用超类的无参数的构造函数。前面给出了Trivial的定义(它没有显式地指定构造函数),实际上Java编译器会自动为它创建一个构造函数:
public Trivial() { super(); }
如上所示,由于LessTrivial类显式定义了一个构造函数,因此Java不会再给它隐式地定义一个默认的构造函数。这意味着如果创建一个没有参数的LessTrivial对象,会出现错误:
LessTrivial fail = new LessTrivial(); // ERROR!!
LessTrivial ok = new LessTrivial(18); // ... works
有两个不同的概念,需要对它们进行区分:“无参数的构造函数”和“默认的构造函数”。“默认的构造函数”是没有给一个类定义任何构造函数时,Java隐式地创建的构造函数,这个默认的构造函数刚好也是无参数的构造函数。而无参数的构造函数仅仅是没有参数的构造函数。Java不要求一个类包含没有参数的构造函数,也不需要定义无参数的构造函数,除非存在某些特定的需求。
警告: 有一种特殊情况,需要无参数的构造函数,需要特别注意。有些库需要能够创建通用的新的对象。例如,JUnit框架,不管要测试什么,都需要能够创建新的测试用例。对持久性存储或网络连接进行编码(marshal)和解码(unmarshal)的库也需要能够创建新的对象。因为这些库在运行时难以确定具体对象所需要的调用函数,它们通常要求显式指定没有参数的构造函数。
如果一个类有多个构造函数,则最好采用级联(cascade)的方法创建它们,从而确保只会有一份代码对实例进行初始化,所有其他构造函数都调用它。为了便于说明,我们用一个例子来演示一下。为了更好地模拟常见情况,我们给LessTrivial类增加一个无参数的构造函数:
screenshot
级联方法(cascading method)是Java中标准的用来为一些参数赋默认值的方法。一个对象的初始化代码应该统一放在一个单一、完整的方法或构造函数中,所有其他方法或构造函数只是简单地调用它。在级联方法中,在类的构造函数中必须显式调用其超类的构造函数。
构造函数应该是简单的,而且只应该包含为对象的成员变量指定一致性的初始状态的操作。举个例子,设计一个对象用来表示数据库或网络连接,可能会在构造函数中执行连接的创建、初始化和可用性的验证操作。虽然这看起来很合理,但实际上这种方式会导致代码模块化程度不够,从而难以调试和修改。更好的设计是构造函数只是简单地把连接状态初始化为closed,并另外创建一个方法来显式地设置网络连接。

2.2.4 对象类及其方法

Java类Object(java.lang.Object)是所有类的根类,每个Java对象都是一个Object。如果一个类在定义时没有显式指定其超类,它就是Object类的直接子类。Object类中定义了一组方法,这些方法是所有对象都需要的一些关键行为的默认实现。除非子类重写了(override)这些方法,否则都会直接继承自Object类。
Object类中的wait、notify和notifyAll方法是Java并发支持的一部分。2.4.6节将对这些方法进行探讨。
toString方法是对象用来创建一个自我描述的字符串的方法。toString方法的一个有趣的使用方式是用于字符串连接,任何一个对象都可以和一个字符串进行连接。以下这个例子给出了输出相同消息的两种方式,它们的运行结果完全相同。在这两个方法中,都为Foo类创建了新的实例并调用其toString方法,随后把结果和文本字符串连接起来,最后输出结果:
screenshot

在Object类中,toString方法的实现基于对象在堆中的位置,其返回一个没什么用的字符串。在代码中对toString方法进行重写是方便后期调试良好的开端。
clone方法和finalize方法属于历史遗留,只有在子类中重写了finalize方法时,Java才会在运行时调用该方法。但是,当类显式地定义了finalize方法时,对该类的对象执行垃圾回收时会调用该方法。Java不但无法保证什么时候会调用finalize方法,实际上,它甚至无法确保一定会调用这个方法。此外,调用finalize方法可能会重新激活一个对象!其中的道理很复杂。当一个对象不存在可用的引用时,Java就会自动对它执行垃圾回收。但是,finalize方法的实现会为这个对象“创建”一个新的可用的引用,例如把实现了finalize的对象加到某个列表中!由于这个原因,finalize方法的实现阻碍了对所定义的类的很多优化。使用finalize方法,不会带来什么好处,却带来了一堆的坏处。
通过clone方法,可以不调用构造函数而直接创建对象。虽然在Object类中定义了clone方法,但在一个对象中调用clone方法会导致异常,除非该对象实现了Cloneable接口。当创建一个对象的代价很高时,clone方法可以成为一种有用的优化方式。虽然在某些特定情况下,使用clone方法可能是必需的,但是通过复制构造函数(以已有的实例作为其唯一参数)显得更简单,而且在很多情况下,其代价是可以忽略的。
Object类的最后两个方法是hashCode和equals,通过这两个方法,调用者可以知道一个对象是否和另一个对象相同。在API文档中,Object类的equals方法的定义规定了equals的实现准则。equals方法的实现应确保具有以下4个特性,而且相关的声明必须始终为真:
自反性
x.equals(x)
对称性
x.equals(y) == y.equals(x)
传递性
(x.equals(y) && y.equals(z)) == x.equals(z)
一致性
如果x.equals(y)在程序生命周期的任意点都为真,只要x和y值不变,则x.equals(y)就始终为真。
要满足这4大特性,实际上需要很细致的工作,而且其困难程度可能超出预期。常见的错误之一是定义一个新的类(违反了自反性),它在某些情况下等价于已有的类。假设程序使用了已有的定义了类EnglishWeekdays的库,假设又定义了类FrenchWeekdays。显然,我们很可能会为FrenchWeekdays类定义equals方法,该方法和EnglishWeekdays相应的French等值进行比较并返回真。但是千万不要这么做!已有的EnglishWeekdays类看不到新定义的FrenchWeekdays类,因而它也永远都无法确定你所定义的类的实例是否是等值的。因此,这种方式违反了自反性!
hashCode方法和equals方法应该是成对出现的,只要重写了其中一个方法,另外一个也应该重写。很多库程序把hashCode方法作为判断两个对象是否等价的一种优化方式。这些库首先比较两个对象的哈希码,如果这两个对象的哈希码不同,那么就没有必要执行代价更高的比较操作,因为这两个对象一定是不同的。哈希码算法的特点在于计算非常快速,这方面可以很好地取代equals方法。一方面,访问大型数组的每个元素来计算其哈希码,很可能还比不上执行真正的比较操作,而另一方面,通过哈希码计算可以非常快速地返回0值,只是可能不是非常有用。

2.2.5 对象、继承和多态

Java支持多态(polymorphism),多态是面向对象编程的一个关键概念。对于某种语言,如果单一类型的对象具备不同的行为,则认为该语言具备多态性。如果某个类的子类可以被赋给其基础类型的变量,那么就认为这个类是多态的,下面通过例子说明会更清晰。
在Java中,声明子类的关键字是extends。Java继承的例子如下:
screenshot

        // optionally use a superclass method        
        super.drive();       
         System.out.println("Got the radio on!");    
    }
}

Ragtop是Car的子类。从前面的介绍中,可以知道Car是Object的子类。Ragtop重新定义(即重写)了Car的drive方法。Car和Ragtop都是Car类型(但它们并不都是Ragtop类型),它们的drive方法有着不同的行为。
现在,我们来演示一个多态的例子:
Car auto = new Car();
auto.drive();
auto = new Ragtop();
auto.drive();
尽管把Ragtop类型赋值给了Car类型的变量,但这段代码可以编译通过(虽然把Ragtop类型赋值给Car类型的变量)。它还可以正确运行,并输出如下结果:
Going down the road!
Top down!
Going down the road!
Got the radio on!
auto这个变量在生命的不同时期,分别指向了两个不同的Car类型的对象引用。其中一个对象,不但是Car类型,也是其子类Ragtop类型。auto.drive()语句的确切行为取决于该变量当前是指向基类对象的引用还是子类对象的引用,这就是所谓的多态行为。
类似很多其他的面向对象编程语言,Java支持类型转换,允许声明的变量类型为多态形式下的任意一种变量类型。
Ragtop funCar;
Car auto = new Car();
funCar = (Ragtop) auto; //ERROR! auto is a Car, not a Ragtop!
auto.drive();
auto = new Ragtop();
Ragtop funCar = (Ragtop) auto; //Works! auto is a Ragtop
auto.drive();
虽然类型转换(casting)在某些情况下是必要的,但过度使用类型转换会使得代码很杂乱。显然,根据多态规则,所有的变量都可以声明为Object类型,然后进行必要的转换,但是这种方式违背了静态类型(static typing)准则。
在上面两个实例中,静态类定义和静态方法定义的共同点在于静态对象在其命名空间内都是可见的,而动态对象只能通过每个实例的引用才可以使用。此外,静态对象和动态对象之间的区别则更为微妙。
静态方法和动态方法之间的一个显著区别在于静态方法在子类中不能重写。例如,下面的代码在编译时会出错:
screenshot

在方法park(Car auto)的声明中,Car类型的对象是其唯一参数。但是在方法letsGo()中,在调用它时传递的参数类型是Ragtop,即Car的子类。同样,变量myCar赋值的类型为Ragtop,方法whatsInTheGarage返回变量myCar的值。如果一个对象是Ragtop类型,当调用drive方法时,它会输出“Top down!”和“Got the radio on!”信息;另一方面,因为它又是Car类型,它还可以用于任何Car类型可用的方法调用中。这种子类型可取代父类型是多态的一个关键特征,也是其可以保证类型安全的重要因素。在编译阶段,一个对象是否和其用途兼容也已经非常清晰。类型安全使得编译器能够及早发现错误,这些错误如果只是在运行时才出现,那么发现这些错误的成本就会高很多。

2.2.6 Final声明和Static声明

Java有11个关键字可以用作声明的修饰符,这些修饰符会改变被声明对象的行为,有时是很重要的改变。例如,在前面的例子中使用了多次的关键字:public和private。这两个修饰符的作用是控制对象的作用域和可见性。在后面的章节中还会更详细地介绍它们。在本节中,我们将探讨的是另外两个修饰符,这两个修饰符是全面理解Java类型系统的基础:final和static。
如果一个对象的声明前面包含了final修饰符,则意味着这个对象的内容不能再被改变。类、方法、成员变量、参数和局部变量都可以是final类型。
当用final修饰类时,意味着任何为其定义子类的操作都会引发错误。举个例子,String类是final类型,因为作为其内容的字符串必须是不可改变的(也就是说,创建了一个字符串后,就不能够改变它)。如果你仔细思考一下,就会发现,确保其内容不被改变的唯一方式就是确保不能以String类型为基类来创建子类。如果能够创建子类,例如DeadlyString,就可以把DeadlyString类的实例作为参数,并在验证完其内容后,马上在代码中把该实例的值从“fred”改成“‘; DROP TABLE contacts;”(把恶意SQL注入你的系统中,对你的数据库进行恶意修改)!
当用final修饰方法时,它表示子类不能重写(override)译注1这个方法。开发人员使用final方法来设计继承性(inheritance),子类的行为必须和实现高度相关,而且不允许改变其实现。举个例子,一个实现了通用的缓存机制的框架可能会定义一个基类CacheableObject,编程人员使用该框架的子类型来创建每个新的可缓存的对象类型。然而,为了维护框架的完整性,CacheableObject可能需要计算一个缓存键(cache key),该缓存键对于各对象类型都是一致的。在这种情况下,该缓存框架就可以把其方法computeCacheKey声明为final类型。
当用final修饰变量——成员变量、参数和局部变量时,它表示一旦对该变量进行了赋值,就不能再改变。这种限制是由编译器负责保障的:不但变量的值“不会”发生变化,而且编译器必须能够证明它“不能”发生改变。用final修饰成员变量时,表示该成员变量的值必须在变量的声明或者构造函数中指定。如果没有在变量的声明或构造函数中对final类型的成员变量进行初始化,或者试图在任何其他地方对它进行赋值,都会出现错误。
当用final修饰参数时,表示在这个方法内,该参数的值一直都是在调用时传递进来的那个值。如果对final类型的参数进行赋值,就会出现错误。当然,由于参数值很可能是某种对象的引用,对象内部的内容是有可能会发生变化的。用关键字final修饰参数时仅仅表示该参数不能被赋值。
注意: 在Java中,参数都是按值传递:函数的参数就是调用时所传递值的一个副本。另一方面,在Java中,在大部分情况下,变量是对象的引用,Java只是复制引用,而不是整个对象!引用就是所传递的值!
final类型的变量只能对其赋值一次。由于使用一个没有初始化的变量在Java中会出现错误,因此final类型的变量只能够被赋值一次。该赋值操作可以在函数结束之前任何时候进行,当然要在使用该参数之前。静态(static)声明可以用于类,但不能用于类的实例。和static相对应的是dynamic(动态)。任何没有声明为static的实体,都是默认的dynamic类型。下述例子是对这一特点的说明:
screenshot

在这个例子中,QuietStatic是一个类,ex是该类的一个实例的引用。静态成员变量classMember是QuietStatic的成员变量;可以通过类名引用它(QuietStatic. classMember)。反之,instanceMember是QuietStatic类的实例的成员变量,通过类名引用它(QuietStatic.instanceMember)就会出现错误。这种处理机制是有道理的,因为可以存在很多个名字为instanceMember的不同的变量,每个变量属于QuietStatic类的一个实例。如果没有显式指定是哪个instanceMember,那么Java也不可能知道是哪个instanceMember。
正如下一组语句所示,Java确实允许通过实例引用来引用类的(静态)变量。这容易让人产生误解,被认为是不好的编程习惯。如果这么做,大多数编译器和IDE就会生成警告。
静态声明和动态声明的含义之间的区别很微妙。最容易理解的是静态成员变量和动态成员变量之间的区别。再次说明,静态定义在一个类中只有一份副本,而动态定义对于每个实例都有一份副本。静态成员变量保存的是一个类的所有成员所共有的信息。
screenshot
screenshot

该程序的输出是:
classMember: 2, instanceMember: 1
classMember: 2, instanceMember: 1
在前面这个例子中,变量classMember的初始值被设置为0。在两个不同的实例ex1和ex2中,分别调用incr()方法对它执行递加操作,两个实例输出的classMember值都是2。变量instanceMember在每个实例中,其初始值也都是被设置为0。但是,每个实例只对自己的instanceMember执行递加操作,因此输出的instanceMember值都为1。
在上面的两个实例中,静态类和方法定义的相似之处在于静态对象都是可见的,而动态对象只能通过每个实例的引用才可见。然而,实际上其不同之处更复杂。
screenshot

在Java中,几乎没有理由要使用静态方法。在Java的早期实现中,动态方法调用明显慢于静态方法。开发人员常常倾向于使用静态方法来“优化”其代码。在Android的即时编译Dalvik环境中,不再需要这种优化。过度使用静态方法通常意味着架构设计不良。
静态类和动态类之间的区别是最微妙的。应用中的绝大部分类都是静态的。类通常是在最高层声明和定义的——在任何代码块之外。默认情况下,所有这些声明都是静态的;相反,很多其他声明,在某些类之外的代码块,默认情况下是动态的。虽然成员变量默认是动态的,其需要显式地使用静态修饰符才会是静态的,但类默认是静态的。
注意: 代码块(block)是指两个大括号之间的代码,即{和}之间的代码。在代码块内所定义的一切,即变量、类型和方法等,在代码块内及其内嵌的代码块内都是可见的。而在一个代码块内,不止是在其中定义的类,所定义的一切在代码块外都是不可见的。
实际上,这完全符合一致性要求。根据对“静态”的定义(属于类但不属于类的实例),高层声明应该是静态的,因为它们不属于任何一个类。但是,如果是在代码块内定义的(例如在高层类内定义),那么类的定义默认也是动态的。因此,为了动态地声明一个类,只需要在另一个类内定义它。
这一点也说明了静态类和动态类之间的区别。动态类能够访问代码块内的类(因为它属于实例)的实例成员变量,而静态类却无法访问。以下代码是对这个特点的示例说明:
screenshot

稍加思考,这段代码就可理解。成员变量x是类Class的实例的成员变量,也就是说,可以有很多名字为x的变量,每个变量都是Outer的运行时实例的成员变量。类InnerTube是类Outer的一部分,但不属于任何一个Outer实例。因此,在InnerTube类中无法访问Outer的实例成员变量x。相反,由于类InnerOne是动态的,它属于类Outer的一个实例。因此可以把类InnerOne理解成隶属于类Outer的每个实例的独立的类(虽然不是这个含义,但实际上就是这么实现的)。因此,InnerOne能够访问其所属的Outer类的实例的成员变量x。
类OuterTest说明了对于成员变量,我们可以使用类名.内部静态类来定义,并可以使用该静态类型的类的内部定义Outer.InnerTube(在这个例子中,是创建该类的一个实例),而动态类型的类的定义只有在类的实例中才可用。

2.2.7 抽象类

在Java的声明中,如果将类及其一个或者多个方法声明为抽象类型,则允许这个类的定义中可以不包含这些方法的实现:
screenshot
screenshot

不能对抽象类进行实例化。抽象类的子类必须提供其父类的所有抽象方法的定义,或者该子类本身也定义成抽象类。
正如前面的例子所示,抽象类可以用于实现常见的模板模式,它提供可重用的代码块,支持在执行时自定义特定点。可重用代码块是作为抽象类实现的。子类通过实现抽象方法对模板自定义。

2.2.8 接口

其他编程语言(例如C++、Python和Perl)支持多继承,即一个对象可以有多个父类。多继承有时非常复杂,程序执行和预期的不同(如从不同的父类中继承两个相同名字的成员变量)。为了简单起见,Java不支持多继承性。和C++、Python和Perl等不同,在Java中,一个类只能有一个父类。
和多继承性不同,Java支持一个类通过接口(interface)实现对多种类型的继承。接口支持只对类型进行定义但不实现。可以把接口想象成一个抽象类,其所有的方法也都是抽象方法。Java对一个类可以实现的接口的数量没有限制。
下面这个例子是关于Java接口和实现该接口的类的示例:
screenshot
screenshot

再次说明,接口只是方法的声明,而没有方法的实现。这种分工在日常生活中也是很常见的。假如你和同事正在准备鸡尾酒会,你可能会分派任务,让同事去买薄荷。当你搅拌杯子里的东西时,你的同事是开车去商店还是步行去后院的果汁店买薄荷和你没有关系,重要的是你拿到了薄荷。
关于接口,再举个例子。假设程序需要根据邮件地址排序,显示一个联系人列表。我们肯定会期望Android的运行时库包含一些通用的排序程序。但是,由于这些程序是通用的,它们无法知道某个特定类的实例期望用什么方式来进行排序。为了使用库中的排序程序,在类中需要定义自己的排序方法。在Java中,是通过接口Comparable定义排序
方法。
Comparable类的对象实现方法compareTo。一个对象接受另一个相同类型的对象作为参数,如果作为参数的对象大于、等于或小于原目标对象,就分别返回不同的整数值。程序库可以对任何Comparable类型的对象进行排序。要实现对联系人列表的排序,只需要把联系方式Contact定义成Comparable类型,实现compareTo方法,就可以做到对这些联系方式进行排序:
screenshot
screenshot

在类内部,Collections.sort程序只知道contacts包含一组类型为Comparable的列表。它调用类的compareTo方法来决定如何对这些列表进行排序。正如这个例子所说明的,接口使得开发人员可以复用通用的程序,这些程序能够对任何实现了Comparable接口的列表进行排序。除了这个简单的示例,Java接口库中还提供了一组复杂的编程模式的实现。这里强烈推荐一本优秀的书籍《Effective Java》,Joshua Bloch著(Prentice Hall出版社出版)。

2.2.9 异常

Java语言使用异常(exceptions)作为处理异常情况的简便方式。通常情况下,这些情况是错误的。
举个例子,要解析Web页面的代码,如果不能通过网络读取页面,就无法继续执行。当然,可以先检查网络读取页面是否成功,确认成功后再继续其他操作,如下例所示:
screenshot

使用异常,程序可以更完善和健壮:
screenshot
screenshot
screenshot

这段代码的功能是在网络失败时进行重试。注意,抛出NetworkException异常的点是在另一个方法readPageFromNet中。这里提到的程序从“最近的”try-catch代码块恢复执行这种机制,是Java中的异常处理方式。
如果在方法内的throw语句没有和try-catch代码块一起使用,那么抛出异常类似于马上执行return(返回)语句。不需要执行进一步的操作,返回为空。例如,在之前的例子中,网络获取页面之后的代码,都不需要关注其前提条件(读取到页面)是否有得到满足。当出现异常时,方法会立即终止,程序返回到getActions方法。由于getActions方法也没有包含try-catch代码块,它也会立即终止并返回到它的调用函数处。
在这个例子中,当抛出NetworkException异常时,程序会跳转到catch代码块的第一条语句,即调用日志,记录网络错误。异常会在第一个catch语句中被捕获,其参数是抛出的异常的类型或者是其父类。处理会从catch代码块的第一条语句处恢复,并依次执行后面的操作。
在这个例子中,从网络读取页面时如果出现网络错误将会导致ReadPageFromNet方法和getPage方法都被终止。在catch代码块中记录过失败信息后,在for循环中会重新尝试获取页面,最多尝试执行MAX_RETRIES次。对Java异常类树结构有清晰的理解是很有帮助的,如图2-1所示。

screenshot

所有异常都是Throwable类的子类。在代码中,基本不需要引用Throwable类。可以把Throwable类当作一个抽象类型的基类,其包含两个子类:Error和Exception。Error类及其子类是保留类,只用于Dalvik运行时环境本身的错误。虽然可以写代码来捕获Error(或Throwable),但实际上,无法捕捉到这些错误。这种情况的一个例子是OOME,即OutOfMemoryException错误。当Dalvik系统出现内存溢出时,再简单的代码都无法继续运行。实现一些复杂的代码来捕捉OOME,并释放一些预分配的内存也许是可行的——也许不可行。尝试捕捉Throwable或Error的代码绝对是徒劳。
Java要求在方法的声明中包含其将要抛出的异常。在前面这个例子中,getPage声明其抛出三个异常,因为它调用了三个方法,每个方法捕捉一个错误。调用getPage的方法的定义中必须指明getPage抛出的三个异常及它调用的其他方法抛出的异常。
不难想象,在这种机制中,调用树的最高层方法会显得多么臃肿。最高层方法可能需要指明数10种不同类型的异常,仅仅因为它调用的方法抛出了这些异常。这个问题可以通过创建一棵和应用树一致的异常树来缓解,一个方法只需要声明其抛出的所有异常的超类。如果创建一个名为MyApplicationException的基类,然后创建其子类MyNetworkException和MyUIException,分别用于网络和UI子系统中,则最高层代码只需要处理MyApplicationException异常。
这实际上只是缓解了部分问题。例如,假设这段网络连接的代码没有成功地建立起网络连接。随着异常在重试和其他条件选择代码中不断上传给上层代码的过程中,有时会出现能够说明真实问题的异常信息被丢失的情况。例如,一个具体的数据库异常对于尝试预安装电话号码的代码是没有任何意义的。把该数据库异常加到方法签名中是毫无用处的,还不如简单地让所有方法声明抛出Exception异常类。
RuntimeException类是Exception类的特殊子类。RuntimeException的子类被称为“未检查的(unchecked)”异常,不需要声明。例如,以下代码可以编译通过:

在Java社区中,关于何时使用及何时不使用未检查的异常有很多争论。显然,可以在应用中使用未检查的异常,而从不声明任何在你的方法签名中的异常。一些Java编程学派甚至推荐这种方式。然而,使用未检查的异常,使得能够利用编译器来检查代码错误,这很符合“静态类型”(static typing)的思想,以经验和风格为指南。
2.2.10 Java Collections框架
Java Collections框架是Java最强大和便捷的工具之一,它提供了可以用来表示对象的集合(collections)的对象:list、set和map。Java Collections框架库的所有接口和实现都可以在java.util包中获取。
在java.util包中,几乎没有什么历史遗留类,基本都是Java Collections框架的一部分,最好记住这些类,并避免定义具有相同名字的类。这些类是Vector、Hashtable、Enumeration和Dictionary。
Collection接口类型
Java Collections库中的5种主要对象类型都是使用接口定义的,如下所示。
Collection
这是Collections库中所有对象的根类型。Collection表示一组对象,这些对象不一定是有序的,也不一定是可访问的,还可能包含重复对象。在Collection中,可以增加和删除对象,获取其大小并对它执行遍历(iterate)操作(后面将对iteration作更多说明)。
List
List是一种有序的集合。List中的对象和整数从0到length-1一一映射。在List中,可能存在重复元素。List支持Collection的所有操作。此外,在List中,可以通过get方法获取索引对应的对象,反之,也可以通过indexOf方法获取某个对象的索引。还可以用add(index,e)方法改变某个特定索引所对应的元素。List的iterator(迭代器)按序依次返回各个元素。
Set
Set是一个无序集合,它不包含重复元素。Set也支持Collection的所有操作。但是,如果在Set中添加的是一个已经存在的元素,则Set的大小并不会改变。
Map
Map和List类似,其区别在于List把一组整数映射到一组对象中,而Map把一组key对象映射到一组value对象。与其他集合类一样,在Map中,可以增加和删除key-value对(键值对),获取其大小并对它执行遍历操作。Map的具体例子包括:把单词和单词定义的映射,日期和事件的映射,或URL和缓存内容的映射等。
Iterator
Iterator(迭代器)返回集合中的元素,其通过next方法,每次返回一个元素。Iterator是对集合中所有元素进行操作的一种较好的方式。一般不建议使用下面这种方式遍历:
screenshot

Collection实现方式
这些接口类型有多种实现方式,每个都有其适用的场景。最常见的实现方式包括以下
几种。
ArrayList
ArrayList(数组列表)是一个支持数组特征的List。它在执行索引查找操作时很快,但是涉及改变其大小的操作的速度很慢。
LinkedList
LinkedList(链表)可以快速改变大小,但是查找速度很慢。
HashSet
HashSet是一个以hash方式实现的set。在HashSet中,增、删元素,判断是否包含某个元素及获取HashSet的大小这些操作都可以在常数级时间内完成。HashSet可以
为空。
HashMap
HashMap是使用hash表作为索引,其实现了Map接口。在HashMap中,增、删元素,判断是否包含某个元素及获取HashMap的大小这些操作都可以在常数级时间内完成。它最多只可以包含一个空的key值,但是可以有任意个value值为空的元素。
TreeMap
TreeMap是一个有序的Map。如果实现了Comparable接口,则TreeMap中的对象是按自然序排序;如果没有实现Comparable接口,则是根据传递给TreeMap构造函数的Comparator类来排序。
经常使用Java的用户只要可能,往往倾向于使用接口类型的声明,而不是实现类型的声明。这是一个普遍的规则,但在Java Collections框架下最易于理解其中的原因。
假设有一个会返回一个新的字符串列表的方法,其主要内容是传递给它的第二个参数的字符串列表,但是在返回的新的字符串列表中,每个字符串的前缀是第一个参数。该方法如下所示:
screenshot

然而,这种实现方式存在一个问题:它无法在所有类型的List上都正常工作!它只能在ArrayList上正常工作。如果调用这个方法的代码需要从ArrayList改成LinkedList,就不能再使用这个方法,因此没有理由要使用这样的实现方式。
更好的实现方式如下所示:
screenshot

这个版本的灵活性更强,因为它没有把方法绑定到特定的实现。该方法只依赖于参数实现了某个接口,并不关心是如何实现的。使用接口类型作为参数,它确切地知道自己要做什么。
事实上,还可以进一步对该版本进行改进,把参数和返回类型设置成Collection类型。
Java泛型(Java generics)
在Java中,泛型是相当大且复杂的一个话题。有些书整本都在探讨这个主题。本节介绍Java泛型中最常用的设置,即Collections Library(集合库),但是不会详细探讨它们。
在引入Java泛型之前,是无法对容器类的内容作静态类型化的(statically type)。我们经常看到这样的代码:
screenshot

以上程序的问题非常明显,useList方法不能保证makeList方法创建了一个Thing类型的对象列表。Java编译器不能验证useList中的转换可以工作,代码可能会在运行时崩溃。
Java泛型解决了这个问题,但其代价是使得实现变得较为复杂。以下是对上面代码的改写,在其中加入了泛型

容器中的对象类型是在尖括号(<>)中指定,它是容器类型的一部分。注意,在useList中不再需要类型转换,因为编译器知道第一个参数是Thing类型的list。
泛型描述可能会变得非常烦琐冗长,下面这样的声明是很常见的:

2.2.11 垃圾收集

Java是一种支持垃圾收集的语言,这意味着代码不需要对内存进行管理。相反,我们的代码可以创建新的对象,可以分配内存,当不再需要这些对象时,只是停止使用这些对象而已。Dalvik运行时会自动删除这些对象,并适当地执行内存压缩。
在不远的过去,开发人员不得不为垃圾收集器担心,因为垃圾收集器可能会暂停下所有的应用处理以恢复内存,导致应用长时间、不可预测地、周期性地没有响应。很多开发人员,早期那些使用Java及后来使用J2ME的开发人员,都还记得那些技巧、应对方式及不成文的规则来避免由早期垃圾收集器造成的长时间停顿和内存碎片。垃圾收集机制在这些年有了很大改进。Dalvik明显不存在这些问题。创建新的对象基本上没有开销,只有那些对UI响应要求非常高的应用程序(例如游戏)需要考虑垃圾收集造成的程序
暂停。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享: