7.1.3 从 Java 调用 Kotlin

原文链接: https://kotlinlang.org/docs/java-to-kotlin-interop.html

7.1.3 从 Java 调用 Kotlin

Kotlin 代码可以很容易地从 Java 调用。例如,Kotlin 类的实例可以在 Java 方法中无缝地创建和操作。不过,Java 与 Kotlin 之间存在一些差异,在把 Kotlin 代码集成到 Java 中时需要留意。本页将介绍如何为你的 Kotlin 代码与其 Java 客户端之间的互操作做定制。

属性

一个 Kotlin 属性会被编译为以下 Java 元素:

  • 一个 getter 方法,其名称通过在属性名前加上 get 前缀计算得出。
  • 一个 setter 方法,其名称通过在属性名前加上 set 前缀计算得出(仅对 var 属性)。
  • 一个私有字段,其名称与属性名相同(仅对有幕后字段的属性)。

例如,var firstName: String 会编译为以下 Java 声明:

1
2
3
4
5
6
7
8
9
private String firstName;

public String getFirstName() {
    return firstName;
}

public void setFirstName(String firstName) {
    this.firstName = firstName;
}

如果属性名以 is 开头,则使用不同的名称映射规则:getter 的名称与属性名相同,setter 的名称则通过把 is 替换为 set 得到。例如,对于属性 isOpen,getter 名为 isOpen(),setter 名为 setOpen()。该规则适用于任何类型的属性,而不只是 Boolean。

包级函数

在包 org.example 中的文件 app.kt 里声明的所有函数和属性(包括扩展函数)都会被编译为名为 org.example.AppKt 的 Java 类的静态方法。

1
2
3
4
5
6
// app.kt
package org.example

class Util

fun getTime() { /*...*/ }
1
2
3
// Java
new org.example.Util();
org.example.AppKt.getTime();

要为生成的 Java 类设置自定义名称,请使用 @JvmName 注解:

1
2
3
4
5
6
7
@file:JvmName("DemoUtils")

package org.example

class Util

fun getTime() { /*...*/ }
1
2
3
// Java
new org.example.Util();
org.example.DemoUtils.getTime();

多个文件具有相同的生成 Java 类名(同一个包加上相同名称或相同的 @JvmName 注解)通常是一个错误。不过,编译器可以生成一个具有指定名称的单一 Java 外观类,其中包含所有具有该名称的文件中的全部声明。要启用这种外观类的生成,请在所有这类文件中使用 @JvmMultifileClass 注解。

1
2
3
4
5
6
7
// oldutils.kt
@file:JvmName("Utils")
@file:JvmMultifileClass

package org.example

fun getTime() { /*...*/ }
1
2
3
4
5
6
7
// newutils.kt
@file:JvmName("Utils")
@file:JvmMultifileClass

package org.example

fun getDate() { /*...*/ }
1
2
3
// Java
org.example.Utils.getTime();
org.example.Utils.getDate();

实例字段

如果你需要在 Java 中把 Kotlin 属性暴露为字段,请用 @JvmField 注解标注它。该字段的可见性与底层属性相同。你可以为满足以下条件的属性添加 @JvmField 注解:

  • 有幕后字段
  • 不是 private
  • 没有 open、override 或 const 修饰符
  • 不是委托属性
1
2
3
class User(id: String) {
    @JvmField val ID = id
}
1
2
3
4
5
6
// Java
class JavaClient {
    public String getID(User user) {
        return user.ID;
    }
}

延迟初始化的属性也会被暴露为字段。该字段的可见性与 lateinit 属性 setter 的可见性相同。

静态字段

在具名对象或伴生对象中声明的 Kotlin 属性,会在该具名对象中或包含伴生对象的类中拥有静态幕后字段。

通常这些字段是私有的,但可以通过以下方式之一暴露它们:

  • @JvmField 注解
  • lateinit 修饰符
  • const 修饰符

用 @JvmField 标注这样的属性会让它成为静态字段,可见性与属性本身相同。

1
2
3
4
5
6
class Key(val value: Int) {
    companion object {
        @JvmField
        val COMPARATOR: Comparator<Key> = compareBy<Key> { it.value }
    }
}
1
2
3
// Java
Key.COMPARATOR.compare(key1, key2);
// Key 类中的 public static final 字段

