Writing my own JVM compiler, I am facing a giant problem that I am desperately unable to solve:
Lambda Return Type Inference
1. Overview of the compiler lifecycle
More specifically, the order in which things should happen in a compiler to make this work correctly. First of all, as probably every compiler, mine is divided in several phases that run on the AST and do the work. After tokenizing, parsing and building the ast, the following phases happen in that order:
- RESOLVE_TYPES - Links type tokens to their classes
- RESOLVE - Links fields and methods
- CHECK_TYPES - Re-checks types (boxing, ...), Infers generic type arguments
- CHECK - More uninteresting semantic checks that could be skipped in a perfect world
The phases in question are RESOLVE and CHECK_TYPES. The first resolves method calls and field accesses / assignments as well as their callees and arguments, basically everything important. Between the two, something happens that I call withType
. Basically, the context in which an expression appears calls the withType
method of the expression with the type it expects the expression to have.
Example
Consider this method and an invocation:
class List[E]
{
public List[U] mapped(Function[E, U] mapper) = ...
}
List[String] list = [ "a", "b", "c" ]
var list2 = list.mapped { s => s.toUpperCase }
What the compiler does
This is what is going to happen in my compiler now (we ignore what happens with the List
class):
- Read File, Tokenize, Parse - nothing spectacular
- Resolve Types - nothing special, link all explicit types (
List[String]
) - Resolve - link method calls
It is known that list
has the type List[String]
, so we can search that for methods called mapped
. Iterate through all methods in the class, and if the name matches the method name, continue with argument types. This works like this: The argument type is Function[E, U]
, so call isType(Function[E, U])
on the argument { s => s.toUpperCase }
.
The Statement List { }
delegates the call to the lambda expression, which checks if the type has a functional method.
interface Function[T, R]
{
R apply(T par1) // <-- Here it is
}
Now, link the method and the type Function[E, U]
to the lambda expression.
- Check Types - Infer the type arguments of generic types
We start with Function[E, U]
. It is known that E
is a type parameter of the generic type List[E]
, which is also the type of the callee, but more concrete: List[String]
. That means that E
can be inferred to String
. But here comes the problem: Since we need E
for the type of the lambda parameter s
, it was not possible to resolve the lambda body, i.e. s.toUpperCase
. Because of that, it is not possible to infer U
at the moment, so the compiler continues without inferring the right type (it simply uses any
, which is java.lang.Object
). That means that our inferred type is now Function[String, any]
.
In the next step, the compiler calls the withType
method of the lambda expression, which updates the type stored in the lambda AST node. Now we can actually call lambda.checkTypes
, which will a) infer the type of s
to String
and b) call resolve (!), withType and checkTypes on the lambda body, allowing us to compute the return type of the lambda.
2. The problem
As you can see, although we now have the type of the lambda parameter s
and the return type of the lambda (String
), the type of the lambda is still Function[String, any]
, which means the result of the call to mapped
and therefore the type of the variable list2 is List[any]
. However, since the return type of the lambda is actually String
, this is not the correct type (since List
is invariant on E
, the types are not even compatible). The generic type system of the compiler is not advanced enough to make things like
List[String] list2 = list.mapped { s => s.toUpperCase }
impossible, but neither variant is technically correct as long as the lambda type gets inferred to Function[String, any]
while it is actually Function[String, String]
.
Furthermore, consider this expression:
int length = list.mapped(s => s.replace("a", "")).getFirst.length
The call to mapped
returns a List[any]
, therefore getFirst
returns an any
, which does not have a length
member. Normally, one would expect this expression to work, and in fact lambda expressions work like this in Java and Scala (which makes things a little simpler by enforcing their built-in function types, disallowing FunctionInterface
s).
3. The question
Now that you know the details of my compiler lifecycle, type inference and lambda expressions in my programming language, is there any way I could restructure it to make lambda return type inference work correctly? Although they have very different compiler phases and -lifecycles, Java 8 and Scala have to do similar things after all as well.
4. My potential solution
I was able to come up with at least a sort of solution that covers around 10% of the problem, which would be to infer types in RESOLVE so the lambda can actually call resolve
of its body in resolve instead of checkTypes. That means that the return type of the lambda is known after resolve and further calls in the chain have the proper callee type (e.g., the getFirst
method). However, I am unsure if that would be possible with my current type inference system. Any opinion / advice is greatly appreciated.
Compiler Source Code:
- GitHub Repository
- IType class
- GenericType class
- Method class (
inferType
around line 880) - IValue class
- LambdaExpression class
View at own risk, 90k lines of code.
Because of that, it is not possible to infer U at the moment
- why? You say that you know you haveList[String]
so can inferE
, why can you not then infer the return type of the lambda, either there or later in the same stage? You should have all the information you need to do it at that point.s.toUpperCase
) is not resolved yet - we don't know its type.U
in this context stands for theR
type variable ofFunction
(so itsFunction[T: E, R: U]
), which can only be inferred from the functional method. That meansU
=R
=s.toUpperCase.type
, which is not available at the time.Resolve
beforeCheckTypes
where the problem occurs. Even if you don't have that resolution, you know the type ofs
, so you should be able to resolve the type ofs.toUpperCase
, which infers the type ofU
this.value.resolve(...)
, which is why it is not resolved -> its type is undefined.