Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve resolution using expected type #378

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Improvements after meeting
serras committed May 30, 2024
commit a1c7ee231485681d812558747b5483b47512674f
72 changes: 54 additions & 18 deletions proposals/improved-resolution-expected-type.md
Original file line number Diff line number Diff line change
@@ -17,12 +17,12 @@ We propose an improvement of the name resolution rules of Kotlin based on the ex
* [Table of contents](#table-of-contents)
* [Motivating example](#motivating-example)
* [The issue with overloading](#the-issue-with-overloading)
* [Importing the entire static scope](#importing-the-entire-static-scope)
* [Technical details](#technical-details)
* [Additional candidate resolution scope](#additional-candidate-resolution-scope)
* [Additional type resolution scope](#additional-type-resolution-scope)
* [Potential additions](#potential-additions)
* [Equality](#equality)
* [Nested inheritors](#nested-inheritors)
* [Design decisions](#design-decisions)
* [Risks](#risks)
* [Implementation note](#implementation-note)

## Motivating example
@@ -54,11 +54,11 @@ This KEEP addresses many of these problems by considering explicit expected type
* conditions on `when` expressions with subject,
* type checks and casts (`is`, `as`),
* `return`, both implicit and explicit,
* we propose an alternative to improve equality (`==`, `!=`).
* equality checks (`==`, `!=`).

### The issue with overloading

We leave out of this KEEP any extension to the propagation of information from function calls to their argument. One particularly visible implication of this choice is that you still need to qualify enumeration entries that appear as arguments as currently done in Kotlin. Following with the example above, you still need to qualify the argument to `message`.
We leave out of this KEEP any extension to the propagation of information from function calls to their argument. One particularly visible implication of this choice is that you still need to qualify enumeration entries that appear as arguments as currently done in Kotlin. Following the example above, you still need to qualify the argument to `message`.

```kotlin
val s = message(Problem.CONNECTION)
@@ -72,13 +72,35 @@ What sets function calls apart from the constructs mentioned before is overloadi

Improving the resolution of arguments based on the function call would amount to reversing the order of (1) and (2), at least partially. There are potential techniques to solve this problem, but another KEEP seems more appropriate.

As a consequence, operators in Kotlin that are desugared to function calls, like `in` or `thing[x]`, are also outside of the scope of this KEEP.
As a consequence, operators in Kotlin that are desugared to function calls which in turn get resolved, like `in` or `thing[x]`, are also outside of the scope of this KEEP. Note that [value equalities](https://kotlinlang.org/spec/expressions.html#value-equality-expressions) (`==`, `!=`) are not part of that group, since they are always resolved to `kotlin.Any.equals`.

### Importing the entire static scope

The current proposal imports the _entire_ static scope, which includes classes, properties, and functions. Whereas the first two are needed to cover common cases like enumeration entries, and comparison with objects, the usefulness of methods seems debatable. However, it allows some interesting constructions where we compare against a value coming from a factory:

```kotlin
class Color(...) {
companion object {
val WHITE: Color = ...
val BLACK: Color = ...
fun fromRGB(r: Int, g: Int, b: Int): Color = ...
}
}

// now when we match on a color...
when (color) {
WHITE -> ...
fromRGB(10, 10, 10) -> ...
}
```

An additional advantage is that of uniformity. In particular, the code behaves as you had `*`-imported the `Color` type.

## Technical details

We introduce an additional scope, present both in type and candidate resolution, which always depends on a type `T` (we say we **propagate `T`**). This scope contains the static and companion object callables of the aforementioned type `T`, as defined by the [specification](https://kotlinlang.org/spec/overload-resolution.html#call-with-an-explicit-type-receiver).

This scope is added at the lowest priority level. This ensures that we do not change the current behavior, we only extend the amount of programs that are accepted.
This scope has the same priority as `*`-imports. That way, the user may have the mental model that using the name of an enumeration entry without qualification, for example, is the same as if the enumeration was `*`-imported into the file.

This scope is **not** propagated to children of the node in question. For example, when resolving `val x: T = f(x, y)`, the additional scope is present when resolving `f`, but not when resolving `x` and `y`. After all, `x` and `y` no longer have an expected type `T`.

@@ -109,6 +131,9 @@ For other statements and expressions, we have the following rules. Here "known t
* _Not-null assertion_: if type `T` is propagated to `e!!`, then we propagate `T?` to `e`,
* _Type cast_: in `e as T` and `e as? T`, the type `T` is propagated to `e`,
* This rule follows from the similarity to doing `val x: T = e`.
* _Equality_: in `a == b` and `a != b`, the known type of `a` is propagated to `b`.
* This helps in common cases like `p == Problem.CONNECTION`.
* Note that in this case the expected type should only be propagated for the purposes of name resolution. The [specification](https://kotlinlang.org/spec/expressions.html#value-equality-expressions) mandates `a == b` to be equivalent to `(A as? Any)?.equals(B as Any?) ?: (B === null)` in the general case, so from a typing perspective there should be no constraint on the type of `b`.

All other operators and compound assignments (such as `x += e`) do not propagate information. The reason is that those operators may be _overloaded_, so we cannot guarantee their type.

@@ -119,24 +144,25 @@ We introduce the additional scope during type solution in the following cases:
* _Type check_: in `e is T`, `e !is T`, the known type of `e` is propagated to `T`.
* _`when` expression with subject_: in `when (x) { is T -> ... }`, then known type of `x` is propagated to `T`.

## Potential additions
## Design decisions

**Priority level**: the current proposal puts the additional scope to be searched when the expected type is known at the same level as `*`-imports. This means that this feature is _not_ 100% backward-compatible, as we have the risk of ambiguity between a declaration imported in such a way, and one available in the static scope of the expected type.

### Equality
The most conservative option is for the new scope to have the lowest priority. In practical terms, that means that even built-ins and automatically imported declarations have higher priority, which seems like an odd choice too. As mentioned above, the mental model of these scopes working as `*`-imports seems like a useful tool for understanding the feature, so making them have the same priority level feels like a natural next step.

It is possible (and implemented in the prototype) to add the additional propagation rule:
**No `.value` syntax**: Swift has a very similar feature to the one proposed in this KEEP, namely [implicit member expressions](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/expressions#Implicit-Member-Expression). The main difference is that one has to prepend `.` to access this new type-resolution-guided scope.

* _Equality_: in `a == b` and `a != b`, then known type of `a` is propagated to `b`.
The big drawback of this choice is that new syntax ambiguities may arise, and these are very difficult to predict upfront. Code that compiled perfectly may now have an ambiguous reading by the compiler.

This helps in common cases like `p == Problem.CONNECTION`.
Furthermore, Kotliners _already_ use a pattern to avoid re-writing the name of an enumeration over and over, namely importing all the enumeration entries, which makes them available without any `.` at the front. It seems better to support the style that the community already uses than trying to make people change their habits.

However, there are two potential problems with this approach, which require further discussion.
**On equality**: using the rules above, equalities propagate type information from left to right. But there are other two options: propagating from right to left, or even not propagating any information at all.

1. It is not symmetric: it might be surprising that `p == CONNECTION` is accepted, but `CONNECTION == p` is rejected.
2. The [specification](https://kotlinlang.org/spec/expressions.html#value-equality-expressions) mandates `a == b` to be equivalent to `(A as? Any)?.equals(B as Any?) ?: (B === null)` in the general case. So there is no actual type expected from `b` merely from participating in equality.
The current choice is obviously not symmetric: it might be surprising that `p == CONNECTION` is accepted, but `CONNECTION == p` is rejected. A preliminary assessment shows that the pattern `CONSTANT == variable` is not as prevalent in the Kotlin community as in other programming languages.

### Nested inheritors
On the other hand, the proposed flow of information is consistent with the ability to refactor `when (x) { A -> ...}` into `when { x == A -> ...}`, without any further qualification required. We think that this uniformity is important for developers.

The rules above handle enumeration, and it's also useful when defining a hierarchy of classes nested on the parent.
**Sealed subclasses**: the rules above handle enumeration, and it's also useful when defining a hierarchy of classes nested on the parent.

```kotlin
sealed interface Either<out E, out A> {
@@ -145,7 +171,17 @@ sealed interface Either<out E, out A> {
}
```

One way in which we can improve resolution even more is by considering the subclasses of the known type of an expression. Making every potential subclass available would be quite surprising, but sealed hierarchies form an interesting subset (and the information is directly accessible to the compiler). However, this means getting away from a more "syntactical" choice (nested elements of the type), which may be surprising.
One way in which we can improve resolution even more is by considering the subclasses of the known type of an expression. Making every potential subclass available would be quite surprising, but sealed hierarchies form an interesting subset (and the information is directly accessible to the compiler).

At this point, we have decided against it for practical reasons. If the subclasses are defined inside the parent class (like in `Either` above), this proposal already helps because the subclasses are in the static scope of the parent. If they are inside the parent, then we are not making the particular piece of code any smaller, only avoiding one import. Since imports are usually disregarded by the developers anyway, it seems that adding all sealed subclasses to the scope brings no additional benefit.

### Risks

One potential risk of this proposal is the difficulty of understanding _when_ exactly it is OK to drop the qualifier, which essentially corresponds to understanding the propagation of the expected type through the compiler. On the other hand, maybe this complete understanding is not required, as developers will be able to count on the main scenarios: the conditions on a `when` with subject, the immediate expression after a `return`, or the initializer of a property, given that the return type is known.

Another potential risk is that we add more coupling between type inference and candidate resolution. On the other hand, in Kotlin those two processes are inevitably linked together -- to resolve the candidates of a call you need the type of the receivers and arguments -- so the step taken by this proposal feels quite small in comparison.

The third potential risk is whether this additional scope may lead to surprises. In particular, whether programs are accepted which are not expected by the developer, or the resolution points to a different declaration than expected. We think that the very low priority of the new scope is enough to mitigate those problems. In any case, IDE implementors should be aware of this new feature of the language, providing their usual support for code navigation.

## Implementation note