(I’ve placed this in Shared Code rather than Articles because this is more me exploring a concept rather than writing a how-to on the subject. If it belongs better in the Articles section, let me know and I will move it.)
Referenced links:
Phantom Types in Java (Gabriel)
Phantom Types in Java (Maniagnosis)
Type Safe Builder Pattern in Java
Making Wrong Code Look Wrong
There have been a couple of articles posted to reddit regarding Phantom Types. They look interesting and potentially useful for gamedev but I’m not entirely sure. The appeal of this approach depends a fair bit on programmer preference (doesn’t everything!) and there are other ways to do what phantom types do (aren’t there always!) but I thought I would post this to share what I understand and see if others have thoughts.
Phantom types are a way to enforce certain constraints through the type system. In short, a Phantom Type is when a type is created without the intent that there be any instances of that type created. Let’s create some using interfaces.
public interface VecType { }
public interface Acceleration extends VecType {}
public interface Velocity extends VecType { }
public interfact Position extends VecType { }
Now let’s create a simple 2D Vector class.
public class Vec2<V extends VecType> {
public final float x;
public final float y;
public Vec2(float xi, float yi) {
x = xi;
y = yi;
}
public void addTo(Vec2<V> v) {
x += v.x;
y += v.y;
}
}
You can see that Vec2 takes a type parameter but no instances of V are created or stored. Parameterizing like this allows us to do something like the following:
Vec2<Position> p1 = new Vec2<Position>(4,5);
Vec2<Position> p2 = new Vec2<Position(1,1);
Vec2<Velocity> v1 = new Vec2<Velocity>(5,0);
p1.add(p2); // Is fine, produces vector 5,6.
p1.add(v1); // compile time error, can't add a velocity to a position.
We are using the type system to prevent us from accidentally adding a velocity to a position. One difficulty is that type erasure prevents things that would make this really nice. For instance, I would like to be be able to do something like:
interface Unit { }
interface Meter extends Unit { }
interface Foot extends Unit { }
class Length<U extends Unit> {
public float value;
public Length(float initialValue) {
value = initialValue;
}
public static Length<Foot> convert(Length<Meter> l) {
return new Length<Foot>(l.value*3.28);
}
public static Length<Meter> convert(Length<Foot> l) {
return new Length<Meter>(l.value/3.28);
}
}
It would be nice to be able to do this, but Java complains because after type erasure both convert methods have the same signature. Another use of phantom types is for things that you want to make it very explicit what type of input you want to receive. Imagine you have a game with an online scoreboard where people can enter their names. You want to prevent SQL injection attacks. You could do something like:
interface UserInput { }
interface Sanitized extends UserInput { }
interface Unsanitized extends UserInput { }
public class InputString<I extends UserInput> {
final String value;
private InputString(String value) {
this.value = value;
}
public String getValue() {
return value;
}
public static InputString<Unsanitized> makeInput(String val) {
return new InputString<Unsanitized>(val);
}
public static InputString<Sanitized> sanitize(InputString<Unsanitized> input) {
// Lots of messy logic to sanitize a string against sql injection attacks
}
}
By making the constructor private, we’ve ensured that the only way to create a Sanitized input is to run it through our sanitize method. If we look later in our code where this might be used
public void updateLeaderBoard(int score, InputString<Sanitized> playerName) {
// logic around updating the database
}
Here, the type helps better declare our intention, that the value must be sanitized and because the only way of getting an InputString is via your sanitize method, you can be sure (within the quality of sanitize method) that the value is in fact sanitized. Trying to submit an InputString will not fail at run time, it will fail at compile time.
Another situation may be encoding the State of a game entity.
interface FighterState { }
interface Hangar extends FighterState {}
interface InFlight extends FighterState {}
public Fighter<S extends FighterState> extends Entity {
}
and then in some another class:
public Fighter<InFlightState> launch(Fighter<InHangar> fgt) {
//..code that launches the fighter
}
public Fighter<HangarState> recover(Fighter<InFlight> fgt) {
//...code the recovers the fighter
}
Like in the example above, the phantom type help makes it more explicit in the method signature what is being returned and what is expected. Trying to launch a Fighter will generate a compile time error. You can also see that we are able to extend by one type while also using the phantom type to parameterize.
There is no reason why you need to limit to a single type. An example of this is using a builder. Imagine you have a builder where you may not be able to (or may not want to) build the entire thing at once.
public interface State { }
public interface Defined extends State { }
public interface Undefined extends State { }
public LevelBuilder<A extends State, W extends State> {
private LevelBuilder( ) { }
/*
Will not let you add World Data if it has already been defined.
*/
public static <S extends State> LevelBuilder<A,Undefined> addWorldData(LevelBuilder<S,Undefined> lb, ) { ... }
/*
Will not let you add Asset data if it has already been defined.
*/
public static <S extends State> LevelBuilder<Defined,S> addAssets(LevelBuilder<Undefined,S> lb, // asset parameters) {...}
/*
Only way to create a builder. Initializes it with both sets undefined to ensure that the
two pieces are initialized by the programmer.
*/
public static LevelBuilder<Undefined,Undefined> getBuilder() {...}
}
public class Level {
private Level() { } // private constructor
/**
Only way to get a Level. Type signature ensures that a level can only be created from
a bulider that has both WorldData and Assets defined.
*/
public static Level load(LevelBuilder<Defined,Defined> lb) {
// ...build the level
}
}
Some random final comments/summary:
-
If you’ve stuck it out this long, Thank you!
-
Overall I find phantom types to be interesting and worth considering. The more I work with type systems the more I like the idea of specifying as much as possible through them. I acknowledge that not everyone likes to work this way in which case phantom types may seem like overkill or over engineering.
-
They also seem to be a useful way to use the type system to better express/enforce programmer intent. Joel Spolsky has an article Making Wrong Code Look Wrong where they preface variables holding unsanitized strings with “us” (e.g. usUserName). But this is merely a convention and a programmer could accidentally or carelessly use usUserName where the sanitized version is required. Using phantom types like Input turns this convention into something enforced by the compiler.
-
Phantom types can also prevent certain types of errors (e.g. adding velocities to positions), although I am not sure how often those types of errors occurr and how bothersome they really are.
-
Type erasure limits the utility of phantom types in certian situations. Specifically it prevents overloading methods by the phantom type which could make things nicer.
-
We could achieve something similar to what phantom types achieve by extending base classes:
class Vec2 {...}
class PositionVec2 extends Vec2 {...
// Only adds another position vector to this one.
public void addTo(PositionVec2 v2) {...}
}
But due to single inheritance, you are then making your class structyre a lot less flexible. You may not want to tie your classes together this way. Also you can use multiple phantom types at a time where as with extension, you would have to create a subtype for each combination.
- Similar results could also be handled by storing internal flags and doing checks but the failure will then be deferred until runtime.
class Vec2 {
public VecType vType;
public int x,y;
public Vec2(VecType vType,int xi, int yi) { }
//
public void addTo(Vec2 v2) {
if (vType != v2.vType) throw new IllegalArgumentException("Cannot add vectors of different types");
// ...
}
}
/*
Later in some other method...
Vec2 v1 = new Vec2(VecType.Position,12,45);
Vec2 v2 = new Vec2(VecType.Velocity,55,34);
v1.addTo(v2); // This will compile fine but fail at run time.
*/