先上结论:幽灵类型(Phantom Type)是一种可以把有些运行时才能检测到的错误,在编译时检测出来的技巧。按照有些老外的观点,就是“Making Wrong Code Look Wrong”。在面向对象的编程语言之中,幽灵类型的实现,往往与状态模式较为接近,但比状态模式提供了更强的纠错功能。在Java 5 以后的版本里,程序员可以使用泛型。通过泛型的类型参数,Java 中也拥有了幽灵类型的能力。
上面的阐述是不是很难看懂?我也觉得拗口,让我们直接进入具体的例子。假设我们要写一个飞机控制程序,操作飞机起飞或者落地。这个程序有一个非常强的业务约束,就是必须保证飞机一开始必须出现在的地上,只有在地上的飞机可以起飞,只有起飞的飞机可以落地,那么我们应该怎样设计我们的程序(主要是类型关系),来保证这个约束必然成立呢?
让我们先来定义一组状态接口:
/** * FlightStatus.java *@authorliangchuan */publicinterfaceFlightStatus{}/** * Flying.java *@authorliangchuan */publicinterfaceFlyingextendsFlightStatus{}/** * Landed.java *@authorliangchuan */publicinterfaceLandedextendsFlightStatus{}
从字面上即可看出,这是三个接口表示状态的接口类型。Flying 与 Landed 分别是 FlightStatus 的子类型,它们全都不包含任何可以使用的内容,完全通过类型名称来进行识别和区分。在 Java 这种指称类型(Nominal Typing) 的语言中,这通常被称为 Tagging Interface 或者叫 Mark Interface。
接下来我们来定义一个飞机类型:
/** * Plane.java * 这个类型可以被用类型参数具体化为任何 FlightStatus 的 飞机。即 Plane 与 Plane。 *@authorliangchuan */publicclassPlane{privateintpassenger;publicintgetPassenger(){returnpassenger; }// 禁掉了除工厂方法和指定的状态构造方法以外的所有其他构造方法。当然,防不了反射攻击(reflection attack)。privatePlane(intpassenger){this.passenger = passenger; }/** * 工厂方法 *@return*/publicstaticPlanenewPlane(){returnnewPlane(10); }/** * 状态构造方法 * 在这里每次飞机从一个状态转成另一个飞机状态,都产生了一个新的对象,类似 Value Object 的模式。 *@paramp */privatePlane(Plane p){// 在这里,我们可以使用装饰器模式。也可以使用 clone 模式,把乘客(也就是内部状态)移交过去。这取决于我们要不要把旧飞机实例的状态迁移到新飞机实例上。this.passenger = p.getPassenger();// 做任何想要做的事情}publicstaticclassAirTrafficController{publicstaticPlaneland(Plane p){returnnewPlane(p); }publicstaticPlanetakeOff(Plane p){returnnewPlane(p); } }}
这个 Plane 类型有什么特别的地方呢?
它只能使用有限的构造器来构造飞机,除此之外,都会因为方法签名带来编译错误。
实际上,一开始只有用工厂方法才能构造出落地的飞机,无法一开始就制造出在天上飞的飞机,否则,也会因为方法签名带来编译错误。
只有有状态的飞机,才能产生新的有状态的飞机。而这个有状态的飞机的转换构造函数(类似 CPP 的拷贝构造函数),只有 AirTrafficController 可以访问。
AirTrafficController 提供了两个状态转换方法: land 与 takeOff 。这两个方法会根据一个输入飞机的状态,来切换出另一个状态的飞机。而它们因为方法签名的关系,只能接受有限的飞机状态,否则会产生编译错误。
到此我们的类库已经写完了。试试写一个应用程序来测试它:
/** * * AirPlaneApp.java *@authorliangchuan */publicclassAirPlaneApp{publicstaticvoidmain(String[] args){ Plane p = Plane.newPlane(); Plane fly= Plane.AirTrafficController.takeOff(p); Plane land= Plane.AirTrafficController.land(fly);// 无法编译通过:///Plane reallyLanded = Plane.AirTrafficController.land(land);//Plane reallyFlying = Plane.AirTrafficController.takeOff(fly);}}
想一想,如果我们把我们的程序当做类库发布出去给其他的程序员用。类库使用者因为加班上线已经写代码到了凌晨一点,错误地试图把一架正在起飞的飞机再次起飞,立刻就会得到编译器的错误提醒。这种预先设计的防呆类型系统,成功地降低了系统在变得复杂的以后,出现低级错误的可能。
为什么这种技巧叫幽灵类型呢?因为我们只在方法的签名的类型参数(type parameter)里指定了一个具体类型,并没有实际在方法体内部真的使用到这种类型的任何具体内容。诚如我们在代码中所见,FlightStatus 这种接口只是一种编译时类型识别的 type witness(类型见证人),帮助编译器推导当前的代码的合法性,其本身及其子类型,都不包含任何可以使用的内容。
可能有读者会问,这种方法很像状态模式,它和状态模式的区别在哪里呢?
一个最显著的区别就是,状态模式里面,表示 state 的是实例里的一个 state 变量,而不是写在实例类型参数里的 state 类型见证人。使用状态模式,很容易让程序员写出if(state == flying) throw new Exception()之类的代码,这种代码即使写错了,编译器也检测不出来,因为这是运行时检测(是不是很讽刺,检测出错的代码,自己也会出错)。
更重要的是,类型参数的出现,使得一段代码里 plane 的状态表面化了。想一想,一个使用状态模式的 plane,我们在客户端代码里未必就能在当前上下文里知道它内部的 state 现在变成什么样了。但如果我们使用幽灵类型,那么我们只要看看当前上下文的方法签名的类型参数,就能明确理解当前飞机的 state。
我们应该什么时候使用幽灵类型呢?这是一个很难把控的问题。读者已经看到了,实际上这个飞机的例子也是非常精巧,需要仔细思考才能明白其中奥妙的,所以幽灵类型在 Java 的世界里长久不为人知。笔者的愚见是,在像飞机这类例子里面,有需要严格区分状态(或者子类型)和方法的匹配的需求,可以考虑使用幽灵类型。
这篇文章缘于知乎上的一个有意思的问答《你见过哪些让你瞠目结舌的 Java 代码技巧?》。当时看到这种用法,我就觉得这是一种很有意思的利用编译器进行防御性编程的例子。此外,本文的飞机例子基本源自于此,但加上了一些我自己的注释和修改,便于读者理解(在原文的例子中,原作者似乎意识不到Plane(Plane p)不应该是个公有方法,而AirTrafficController应该是个内部类。请读者自行思考为什么。 )。实际上还有更多的例子,可以在这里看到。在函数式编程语言的世界,如 Haskell、Scala、OCaml 里,幽灵类型是天然被支持的,但在 Java 的世界里,必须要到提供泛型能力的 Java 5 版本以后,才能这种技巧。