-
Notifications
You must be signed in to change notification settings - Fork 364
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
Compile-time Extension Interfaces #87
Changes from 32 commits
13df9dd
7494c84
a5f9659
de8032b
47e1348
eb4f687
3777fa6
374e848
2f2f27e
3b568bf
7d10901
f8dce43
323a550
a105496
505619c
115a483
8a7dd15
8a74e37
20976f6
73b5d33
7027bde
4a98c86
0496c73
da913fe
6a10e93
00d6aca
d47eeef
f495563
d70f40c
23b5276
4671389
84d16ce
b632229
2a8b95a
3b74240
5f7b61c
ce47c2e
2417af2
34d89c8
97f772f
c6835ea
9b6c6fd
f4ba65a
ae5c0a7
8c0737a
eff56ad
33f55fc
988c8a9
94a1a99
bcbd746
dfc0b64
a4fd7f4
a32eaa5
c811a73
e6ba5e6
5485db1
ed8561e
535c453
fba1db3
c563312
21aeaed
3dd43f5
c8bf869
7792dd7
67c8f3e
6ba2577
87d1c43
531711d
bd1416d
7bed642
87eafe6
3753301
951ea3d
297e65f
071d6b7
6ac0d48
e564b61
21003c6
83a1a94
abcd547
46e6a34
e863b25
d488d79
78f5459
da6a274
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,353 @@ | ||
# Type Classes | ||
|
||
* **Type**: Design proposal | ||
* **Author**: Raul Raja | ||
* **Contributors**: Francesco Vasco, Claire Neveu, Tomás Ruiz López | ||
* **Status**: New | ||
* **Prototype**: [initial implementation](https://github.com/arrow-kt/kotlin/pull/6) | ||
|
||
## Summary | ||
|
||
The goal of this proposal is to enable `type classes` and lightweight `Higher Kinded Types` in Kotlin to enable ad-hoc polymorphism and better extension syntax. | ||
|
||
Type classes are a form of interface that provide a greater degree of polymorphism than classical interfaces. Typeclasses can also improve code-reuse in contrast to classical interfaces if done correctly. | ||
|
||
Introduction of type classes improves usages for `reified` generic functions with a more robust approach that does not require those to be `inline` or `reified`. | ||
|
||
## Motivation | ||
|
||
* Support type class evidence compile time verification. | ||
* Support a broader range of Typed FP patterns. | ||
* Enable multiple extension functions groups for type declarations. | ||
* Enable better compile reified generics without the need for explicit inlining. | ||
* Enable definition of polymorphic functions whose constraints can be verified at compile time in call sites. | ||
|
||
## Description | ||
|
||
We propose to use the existing `interface` semantics allowing for generic definition of type classes and their instances with the same style interfaces are defined | ||
|
||
```kotlin | ||
interface Monoid<A> { | ||
fun A.combine(b: A): A | ||
val empty: A | ||
} | ||
``` | ||
|
||
The above declaration can serve as target for implementations for any arbitrary data type. | ||
In the implementation below we provide evidence that there is a `Monoid<Int>` instance that enables `combine` and `empty` on `Int` | ||
|
||
```kotlin | ||
package intext | ||
|
||
extension object : Monoid<Int> { | ||
fun Int.combine(b: Int): Int = this + b | ||
val empty: Int = 0 | ||
} | ||
``` | ||
|
||
Type class implementations can be given a name for Java interop. | ||
```kotlin | ||
package intext | ||
|
||
extension object IntMonoid : Monoid<Int> { | ||
fun Int.combine(b: Int): Int = this + b | ||
val empty: Int = 0 | ||
} | ||
``` | ||
|
||
```kotlin | ||
|
||
1.combine(2) // 3 | ||
Monoid<Int>.empty() // 0 | ||
``` | ||
|
||
Because of this constraint where we are stating that there is a `Monoid` constraint for a given type `A` we can also encode polymorphic definitions based on those constraints: | ||
|
||
```kotlin | ||
fun <A> add(a: A, b: A, with Monoid<A>): A = a.combine(b) | ||
add(1, 1) // compiles | ||
add("a", "b") // does not compile: No `String: Monoid` instance defined in scope | ||
``` | ||
|
||
## Overcoming `inline` + `reified` limitations | ||
|
||
Type classes allow us to workaround `inline` `reified` generics and their limitations and express those as type classes instead: | ||
|
||
```kotlin | ||
extension interface Reified<A> { | ||
val A.selfClass: KClass<A> | ||
} | ||
``` | ||
|
||
Now a function that was doing something like: | ||
|
||
```kotlin | ||
inline fun <reified A> foo() { .... A::class ... } | ||
``` | ||
|
||
can be replaced with: | ||
|
||
```kotlin | ||
fun <A> fooTC(with Reified<A>): Klass<A> { .... A.selfClass ... } | ||
``` | ||
|
||
This allows us to obtain generics info without the need to declare the functions `inline` or `reified` overcoming the current limitations of inline reified functions that can't be invoked unless made concrete from non reified contexts. | ||
|
||
Not this does not remove the need to use `inline reified` where one tries to instrospect generic type information at runtime with reflection. This particular case is only relevant for those cases where you know the types you want `Reified` ahead of time and you need to access to their class value. | ||
|
||
```kotlin | ||
extension class Foo<A> { | ||
val someKlazz = foo<A>() //won't compile because class disallow reified type args. | ||
} | ||
``` | ||
|
||
```kotlin | ||
extension class Foo<A> { | ||
val someKlazz = fooTC<A>() //works and does not requires to be inside an `inline reified` context. | ||
} | ||
``` | ||
|
||
## Composition and chain of evidences | ||
|
||
Type class instances and declarations can encode further constraints in their generic args so they can be composed nicely: | ||
|
||
```kotlin | ||
package optionext | ||
|
||
extension class OptionMonoid<A>(with Monoid<A>): Monoid<Option<A>> { | ||
|
||
val empty: Option<A> = None | ||
|
||
fun Option.combine(ob: Option<A>): Option<A> = | ||
when (this) { | ||
is Some<A> -> when (ob) { | ||
is Some<A> -> Some(this.value.combine(b.value)) //works because there is evidence of a Monoid<A> | ||
is None -> ob | ||
} | ||
is None -> this | ||
} | ||
|
||
} | ||
``` | ||
|
||
The above instance declares a `Monoid<Option<A>>` as long as there is a `Monoid<A>` in scope. | ||
|
||
```kotlin | ||
Option(1).combine(Option(1)) // Option(2) | ||
Option("a").combine(Option("b")) // does not compile. Found `Monoid<Option<A>>` instance providing `combine` but no `Monoid<String>` instance was in scope | ||
``` | ||
|
||
We believe the above proposed encoding fits nicely with Kotlin's philosophy of extensions and will reduce the boilerplate compared to other langs that also support typeclasses such as Scala where this is done via implicits. | ||
|
||
## Typeclasses over type constructors | ||
|
||
We recommend if this proposal is accepted that a lightweight version of higher kinds support is included to unveil the true power of typeclasses through the extensions mechanisms | ||
|
||
A syntax that would allow for higher kinds in these definitions may look like this: | ||
|
||
```kotlin | ||
extension interface FunctionK<F<_>, G<_>> { | ||
fun <A> invoke(fa: F<A>): G<A> | ||
} | ||
|
||
extension object : FunctionK<Option, List> { | ||
fun <A> invoke(fa: Option<A>): List<A> = | ||
fa.fold({ emptyList() }, { listOf(it) }) | ||
} | ||
``` | ||
|
||
Here `F<_>` refers to a type constructor meaning a type that has a hole on it such as `Option`, `List`, etc. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made a comment below, on the main thread, as to why IMHO we should avoid using |
||
|
||
A use of this declaration in a polymorphic function would look like: | ||
|
||
```kotlin | ||
fun <F<_>, A, B> transform(fa: F<A>, f: (A) -> B, with Functor<F>): F<B> = F.map(fa, f) | ||
|
||
transform(Option(1), { it + 1 }) // Option(2) | ||
transform("", { it + "b" }) // Does not compile: `String` is not type constructor with shape F<_> | ||
transform(listOf(1), { it + 1 }) // does not compile: No `Functor<List>` instance defined in scope. | ||
``` | ||
|
||
## Language Changes | ||
|
||
- Add `with` to require instances evidences in both function and class/object declarations | ||
- Add `extension` to provide instance evidences for a given type class | ||
|
||
As demonstrated by previous and below examples: | ||
```kotlin | ||
extension class OptionMonoid<A>(with M: Monoid<A>) : Monoid<Option<A>> // class position using parameter "M" | ||
extension class OptionMonoid<A>(with Monoid<A>) : Monoid<Option<A>> // class position using anonymous `Monoid` parameter | ||
|
||
fun <A> add(a: A, b: A, with M: Monoid<A>): A = a.combine(b) // function position using parameter "M" | ||
fun <A> add(a: A, b: A, with Monoid<A>): A = a.combine(b) // function position using anonymous `Monoid` parameter | ||
``` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the difference between named and unnamed variants? Are these two options to choose between? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @ilya-g I consider better treat is as anonymous declaration, so
is same as
In such case it is possible to use a type class easy, however if you want to reference to it then you have to declare explicilty its name. |
||
|
||
## Type Class Instance Rules | ||
|
||
Classical interfaces only allow the implementation of interfaces to occur when a type is defined. Type classes typically relax this rule and allow implementations outside of the type definition. When relaxinng this rule it is important to preserve the coherency we take for granted with classical interfaces. | ||
|
||
For those reasons type class instances must be declared in one of these places: | ||
|
||
1. In the companion object of the type class (interface-side implementation). | ||
2. In the companion object of the type implementing the type class (type-side implementation). | ||
3. In a subpackage of the package where the type class is defined. | ||
4. In a subpackage of the package where the type implementing the type class is defined. | ||
|
||
All other instances are orphan instances and are not allowed. See [Appendix A](#Appendix-A) for a modification to this proposal that allows for orphan instances. | ||
|
||
Additionally a type class implementation must not conflict with any other already defined type class implementations; for the purposes of checking this we use the normal resolution rules. | ||
|
||
### Interface-Side Implementations | ||
|
||
This definition site is simple to implement and requires to rules except that the instances occurs in the same package. E.g. the following implementation is allowed | ||
```kotlin | ||
package foo.collections | ||
|
||
interface Monoid<A> { | ||
... | ||
companion object { | ||
extension object IntMonoid : Monoid<Int> { ... } | ||
} | ||
} | ||
``` | ||
|
||
```kotlin | ||
package foo.collections.instances | ||
|
||
extension object : Monoid<String> { | ||
... | ||
} | ||
``` | ||
|
||
### Type-Side Implementations | ||
|
||
This definition site poses additional complications when you consider multi-parameter typeclasses. | ||
|
||
```kotlin | ||
package foo.collections | ||
|
||
interface Isomorphism<A, B> { | ||
... | ||
} | ||
``` | ||
|
||
```kotlin | ||
package data.collections.foo | ||
|
||
data class Foo(...) | ||
extension class<A> : Isomorphism<Foo, A> { | ||
... | ||
} | ||
``` | ||
|
||
```kotlin | ||
package data.collections.bar | ||
|
||
data class Bar(...) | ||
extension class<A> : Isomorphism<A, Bar> { | ||
... | ||
} | ||
``` | ||
|
||
The above instances are each defined alongside their respective type definitions and yet they clearly conflict with each other. We will also run into quandaries once we consider generic types. We can crib some prior art from Rust<sup>1</sup> to help us out here. | ||
|
||
To determine whether a typeclass definition is a valid type-side implementation we perform the following check: | ||
|
||
1. A "local type" is any type (but not typealias) defined in the current file (e.g. everything defined in `data.collections.bar` if we're evaluating `data.collections.bar`). | ||
2. A generic type parameter is "covered" by a type if it occurs within that type, e.g. `MyType` covers `T` in `MyType<T>` but not `Pair<T, MyType>`. | ||
3. Write out the parameters to the type class in order. | ||
4. The parameters must include a type defined in this file. | ||
5. Any generic type parameters must occur after the first instance of a local type or be covered by a local type. | ||
|
||
If a type class implementation meets these rules it is a valid type-side implementation. | ||
|
||
|
||
## Compile Resolution Rules | ||
|
||
When the compiler finds a call site invoking a function that has type class instances constraints declared with `with` as in the example below: | ||
|
||
Declaration: | ||
```kotlin | ||
fun <A> add(a: A, b: A, with Monoid<A>): A = a.combine(b) | ||
``` | ||
Call site: | ||
```kotlin | ||
fun addInts(): Int = add(1, 2) | ||
``` | ||
|
||
1. The compiler first looks at the function context where the invocation is happening. If a function argument matches the required instance for a typeclass, it uses that instance; e.g.: | ||
|
||
```kotlin | ||
fun <a> duplicate(a : A, with M: Monoid<A>): A = a.combine(a) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should |
||
``` | ||
|
||
The invocation `a.combine(a)` requires a `Monoid<A>` and since one is passed as an argument to `duplicate`, it uses that one. | ||
|
||
2. In case it fails, it inspects the following places, sequentially, until it is able to find a valid unique instance for the typeclass: | ||
a. The current package (where the invocation is taking place), as long as the `extension` is `internal`. | ||
b. The companion object of the type parameter(s) in the type class (e.g. in `Monoid<A>`, it looks into `A`'s companion object). | ||
c. The companion object of the type class. | ||
d. The subpackages of the package where the type parameter(s) in the type class is defined. | ||
e. The subpackages of the package where the type class is defined. | ||
3. If no matching implementation is found in either of these places fail to compile. | ||
4. If more than one matching implementation is found, fail to compile and indicate that there or conflicting instances. | ||
|
||
Some of these examples were originally proposed by Roman Elizarov and the Arrow contributors where these features where originally discussed https://github.com/Kotlin/KEEP/pull/87 | ||
|
||
## Appendix A: Orphan Implementations | ||
|
||
Orphan implementations are a subject of controversy. Combining two libraries, one defining a data type, the other defining an interface, is a feature that many programmers have longed for. However, implementing this feature in a way that doesn't break other features of interfaces is difficult and drastically complicates how the compiler works with those interfaces. | ||
|
||
Orphan implementations are the reason that type classes have often been described as "anti-modular" as the most common way of dealing with them is through global coherency checks. This is necessary to ensure that two libraries have not defined incompatible implementations of a type class interface. | ||
|
||
Relaxing the orphan rules is a backwards-compatible change. If this proposal is accepted without permitting orphans it is useful to consider how they could be added in the future. | ||
|
||
Ideally we want to ban orphan implementations in libraries but not in executables; this allows a programmer to manually deal with coherence in their own code but prevents situations where adding a new library breaks code. | ||
|
||
### Package-based Approach to Orphans | ||
|
||
A simple way to allow orphan implementations is to replace the file-based restrictions with package-based restrictions. Because there are no restrictions on packages it is posible to do the following. | ||
|
||
```kotlin | ||
// In some library foo | ||
package foo.collections | ||
|
||
extension class Monoid<A> { | ||
... | ||
} | ||
``` | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So, no I'd suspect that we'll need to think in advance of a strategy for code completion for such functions: where does the IDE look for instances and what does it import if needed? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Something worth mentioning is that instances are only resolved at call sites where the invocation is concrete so the compiler does not need to look into all There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
It's very-very many places in the code :)
Not quite. Other symbols are bound by name, here we are binding by type, and it's a lot more work for the compiler There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What about cases where you want to import two implementations of the Do you have to resolve this conflict by calling each scoped by a import intext.IntMonoid1
import intext.IntMonoid2
fun addInts1(a: Int, b: Int): Int = with(IntMonoid1) { add(a, b) }
fun addInts2(a: Int, b: Int): Int = with(IntMonoid2) { add(a, b) } Edit: |
||
```kotlin | ||
// In some application that uses the foo library | ||
package foo.collections | ||
|
||
extension object : Monoid<Int> { | ||
... | ||
} | ||
``` | ||
|
||
This approach would not forbid orphan implementations in libraries but it would highly discourage them from providing them since it would involve writing code in the package namespace of another library. | ||
|
||
### Internal Modifier-based Approach to Orphans | ||
|
||
An alternate approach is to require that orphan implementations be marked `internal`. The full rules would be as follows: | ||
|
||
1. All orphan implementations must be marked `internal` | ||
2. All code which closes over an internal implementations must be marked internal. Code closes over a type class instance if it contains a static reference to such an implementation. | ||
3. Internal implementations defined in the same module are in scope for the current module. | ||
4. Internal implementations defined in other modules are not valid for type class resolution. | ||
|
||
This approach works well but it has a few problems. | ||
|
||
1. It forces applications that use orphan implementations to mark all their code as internal, which is a lot of syntactic noise. | ||
2. It complicates the compiler's resolution mechanism since it's not as easy to enumerate definition sites. | ||
|
||
The first problem can actually leads us to a better solution. | ||
|
||
### Java 9 Module-based Approach to Orphans | ||
|
||
Currently Kotlin does not make use of Java 9 modules but it is easy to see how they could eventually replace Kotlin's `internal` modifier. The rules for this approach would be the same as the `internal`-based approach; code which uses orphans is not allowed to be exported. | ||
|
||
## Footnotes | ||
|
||
1. [Little Orphan Impls](http://smallcultfollowing.com/babysteps/blog/2015/01/14/little-orphan-impls/) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why does this work? I don't see any evidence that
Reified<A>
is satisfied here.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't this require putting
reified
on the classes generic to work properly?