对象或伴生对象中延迟初始化的属性拥有静态幕后字段,其可见性与该属性的 setter 相同。

1
2
3
object Singleton {
    lateinit var provider: Provider
}
1
2
3
// Java
Singleton.provider = new Provider();
// Singleton 类中的 public static 非 final 字段

声明为 const 的属性(在类中以及在顶层)在 Java 中会变成静态字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 文件 example.kt

object Obj {
    const val CONST = 1
}

class C {
    companion object {
        const val VERSION = 9
    }
}

const val MAX = 239

在 Java 中:

1
2
3
int constant = Obj.CONST;
int max = ExampleKt.MAX;
int version = C.VERSION;

静态方法

Kotlin 把包级函数表示为静态方法。如果给具名对象或伴生对象中定义的函数加上 @JvmStatic 注解,也可以为它们生成静态方法。

如果你在伴生对象的某个函数上使用 @JvmStatic,编译器会在外层类中生成一个静态方法,同时在伴生对象中生成一个实例方法:

1
2
3
4
5
6
7
// Kotlin
class C {
    companion object {
        @JvmStatic fun callStatic() {}
        fun callNonStatic() {}
    }
}

在 Java 中,你既可以在外层类上调用 callStatic(),也可以在伴生对象上调用它,而 callNonStatic() 只能通过伴生对象访问:

1
2
3
4
5
// Java
C.callStatic(); // 成功
C.callNonStatic(); // 错误:不是静态方法
C.Companion.callStatic(); // 实例方法仍然保留
C.Companion.callNonStatic(); // 成功

对于具名对象(单例),@JvmStatic 会把该函数变成对象类的静态方法,但不会生成单独的实例方法:

1
2
3
4
5
// Kotlin
object Obj {
    @JvmStatic fun callStatic() {}
    fun callNonStatic() {}
}

在 Java 中,你可以在具名对象上调用 callStatic() 方法,而 callNonStatic() 只能通过单例实例访问:

1
2
3
4
// Java
Obj.callStatic(); // 成功
Obj.callNonStatic(); // 错误:不是静态方法
Obj.INSTANCE.callNonStatic(); // 成功,该调用经由单例实例

你也可以给接口伴生对象中的函数加上 @JvmStatic 注解。这类函数会编译为接口中的静态方法:

1
2
3
4
5
6
7
interface ChatBot {
    companion object {
        @JvmStatic fun greet(username: String) {
            println("Hello, $username")
        }
    }
}

你也可以把 @JvmStatic 注解应用到属性上。

接口中的默认方法

面向 JVM 时,除非另有配置,Kotlin 会把接口中声明的函数编译为默认方法。它们是接口中的具体方法,Java 类可以直接继承,无需重新实现。

下面是一个带默认方法的 Kotlin 接口示例:

1
2
3
4
interface Robot {
    fun move() { println("~walking~") } // 在 Java 接口中会成为默认方法
    fun speak(): Unit
}

默认实现可供实现该接口的 Java 类使用,这样的 Java 类可以直接使用它:

1
2
3
4
5
6
7
8
// Java 实现
public class C3PO implements Robot {
    // Robot 中的 move() 实现可以隐式使用
    @Override
    public void speak() {
        System.out.println("I beg your pardon, sir");
    }
}
1
2
3
C3PO c3po = new C3PO();
c3po.move(); // Robot 接口提供的默认实现
c3po.speak();

接口的实现可以重写默认方法,也可以在不重写的情况下使用默认实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Java
public class BB8 implements Robot {
    // 该默认方法的自有实现
    @Override
    public void move() {
        System.out.println("~rolling~");
    }

    @Override
    public void speak() {
        System.out.println("Beep-beep");
    }
}

默认方法的兼容模式

Kotlin 提供三种模式来控制接口中函数如何在 JVM 上编译为默认方法。

你可以使用 -jvm-default 编译器选项控制这一行为。

注意: -jvm-default 编译器选项取代了已弃用的 -Xjvm-default 选项。

进一步了解各兼容模式:

enable

默认行为。在接口中生成默认实现,并在子类中包含桥接函数和 DefaultImpls 类。

no-compatibility

