Phantom types are designed to support compile time type evidences without any overhead costs to runtime. Phantom evidences are usually in the form of implicit arguments, which once resolved, can be erased by the compiler.
Because of these properties, phantom types are completely outside of the normal
type lattice, and as such these phantom types do not inherit the semantics of
Any
.
+-----+ +------------+
| Any | | PhantomAny |
+-----+ +------------+
| |
+----+------+ +-----+------+
| | | |
+--------+ +--------+ +------------+ +-----+
| AnyRef | | AnyVal | | Capability | | Boo |
+--------+ +--------+ +------------+ +-----+
| | | |
+------+ | +-----+------+
| Null | | |
+------+ | |
| | |
+-------+----+ |
| |
+---------+ +----------------+
| Nothing | | PhantomNothing |
+---------+ +----------------+
Note: The name PhantomAny
is a bit too long. Any sugestions for a shorter one? The best contender so far if GhostAny
.
PhantomAny
is the top of the phantom lattice as Any
is the top of the
default typesystem's lattice. To implement this we need a class symbol that has
no parent, just like the class symbol for Any
.
In addition, type derivation checks must be adapted and remove the assumption
that everything derives from Any
.
PhantomNothing
is the bottom of the phantom lattice as Nothing
is the
bottom of the current existing typesystem. To implement this we need a class
symbol that has as parent PhantomAny
, just like the class symbol for
Nothing
. In addition, type derivation checks must be adapted and remove all
assumptions that Nothing
derives from everything. The new rule for bottom
types is as follows:
def derivesFrom(tp1, tp2) =
if (tp1 == Nothing) derivesFrom(tp2, Any)
else if (tp1 == PhantomNothing) derivesFrom(tp2, PhantomAny)
else // do usual checks
When we have a type member or parameter we need to have bounds for it. By
default the bounds are <: Any >: Nothing
. If we wish to have a type that is
in the phantom lattice we need to explicitly state the bounds <: PhantomAny >: PhantomNothing
(or some tighter bounds in the phantom lattice).
type Boo <: PhantomAny >: PhantomNothing
def boo[P <: PhantomAny >: PhantomNothing]()
We also add inference on missing phantom bounds. If we have a phantom upper
bound we can infer that the lower bound is PhantomNothing
and if we have a
phantom lower bound we can infer that the upper bound is PhantomAny
. We could
also envision a shorthand to mark the types as phantom. The following two
examples are equivalent to Boo
.
type Boo1 <: PhantomAny
type Boo2 >: PhantomNothing
We also disallow mixed lattice bounds such as <: Any >: PhantomNothing
and
<: PhantomAny >: Nothing
. These bounds are not allowed because they have no
inhabitants and it would be impossible to determine if some type is a phantom
type or not.
We disallow unions and intersections of types from different latices. The reasons are the same as for mixed lattice bounds.
Methods are allowed to have parameters of any type regardless of the lattice. This also includes constructors.
None of these are allowed to contain a value of phantom type. Furthermore, if they where, they would have restrictions that would make them semantically equivalent to a def with no parameters.
To call a method with a phantom parameter we need a term of that phantom type. This term could be a parameter taken of the current method, a call on a def that returns a phantom type or a newly instantiated phantom term. Note that the call and instantiations are not at runtime but only at compile time. These are the only valid phantom type expressions.
Phantom expressions cannot be in statement position. This is to not allow the user to write statements that have no effects. This implies that all phantom terms will be in expression positon.
None of the branches can be of phantom type.
Just like the if expressions, the reality of the cases can not be of phantom type. We can also not match on a phantom expression as this is an operation that is done at runtime.
The try block cannot be of phantom type, it can't throw. The catch block can't have branches with phantom type. The finally block can't be of phantom type.
return
cannot return a phantom expression.
As stated earlier, to call a method with phantom parameters we need an instance of that type. At the head of the call chain, there will be a place where the phantom type is instantiated. Note that each type has exactly one logical term as two of them will only be comparable with type equality at compile time.
This is the simplest way to generate phantom terms. It consists of a magical
function def phantom[T <: PhantomAny]: T
which just materializes the phantom
term of any phantom type.
Here are some examples of how it could be used:
type CanEat <: PhantomAny
type CanEatGrass <: CanEat
type CanEatMeat <: CanEat
def eat(food: Grass, canEat: CanEatGrass)
def eat(food: Meat, canEat: CanEatMeat)
eat(new Grass, phantom[CanEatGrass])
eat(new Meat, phantom[CanEatMeat])
This approach has some issues as it allows the creation of terms of any phantom type, namely we could generate terms of type PhantomNothing
.
eat(new Grass, phantom[PhantomNothing]) // this should be disallowed
But in general it is hard as it could be hidden behind some levels of abstraction.
type MyPhantomNothing <: PhantomNothig
eat(new Grass, phantom[MyPhantomNothing])
This is another approach where we reuse the class system to create phantom instances. These classes will have a considerable amount of restrictions that are relatively simple to enforce. In this version, the example for the magic function would look like:
abstract class CanEat extends PhantomAny // abstract because we don’t want to allow instantiation of this particular class
class CanEatGrass extends CanEat
class CanEatMeat extends CanEat
def eat(food: Grass, canEat: CanEatGrass)
def eat(food: Meat, canEat: CanEatMeat)
eat(new Grass, new CanEatGrass)
eat(new Meat, new CanEatMeat)
Using phantom classes we can use private/(package private) constructors to disallow instantiation of phantom parameters based on packages/class hierarchy.
- A class will become a phantom class if it extends
PhantomAny
directly or indirectly. - Phantom classes can only be defined at top level or inside phantom objects.
- A phantom class cannot inherit from the
Any
lattice. - Phantom class constructors can only have phantom parameters as it is a phantom method returning a phantom.
- Phantom classes can only define
type
members. Nodef
,val
,lazy val
,var
,class
,object
, or or statements of any kind.
- They have the same restrictions as phantom classes except that they are allowed to define
def
s that return a phantom. Thesedef
s are useful for implementing implicitdef
s for the phantom class. def
s are not allowed to produce a cyclic call chain.- If the class can be phantom if and only if its companion object is also phantom. If only one of them is defined the other one is assumed to be of the same phantomicity.
A method can only return a phantom type if all of its parameters are phantom types. This is because the phantom results can only be used at compile time, hence it can only use compile time information, such as the kind of its type parameters and phantom parameters.
Calls on these methods will have no effects or computation, this will be ensured by the restrictions on expressions of the phantom type (see below).
As terms of the phantom lattice have no effect on runtime, by design they can be erased. Hence the name phantom.
The first thing to erase is all parameters and arguments of any method call and definition. This is defined in one dotty miniphase.
Given the restrictions on phantom classes it is possible to safely delete any argument passed. The arguments will be a tree with one of the following.
new
phantom: then by definition it has no effect and can be safely removed.- phantom object: where the initialization can only initialize other phantom, which has no effect.
- call to a def returning a phantom: the prefix will be a phantom object and the args will be other phantoms which can also be deleted (or could be seen as already deleted).
Note that erasing the parameter might create methods that have the same signature. If that happens we need to emmit some error similar to the one emmited by type erasure.
We will only erase phantom arguments/parameters of methods that do not return a phantom (only delete in non phantom classes). Note that by doing so we remove all term references of phantom classes/methods from non phantom claases. This implies that after this transformation phantom and non phantom are completely disjoint on the term level.
After erasing the arguments/parameters we have two disjoint sets of trees, the one that are in non phantom class an the ones that are in phantom classes.
If the trees are in non phantom classes, we do not have any phantom term left and we can leave the rest to type erasure.
If the tree is phantom class, the we do not need it anymore and we could safely delete it. But we will not do that, we will erase PhantomAny into some dummy runtime class and let all phantom clases export thier class files. This way we are also saving the tasty trees that we need for separate compilation.
As we do not allow the definitions of def
or val
with phantom types in normal classes/methods we do not erase them.
But, some trasnformations normally try to insert them (for example parameters in inline
). This must be avoided in all those thransformations. Luckily these thransformations alredy use the expressions purity checks to avoid generating them when not needed. Any phantom can trivially be considered as a pure expression as in fact it satisfies stronger condition than a pure expression.
We also need to support functions that receive parameters of phantom type. Note
that Function1
to Function22
have type bounds defined as with <: Any > Nothing
. To overcome this we create types for all combinations of bound
phantomicity in the function parameters. For a function with two parameters it
would be:
trait Function2[T1, T2, R] {
def apply(x1: T1, x2: T2): R
}
trait PhantomFunctionX0[T1 <: PhantomAny, T2, R] {
def apply(x1: T1, x2: T2): R
}
trait PhantomFunction0X[T1, T2 <: PhantomAny, R] {
def apply(x1: T1, x2: T2): R
}
trait PhantomFunctionXX[T1 <: PhantomAny, T2 <: PhantomAny, R] {
def apply(x1: T1, x2: T2): R
}
Note that this implies that for N
arguments we need to add 2^N - 1
new
traits. For this reason we do not actually implement them, but we use synthetic
symbols on demand as is done for functions with more than 22 arguments in
dotty. Then we erase them into actual implemented function classes, currently
they are erased on to the corresponding FunctionN
where N
is the number of
non phantom parameters.
This erasure should be done after removing the phantom parameters/arguments as
this will make the parameters/arguments match the erased version. We only
replace the types PhantomFunctionM
the corresponding FunctionN
and
the call to the new apply
method.
We do not wish to write PhantomFunction0X
where 0X
is the string
encoding of the phantomicity of the parameters. It would be much simpler to
write Function2[Int, Boo, Unit]
and infer that Function2
should actually be
PhantomFunction0X
and hence the full type would be
PhantomFunction0X[Int, Boo, Unit]
.
We can also extend this inference to support (Int, Boo) => Unit
, which would also become Function2[Int, Boo, Unit]
and then PhantomFunction0X[Int, Boo, Unit]
.
In dotty we represet implicit functions with special synthetic traits ImplictFunctionN[...] extends FunctionN[...]
. To represent implict functions with phantoms we will use the same inference used for normal functions but mapping them to ImplicitPhantomFunctionM[...] extends PhantomFunctionM[...]
. We use can erase the phantoms this function into its corresponding ImplictFunctionN[...]
and then let it be eraed to FunctionN[...]
When a FunctionN
(or ImplicitFunctionN
) has more than 22 parameters it is erased to a generic function FunctionXXL
. We just need to extend this rule to PhantomFunctionM
/ImplicitPhantomFunctionM
where M
has more than 22 non phantoms.