Skip to content

Instantly share code, notes, and snippets.

@nicolasstucki
Last active July 10, 2024 15:07
Show Gist options
  • Save nicolasstucki/572c3ee016e966c26491990c6eac8b80 to your computer and use it in GitHub Desktop.
Save nicolasstucki/572c3ee016e966c26491990c6eac8b80 to your computer and use it in GitHub Desktop.
Update to new scheme

Phantom types

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

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

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

Type bounds

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.

Union and intersection types

We disallow unions and intersections of types from different latices. The reasons are the same as for mixed lattice bounds.

Methods with phantoms (outdated)

Methods are allowed to have parameters of any type regardless of the lattice. This also includes constructors.

var, val and lazy val

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.

Phantom terms (outdated)

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

Statements

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.

If expressions

None of the branches can be of phantom type.

Match

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.

Try/catch/finally

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.

Returns

return cannot return a phantom expression.

Phantom instances

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.

Magic function (currently deprecated)

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]) 

Phantom classes (current implementation)

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.

Restrictions on classes and traits
  • 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. No def, val, lazy val, var, class, object, or or statements of any kind.
Restrictions on phantom companion objects
  • They have the same restrictions as phantom classes except that they are allowed to define defs that return a phantom. These defs are useful for implementing implicit defs for the phantom class.
  • defs 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.
Methods returning phantoms

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).

Phantom erasure (outdated)

As terms of the phantom lattice have no effect on runtime, by design they can be erased. Hence the name phantom.

Erasing arguments/parameters

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.

Erasing phantom types

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.

Extending purity to know about phantoms

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.

Functions with phantom parameters

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.

Function inference

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].

Support lambdas

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].

Implicit functions with phantom parameters (partially implemented)

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[...]

FunctionXXL with phantom parameters

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment