第15条:使可变性最小化

不可变类只是其实例不能被修改的类。每个实例中包含的所有信息都必须在创建该实例的时候就提供,并在对象的整个生命周期(lifetime)内固定不变。Java平台类库中包含许多不可变的类,其中有String、基本类型的包装类、BigIntegerBigDecimal。存在不可变的类有许多理由:不可变的类比可变类更加易于设计、实现和使用。它们不容易出错,且更加安全。

为了使类成为不可变,要遵循下面五条规则:

  1. 不要提供任何会修改对象状态的方法(也成为mutator)。[注1]
  2. 保证类不会被扩展。这样可以防止粗心或者恶意的子类假装对象的状态已经改变,从而破坏该类的不可变行为。为了防止子类化,一般做法是使这个类成为final的,但是后面我们还会讨论到其他的做法。
  3. 使所有的域都是final。通过系统的强制方式,这可以清楚地表明你的意图。而且,如果一个指向新创建实例的引用在缺乏同步机制的情况下,从一个线程被传递到另一个线程,就必需确保正确的行为,正如内存模型(memory model)中所述[JLS,17.5;Goetzo6 16]。
  4. 使所有的域都成为私有的。这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端直接修改这些对象。虽然从技术上讲,允许不可变的类具有公有的final域,只要这些域包含基本类型的值或者指向不可变对象的引用,但是不建议这样做,因为这样会使得在以后的版本中无法再改变内部的表示法(见第13条)。
  5. 确保对于任何可变组件的互斥访问。如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法(accessor)中返回该对象引用。在构造器、访问方法和readObject方法(见第76条)中请使用保护性拷贝(defensive copy)技术(见第39条)。

前面条目中的许多例子都是不可变的,其中一个例子是第9条中的PhoneNumber,它针对每个属性都有访问方法(accessor),但是没有对应的设值方法(mutator)。下面是个稍微复杂一点的例子:

public final class Complex {
    private final double re;
    private final double im;

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    // Accessors with no corresponding mutators
    public double realPart()      { return re; }
    public double imaginaryPart() { return im; }

    public Complex add(Complex c) {
        return new Complex(re + c.re, im + c.im);
    }

    public Complex subtract(Complex c) {
        return new Complex(re - c.re, im - c.im);
    }

    public Complex multiply(Complex c) {
        return new Complex(re * c.re - im * c.im,
                           re * c.re + im * c.im);
    }

    public Complex divide(Complex c) {
        double tmp = c.re * c.re + c.im * c.im;
        return new Complex((re * c.re + im * c.im) / tmp,
                           (im * c.re - re * c.im) / tmp);
    }

    @Override public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Complex))
            return false;
        Complex c = (Complex) o;

        // See page 43 to find out why we use compare instead of ==
        return Double.compare(re, c.re) == 0 &&
               Double.compare(im, c.im) == 0;
    }

    @Override public int hashCode() {
        int result = 17 + hashDouble(re);
        result = 31 * result + hashDouble(im);
        return result;
    }

    private int hashDouble(double val) {
        long longBits = Double.doubleToLongBits(re);
        return (int) (longBits ^ (longBits >>> 32));
    }

    @Override public String toString() {
        return "(" + re + " + " + im + "i)";
    }
}

这个类表示一个复数complex number,具有实部和虚部)。除了标准的Object方法之外,它还提供了针对实部和虚部的访问方法,以及4种基本的算数运算:加法、减法、乘法和除法。注意这些算数运算是如何创建并返回新的Complex实例,而不是修改这个实例。大多数重要的不可变类都使用了这种模式。它被称为函数的(functional)做法,因为这些方法返回了一个函数的结果,这些函数对操作数进行运算但并不修改它。与之相对应的更常见的是过程的(procedural)或者命令式的(imperative)做法,使用这些方式时,将一个过程作用在它们的操作数上,对导致它的状态发生改变。

如果你对函数方式的做法还不太熟悉,可能会觉得它显得不太自然,但是它带来了不可变性,具有许多优点。不可变对象比较简单。不可变对象可以只有一种状态,即被创建是的状态。如果你能够确保所有的构造器都建立了这个类的约束关系,就可以确保这些约束关系在整个生命周期内永远不再发生变化你和使用这个类的程序员都无需再做额外的工作来维护这些约束关系。另一方面,可变的对象可以有任意复杂的状态空间。如果文档中没有对mutator方法所执行的状态转换提供精确的描述,要可靠地使用一个可变类是非常困难的,甚至是不可能的。

不可变对象本质上是线程安全的,它们不要求同步。当多个线程并发访问这样的对象时,它们不会遭到破坏。这无疑是获得线程安全最容易的办法。实际上,没有任何线程会注意到其他线程对于不可变对象的影响。所以,不可变对象可以被自由地共享。不可变类应该充分利用这种优势,鼓励客户端尽可能地重用现有的实例。要做到这一点,一个很简单的办法就是,对于频繁用到的值,为它们提供公有的静态final常量。例如,Complex类有可能会提供下面的常量:

public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE  = new Complex(1, 0);
public static final Complex I    = new Complex(0, 1);

这种方法可以被进一步扩展。不可变的类可以提供一些静态工厂(见第1条),它们把频繁被请求的实例缓存起来,从而当现有实例可以符合请求的时候,就不必创建新的实例。所有基本类型的包装类和BigInteger都有这样的静态工厂。使用这样的静态工厂也使得客户端之间可以共享现有的实例,而不用创建新的实例,从而降低内存占用和垃圾回收的成本。在设计新的类时,选择用静态工厂代替公有的构造器可以让你以后有添加缓存的灵活性,而不必影响客户端。

“不可变对象可以被自由地共享”导致的结果是,永远也不需要进行保护性拷贝(见第39条)。实际上,你根本无需做任何拷贝,因为这些拷贝始终等于原始的对象。因此,你不需要,也不应该为不可变的类提供clone方法或者拷贝构造器copy constructor,见第11条)。这一点在Java平台的早起并不好理解,所以String类仍然具有拷贝构造器,但是应该尽量少用它(见第5条)。

不仅可以共享不可变对象,甚至也可以共享它们的内部信息。例如,BigInteger类内部使用了符号数值表示法。符号用一个int类型的值来表示,数值则用一个int数组表示。negate方法产生一个新的BigInteger,其中数值是一样的,符号则是相反的。它并不需要拷贝数组;新建的的BigInteger也指向原始实例中的同一个内部数组。

不可变对象为其他对象提供了大量的构建(building blocks),无论是可变的还是不可变的对象。如果知道一个复杂对象内部的组件对象不会改变,要维护它的不变性约束是比较容易的。这条原则的一种特例在于,不可变对象构成了大量的映射键(map key)和集合元素(set element);一旦不可变对象进入到映射(map)或者集合(set)中,尽管这破坏了映射或者集合的不变性约束,但是也不用担心它们的值会发生变化。

不可变类真正唯一的缺点是,对于每个不同的值都需要一个单独的对象。创建这种对象的代价可能很高,特别是对于大型对象的情形。例如,假设你有一个上百万位的BigInteger,想要改变它的低位:

BigInteger moby = ...;
moby = moby.flipBit(0);

flipBit方法创建了一个新的BigInteger实例,也有上百万位长,它与原来的对象只差一位不同。这项操作所消耗的时间和空间与BigInteger的成正比。我们拿它与java.util.BitSet进行比较。与BigInteger类似,BitSet代表一个任意长度的位序列,但是与BigInteger不同的是,BitSet是可变的。BitSet类提供了一个方法,允许在固定时间(constant time)内改变此“百万位”实例中单个位的状态。

如果你执行一个多步骤的操作,并且每个步骤都会产生一个新的对象,除了最后的结果之外其他的对象最终都会被丢弃,此时性能问题就会显露出来。处理这种问题有两种办法。第一种办法,先猜测一下会经常用到哪些多步骤的操作,然后将它们作为基本类型提供。如果某个多步骤操作已经作为基本类型提供,不可变的类就可以不必在每个步骤单独创建一个对象。不可变的类在内部可以更加灵活。例如,BigInteger有一个包级私有的可变“配套类(companing class)”,它的用途是加速诸如“模指数(modular exponentiation)”这样的多步骤操作。由于前面提到的诸多原因,使用可变的配套类比使用BigInteger要困难得多,但幸运的是,你并不需要这样做。因为BigInteger的实现者已经替你完成了所有困难的工作。

如果能够精确地预测出客户端将要在不可变的类上执行哪些复杂的多阶段操作,这种包级私有的可变配套类就可以工作得很好。如果无法预测,最好的办法是提供一个公有的可变配套类。在Java平台类库中,这种方法的主要例子是String类,它的可变配套类是StringBuilder(和基本上已经废弃的StringBuffer)。可以这样认为,在特定的环境下,相对于BigInteger而言,BitSet同样扮演了可变配套类的角色。

现在你已经知道了如何构建不可变的类,并且了解了不可变性的优点和缺点,现在我们来讨论其他的一些设计方案。前面提到过,为了确保不可变性,类绝对不允许自身被子类化,除了“使类成为final的”这种方法之外,还有一种更加灵活的办法也可以做到这一点。让不可变的类变成final的另一种办法就是,让类的所有构造器都变成私有的或者包级私有的,并添加公有的静态工厂(static factory)来代替公有的构造器(见第1条)。

为了具体说明这种方法,下面以Complex为例,看看如何使用这种方法:

// Immutable class with static factories instead of constructors
public class Complex {
    private final double re;
    private final double im;

    private Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public static Complex valueOf(double re, double im) {
        return new Complex(re, im);
    }

    ... // Remainder unchanged
}

虽然这种方法并不常用,但它经常是最好的替代方法。它最灵活,因为它允许使用多个包级私有的实习类。对于处在它的包外部的客户端而言,不可变类实际上是final的,因为不可能把来自另一个包的类、缺少公有的活受保护的构造器的类进行扩展。除了允许多个实现类的灵活性之外,这种方法还使得有可能通过改善静态工厂的对象缓存能力,在后续的发行版本中改进该类的性能。

