Skip to content
This repository has been archived by the owner on Aug 5, 2022. It is now read-only.

Latest commit

 

History

History
391 lines (321 loc) · 15 KB

java-annotations-for-kotlin-sugar.md

File metadata and controls

391 lines (321 loc) · 15 KB

Java annotations for Kotlin sugar

  • Type: Design proposal
  • Author: Jake Wharton
  • Status: Under consideration
  • Prototype: Partially implemented in Kotlin 1.1.60

Feedback

Discussion of this proposal is held in this issue.

Summary

This proposal adds annotations which enable the use of more Kotlin language features like extension functions, extension properties, default values, and property names for Java code.

  • @ExtensionFunction / @ExtensionProperty - Turn a static method with at least one argument into an extension function or an extension property.
  • @ParameterName - An explicit name for parameters.
  • @DefaultValue - Default parameter values.

Motivation / use cases

There is a large corpus of Java libraries which, for various reasons, can't or won't port to Kotlin for the foreseeable future. This fact does not render them as obsolete or inferior to a Kotlin library. In fact, a certain portion of Java library authors are sympathetic to Kotlin users and are willing to do more for compatibility.

Java libraries following Effective Java item 1 have static factory methods for adapting arguments into instances of the enclosing type. For example, to create a Guava ByteSource from a File:

val file = File("hello.txt")
val source = Files.asByteSource(file)

This calling convention is significantly different than what would be used creating an InputStream from a File with the Kotlin standard library:

val file = File("hello.txt")
val input = file.inputStream()

A Kotlin-friendly version of Guava's ByteSource factory could be changed to:

@ExtensionFunction
public static ByteSource asByteSource(File file) { .. }

which would allow Kotlin callers to instead use it as an extension:

val file = File("hello.txt")
val source = file.asByteSource()

To complete the calling convention parity with the standard library, the function name can be adjusted solely on the Kotlin side:

@ExtensionFunction("byteSource")
public static ByteSource asByteSource(File file) { .. }

to enable:

val file = File("hello.txt")
val source = file.byteSource()

Changing the name from the Kotlin side is essential as the context of the class name is lost when changing to an extension function. For example, if we naively translate Guava's ImmutableSet factory:

@ExtensionFunction
public static <T> ImmutableSet<T> copyOf(Collection<T> list) { .. }

the function lacks clarity in the operation it performs:

val list = listOf("a", "b", "c")
val immutableSet = list.copyOf()

By supplying a name we can again create parity with the calling convention of Kotlin:

@ExtensionFunction("toImmutableSet")
public static <T> ImmutableSet<T> copyOf(Collection<T> list) { .. }
val list = listOf("a", "b", "c")
val immutableSet = list.toImmutableSet()

Static methods annotated with @ExtensionProperty will be turned into extension properties. For example, to get the root cause of an exception with Guava:

} catch (e: InvocationTargetException) {
  // Original calling convention
  val cause = Throwables.getRootCause(e)
}
// Updated Java signature with annotation
@ExtensionProperty
public static Throwable getRootCause(Throwable t) { .. }
} catch (e: InvocationTargetException) {
  // New calling convention
  val cause = e.rootCause
}

@ExtensionProperty methods with names that match the "Getters and Setters" Java-to-Kotlin interop rules will have the same renaming behavior as members. Supplying an alternate name allows overriding this behavior, if desired.

While the Java class file format does allow parameter names as of version 52 (corresponding to Java 8), they are opt-in and almost never included. Once enabled, they also require committing to stable parameter names for the entirety of the API surface. @ParameterName can be used to define stable parameter names. For example, Android's View.setPadding has four parameters in an order that can be difficult to remember:

public void setPadding(int left, int top, int right, int bottom) { .. }

By adding @ParameterName, Kotlin callers can specify the parameters in any order:

public void setPadding(
    @ParameterName("left") int left,
    @ParameterName("top") int top,
    @ParameterName("right") int right,
    @ParameterName("bottom") int bottom) { .. }
val view = View(context)
view.setPadding(top = 5, bottom = 5, left = 10, right = 10)

Default values can also be specified with @DefaultValue which are interpreted as expressions in a similar fashion to ReplaceWith. In keeping with Android's View.setPadding example, we can now add defaults that look up the current value allowing a subset of values to be passed:

public void setPadding(
    @ParameterName("left") @DefaultValue("paddingLeft") int left,
    @ParameterName("top") @DefaultValue("paddingTop") int top,
    @ParameterName("right") @DefaultValue("paddingRight") int right,
    @ParameterName("bottom") @DefaultValue("paddingBottom") int bottom) { .. }
val view = View(context)
view.setPadding(left = 10, right = 10)

These annotations can all combine together to adapt a Java API which is otherwise unidiomatic to something which feels very natural.

@ExtensionFunction("toByteString")
public static ByteString of(
    byte[] bytes,
    @ParameterName("offset") @DefaultValue("0") int offset,
    @ParameterName("count") @DefaultValue("bytes.size - offset") int count) { .. }
val bytes = byteArrayOf(1, 2, 3)
// without annotations:
val byteString1 = ByteString.of(bytes, 0, bytes.length)
// with annotations:
val byteString2 = bytes.toByteString()

Alternatives

At present, libraries written in Java that want to be Kotlin friendly can add nullness annotations to avoid platform types and can use well-known names to enable some operator use and property use. This is the limit of the Kotlin language features they have access to.

A Java library can do one of two things to go farther with its support:

  1. Publish Kotlin extensions inside their library or as a sibling artifact.
  2. Rewrite their public API (or the entire library) in Kotlin.

Since rewriting in Kotlin would obviously solve all of the interoperability problems, option #1 is the only real comparison. JUnit 5 and Project Reactor are two examples which include Kotlin extensions in their primary artifact but with an optional Kotlin dependency. RxKotlin and Android KTX are two examples of separate artifacts (for RxJava and the Android framework, respectively) to provide extensions.

The majority of features provided by these libraries are adapting Java static methods into Kotlin instance extensions. Functionality-wise, there should be no difference between an annotated Java method and a manually-written Kotlin extension. For the Guava ByteSource factory, a zero-overhead extension can be written:

inline fun File.byteSource(): ByteSource = Files.asByteSource(this)

Adding parameter names and default values to manually-written Kotlin extensions works for adapting Java static method but does not fully work for instance methods. For the Android padding example, you can write an extension that can enables the use of parameter names for a subset of arguments, but fails when you try to include all four:

inline fun View.setPadding(
  left: Int = paddingLeft,
  top: Int = paddingTop,
  right: Int = paddingRight,
  bottom: Int = paddingBottom
) {
  setPadding(left, top, right, bottom)
}

// works:
view.setPadding(left = 5, right = 5)
// error:
view.setPadding(left = 5, right = 5, top = 10, bottom = 10)

This is because Kotlin will always prefer calling a real member over an extension. When all four arguments are provided the member (and its lack of parameter name support) is resolved.

As outlined in the "Migration" section, the annotation-based approach enables Kotlin consumers to be gradually introduced to the Kotlin calling convention without having to know they're available and without having to seek out a sibling artifact.

Other advantages start to become subjective:

  • The annotation-based approach keeps a single source of truth for both Java and Kotlin callers. This is similar to what @JvmName and @JsName provide to Kotlin API authors for controlling how Java and Javascript consumers see their API.

    A potential downside of this is that you are limited to the functionality that the annotations provide.

    A single source of truth can be both an advantage and disadvantage for documentation. For usage samples, it can be challenging to cover the conventions of each language. The full documentation is always present, though, regardless of the calling language. Manually-written extensions tend to contain only a summary and a @see directive to the original Java method.

  • No new compiler / toolchain has to be introduced for the annotation-based approach. Your compilation and distribution mechanism for Java does not have to be updated to include Kotlin in the main or a sibling artifact.

