先上结论:幽灵类型(Phantom Type)是一种可以把有些运行时才能检测到的错误,在编译时检测出来的技巧。按照有些老外的观点,就是“Making Wrong Code Look Wrong”。在面向对象的编程语言之中,幽灵类型的实现,往往与状态模式较为接近,但比状态模式提供了更强的纠错功能。在Java 5 以后的版本里,程序员可以使用泛型。通过泛型的类型参数,Java 中也拥有了幽灵类型的能力。

  上面的阐述是不是很难看懂?我也觉得拗口,让我们直接进入具体的例子。假设我们要写一个飞机控制程序,操作飞机起飞或者落地。这个程序有一个非常强的业务约束,就是必须保证飞机一开始必须出现在的地上,只有在地上的飞机可以起飞,只有起飞的飞机可以落地,那么我们应该怎样设计我们的程序(主要是类型关系),来保证这个约束必然成立呢?

  让我们先来定义一组状态接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* FlightStatus.java
* @author liangchuan
*/
public interface FlightStatus {
}

/**
* Flying.java
* @author liangchuan
*/
public interface Flying extends FlightStatus {
}


/**
* Landed.java
* @author liangchuan
*/
public interface Landed extends FlightStatus {
}

  从字面上即可看出,这是三个接口表示状态的接口类型。Flying 与 Landed 分别是 FlightStatus 的子类型,它们全都不包含任何可以使用的内容,完全通过类型名称来进行识别和区分。在 Java 这种指称类型(Nominal Typing) 的语言中,这通常被称为 Tagging Interface 或者叫 Mark Interface。

  接下来我们来定义一个飞机类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* Plane.java
* 这个类型可以被用类型参数具体化为任何 FlightStatus 的 飞机。即 Plane<Landed> 与 Plane<Flying>。
* @author liangchuan
*/
public class Plane<Status extends FlightStatus> {

private int passenger;

public int getPassenger() {
return passenger;
}

// 禁掉了除工厂方法和指定的状态构造方法以外的所有其他构造方法。当然,防不了反射攻击(reflection attack)。
private Plane(int passenger) {
this.passenger = passenger;
}

/**
* 工厂方法
* @return
*/
public static Plane<Landed> newPlane() {
return new Plane<Landed>(10);
}

/**
* 状态构造方法
* 在这里每次飞机从一个状态转成另一个飞机状态,都产生了一个新的对象,类似 Value Object 的模式。
* @param p
*/
private Plane(Plane<? extends FlightStatus > p) {
// 在这里,我们可以使用装饰器模式。也可以使用 clone 模式,把乘客(也就是内部状态)移交过去。这取决于我们要不要把旧飞机实例的状态迁移到新飞机实例上。
this.passenger = p.getPassenger();
// 做任何想要做的事情
}

public static class AirTrafficController {

public static Plane<Landed> land(Plane<Flying> p) {
return new Plane<Landed>(p);
}

public static Plane<Flying> takeOff(Plane<Landed> p) {
return new Plane<Flying>(p);
}
}

}

  这个 Plane 类型有什么特别的地方呢?

  1. 它只能使用有限的构造器来构造飞机,除此之外,都会因为方法签名带来编译错误
  2. 实际上,一开始只有用工厂方法才能构造出落地的飞机,无法一开始就制造出在天上飞的飞机,否则,也会因为方法签名带来编译错误
  3. 只有有状态的飞机,才能产生新的有状态的飞机。而这个有状态的飞机的转换构造函数(类似 CPP 的拷贝构造函数),只有 AirTrafficController 可以访问。
  4. AirTrafficController 提供了两个状态转换方法: land 与 takeOff 。这两个方法会根据一个输入飞机的状态,来切换出另一个状态的飞机。而它们因为方法签名的关系,只能接受有限的飞机状态,否则会产生编译错误

  到此我们的类库已经写完了。试试写一个应用程序来测试它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
*
* AirPlaneApp.java
* @author liangchuan
*/
public class AirPlaneApp {
public static void main(String[] args){
Plane<Landed> p = Plane.newPlane();

Plane<Flying> fly= Plane.AirTrafficController.takeOff(p);
Plane<Landed> land= Plane.AirTrafficController.land(fly);

// 无法编译通过:
///Plane<Landed> reallyLanded = Plane.AirTrafficController.land(land);
//Plane<Flying> reallyFlying = Plane.AirTrafficController.takeOff(fly);
}
}

  想一想,如果我们把我们的程序当做类库发布出去给其他的程序员用。类库使用者因为加班上线已经写代码到了凌晨一点,错误地试图把一架正在起飞的飞机再次起飞,立刻就会得到编译器的错误提醒。这种预先设计的防呆类型系统,成功地降低了系统在变得复杂的以后,出现低级错误的可能。

  为什么这种技巧叫幽灵类型呢?因为我们只在方法的签名的类型参数(type parameter)里指定了一个具体类型,并没有实际在方法体内部真的使用到这种类型的任何具体内容。诚如我们在代码中所见,FlightStatus 这种接口只是一种编译时类型识别的 type witness(类型见证人),帮助编译器推导当前的代码的合法性,其本身及其子类型,都不包含任何可以使用的内容。

  可能有读者会问,这种方法很像状态模式,它和状态模式的区别在哪里呢?

  1. 一个最显著的区别就是,状态模式里面,表示 state 的是实例里的一个 state 变量,而不是写在实例类型参数里的 state 类型见证人。使用状态模式,很容易让程序员写出 if(state == flying) throw new Exception() 之类的代码,这种代码即使写错了,编译器也检测不出来,因为这是运行时检测(是不是很讽刺,检测出错的代码,自己也会出错)。
  2. 更重要的是,类型参数的出现,使得一段代码里 plane 的状态表面化了。想一想,一个使用状态模式的 plane,我们在客户端代码里未必就能在当前上下文里知道它内部的 state 现在变成什么样了。但如果我们使用幽灵类型,那么我们只要看看当前上下文的方法签名的类型参数,就能明确理解当前飞机的 state。

  我们应该什么时候使用幽灵类型呢?这是一个很难把控的问题。读者已经看到了,实际上这个飞机的例子也是非常精巧,需要仔细思考才能明白其中奥妙的,所以幽灵类型在 Java 的世界里长久不为人知。笔者的愚见是,在像飞机这类例子里面,有需要严格区分状态(或者子类型)和方法的匹配的需求,可以考虑使用幽灵类型。

   这篇文章缘于知乎上的一个有意思的问答《你见过哪些让你瞠目结舌的 Java 代码技巧?》。当时看到这种用法,我就觉得这是一种很有意思的利用编译器进行防御性编程的例子。此外,本文的飞机例子基本源自于,但加上了一些我自己的注释和修改,便于读者理解(在原文的例子中,原作者似乎意识不到 Plane(Plane<? extends FlightStatus > p)不应该是个公有方法,而 AirTrafficController 应该是个内部类。请读者自行思考为什么。 )。实际上还有更多的例子,可以在这里看到。在函数式编程语言的世界,如 Haskell、Scala、OCaml 里,幽灵类型是天然被支持的,但在 Java 的世界里,必须要到提供泛型能力的 Java 5 版本以后,才能这种技巧。