effekt-lang / effekt

A research language with effect handlers and lightweight effect polymorphism

Home Page:https://effekt-lang.org

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Function call type checking for operations

dvdvgt opened this issue · comments

Description

When calling operations, the argument types are of course checked against the declared type. But which actually is the declared type if there are multiple definition of an operation given by the following?

interface Eff[A] {
  def op(x: A): A
}

Consider this object:

def impl: Eff[Int] = new Eff[Int] {
  def op(x: Int) = x / 1
}

When calling impl.op("hello"), the function type for the symbol of op is looked up. However, the function type is annotated as [A](x: A): A once. Thus, when checking the function call, the call is only ever checked with respect to the original declared type and not all potentially existing types of concrete implementations.

One solution could be to annotate each concrete type during typechecking the implementation of Eff, but since we only have exactly one symbol for all possible implementations of op, updating the type of op overwrites the previous annotation.

Note that this is not an issue exclusive to objects, but to also for handlers.

try { eff.op("hello") }
with Eff[Int] { def op(x) = resume(x / 1) }

Both of these examples compile and yield NaN.

Idea

We need to have unique symbols for each operation implementation and then annotate the specific type for each implementation to this unique symbol.

Relevant

Fun, you can even chain it! :)

interface Eff[A] { 
    def op(x: A): A
}

def eff1: Eff[Int] = new Eff[Int] {
    def op(x) = x + 42
}

def eff2: Eff[String] = new Eff[String] {
    def op(x) = eff1.op(x) ++ "!!!"
    // => Should be an error, `x : String`, but `eff1.op` expects its argument to be `Int`
}

record Person(name: String, hairColour: String)
def main() = {
    val jolene = Person("Jolene", "red")
    println(eff2.op(jolene)) 
    // => Should be an error, `jolene: Person`, but `eff2.op` expects its argument to be `String`
    // prints "[object Object]42!!!"
}

This also, of course, breaks (my) reasoning about effects. 😭

interface Exc { def raise(msg: String): Nothing }

interface Eff[A] { 
    def op(x: A): A / Exc
}

def eff: Eff[Int] = new Eff[Int] {
    def op(x) = 
        try { do raise("ohno") }
        with Exc { def raise(msg) = println("error: " ++ msg) match {} }
}

record Person(name: String, hairColour: String)
def main() = {
    val jolene = Person("Jolene", "red")
    println(eff.op(jolene)) 
}

produces a compiler error with:

[error] test.effekt:1:1: Main cannot have user defined effects, but includes effects: { Exc }
interface Exc { def raise(msg: String): Nothing }
^

but Exc has been handled already in eff (verified in Core :) )

Tangentially related:

In the following example, if I right-click on eff.op and click on [Go to definition],
the cursor jumps to the declaration site in the interface.
This brings up the question: what is the definition of eff.op? (Here's the obvious analogy to object-oriented languages: where do we jump there?)

I'd expect it to be the "closest" definition in the scope graph (especially if we can only go to a "single" definition...)

interface Eff[A] { 
    def op(x: A): A              // <- currently it jumps here
}

def eff: Eff[Int] = new Eff[Int] {
    def op(x) = x / 1            // <- but this is the "closer" definition
}

def main() = {
    println(eff.op("Hello"))  //   /|\
    //          ^^                  |
    //     [Go to definition] ------/
}

Getting back to the original question, actually the type of op for this concrete callsite should not be [A](x: A): A but (x: Int): Int, obviously. It becomes even more tricky if both the type and the operation have a type parameter.

interface Eff[A] {
  def op[B](x: B): A
}
def eff: Eff[Int] = new Eff[Int] {
    def op[C](x) = x / 1
}

then at the callsite eff.op("Hello") the type of op should be [B](x: B): Int where B will be inferred to be String.

The concrete implemention, however, should not matter. Only the fact that it is Eff[Int].

I think this substitution should be performed somewhere around here:

val (successes, errors) = tryEach(candidates) { op =>
val (funTpe, capture) = findFunctionTypeFor(op)
checkCallTo(call, op.name.name, funTpe, targs, vargs, bargs, expected)
}

That is before checkCallTo.

Similarly, here we would somehow need to substitute the type parameters with unification variables since the receiver is not yet known:

val Result(tpe, effs) = checkOverloadedFunctionCall(c, op, targs map { _.resolve }, vargs, Nil, expected)

We should definitely revisit the design decision to add type parameters like A in your example as parameter to the operation

val op = Operation(name, effectSym.tparams ++ tps, params map { p => resolve(p) }, result, effects, effectSym)

This was a trick in the beginning to treat every function-like thing uniformly, but seems ad-hoc now.