什么是幽灵类型

先上结论:幽灵类型(Phantom Type)顾名思义,就是幽灵般的类型,这种类型往往在运行时可以消失,因为在运行时没有任何作用,它们最大的特点就是没有任何实例(Java 的 Void 就是一个不可实例化类型的例子,常被用作幽灵类型的类型参数,如 Future<Void>)。幽灵类型是一种可以把有些运行时才能检测到的错误,在编译时检测出来的技巧。按照有些老外的观点,就是"Making Wrong Code Look Wrong"。在面向对象的编程语言之中,幽灵类型的实现,往往与状态模式较为接近,但比状态模式提供了更强的纠错功能。在 Java 5 以后的版本里,程序员可以使用泛型。通过泛型的类型参数,Java 中也拥有了幽灵类型的能力。

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

定义状态接口

先来定义一组状态接口:

classDiagram
    class FlightStatus {
        <<interface>>
    }
    class Flying {
        <<interface>>
    }
    class Landed {
        <<interface>>
    }
    FlightStatus <|-- Flying
    FlightStatus <|-- Landed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 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 {
}

从字面上即可看出,这是三个表示状态的接口类型。FlyingLanded 分别是 FlightStatus 的子类型,它们全都不包含任何可以使用的内容,完全通过类型名称来进行识别和区分。在 Java 这种名义类型系统(Nominal Typing)的语言中,这通常被称为 Tagging Interface 或者叫 Marker 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
/**
* 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;
}

/**
* 工厂方法,创建一架初始状态为落地的飞机
*/
public static Plane<Landed> newPlane() {
return new Plane<Landed>(10);
}

/**
* 状态构造方法。
* 每次飞机从一个状态转成另一个飞机状态,都产生了一个新的对象,类似 Value Object 的模式。
*/
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. 只有有状态的飞机,才能产生新的有状态的飞机。而这个有状态的飞机的转换构造函数(类似 C++ 的拷贝构造函数),只有 AirTrafficController 可以访问。
  4. AirTrafficController 提供了两个状态转换方法:landtakeOff。这两个方法会根据一个输入飞机的状态,来切换出另一个状态的飞机。而它们因为方法签名的关系,只能接受有限的飞机状态,否则会产生编译错误

使用示例

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

stateDiagram-v2
    [*] --> Landed: newPlane()
    Landed --> Flying: takeOff()
    Flying --> Landed: land()
    Landed --> [*]
    Flying --> [*]
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)里指定了一个具体类型的绑定边界(? extends FlightStatus),并没有实际在方法体内部真的使用到这种类型的任何具体内容。诚如代码中所见,FlightStatus 这种接口只是一种编译时类型识别的类型见证人(type witness),帮助编译器推导当前代码的合法性,其本身及其子类型,都不包含任何可以使用的内容。从 JLS(Java Language Specification)的角度看,泛型的类型参数在编译后会被擦除(JLS §4.6 Type Erasure),幽灵类型正是利用了"编译期存在、运行时消失"这一特性来实现编译期约束。

根据 JLS §4.6 的规定,类型擦除会将泛型类型参数替换为其边界类型或 Object。例如:

  • Plane<Flying> 擦除为 Plane
  • Plane<Landed> 擦除为 Plane
  • List<String> 擦除为 List

更准确地说,幽灵类型的"幽灵"体现在两个层面:

  1. 类型参数从未被实例化FlightStatusFlyingLanded 这些接口永远不会有实例被创建。它们存在的唯一目的是作为类型参数出现在泛型签名中。
  2. 类型参数在运行时消失:由于类型擦除,Plane<Flying>Plane<Landed> 在运行时都是同一个类 Plane。所有的类型约束检查都在编译期完成,运行时没有任何额外开销。

这意味着幽灵类型是一种零成本抽象(zero-cost abstraction)——它在编译期提供了强大的类型安全保证,但在运行时不产生任何性能开销。这与 Rust 的 zero-cost abstraction 理念不谋而合。

类型擦除:幽灵类型的双刃剑

