[Java] 如何实现一个不可变类(Immutable class)

前言


不可变类在日常工作中经常用到,如JDK中的String类和所有的包装类。使用不可变类有很多好处,其中之一就有多线程里很重要的线程安全。还有使用简单,不用担心被人有意无意改掉实例的状态等优点。
那么如何实现一个不可变类呢?本文将介绍在Java中如何实现一个不可变类并用实战代码来帮助读者理解。

参考


  1. JSR 133 (Java Memory Model) FAQ
  2. Java Language Specification 13 Edition
  3. JVM Specification 13 Edition
  4. Immutable Objects - Java Practices
  5. Avoid JavaBeans style of construction - Java Practices
  6. 《Effective Java 3rd Edition》- Item17 minimize mutability

什么是不可变类(immutable class)


对于不可变类,笔者并没有找到一个标准的定义,所以摘取Java工具宝典《Effective Java 3rd edition》一书里对于不可变类的定义来说明。

An immutable class is simply a class whose instances cannot be modified. All of the information contained in each instance is fixed for the lifetime of the object, so no changes can ever be observed.
---- “Effective Java - 3rd edition”


“不可变类,简单来说就是其实例无法被修改,其实例中包含的所有信息(情报、数据)在其生命周期里都是固定的。在外部看来,没有任何改变能被观察到。
---- 笔者译

一言以蔽之,这个类的实例在被创建之后,实例的使用者不需要担心其数据在别的地方(线程)被修改。可以放心地传递给其他方法、其他线程。在JDK提供的类里,有着大量的不可变类。

  • java.lang.String类
  • 原生类型的包装类(八种,Boolean、Character、Integer等)
  • java.math.BigInteger、java.math.BigDecimal类
  • java.net.Inet4Address、java.net.Inet6Address

等等

Java里实现不可变类的四大要素


为了在Java里实现一个不可变类,我们需要满足以下几个条件。

1. 尽量使用final修饰所有的属性(field)

使用final将从系统层面上保证初始化完成之后,各个线程看到的属性一定是一样的。而未使用final的属性,则会出现另一个线程可能无法看到正确的值的情况。这在「jls13 § 17.5 final Field Semantics」里有下列例子来说明。

// Example 17.5-1. final Fields In The Java Memory Model
public class FinalFieldExample {
	final int x;
	int y;
	
	static FinalFieldExample f;
	
	public FinalFieldExample() {
		x = 3;
		y = 4;
	}
	
	static void writer() {
		f = new FinalFieldExample();
	}
	
	static void reader() {
		if (f != null) {
			int i = f.x; // guaranteed to see 3
			int j = f.y; // could see 0
		}
	}
}

当一个线程调用writer()方法完成了初始化并完成对f的赋值之时,另一个线程调用reader()方法可能会无法看到被初始化为4的y属性的值。而被final修饰的x则可以保证看到正确的值3,这看上去似乎非常不合情理。这涉及到编译器对指令进行重排序的问题,笔者将在之后的博客里介绍Java Memory Model(Java 内存模型)的相关知识时,解释为什么会出现这种情况的原因。

1.1. 避免JavaBeans风格的代码构建实例

被final修饰的属性,被限定必须在构造器里完成初始化。这将迫使该类的使用者避免使用JavaBeans风格的代码构建对象实例,JavaBeans风格的构建实例代码其特征无参构造器大量使用setter。JavaBeans风格的构建实例代码因其复杂性以及难以保证被构建实例状态的正确性(各属性被正确设置)等缺陷,已被摒弃。

// JavaBean风格的实例构建代码。
ClassA instanceA = new ClassA(); 
instanceA.setFieldA("A");
instanceA.setFieldB("B");
instanceA.setFieldC('c');
instanceA.setFieldD(100);

想知道更多的,请看延伸阅读:《Avoid JavaBeans style of construction》

2. 尽量使用private修饰属性。

对于不可变类的属性,尽量使用private修饰其属性,原因如下:

  • 对于非final的属性、以及对可变实例的引用,必须使用private修饰,来对外部隐藏其存在。防止来自外部调用者的恶意或失误导致的修改。
  • 而对于final的属性,由于其不变的特性,可以(但不推荐)对外公开其存在。一旦公开,根据开闭原则,在后续版本想对其进行修改将变得非常麻烦,即使仅是改变其变量名。

3. 禁止提供可改变实例状态的公开接口

和2类似,对于非final的属性、或可变类的引用,要禁止一切外部修改其属性的可能,这需要我们做到以下几点。

  • 禁止提供如类似Setter的方法供外部修改其值。
  • 禁止直接使用外部的提供的可变类的实例,接收来自外部的可变类实例时需要进行防御性拷贝(defensive copy)。
  • 禁止直接对外返回可变类实例的引用,对外提供可变类的实例时需要进行防御性拷贝。

4. 禁止不可变类被“外部”继承

禁止类被外部继承有多种手法。笔者将在本节讨论多种使类不可被外部继承的手法。

手法1:使用final关键字修饰类

不可变类最典型也比较简单的做法,比如java.lang.String,直接把整个class定义为final的,直接堵死了这个类扩展的可能性。JDK里典型的此种不可变类有

手法2:构造器私有化 & 提供静态构造方法

相比直接使用final修饰类,还有另一种相对”温和“的手法,即通过私有化构造器并对外提供公开的静态构造方法来实现禁止外部继承。

I. 构造器私有化
禁止外部继承,但同时也保留了一定的内部可扩展性,可由静态构造方法选择构建内部不同的子类实现返回给外部。

II. 提供公开的静态构造方法
伴随构造器的私有化,相应地我们要提供公开的静态构造方法供外部调用。也就是著名四人帮著作《Design Patterns》里23种设计模式中的其中一种 ,名为 Factory Method Pattern(工厂方法模式)

通过这种方式,不可变类的设计者把创建不可变类的任务交由不可变类自己来完成,而设计者也可以藉由这种方式,在构造方法里添加自己的缓存策略,优化构造不可变类时的性能。JDK里的典型此类不可变类有

手法2看上去很麻烦,为什么我们要用它?

手法1直接堵死了类内部扩展的可能性,而手法2则是实现不可变类扩展性最高的一种手法。也是被推崇的一种手法。手法2可在后续版本release的时候可以在工厂方法里改进缓存功能而改善其性能。

Effective Java 3rd Edition
ITEM 17: MINIMIZE MUTABILITY:
This approach is often the best alternative. It is the most flexible beause it allows the use of mutiple package-private implementation classes.
Besides allowing the flexibility of multiple implementation classes, thie approach makes it possible to tune the performance of the class in subsequent releases by improving the object-caching capabilities of the static factories.

BigInteger & BigDecimal 的设计缺陷

这两个类在设计之初,其设计者是根据手法2来设计的。不过当时手法2并没有被广泛地理解而出现设计缺陷。这两个类提供了public的构造器,导致其能被第三者继承并重写(Not Effectively Final)。而为了保持向后的兼容性,这个设计缺陷一直没能被后续release修复。

Effective Java 3rd Edition
ITEM 17: MINIMIZE MUTABILITY:
… If you write a class whose security depends on the immutability of a BigInteger or BigDecimal argument from an untrusted client, you must check to see that the argument is a “real” BigInteger or BigDecimal, rather than an instace of an untrusted subclass. If it is the latter, you must defensively copy it under the assumption that it might be mutable (Item 50)

由不可信的调用者传来的实例不能保证为不可变的,你需要对其做安全检查,如果检查有问题,则需对其做防御性拷贝后再使用。

public static BigInteger safeInstance(BigInteger val) {
	return val.getClass() == BigInteger.class ? val: net BigInteger(val.toByteArray()); 
}

不可变类的可变搭档类(companion class)

通常我们不会用到此类,但笔者还是简单描述一下此类的作用。不可变类有着诸如使用简单、线程安全等优点,不过也有其缺点,就是大型的不可变类通常会导致性能问题,而为了应付这种情况,优化性能,在必要的时刻,可以设计其可变搭档类(companion class)如StringBuffer(旧),StringBuilder,来减少每一次改变都需要新建一个不可变实例的开销。

实战:实现一个不可变类 (手法1)

下面,笔者简单实现一个不可变类,分别有3种不同属性。读者留意一下对于每种数据类型的不同的处理即可。

  • 原生数据类型。
  • 不可变的引用数据类型。
  • 可变的引用数据类型。
import java.util.Date;
import java.util.Objects;

public final class TestImmutableClass {
    private final int primitiveTypeField1;
    private final String immutableReferenceTypeField2;
    private final Date mutableReferenceTypeField3;

    public TestImmutableClass(int primitiveTypeField1,
                              String immutableReferenceTypeField2,
                              Date mutableReferenceTypeField3) {
        this.primitiveTypeField1 = primitiveTypeField1;
        this.immutableReferenceTypeField2 = immutableReferenceTypeField2;
        this.mutableReferenceTypeField3 = new Date(mutableReferenceTypeField3.getTime()); // 防御性拷贝
    }

    public int getPrimitiveTypeField1() { return primitiveTypeField1; }
    public String getImmutableReferenceTypeField2() { return immutableReferenceTypeField2; }
    public Date getMutableReferenceTypeField3() { return new Date(mutableReferenceTypeField3.getTime()); } // 防御性拷贝

    @Override public boolean equals(Object obj) {
        if (obj == this) return true;
        if (!(obj instanceof TestImmutableClass)) return false;
        TestImmutableClass instance = (TestImmutableClass) obj;

        return Integer.compare(this.primitiveTypeField1, instance.primitiveTypeField1) == 0
            && Objects.equals(immutableReferenceTypeField2, instance.immutableReferenceTypeField2)
            && Objects.equals(mutableReferenceTypeField3, instance.mutableReferenceTypeField3);
    }

    private int hashCode; // Lazy-initialized hashcode field.
    // 重写equals方法之后,需重写hasCode方法。
    @Override public int hashCode() {
        int result = hashCode;
        if (result != 0) return result; // 使用既存値

        // 计算hash值
        result = Integer.hashCode(primitiveTypeField1);
        result = 31 * result + (immutableReferenceTypeField2 == null? 0: immutableReferenceTypeField2.hashCode());
        result = 31 * result + (mutableReferenceTypeField3 == null? 0: mutableReferenceTypeField3.hashCode());
        return result;
    }

    // 总是重写toString方法。
    @Override public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("{Field1=").append(primitiveTypeField1).append(',')
          .append("Field2=").append(immutableReferenceTypeField2).append(',')
          .append("Field3=").append(mutableReferenceTypeField3).append('}');
        return sb.toString();
    }
}

结语

实现不可变类无疑是防止外部的调用者、使用者有意无意改变实例状态的一种很好的手段,我们应该谨记最小化类的可变性,这样才能够使得程序的运行更加安全,并且同时减少技术债务的产生使得今后维护更加地简单。看完了本文,相信读者对于不可变类和实现一个不可变类都有简单的认识。这里笔者在这里提出一个问题希望读者能够自己思考。

DTO(Data Transfer Object)应该做成不可变类吗?
Is it common to make a DTO object as an immutable object? - quora

发布了24 篇原创文章 · 获赞 24 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/ToraNe/article/details/103003531