静态工厂与构造器想必具有许多其他的优势,正如在第1条中所讨论的。例如,假设你希望提供一种“基于极坐标创建复数”的方式。如果使用构造器来实现这样的功能,可能会使得这个类很零乱,因为这样的构造器与已用的构造器Complex(double, double)具有相同的签名。通过静态工厂,这很容易做到。只需添加第二个静态工厂,并且工厂的名字清楚地表明了它的功能即可:

public static Complex valueOfPolar(double r, double theta) {
    return new Complex(r * Math.cos(theta),
                       r * Math.sin(theta));
}

BigIntegerBigDecimal刚被编写出来的时候,对于“不可变的类必须为final的”还没有得到广泛地理解,所以它们的所有方法都可能会被覆盖。遗憾的是,为了保持向后兼容,这个问题一直无法得以修正。如果你在编写一个类,它的安全性依赖于(来自不可信客户端的)BigInteger或者BigDecimal参数的不可变性,就必须进行检查,以确定这个参数是否为“真正的”BigInteger或者BigDecimal,而不是不可信任子类的实例。如果是后者的话,就必须在假设它可能是可变的前提下对它进行保护性拷贝(见第39条):

public static BigInteger safeInstance(BigInteger val) {
    if (val.getClass() != BigInteger.calss)
        return new BigInteger(val.toByteArray());
    return val;
}

本条目开头初关于不可变类的诸多规则指出,没有方法会修改对象,并且它的所有域都必须是final的。实际上,这些规则比真正的要求更强硬了一点,为了提供性能可以有所放松。事实上应该是这样:没有一个方法能够对对象的状态产生外部可见(externally visible)的改变。然而,许多不可变的类拥有一个或者多个非final的域,它们在第一次被请求执行这些计算的时候,把一些开销昂贵的计算结果缓存在这些域中。如果将来再次请求同样的计算,就直接返回这些缓存的值,从而节约了重新计算所需要的开销。这种技巧可以很好地工作,因为对象是不可变的,它的不可变性保证了这些计算如果被再次执行,就会产生同样的结果。

例如,PhoneNumber类的hashCode方法(见第9条)在第一次被调用的时候,计算出散列码,然后把它缓存起来,以备将来被再次调用时使用。这种方法是延迟初始化(lazy initialization)(见第71条)的一个例子,String类也用到了。

有关序列化功能的一条告诫有必要在这里提出来。如果你选择让自己的不可变类实现Serializable接口,并且它包含一个或者多个指向可变对象的域,就必须提供一个显式的readObject或者readResolve方法,或者使用ObjectOutputStream.writeUnsharedObjectInputStream.readUnshared方法,即使默认的序列化形式是可以接受的,也是如此。否则攻击者可能从不可变的类创建可变的实例。这个话题的详细内容请参见第76条。

总之,坚决不要为每个get方法编写一个相应的set方法。除非有很好的利用要让类成为可变的类,否则就应该是不可变的。不可变的类有许多有点,唯一确定是在特定的情况下存在潜在的性能问题。你应该总是使一些小的值对象,比如PhoneNumberComplex,成为不可变的(在Java平台类库中,有几个类如java.util.Datejava.awt.Point,它们本应该是不可变的,但实际上却不是)。你也应该认真考虑把一些较大的值对象做成不可变的,例如StringBigInteger。只有当你确认有必要实现令人满意的性能时(见第55条),才应该为不可变的类提供公有的可变配套类。

对于有些类而言,其不可变性是不切实际额的。如果类不能被做成是不可变的,仍然应该尽可能地限制它的可变性。降低对象可以存在的状态数,可以更容易地分析该对象的行为,同时降低出错的可能性。因此,除非有令人信服的理由要使域编程是非final的,否则要使每个域都是final

构造器应该创建完全初始化的对象,并建立起所有的约束关系。不要再构造器或者静态工厂之外再提供共有的初始化方法,除非有令人信服的理由必须这么做。同样地,也不应该提供“重新初始化”方法(它使得对象可以被重用,就好像这个对象是由另一不同的初始状态构造出来的一样)。与所增加的复杂性想必,“重新初始化”方法通常并没有带来太多的性能优势。

可以通过TimerTask类来说明这些原则。它是可变的,但是它的状态空间被有意地设计得非常小。你可以创建一个实例,对它进行调度使它执行起来,也可以随意地取消它。一旦一个定时器任务(timer task)已经完成,或者已经取消,就不可能再对它重新调度。

最后值得注意的一点与本条目中的Complex类有关。这个例子只是被用来演示不可变性的,它不是一个工业强度(即产品级)的复数实现。它对复数乘法和除法使用标准的计算公式,会进行不正确的舍入,并对复数NaN和无穷大没有提供很好的语义[Kaha91,Smith63,Thomas94]。


注:

  1. 即改变对象属性的方法。——编辑注

results matching ""

    No results matching ""