类型擦除既是幽灵类型得以存在的基础,也是它的主要局限。由于擦除的存在,以下操作在幽灵类型中是不可能的:

1
2
3
4
5
6
7
8
9
10
// 编译错误:无法在运行时检查泛型类型参数
if (plane instanceof Plane<Flying>) { ... }

// 编译错误:无法创建泛型类型参数的实例
Status status = new Status();

// 运行时无法区分:以下断言永远为 true
Plane<Flying> flying = ...;
Plane<Landed> landed = ...;
assert flying.getClass() == landed.getClass(); // true!

这意味着幽灵类型的所有约束必须在编译期完成验证。一旦代码通过编译,运行时就不再有任何幽灵类型的痕迹。这是一个重要的设计约束:幽灵类型只能防止"写出错误的代码",不能防止"运行时的错误状态"。如果有人通过反射或者 @SuppressWarnings("unchecked") 绕过了编译器检查,幽灵类型就无能为力了。

幽灵类型 vs 状态模式

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

graph LR
    subgraph 状态模式
        A[对象引用] --> B[内部状态变量]
        B --> C{运行时检查}
        C -->|正确| D[执行操作]
        C -->|错误| E[抛出异常]
    end

    subgraph 幽灵类型
        F[编译期类型检查] -->|Plane<Landed>| G[只能调用 land]
        F -->|Plane<Flying>| H[只能调用 takeOff]
        I[类型不匹配] --> J[编译错误]
    end

    style C fill:#f9f,stroke:#333,stroke-width:2px
    style J fill:#ff9,stroke:#333,stroke-width:2px
  1. 一个最显著的区别就是,状态模式里面,表示 state 的是实例里的一个 state 变量,而不是写在实例类型参数里的类型见证人。使用状态模式,很容易让程序员写出 if(state == flying) throw new Exception() 之类的代码,这种代码即使写错了,编译器也检测不出来,因为这是运行时检测(是不是很讽刺,检测出错的代码,自己也会出错)。
  2. 更重要的是,类型参数的出现,使得一段代码里 plane 的状态表面化了。想一想,一个使用状态模式的 plane,在客户端代码里未必就能在当前上下文里知道它内部的 state 现在变成什么样了。但如果使用幽灵类型,那么只要看看当前上下文的方法签名的类型参数,就能明确理解当前飞机的 state。
  3. 还有一个容易被忽视的区别:状态模式允许同一个对象在不同状态间切换(对象身份不变,内部状态变化),而幽灵类型的状态转换必须产生新的对象(因为 Java 的泛型类型参数在对象创建后就固定了,无法改变)。这使得幽灵类型天然具有不可变性(immutability)的倾向,每次状态转换都像函数式编程中的值转换,旧状态的对象可以安全地被持有和引用,不会被意外修改。