Migration

Since these annotations change the calling convention and/or the name of functions and properties, their addition is otherwise a source-incompatible change for Kotlin users. In an effort to mitigate the pain that this would cause and to guide the consumer to discoverability of the Kotlin-specific calling convention, both the Java-style and Kotlin-style invocations are allowed.

The previous example:

@ExtensionFunction("toByteString")
public static ByteString of(
    byte[] bytes,
    @ParameterName("offset") @DefaultValue("0") int offset,
    @ParameterName("count") @DefaultValue("bytes.length") int count) { .. }
val bytes = byteArrayOf(1, 2, 3)
// Java-style:
val byteString1 = ByteString.of(bytes, 0, bytes.length)
// Kotlin-style:
val byteString2 = bytes.toByteString()

would compile as-is having both styles in use.

To gently migrate users to the Kotlin-style syntax, the Kotlin IDEA plugin would highlight Java-style call sites with a yellow underline. An intention action would automatically rewrite the Java-style to Kotlin-style, using default values where appropriate.

This migration behavior is designed to match what happens when other Java-style conventions are used such as calling foo.equals(bar) (which should be foo == bar) or foo.getBaz() (which should be foo.baz).

The compiler would not emit these as actual warnings.

Failure scenarios

If a library author chooses to ignore validation of any kind and creates an invalid configuration the Kotlin consumer shouldn't be punished.

For example, the ability to supply an alternate name for extensions and parameters opens up the possibility to create name collisions:

public static String one(
    @ParameterName("one") int one,
    @ParameterName("one") int uno) { .. }

In this case the entire one method would be rejected from any annotation enhancement. This is important as the intent can be ambiguous in certain configurations. For example, both @ExtensionFunction and @ExtensionProperty may be present on a single method.

The Kotlin compiler, when consuming an annotated Java library, should emit a warning for each Java method with an invalid configuration. The Kotlin IDEA plugin should not show any warnings inline, not offer any migration assistance, and treat the method as otherwise being pure, vanilla Java.

Annotation artifact

Since these annotations are meant for consumption by Java libraries, they should not be part of the Kotlin standard library. Since they are Kotlin-specific, they should not be part of org.jetbrains:annotations.

A new artifact, org.jetbrains.kotlin:kotlin-java-annotations should be created to house these annotations. Since the interpreter for these annotations is the Kotlin compiler and Kotlin IDEA plugin, they should live in the JetBrains/kotlin repository and be versioned and released as part of the normal Kotlin process.

This artifact could also be the same for the annotations proposed in KEEP-99.

Validation

When adding platform-specific annotations such as @JvmName or @JsName to Kotlin code the compiler validates you are not violating the target platform's language rules. When Java code is being annotated, however, the Kotlin compiler is not present to perform validation. An annotation processor will be provided to perform validation.

The annotation processor artifact will be org.jetbrains.kotlin:kotlin-java-annotations-validator and live in the JetBrains/kotlin repository and be versioned and released as part of the normal Kotlin process.

In order to get real-time validation in the IDE, the Kotlin IDEA plugin will also include the validation rules which it will run on Java sources containing the annotations to show errors.

Scope

This proposal focuses on only 4 annotations: @ExtensionFunction, @ExtensionProperty, @DefaultValue, and @ParameterName. These were chosen because a sampling of Java libraries showed them to have the most impact to Kotlin consumers if implemented.

There are certainly other annotations which could be proposed and implemented to further interop. Ones that come to mind are denoting top-level functions, lambda parameters becoming lambda-with-receiver, final classes with a static getInstance() method becoming an object, Class-accepting generic methods being automatically provided with reification, and destructing component markers. While appealing, these are decidedly more rare and would only increase the complexity of this proposal. If desired they can be pursued at a later time.