只在接口中生成默认实现。跳过兼容性桥接和 DefaultImpls 类,因此只与支持默认方法的新 Kotlin 代码兼容。

注意: 如果使用了接口委托,所有接口成员都会被委托,因此不会生成默认方法。

disable

禁用接口中的默认实现,只生成兼容性桥接和 DefaultImpls 类。

可见性

Kotlin 按如下方式把可见性修饰符映射到 Java:

  • private 成员保持 private。
  • private 顶层声明在 Java 中变成 private 顶层声明。如果在类内部访问,还会生成包级私有的访问器。
  • protected 成员保持 protected。

注意,Java 允许从同一个包中的其他类访问受保护成员,而 Kotlin 不允许。

  • internal 声明在 Java 中变成 public。

Kotlin 编译器会对字节码中 internal 成员的名称做名称改编。这可以防止跨模块的意外重写(例如从 Java 继承 Kotlin 类时),并允许对签名相同的成员进行重载。

注意,internal 类的 public 成员的名称不会被改编,仍然可以从 Java 调用。

  • public 成员保持 public。

KClass

有时你需要调用一个参数类型为 KClass 的 Kotlin 方法。从 Class 到 KClass 没有自动转换,因此你必须手动调用等价的 Class<T>.kotlin 扩展属性:

1
kotlin.jvm.JvmClassMappingKt.getKotlinClass(MainView.class)

用 @JvmName 处理签名冲突

有时我们在 Kotlin 中有一个具名函数,但需要它在字节码中具有不同的 JVM 名称。最典型的例子是由类型擦除造成的:

1
2
fun List<String>.filterValid(): List<String>
fun List<Int>.filterValid(): List<Int>

这两个函数不能同时定义,因为它们的 JVM 签名相同:filterValid(Ljava/util/List;)Ljava/util/List;。如果我们确实希望它们在 Kotlin 中同名,可以用 @JvmName 标注其中一个(或两个),并指定一个不同的名称作为实参:

1
2
3
4
fun List<String>.filterValid(): List<String>

@JvmName("filterValidInt")
fun List<Int>.filterValid(): List<Int>

在 Kotlin 中,它们可以通过相同的名称 filterValid 访问,但在 Java 中是 filterValid 和 filterValidInt。

当我们需要让属性 x 与函数 getX() 同时存在时,也可以使用同样的技巧:

1
2
3
4
5
val x: Int
    @JvmName("getX_prop")
    get() = 15

fun getX() = 10

要更改那些没有显式实现 getter 和 setter 的属性所生成的访问器方法名,你可以使用 @get:JvmName 和 @set:JvmName:

1
2
3
@get:JvmName("x")
@set:JvmName("changeX")
var x: Int = 23

生成重载

通常,如果你写一个带默认参数值的 Kotlin 函数,它在 Java 中只以包含所有参数的完整签名可见。

你可以使用 @IntroducedAt 注解或 @JvmOverloads 注解为可选参数生成重载。

当你向已发布的 API 添加新的可选参数,并希望生成的重载能体现每个参数是在哪个版本引入时,请使用 @IntroducedAt。编译器会利用这些信息自动生成相应的隐藏重载。

这样你就可以获得基于版本的重载生成,并有助于为针对你库的较早版本编译的调用方保持二进制兼容性。

警告: @IntroducedAt 注解是实验性的。要启用它,请使用 @OptIn(ExperimentalVersionOverloading::class) 注解。

下面是一个例子,其中 Button() 函数在多个 API 版本中陆续接收了多个可选参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@OptIn(ExperimentalVersionOverloading::class)
fun Button(
    label: String = "",
    color: Color = DefaultColor,
    @IntroducedAt("1.1") borderColor: Color = DefaultBorderColor,
    @IntroducedAt("1.2") borderStyle: Style = DefaultBorderStyle,
    @IntroducedAt("1.2") borderWidth: Int = 1,
    onClick: () -> Unit
) {
    // 函数体
}

基于这些版本,编译器会为原始 API 以及每个引入新可选参数的 API 版本生成隐藏重载:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 原始 API
Button(
    label: String,
    color: Color,
    onClick: () -> Unit
)

// 版本 1.1
Button(
    label: String,
    color: Color,
    borderColor: Color,
    onClick: () -> Unit
)