对比维度 状态模式 幽灵类型
状态表示 实例内部的 state 变量 类型参数中的类型见证人
错误检测时机 运行时(if/throw 编译时(类型不匹配)
状态可见性 需要查看对象内部 方法签名直接可见
对象身份 同一对象,状态可变 每次转换产生新对象
运行时开销 state 字段占用内存 零开销(类型擦除)
适用场景 状态行为差异大、需要多态 状态约束严格、需要编译期保证

实际应用:类型安全的 Builder 模式

幽灵类型在实际工程中最有价值的应用之一是类型安全的 Builder 模式。考虑一个常见的问题:Builder 模式中,某些字段是必填的,但传统的 Builder 无法在编译期保证所有必填字段都被设置了。

1
2
3
4
5
// 传统 Builder 的问题:忘记设置必填字段只能在运行时发现
HttpRequest request = HttpRequest.builder()
.url("https://example.com")
// 忘记设置 method —— 编译通过,运行时才报错
.build();

使用幽灵类型,可以让编译器强制要求所有必填字段都被设置:

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
50
51
public interface HasUrl {}
public interface NoUrl {}
public interface HasMethod {}
public interface NoMethod {}

public class RequestBuilder<U, M> {
private String url;
private String method;

private RequestBuilder() {}

private RequestBuilder(RequestBuilder<?, ?> other) {
this.url = other.url;
this.method = other.method;
}

public static RequestBuilder<NoUrl, NoMethod> create() {
return new RequestBuilder<>();
}

public RequestBuilder<HasUrl, M> url(String url) {
RequestBuilder<HasUrl, M> next = new RequestBuilder<>(this);
next.url = url;
return next;
}

public RequestBuilder<U, HasMethod> method(String method) {
RequestBuilder<U, HasMethod> next = new RequestBuilder<>(this);
next.method = method;
return next;
}

// build 方法定义在一个静态内部类中,通过类型约束限制只有
// RequestBuilder<HasUrl, HasMethod> 才能调用
public static class Finalizer {
public static HttpRequest build(RequestBuilder<HasUrl, HasMethod> builder) {
return new HttpRequest(builder.url, builder.method);
}
}
}

// 使用方式:
// 编译通过 —— 所有必填字段都已设置
HttpRequest req = RequestBuilder.Finalizer.build(
RequestBuilder.create().url("https://example.com").method("GET")
);

// 编译失败 —— 缺少 method,类型是 RequestBuilder<HasUrl, NoMethod>,不匹配
// HttpRequest req = RequestBuilder.Finalizer.build(
// RequestBuilder.create().url("https://example.com")
// );

这种模式在 Google 的 AutoValue、Immutables 等库中都有体现。虽然 Java 的类型系统不如 Scala 或 Haskell 那样表达力强,但幽灵类型已经能覆盖相当多的编译期安全场景。

与其他语言的对比

幽灵类型并非 Java 的发明,它在类型系统更强大的语言中有着更自然的表达:

  • Haskell:幽灵类型是 Haskell 社区最早系统化研究的。Haskell 的 newtypedata 声明天然支持幽灵类型参数,且没有类型擦除的问题,因此可以在运行时也利用类型信息。
  • Scala:Scala 的类型系统比 Java 更强大,支持路径依赖类型(path-dependent types)和类型成员(type members),可以实现比 Java 幽灵类型更精细的编译期约束。
  • Rust:Rust 通过 PhantomData<T> 标记类型显式支持幽灵类型,主要用于告诉编译器关于所有权和生命周期的信息,是 Rust 类型系统中不可或缺的一部分。
  • TypeScript:TypeScript 的结构化类型系统(Structural Typing)与 Java 的名义类型系统不同,实现幽灵类型需要使用 branded types 技巧(通过添加一个永远不会被赋值的私有字段来区分类型)。

Java 中幽灵类型的主要局限在于类型擦除和缺乏高阶类型(Higher-Kinded Types)。这使得某些在 Haskell 或 Scala 中很自然的幽灵类型用法,在 Java 中要么无法实现,要么需要大量的样板代码。

何时使用幽灵类型

应该什么时候使用幽灵类型呢?这是一个很难把控的问题。读者已经看到了,实际上这个飞机的例子也是非常精巧,需要仔细思考才能明白其中奥妙的,所以幽灵类型在 Java 的世界里长久不为人知。

以下场景适合考虑使用幽灵类型:

  1. 状态机约束:像飞机例子这样,有严格的状态转换规则,且违反规则的后果严重(如金融交易的状态流转:Created → Submitted → Settled)。
  2. 类型安全的 Builder:需要在编译期保证所有必填字段都被设置。
  3. 单位类型安全:防止不同单位的数值被混用(如 Distance<Meters>Distance<Feet> 不能直接相加,避免 NASA 火星探测器那样的单位混淆事故)。
  4. 权限标记:标记某个资源是否已经过验证或授权(如 Input<Sanitized> vs Input<Raw>)。

以下场景则不适合:

  1. 状态转换逻辑复杂:如果状态之间的转换规则非常复杂(如有条件分支、并行状态等),幽灵类型会导致类型爆炸。
  2. 需要运行时状态检查:如果业务逻辑需要在运行时根据状态做不同处理,幽灵类型无法提供帮助。
  3. 团队不熟悉:幽灵类型的代码对不了解这一技巧的开发者来说可能难以理解,增加维护成本。

参考与延伸

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