final 知多少
说明:
面向对象设计的同义不同称呼:
Java 称呼“方法”; C++ 称呼“函数”
Java 称呼“成员变量”、“方法”;C++ 称呼”数据成员”、“成员函数”
Java 称呼“超类”、“子类”; C++ 称呼”基类”、“派生类”或更直接的“父类”、“子类”。
因为“超”字容易让人联想到“更多”,即所谓的子类,所以下文可能有意避开使用。
Override 有的译作“覆盖”,有的译成“重写”。
Java 语言的一些特征根本不能被忽略,比方说 interface,广泛用于每一个 Java 规范;又比方说 try/catch 块,是异常处理的基础。其他特征比较晦涩——很有用但是容易被大众忽略。不用说 volatile 关键字了,想想 final 吧,你上一次在代码里用 final 是什么时候?
我是一个 final 迷,final 让我倾心。大学期间的 Java 编程,我就一直认为 final 该用则用。这篇文章我聚焦一下 final 关键字,邀请你一起去发现它的奥妙,以及 Java 使用的模式。我们的目标只有一个:更好的代码!
final 的诸多意义
final 顾名思义,“最终的,不可修改的”,它是形容词,用来修饰名词。
final 修饰符可用于Java 的四个结构。
- 变量: 一个 final 变量能且只能赋值一次。
- 成员变量:一个 final 成员变量也能且只能赋值一次,在类所定义的构造函数里。
- 方法: 一个 final 成员方法既不能覆盖(override),也不能隐藏。
- 类: 一个 final 类不能扩展(extends)
注意到如何使用 final 完全是一种负面行为,final 关键字在删减、限制默认的语言机制:覆盖成员方法或类的能力,为变量或者成员变量赋值。使用 final 的背后动机可归为三类:正确性、健壮性和效率。
final 变量
一个 final 变量只能赋值一次,允许你声明为局部常量。这样的变量可以留后赋值,创建空白final 变量,但是所有的 final 变量必须赋值一次。final 变量在以下两种情形非常有用:防止意外改变方法参数和让匿名类访问变量。
Final 参数
下面的代码声明了 final 参数:
public void doSomething(final int i, final int j)
{
// ...
}
final 确保了两个索引I 和 j 不会被该方法意外修改。在对付因修改参数的值而导致潜在的 bug 时显得很方便。一般来说,代码短的方法(函数)是保证这类错误不产生的较好方法,但是 final 修饰的参数可以成为你代码风格的有用的补充。
注意到 final 参数不是方法签名的一部分,当解析方法的调用时会被编译器忽略。参数声明 final 与否对覆盖的方法没有任何影响(可用 Java 自带的 javap 命令反编译生成的类查看)。而C/C++ 与之对等的 const 关键字则不同,和 volatile 关键字一样属于函数签名(function signature)的一部分,程序员可能由于忘记写 const 而导致子类成员函数没有成功覆盖父类成员函数而出错。(不过新的 C++11 标准加入了 override final 标示符,它们不是关键字,也不影响函数签名,提供给编译器检查。)
匿名局部类
第二种涉及到 final 的情况实际上是语言的语义授权。在此情形,Java 编译器不让使用该变量除非你声明它为 final,这种情形因闭包(closure)而出现,也被称为匿名局部类。局部类只能引用局部变量和声明为 final 的参数。
public void doSomething(int i, int j)
{
final int n = i + j; // must be declared final
Comparator comp = new Comparator()
{
public int compare(Object left, Object right)
{
return n; // return copy of a local variable
}
};
}
如果我们弄清楚了局部类是如何实现的,那么作这种限制的原因就会很明显。匿名局部类可以使用局部变量是因为编译器自动为该类添加 private 成员变量来保存每个局部变量的副本使用。编译器也为该类的构造函数添加了隐含的参数,从而初始化这些自动创建的 private 成员变量。因此,局部类实际上不是在访问局部变量,只不过是自己的 private 副本。保证此方法可行的唯一途径是声明局部变量为 final,以保证它们不会更改。有了这个担保,局部类便可保证内部副本精确地反映到实际的局部变量。
final 字段
final 成员变量能且只能被赋值一次,而且必须在每个构造函数初始化。当然,在其定义的语句里直接赋值也可行。这只是反映这样一个事实:这种直接的赋值被编译进合成的构造函数。例如以下代码例子都正确而且有着等同的语义;第一个因为简短而更可取。
public class MyClass
{
private final int i = 2;
}
public class MyClass
{
private final int i;
public MyClass()
{
i = 2;
}
}
声明常量
final 和 static 在一起修饰常量,这种用途对于所有的 Java 程序员都不陌生,所以我也不会在上面展开太多。知道以这种方式声明的常量将会在编译时直接计算结果。
private static final int ERROR_CODE = 1 + 3 * 4 / 2; public static final String ERROR_MESSAGE = "An error occurred with code=" + ERROR_CODE;
编译器会计算 ERROR_CODE 的值,并把结果连接到字符串的后面,将最终的String 赋值给 ERROR_MESSAGE。
聚合(aggregation)与关联(association)
这两个词在 UML 里面讲得多。用 Gamma 等人的设计模式(Design Patterns)书中的话来讲,聚合意味着一个对象拥有或负责另一个对象。一般我们说,一个对象是另外一个对象的部分。聚合则暗示了一个聚合对象和其聚合属于者有同样的生命周期。关联暗示了一个对象仅仅了解另一个对象,被关联的类可能要求操作对方,但是不对对方负责。所以关联是一种比聚合弱的关系,更宽松的耦合。聚合和关联容易搞混,因为他们实现的方式类似。最终,聚合和关联更多由显式的语言机制而不是意图所区分。聚合关系较关联关系趋向于少但持久。与此相反,关联关系使用更频繁,有时候因为一个持续的操作而存在。关联更灵活多变,使得在源代码里更难辨别。
结果是,Java 语言提供了一个显式机制来区分聚合关系和关联关系:这篇文章的主角,也就是关键字 final。标记成为明确的聚合。等一等,为什么这个对我很重要?还不是为了提高代码的质量。
强制对象的原子性构建
一旦字段(field)被确定为另一个对象的聚合,并且被声明为 final,有趣的性质出现了,这个聚合对象要么全面创建成功,要么不会被创建;也就是说要么所有的字段要么成功初始化,或者抛出异常终止构造。 比方说,一个 Car 对象聚合一个 Engine 对象,因此被定义为绝对需要一个 Engine 才能运转。声明 Engine 为 final 可以保证任何 Car 的实例被完全初始化,或者抛出异常突然中止。而且你看,Engine 的引用没有初始化 Car 类是不会编译通过的。
public class Car
{
private final Engine engine; // always has an engine
public Car()
{
engine = new Engine();
}
}
给一个字段贴上 final 标签,我们就对所有的 Car 实例附加了一个强条件,即必须有 Engine。给对象之间强加正确的聚合关系,这条简单的性质可以极大地提升你代码的质量。照这样定义的对象,以及它所聚合依赖的对象,总是处在一个稳定的状态。
声明不变量
在设计健壮的软件组件中,Design by Contract可是一个有效的编程方法;通过给组件声明条件,断言其行为的正确性,即使是运行态。final 是强加字段不变性的有力工具。因为 final 字段只准赋值一次,任何试图重新赋值的想法,不管是有意还是无意的,都会被编译器检测出来。这种模式在重构(refactor)中也有很大帮助:它会对重构中试图重新初始化字段这种错误把关。
这里提醒一下:如果 final 变量持有对象的引用,那么这个对象是可以修改的。这是因为 final 作用在该对象的引用上,而不是对象的本身。final 变量会指向同一个对象,但是对象本身可以通过调用自己的方法改变自己。
这对 Array 和 Connection 也一样,因为他们都是对象。如果一个 final 变量持有某数组的引用,数组里的元素可以修改,变量还是指向同一个数组。同样,如果一个 final 变量修饰 List,其内容可以随意改变,数组里的元素可以添加或删除。
为了性能
由 JSR 133 提出修订的内存模型,包括了 final 字段的特别规定,一个之前所没有的规定。较新的虚拟机已经实现了该规范,并依此对待不同的 final 字段。因为 final 变量仅赋值一次,在多线程环境下,积极的优化变得可能。具体而言,一个字段可以缓存(cache)而不用重新加载,因为它的值已保证不会变了。
关于 final 字段的结论
final 字段的广泛使用导致一个新的有缺的编程模式。通过在构造阶段强加字段初始化,一段构造过程完成,对象会是正确的,完全初始化的。这种做法虽然简单但是强大,它提高了对象的正确性和健壮性。既然它正确地初始化不会出差错,接下来的方法(函数)可自由处理,可以使用任何需要的字段,而不用担心对象的正确初始化。
这种模式与立即初始化(eager initialization)密切相关:构造的时候,所有字段尽快地初始化,构造完成了后又从不改变。在我的个人经历中,开发者会回避立即初始化因为感觉它比延迟初始化(lazy initialization)的开销大。“我目前不需要这个字段,晚点要,所以现在别去麻烦人家,”他们这样想的。不幸的是,这种思路会导致更复杂的代码,而不是简单地马上初始化所有的字段。每一个用到字段的地方都要检查一下该字段是否初始化过了,如果没有,那么初始化它。它跟我们知道的过早的优化(premature optimization)一样,是万恶之源。
比较下面两个例子。尽管它看起来像是一个微不足道的改变,但是在一个真正的可能有许多字段的类里,通过移除多余的测试,立即初始化会清除大量代码。通过声明所有字段为 final,初始化操作被聚集到一个地方(构造函数),产生简单的更容易维护的代码。
public class LazyCar
{
private Engine engine; // lazily initialized
public void drive()
{
if (engine == null)
{
engine = new Engine();
}
// ...
}
}
public class BetterCar
{
private final Engine engine = new Engine(); // using final
public void drive()
{
// the engine is always present
// ...
}
}
final 方法
final 方法在类里只能实现一次,这样的方法不能被覆盖——子类不能另起炉灶替换父类方法。注意到 private 和 static 修饰符均暗含了 final 属性,因此用在方法上显得冗余。private 和 static 方法总是暗含 final 的,因为他们不能被覆盖。
强制不变性代码
模板方法(template method)模式声明了可以让子类覆盖的 abstract 方法,这样允许父类委托部分算法实现到子类中。final 方法不能被覆盖,所以就产生了反模板方法模式,但事实上,它们通常和模板方法结合使用。通过显式声明哪部分算法可以有所不同(使用 abstract 方法)和哪部分不能变(使用 final 方法),类作者为子类需要完成的工作表述为一个明确的图案。final 方法用于模板方法中声明算法的不变部分。
public abstract class AbstractBase
{
public final void performOperation() // cannot be overridden
{
prepareForOperation();
doPerformOperation();
}
protected abstract void doPerformOperation(); // must override
}
要知道, final 方法给子类实现者强加了一个严格的限制。在软件框架里,声明 final 方法前想长远点、想仔细点,因为它会严重限制框架的可扩展性,以及在不可预见的情形下原来的开发者去适应框架的可能性。
为了安全性
在 Java 里,所有的方法默认可覆盖。这在给我们程序员最大灵活性的同时,这种自由的态度可能会导致某些冲突情况的发生。以 Object 类为例,它声明了两个常用的必须被覆盖的方法 Object.equals 和 Object.toString,Object 也同时包括了方法 Object.wait and Object.notify ——实现核心语言能力的系统级方法,就是不允许 Object.wait 方法被其它实现所替换,否则会导致语言本身语义上的改变。
final 方法可以拯救如此情形,通过声明 Object.wait 为 final,不管有意还是无意,就是不能被改变。这种推论对整个 JDK 类都适用,下面将会讨论。
为了效率?
既然 final 方法只能在声明类中可实现,也就没有动态分配消息(也就是方法调用)给一个 final 方法的必要了。编译器直接静态调用,完全绕过通常的虚方法调用过程。正因为如此,final 方法可能会被 Just-In-Time 编译器或类似的优化工具所内联(inline)。(请记住:private/static 方法已经暗含 final 属性,所以这种优化总是在考虑之列)
静态调用比动态方法的查找更快,这导致大量使用 final 方法成为一种广泛使用的优化技巧,但是这种优化在最近的虚拟机中几乎变得无效:它们能够检测非 final 方法是否被覆盖,如果没有,便静态调用。因此,final 应该因软件工程原因而最先使用,文章的剩余部分将会探讨。
final 类
final 类不能以任何方式被继承,它可以视为 final 方法的泛化:一个final 类的所有的方法声明为 final,另外,final 类的字段不能有特殊的属性。
强制组合(composition) 而非继承(inheritance)
既然 final 类不能被继承,重用他们的唯一途径就是与其他类组合。在自己的代码里鼓励那种做法可能显得很健全;继承作为一个强有力的技巧不应被忽略,它有着自己的那份问题。它给类之间引入了一种很紧的耦合关系,又是导致出现臭名昭著的 Fragile Base Class 问题。它也更复杂,迫使用户在类层次结构跑上跑下去了解内部实现。最终,它通过允许限制较少的方法访问打破封装(encapsulation)。
因此final 类用来强制组合关系,这对定义了框架基本功能的核心类非常重要。我们下次分析这个案例。
为了安全性
Java 环境的一个很好的特征是动态加载类,当然,这是以一个更复杂的安全模型为代价换来的。如果类可以随时动态被加载,虚拟机必须在运行的代码上执行安全策略。Final 类用在此情形下,以防止恶意代码修改对框架来讲有重要意义的类的语义。
关于 final 类,最著名的例子当属 java.lang.String 了,该类对 Java 编译器和解释器是如此重要,以至于必须保证代码在使用字符串的时候,它用的就是 java.lang.String,而不是其他的类的实例。因为 java.lang.String 是 final 的,所以它不能被继承,其方法也不能被覆盖,任何 String 的实例都保证了想要的行为。
不可变对象
我想以关于不可变对象和它们所构成的模式小节来结束这篇文章。
不可变对象是一个保证了状态在整个生命周期保持不变的对象,虽然不用 final 也可以完全实现不变性,但是对人(程序员)、对机器(编译器)来讲,显式地使用 final 使得目的明确些。
不可变对象有一些非常可取的特点:
- 它们简单明了,易于使用
- 它们本质上线程安全,根本不需要同步
- 它们是其他类的构件。
显然 final 能帮助我们定义不可变对象。首先,把对象标记为不可变,这使其他程序员易于使用和理解。其次,保证了对象的状态从不改变,这使得线程安全的性质凸现:当一个线程改变数据的同时另一个线程在读这个数据时,线程并发的问题就很重要了。因为不可变对象从不改变数据,没有必要作同步访问。
当满足所有以下条件时可以创建不可变类:
- 声明所有字段为 private final
- 在构造函数里给所有字段赋值
- 不提供任何修改对象状态的方法,即仅提供 getter 方法,没有 setter 方法
- 声明类为 final,这样所有的方法不可能被覆盖
- 确保互斥访问任何可变的组件,例如通过返回副本。
结论
希望你喜欢上面的细究——一个有时被遗忘的 Java 语言的特征。我的参考部分列出了额外的资源,对渴望继续学习 final 和它用途的读者很有用。