2.4 Java编程惯例
对于一门语言,介于编程语言语义规范和好的面向模式的设计之间的是对语言的良好使用。一名喜欢遵循惯例的程序员会使用一致的代码来表达类似的思想,而且通过使用这种方式,程序会更易于理解,且能够在充分利用运行时环境的同时避免语言中存在的“陷阱”。
2.4.1 Java的类型安全性
Java的一个主要设计目标是编程安全。其中存在的很多冗余和不灵活机制都是为了帮助编译器预防在运行时出现各种错误,而这些措施在其他语言,如Ruby、Python和Objective-C中是不存在的。
Java的静态类型(static typing)是已经被证明的特性,其优越性已不再局限于Java自己的编译器中。机器能够自动解析并识别Java代码的语义是开发强大的工具,如FindBugs和IDE重构工具的主要驱动力。
很多开发人员对这些限制表示认同,尤其是对于使用了一些现代编码工具的开发人员,他们认为,与能够快速定位问题相比,这些限制条件所耗费的代价很低,因为如果不能快速定位这些问题,它们可能只有在部署时才会显现出来。当然,也一定会有大量的开发者认为在动态语言中,他们节省了很多编码时间,使用节省的这些时间可以编写出广泛的单元测试和集成测试用例,也同样能够提早发现问题。
在这些讨论中,无论你支持的是哪一方,尽可能充分地利用工具是非常有意义的。Java的静态绑定绝对是一种约束,而在另一方面,Java是一门非常好的静态绑定的语言。Java不是一门好的动态语言。实际上,使用Java的reflection机制和内省(introspection)API也能执行很多类型转换类的动态功能。除了在非常受限的环境中之外,Java语言的这种使用方式通常是为了实现跨平台。你的程序可能会非常缓慢,即使使用很多Android工具进行优化也无济于事。可能最大的好处是,如果平台很少被使用到的那部分存在bug,你会是第一个找到这些bug的人。希望你能够喜欢Java的静态本质(至少在有另一门更好的动态语言之前)并充分利用Java的静态特征。
封装
开发人员通过代码封装(encapsulation)的方式实现对对象成员可见性的限制。封装的思想是对象不要暴露其本身不想提供的详细信息。回到之前提到的鸡尾酒的例子,当要制作鸡尾酒时,你只在乎同事给你买了必要的配料,却并不关心她是如何买的。然而,假定你和她这么说:“你可以去买一下配料吗?另外,出去的时候,是否可以顺便给玫瑰花浇水?”这句话意味着你不再不关心她是如何买配料的,因为你现在已经对她买配料所要经过的路线有所考虑了。
同样,一个对象的接口(有时简称为API)就是调用者可以访问的方法和变量。仔细封装之后,开发者可以做到其开发的对象的实现细节对于使用它的代码而言是透明的。使用这种控制和保护机制开发出的程序更加灵活,开发者在对其实现进行修改时不需要调用方进行任何修改。
getter和setter方法
在Java中,一种简单常用的封装方式是使用getter和setter方法。下面这段代码是一个简单的命名为Contact的类的定义:
该定义使得外部对象能够直接访问类Contact的成员变量,如下所示:
用过几次之后,发现Contact实际上需要包含多个email地址。遗憾的是,当在这个类的实现中增加多个地址时,整个程序中的每个调用Contact.email的地方都需要进行相应的修改。
下面这个类的情况与其相反:
在上面这个版本的Contact类中,使用了private访问修饰符,它不允许直接访问类的成员变量。提供了public类型的getter方法,使用方使用这些方法来得到所需要的Contact对象的name、age和email地址。例如,可以将email地址保存在对象内部,正如前一个例子那样,也可以使用username和hostname组合,只要这种方式对于给定的应用更便捷即可。在类的内部,成员变量age可以是int类型或integer类型。这个版本的Contact类可以做到支持多个email地址,而不需要对客户端有任何修改。
Java允许对成员变量直接引用,而它和某些语言不同,它不支持对getter和setter方法中的成员变量的引用进行封装。为了防止封装,必须自己定义每个访问方法。大多数IDE会提供代码生成功能,可以快速准确地完成这个功能。
通过getter和setter这种封装方法可以提供灵活性,因为直接的成员变量访问意味着如果某个成员变量的类型发生变化,则使用该成员变量的所有代码都需要进行修改。getter和setter方法代表的是一种简单的对象封装机制。一个良好的惯例是建议所有的成员变量都定义为private类型或final类型。编写良好的Java程序会使用getter和setter方法,以及一些其他更复杂的封装方式,从而保证在复杂程序中的灵活性。
2.4.2 使用匿名类
有UI开发经验的开发人员对于回调函数会很熟悉:当UI发生变化时,需要能够通知你的应用程序。可能是按下了某个按钮,应用模型需要进行相应的状态变化;也可能是在网络中有新的数据,需要对它进行显示。需要在框架中添加一个代码块,以便于自己后期执行它。
尽管Java语言确实提供了传递代码块的机制,但有点怪异的是,代码块或方法都不是类对象。在Java中,无论是代码块还是方法,都无法直接使用它。
可以创建一个类的实例的引用。在Java中,不支持代码块或方法的传递,能够传递的是定义了所需要的代码的整个类,需要使用的代码块和方法是这个类的一个方法。提供回调函数API的服务会使用接口来定义其协议。服务客户端定义该接口的实现,并把它传递给应用框架,如Android中实现用户按键响应的机制。在Android中,类View定义了接口OnKeyListener,该接口又定义了方法onKey。如果在你的代码中,把方法OnKeyListener的实现传递给类View,那么每当类View得到一个新的按键事件时,就会调用其onKey方法。
其代码看起来如下所示:
当创建一个新的MyDataModel类时,需要在其构造函数中以参数的形式告诉类所依附的view。构造函数会创建回调类新的实例KeyHandler,并在view中装载这个实例。后续的所有按键事件都和模型实例的handleKey方法关联起来。
虽然这种方式是可行的,但看起来很丑陋,尤其当你的模型类需要处理多个view的多种事件时!程序执行一段时间后,所有这些类型定义都混杂在最上方。定义和使用方式可能有很大区别,如果你考虑这个问题,它们一点用处都没有。
Java提供了一种简化它的方式,即使用匿名类(anonymous class)。下面这段代码类似于之前给出的,其区别在于用的是匿名类:
除了解析可能需要更多一些时间外,这块代码和前面给出的例子几乎完全相同。在调用中,它把新创建的子类的实例View.OnKeyListener作为参数传递给view.setOnKeyListener。然而,在这个例子中,view.setOnKeyListener的参数包含特殊的语义,它定义了接口View.OnKeyListener的新子类,并在语句中对它进行了实例化。新的实例是没有名字的类的实例:它是匿名类,其定义只存在于对它执行初始化的那条语句中。
匿名类是一个非常便捷的工具,而且是Java实现多种代码块的习惯用法。使用匿名类创建的对象是顶级对象,可以用于任何具有相同类型的其他对象中。举个例子,可以按照下述方式进行赋值:
你可能会觉得奇怪,在这个例子中,为什么匿名类要把其实际实现(handleKey方法)委托(delegate)给其所在的类(containing class)呢?没有什么规则约束匿名类的内容:它绝对也可以包含全部的实现。但是,良好的、惯用的方式是把改变对象状态的代码放到对象类中。如果实现放在包含匿名类的类中,它就可以用于其他方法和调用。匿名类只是起到中间作用,而这正是希望它做的。
关于作用域中变量的使用,Java确实在匿名类内包含一些强约束(任何在块内定义的)。特别是,匿名类只能指向从作用域继承的声明为final类型的变量。例如,以下代码片段将无法编译:
其解决方法是把参数声明为final类型。当然,声明为final类型意味着它在匿名类中不会发生变化,但是如下所示是这种情形的一种简单、惯用的方式:
/** Create a key handler that increments and matches the passed key */
public View.OnKeyListener curry(final int keyToMatch) {
return new View.OnKeyListener() {
private int matchTarget = keyToMatch;
public boolean onKey(View v, int keyCode, KeyEvent event) {
matchTarget++;
if (matchTarget == keyCode) { foundMatch(); }
} };
}
2.4.3 Java中的模块化编程
虽然Java中的类扩展为开发人员提供了很大的灵活性,便于重新定义在不同上下文中使用的对象,但是要想利用好类和接口,还需要相当多的经验。理想情况下,开发人员致力于创建能够经受得住时间考验的代码块,且尽可能在很多不同的上下文中能重用,甚至作为库在多个应用中使用。这种编程方式可以减少代码中的bug,并能缩短应用开发的时间。模块化编程、封装和关注点切分都是在最大程序上增强代码可重用性和稳定性的关键策略。
在面向对象的开发中,委托(delegate)或继承是重用已有代码的基础设计思路。下面给出的这一系列例子,显示了多种组织结构,这些结构可以用来描述汽车类游戏应用中的各种组件。每个例子就是一种模块化方式。
开发人员首先创建一个汽车类,它包含了所有的汽车逻辑及每种类型的引擎的所有逻辑,如下所示:
这段代码很简单。虽然它可能能够正常工作,但它把一些不相关的实现混合在了一起(如所有类型的汽车引擎),可能难以扩展。例如,修改实现以适应新的引擎类型(nuclear类型)。各种引擎的代码相互之间都是可见的。一种引擎的某个漏洞,可能会意外地影响到另外一个完全不相关的引擎。一个引擎的变化也可能意外地导致另外一个引擎发生变化。一台使用电动引擎的汽车必须巡阅一遍已有的各种类型的引擎。今后要使用MonolithicVehicle类的开发人员必须理解清楚所有复杂的交互,才能够对代码进行修改。这种代码不具备可扩展性。
如何改进这个实现呢?一种较常见的方法是使用子类(subclassing)。可以按照下面这段代码中的方式来实现不同的机动车类型,每个类型的机动车都和其引擎类型绑定:
相对上一段代码而言,这段代码显然是个改进。每种类型的引擎的代码封装在一个独立的类中,相互之间不会干扰。可以对每种汽车类型进行扩展,而不会影响到其他类型。在很多情况下,这是一种理想的实现方式。
另一方面,如果把TightlyBoundGasVehicle转换成biodiesel,会发生什么情况呢?在这个实现中,car和engine是同一个对象,不能分开。如果在现实情况中需要分别考虑,那么架构需要更松散一些:
在这个架构中,vehicle类把所有和引擎相关的行为委托给了它的引擎对象。这种方式有时被称之为has-a,这种方式和前面的子类例子中的is-a是有区别的。has-a的方式更灵活,因为它把引擎真正的工作方式方面的信息封装起来了。每个vehicle把这些工作委托给了松散耦合的引擎类型,而不关心该引擎具体会如何实现这些行为。has-a这种方式中使用的是可重用的DelegatingVehicle类,当需要使用一种新的引擎时,该类一点都不需要改变。vehicle可以使用任何类型的引擎接口实现。此外,还可以创建不同的vehicle类型,如SUV、简约型和豪华型等,每个都可以使用多种不同的引擎类型。
使用委托模式最小化两个对象之间的相互依赖,最大化后期对它们进行修改的灵活性。委托模式和继承模式相比,更易于对代码进行后期的扩展和改进。使用接口来定义对象及其委托之间的关系,开发人员能够确保委托会按照期望的行为运转。
2.4.4 Java多线程并发编程基础
Java语言支持多线程并发运行的执行方式。不同线程中的语句是按序执行,但是不同线程中的语句之间不存在顺序关系。Java中并发执行的基础单元封装在类java.lang.Thread中。对线程进行扩展的推荐方式是使用接口java.lang.Runnable的实现,如下例子所示:
在前面这个例子中,方法spawnThread创建了一个新的线程,它把新的ConcurrentTask实例传递给了线程的构造函数,然后该方法对新的线程调用了start方法。当调用线程的start方法时,底层虚拟机(VM)会创建新的执行线程,该线程又会执行Runnable接口的run方法,和扩展的线程并发执行。在这一点,VM是启动两个独立的进程来运行:一个线程中的代码执行的顺序和时间与另一个线程相互独立,完全无关。
Thread类不是final类型。可以通过实现Thread子类的方式来定义一个新的并发任务,并重写其run方法,然而这种实现方式没有什么优势。实际上,使用Runnable接口的灵活性更高。因为Runnable是一个接口,从传递给Thread构造函数的Runnable接口可以扩展出一些其他有用的类。
2.4.5 同步和线程安全
当两个或多个线程都能够访问同一组变量时,某些线程可能会修改这些变量,导致数据不一致,这会破坏其他线程的逻辑。这种无意的并发访问bug称为“线程安全破坏”(thread safety violations)。这种问题复现的难度较大,难以找到,也难以测试。
Java没有明确要求对多个线程都会访问的变量进行强制的访问限制。相反,Java为支持线程安全所提供的主要机制是通过synchronized这个关键字。该关键字序列化访问其控制的代码块,而且更重要的是,它会对两个线程之间的可见状态进行同步。使用Java的并发功能时,很容易忘记同步机制同时控制了访问和可见性。例如下面的程序:
可能有人会认为:“好了,这里不需要同步变量shouldStop。当然,主线程和派生线程在访问该变量时可能会发生冲突。那又有什么关系呢?派生线程总是很快就把这个变量值设置为true。布尔写操作是原子性的。如果主线程这次访问该变量时值为false,那么下一次它访问时应该就是true。”这种思考方式是非常危险的,而且是错误的。它没有考虑到编译器优化和处理器的缓存机制!事实上,该程序可能永远都不会停止。这两个线程很可能只会使用自己的那份shouldStop数据副本,该副本只存在于某个本地处理器缓存中。由于在两个线程之间不存在同步,缓存副本可能永远都不会对外发布,因此派生线程生成的数据值在主线程中可能是始终不可见的。
在Java中,有一个简单的实现线程安全的规则:当两个不同的线程访问同一个可变的状态(变量)时,对该状态的所有访问都必须持有锁才可以执行。
有些开发人员可能会违反这个规则,对其程序中共享状态的行为进行分析,尝试优化代码。因为目前在Android平台上实现的很多设备实际上并不能真正提供并发执行功能(相反,只是在多个线程之间序列化共享一个处理器),这些程序有可能会正确执行。然而,不可避免地,当移动设备中装备了多核处理器及大量的多层处理器缓存,这种带有潜在bug的程序就有可能会出现错误,而且这种错误很严重,是间歇性的,定位特别
困难。
在Java中实现并发时,最佳的方式是使用强大的java.util.concurrent库。在这个库中,几乎可以找到需要的所有并发结构,它们的实现都经过了良好的优化和测试。在Java中,为了实现双向链表,开发人员实在没有什么理由不使用java.util.concurrent库中所提供的双向链表而选择使用底层的并发构造实现一个自己的版本。
关键字synchronized可以用于3种场合:创建代码块、动态方法或静态方法。当synchronized用于定义一个代码块时,该关键字使用一个对象的引用作为参数,也就是信号量。所有对象都可以作为信号量,但基础数据类型不可以。
当synchronized作为动态方法的修饰符时,该关键字的行为类似于该方法的内容包在一个同步块中,其锁就是实例本身。下面这个例子就是对这个特点的说明:
这种实现方式非常便捷,但是必须慎重使用。一个包含多个综合方法的复杂的类,使用这种方式实现同步时,可能会自己导致不同方法之间的锁竞争。如果多个外部线程同时尝试访问不相关的数据片段,则最好使用多个不同的锁分别保护这些数据块。
如果在静态方法中使用synchronized这个关键字,则它的行为表现是,方法的内容似乎是包在基于对象的类的同步代码块中。一个给定类的所有实例的所有静态同步方法会竞争该类本身的那个单独锁。
最后,值得注意的是,在Java中对象锁是可重入的(reentrant)。以下代码非常安全,不会导致任何死锁:
2.4.6 使用wait()和notify()方法的线程控制
类java.lang.Object定义了方法wait()和notify(),作为每个对象的锁协议的一部分。因为Java中的所有类都是从类Object派生的,所有对象实例都支持通过这些方法来控制和实例相关的锁。
关于Java并发的底层机制的全面探讨超出了本书的讨论范畴。对这方面感兴趣的开发人员可参考Brian Goetz的优秀书籍《Java Concurrency in Practice》(Addison-Wesley Professional出版社出版)。下面这个例子说明了支持两个线程执行的必要基础元素。当一个线程在完成其需要执行的任务时,另一个线程暂停执行:
实际上,大多数开发人员不会用到如wait和notify这样的底层工具,通常使用到的是java.util.concurrent包中所提供的更高级的工具。
2.4.7 同步和数据结构
Android支持功能丰富的Java标准版的Java集合库。如果仔细研读Java集合库,会发现大多数集合都包含两个版本:List和Vector、HashMap和Hashtable等。Java在1.3版本中引入了全新的集合框架。这个新的集合框架完全取代了老的集合框架。然而,为了确保向后兼容,老版本还是可用的。
相比于老版本的集合库,应该尽量使用新版本的集合库。在新版本的集合库中,其API更统一,有更好的工具支持等。但是,可能最重要的是,遗留版本的集合库都是同步的。这听起来是件好事,但是正如以下例子所示,亦有其不足之处:
虽然对Vector的每次使用都是完全同步的,并且每次调用其方法都可以确保是原子性的,但是该程序在执行过程中还是会被中断掉。对Vector的完全同步是不够的,当然,由于代码通过size()方法保留了该Vector的大小,即使在使用中其他线程改变了该Vector的大小,它还是会使用原来该Vector的大小。
由于只是对集合对象的一个方法进行同步往往是不够的,因此,Java集合库在新的框架中没有做任何同步。如果调用该集合库的代码本身会进行同步,则在集合库内部再进行同步完全是多余的。