// 版本 1.2
Button(
    label: String,
    color: Color,
    borderColor: Color,
    borderStyle: Style,
    borderWidth: Int,
    onClick: () -> Unit
)

如果你想向 Java 调用方暴露多个重载,也可以使用 @JvmOverloads 注解。

该注解也适用于构造函数、静态方法等。它不能用于抽象方法,包括接口中定义的方法。例如,考虑一个带默认参数值的 Circle 类:

1
2
3
class Circle @JvmOverloads constructor(centerX: Int, centerY: Int, radius: Double = 1.0) {
    @JvmOverloads fun draw(label: String, lineWidth: Int = 1, color: String = "red") { /*...*/ }
}

对于每个带默认值的参数,这会额外生成一个重载,该重载会去掉这个参数以及参数列表中位于它右侧的所有参数。在这个例子中,会生成以下内容:

1
2
3
4
5
6
7
8
// 构造函数:
Circle(int centerX, int centerY, double radius)
Circle(int centerX, int centerY)

// 方法
void draw(String label, int lineWidth, String color) { }
void draw(String label, int lineWidth) { }
void draw(String label) { }

由于 @IntroducedAt 和 @JvmOverloads 注解都会生成重载,同时使用它们可能产生冲突的重载。如果你同时使用这两个注解,编译器会发出警告。如果你抑制该警告,编译器会优先使用由 @IntroducedAt 注解生成的重载。

注意,如次构造函数中所述,如果一个类的所有构造函数参数都有默认值,就会为它生成一个无参的 public 构造函数。即使没有指定 @JvmOverloads 注解,这一点也成立。

受检异常

Kotlin 没有受检异常。因此,Kotlin 函数的 Java 签名通常不会声明抛出的异常。所以,如果你有这样一段 Kotlin 代码:

1
2
3
4
5
6
7
// example.kt
package demo

fun writeToFile() {
    /*...*/
    throw IOException()
}

而你想从 Java 调用它并捕获该异常:

1
2
3
4
5
6
7
// Java
try {
    demo.Example.writeToFile();
} catch (IOException e) {
    // 错误:writeToFile() 没有在 throws 列表中声明 IOException
    // ...
}

你会从 Java 编译器得到一条错误消息,因为 writeToFile() 没有声明 IOException。要绕开这个问题,请在 Kotlin 中使用 @Throws 注解:

1
2
3
4
5
@Throws(IOException::class)
fun writeToFile() {
    /*...*/
    throw IOException()
}

空安全

从 Java 调用 Kotlin 函数时,没有人会阻止我们把 null 作为非空参数传入。因此 Kotlin 会为所有期望非空值的 public 函数生成运行时检查。这样我们就会立即在 Java 代码中得到 NullPointerException。

型变泛型

当 Kotlin 类使用声明处型变时,从 Java 代码看它们的使用方式有两种选择。例如,假设你有下面这个类和两个使用它的函数:

1
2
3
4
5
6
7
class Box<out T>(val value: T)

interface Base
class Derived : Base

fun boxDerived(value: Derived): Box<Derived> = Box(value)
fun unboxBase(box: Box<Base>): Base = box.value

把这些函数直白地翻译成 Java 会是这样的:

1
2
Box<Derived> boxDerived(Derived value) { ... }
Base unboxBase(Box<Base> box) { ... }

问题在于,在 Kotlin 中你可以写 unboxBase(boxDerived(Derived())),但在 Java 中这不可能,因为在 Java 里类 Box 在参数 T 上是不变的,因此 Box<Derived> 不是 Box<Base> 的子类型。要让它在 Java 中可用,你必须把 unboxBase 定义成:

1
Base unboxBase(Box<? extends Base> box) { ... }

这个声明使用 Java 的通配符类型(? extends Base)来通过使用处型变模拟声明处型变,因为这是 Java 唯一能做到的方式。

为了让 Kotlin API 在 Java 中可用,当协变定义的 Box(或逆变定义的 Foo)作为参数出现时,编译器会把 Box<Super> 生成为 Box<? extends Super>(Foo<Super> 生成为 Foo<? super Bar>)。当它作为返回值时不会生成通配符,否则 Java 客户端将不得不处理它们(而且这不符合常见的 Java 编码风格)。因此,我们例子中的函数实际会被翻译成:

1
2
3
4
5
6

// 返回类型 —— 不生成通配符
Box<Derived> boxDerived(Derived value) { ... }

// 参数 —— 生成通配符
Base unboxBase(Box<? extends Base> box) { ... }

注意: 当参数类型是 final 时,生成通配符通常没有意义,因此无论 Box<String> 处于哪个位置,它都始终是 Box<String>。

如果你需要在默认不会生成通配符的地方使用通配符,请使用 @JvmWildcard 注解:

1
2
3
fun boxDerived(value: Derived): Box<@JvmWildcard Derived> = Box(value)
// 会被翻译为
// Box<? extends Derived> boxDerived(Derived value) { ... }

相反,如果你不需要在默认会生成通配符的地方使用通配符,请使用 @JvmSuppressWildcards:

1
2
3
fun unboxBase(box: Box<@JvmSuppressWildcards Base>): Base = box.value
// 会被翻译为
// Base unboxBase(Box<Base> box) { ... }

@JvmSuppressWildcards 不仅可以用于单个类型实参,还可以用于整个声明(例如函数或类),从而抑制其中的所有通配符。

Nothing 类型的转换

类型 Nothing 很特殊,因为它在 Java 中没有天然对应的类型。事实上,每个 Java 引用类型(包括 java.lang.Void)都接受 null 作为值,而 Nothing 连 null 都不接受。因此这个类型无法在 Java 世界里被准确表示。这就是为什么当使用类型为 Nothing 的实参时,Kotlin 会生成原始类型:

1
2
3
fun emptyList(): List<Nothing> = listOf()
// 会被翻译为
// List emptyList() { ... }

内联值类

实验性-通用

如果你希望 Java 代码能与 Kotlin 的内联值类顺畅配合,可以使用 @JvmExposeBoxed 注解或 -Xjvm-expose-boxed 编译器选项。这些方式确保 Kotlin 生成 Java 互操作所需的装箱表示。

默认情况下,Kotlin 把内联值类编译为使用未装箱表示,它们通常无法从 Java 访问。例如,你无法从 Java 调用 MyInt 类的构造函数:

1
2
@JvmInline
value class MyInt(val value: Int)

因此下面的 Java 代码会失败:

1
MyInt input = new MyInt(5);

你可以使用 @JvmExposeBoxed 注解,让 Kotlin 生成一个可以直接从 Java 调用的 public 构造函数。你可以在以下级别应用该注解,以便对暴露给 Java 的内容进行细粒度控制:

  • 类
  • 构造函数
  • 函数

在代码中使用 @JvmExposeBoxed 注解之前,你必须通过 @OptIn(ExperimentalStdlibApi::class) 选择启用。例如:

1
2
3
4
5
6
7
8
@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
@JvmInline
value class MyInt(val value: Int)

@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
fun MyInt.timesTwoBoxed(): MyInt = MyInt(this.value * 2)

有了这些注解,Kotlin 会为 MyInt 类生成一个 Java 可访问的构造函数,并且为使用值类装箱形式的扩展函数生成一个变体。因此下面的 Java 代码可以成功运行:

1
2
MyInt input = new MyInt(5);
MyInt output = ExampleKt.timesTwoBoxed(input);

要把这种行为应用到模块内的所有内联值类以及使用它们的函数,请使用 -Xjvm-expose-boxed 选项编译。使用该选项编译的效果,等同于模块中的每个声明都带有 @JvmExposeBoxed 注解。

继承来的函数

@JvmExposeBoxed 注解不会自动为继承来的函数生成装箱表示。

要为继承来的函数生成所需的表示,请在实现类或派生类中重写它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
interface IdTransformer {
    fun transformId(rawId: UInt): UInt = rawId
}

// 不为 transformId() 函数生成装箱表示
@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
class LightweightTransformer : IdTransformer

// 为 transformId() 函数生成装箱表示
@OptIn(ExperimentalStdlibApi::class)
@JvmExposeBoxed
class DefaultTransformer : IdTransformer {
    override fun transformId(rawId: UInt): UInt = super.transformId(rawId)
}

要了解 Kotlin 中继承如何工作,以及如何使用 super 关键字调用超类的实现,请参阅继承。