(二更)Kotlin Conf'24:前瞻一下 Kotlin 2.1 & 2.2 新语法 ,以及闲聊

3,439 阅读13分钟

本文首次写于 2024年5月28日,编辑于 2024年5月30日,根据主题演讲新增大量内容,并调整部分内容

几天前的 Kotlin Conf'24 的主题演讲上,JetBrains 带来了诸多更新。除了备受关注的 Kotlin 2.0.0 稳定版(What's new in Kotlin 2.0.0 | Kotlin Documentation (kotlinlang.org))发布外,还有 Kotlin Multiplatform 和 Compose Multiplatform 的诸多更新。上面视频涉及到的内容很多,感兴趣的很建议去观看,站内也有简单的摘录。今天这篇文章,我们来 KotlinConf 和它里面的一个主题演讲:Kotlin 新语法前瞻。

未来的新语法

对应视频位置:Keynote: www.bilibili.com/video/BV18D… | 主题演讲: www.youtube.com/watch?v=tAG…

Guards | 守卫

假设我们有一个 Status 类,它有三个子类:LoadingOKError。我们想要根据不同的状态来渲染不同的 UI。在之前,我们可能会这样写:

fun render(status: Status): String = 
    when {
        status == Status.Loading -> "Loading" 

        status is Status.OK && status.data.isEmpty() -> "No data" 
        status is Status.OK -> status.data.joinToString()
        status is Status.Error && status.isCritical -> "Critical problem" 
        else -> "Unknown problem"
    }

这段代码工作正常,但我们却不得不写了很多 status 变量,一个理想的解决方案是把它放到 when 语句中,就像:

fun render(status: Status): String = 
    when (status) {
        Status.Loading -> "Loading" 

        is Status.OK && status.data.isEmpty() -> "No data" 
        is Status.OK -> status.data.joinToString()
        is Status.Error && status.isCritical -> "Critical problem" 
        else -> "Unknown problem"
    }

&& 位置会报错,Kotlin 编译器会提醒我们:Error, expecting a '->'。因为像这样使用 when 语句有一个严格的限制条件:每一个分支都仅能有单个判断。为此,Kotlin 将通过引入 Guard Condition(守卫条件)来消除这个限制,如下:

fun render(status: Status): String = 
    when (status) {
        Status.Loading -> "Loading" 
        // 注意:下面的 && 变成了 if
        is Status.OK if status.data.isEmpty() -> "No data" 
        is Status.OK -> status.data.joinToString()
        is Status.Error if status.isCritical -> "Critical problem" 
        else -> "Unknown problem"
    }

通过 if,我们可以额外加上其他判定条件。

该功能将在 Kotlin 2.1 中进入 Beta 状态

Context Sensitive Resolution | 上下文关联解析

继续我们上面的例子,你会发现仍然不够好:我们重复了很多个 Status.xxx。事实上,如果这个 Status 是一个密封类,我们其实已经知道判定条件都是 Status 的子类。为此,新语法进一步简化代码:

fun render(status: Status): String = 
    when (status) {
        // 我们直接省略了前置的 Status. ,由编译器自动推断
        Loading -> "Loading" 
        is OK if status.data.isEmpty() -> "No data" 
        is OK -> status.data.joinToString()
        is Error if status.isCritical -> "Critical problem" 
        else -> "Unknown problem"
    }

对于密闭类型和枚举,前置的冗余类型名称将可以省略了!

enum class Status {
    Loading, OK, Error
}

fun process(
-	status: Status = Status.Loading,
    // 这里的 Status. 也可以省略
+	status: Status = Loading,
    ...
)

该功能将在 Kotlin 2.2 中进入 Experimental 状态

用双 $ 在字符串中引入变量

对于如下 JSON:

{
    "$schema": "https://json-schema.org/draft/2020-12/schema", 
    "$id": "https://example.com/product.schema.json",
    "$dynamicAnchor": "meta", 
    "title": "Product",
    "type": "object" 
}

有一些键是 $ 开头的。如果我们想把它以 Kotlin 的多行字符串形式表示出来,我们可能会写:

val json = """
{
    "$schema": "https://json-schema.org/draft/2020-12/schema", 
    "$id": "https://example.com/product.schema.json",
    "$dynamicAnchor": "meta", 
    "title": "Product",
    "type": "object" 
}
"""

但很可惜,这会报错,因为 Kotlin 会把 schemaiddynamicAnchor 等当作变量。为了解决这个问题,你可能会想在 $ 前面加上一个 \,如下:

val json = """
{
    "\$schema": "https://json-schema.org/draft/2020-12/schema", 
    "\$id": "https://example.com/product.schema.json",
    "\$dynamicAnchor": "meta", 
    "title": "Product",
    "type": "object" 
}
"""

然而,这对多行字符串("""...""")无效。因为多行字符串里没有转义字符。要想解决,目前也有一些奇怪的方案,比如:

// 通过写成 ${'$'} 来间接表示 $
val json = """
{
    ${'$'}schema: "https://json-schema.org/draft/2020-12/schema", 
    ${'$'}id: "https://example.com/product.schema.json",
    ${'$'}dynamicAnchor: "meta", 
    "title": "Product",
    "type": "object" 
}
"""

就……很奇怪。

在新语法中,我们可以在 """ 前面加上 $$,这样字符串将使用 $$ 引入变量,而不是 $

// 通过在 """ 前面加上 $$,单个 $ 将成为普通字符
val json = $$"""
{
    "$schema": "https://json-schema.org/draft/2020-12/schema", 
    "$id": "https://example.com/product.schema.json",
    "$dynamicAnchor": "meta", 
    "title": "Product",
    "type": "object" 
}
"""

该功能将在 Kotlin 2.1 中进入 Beta 状态

Non-Local Continue/Break | 非局部 continue/break

for (i in 0..n) {
    val data = state[i].let {
        when (it) {
            -1 -> break
            0 -> continue
            else -> process(it)
        }
    }
}

在这个例子中,我们循环 [0, n],对每一个 state[i] 进行处理。我们期望:如果 state[i] 为 -1,我们就退出循环;如果为 0,我们就跳过这一次循环;否则,我们就处理它。但这段代码会报错,因为当前 Kotlin 会认为这两个 breakcontinuelet 语句中,而不是在循环中。

但让我们看看 let 的定义:

inline fun <T, R> T.let(block: (T) -> R): R = block(this)

这是个内联函数,所以,其实上面的代码相当于:

for (i in 0..n) {
    when (state[i]) {
        -1 -> break
        0 -> continue
        else -> process(state[i])
    }
}

实际上是合法的。新的 Kotlin 语法将允许此情况发生,上述代码将不再报错。

该功能将在 Kotlin 2.1 中进入 Beta 状态

Context Receivers Parameters | 上下文接收者参数

接下来是一个小的但是也很重要的语法变化。让我们来看个语法,它在过去 Kotlin 的发展中被自然的引入了——Context Parameters(或者也叫 Context Receivers)。假设我们有下面的代码:

withAutoClose {
    // 我们假定这个 Lambda 作用域内,打开的文件能被自动关闭
    val config = open(File("config.json"))

    // 执行完后,文件会被自动关闭
}

为此,我们引入了一个特殊的 Scope 以及它的 open 拓展函数

interface AutoCloseScope

fun withAutoClose(block: AutoCloseScope.() -> Unit)

fun AutoCloseScope.open(file: File): InputStream

现在的问题是,如果我们希望把 open 作为 File 的拓展函数,使得整体的代码更加优雅、易读,我们可能会这样写:

- val config = open(File("config.json"))
+ val config = File("config.json").open()

但这是做不到的,因为 open 已经有上下文 AutoCloseScope 了。这个时候,新的 Context Parameters 就派上用场了。我们可以这样定义 withAutoClose

- fun withAutoClose(block: AutoCloseScope.() -> Unit)
+ fun withAutoClose(block: context(_: AutoCloseScope).() -> Unit)

现在,就可以如下定义 open,它变成了 File 的拓展函数,且在 AutoCloseScope 的上下文中:

context(scope: AutoCloseScope)
fun File.open(): InputStream

该功能将在 Kotlin 2.2 中进入 Beta 状态

GADT-Style Smart-Cast | GADT 风格的智能转换

GADT(Generalized Algebraic Data Types)是一种函数式编程语言中的类型系统,它允许我们在类型中嵌入更多的信息。

此处使用 GADT 的叫法仅是为了便于理解,Kotlin 并没有这个术语,未来也可能变化

先给定以下代码:

sealed class Container<T>(val value: T) {
    // 42? 宇宙的终极答案就是 42 啦
    class IntContainer: Container<Int>(42)
    // 这是本篇博客作者的名字
    class StringContainer: Container<String>("FunnySaltyFish")
}

// 简单的拆箱
fun <V> unbox(container: Container<V>): V = container.value

但如果我们想进一步处理,比如说想写成下面这样子,就会报错:

fun <V> unboxAndProcess(container: Container<V>): V = 
    when (container) {
        is Container.IntContainer -> 42
        // Actual type: String, Expected type: V
        is Container.StringContainer -> "FunnySaltyFish"
    }

现阶段,我们只能强制转换并加上 @Suppress("UNCHECKED_CAST") 来处理这种情况,就像

@Suppress("UNCHECKED_CAST")
fun <V> unboxAndProcess(container: Container<V>): V = 
    when (container) {
        is Container.IntContainer -> 42 as V
        is Container.StringContainer -> "FunnySaltyFish" as V
    }

但显然我们知道,在运行时完全不需要这个强转,是不会有问题的。而在 GADT 风格下,泛型 V 将会是 IntString,取决于类型推断的结果。就像这样:

fun <V> unboxAndProcess(container: Container<V>): V = 
    when (container) {
        is Container.IntContainer -> container.value // V 会被推断为 Int
        is Container.StringContainer -> container.value // V 会被推断为 String
    }

此项目仍在探索中,官方已经取得了可观的进展,预计将很快发布 KEEP(Kotlin Evolution and Enhancement Process)提案。

Name-based Destructuring | 基于名称的解构

对于现有的 data class,Kotlin 允许用任意名称解构它们(依赖于 componentN 函数),但这可能导致一些错误情况,比如:

data class Person(val name: String, val lastName: String)

fun process(person: Person) {
    // 你可能会写成这样,但其实解构解错了
    val (surname, firstName) = person
}

因此,在 Kotlin 2.x 的某一个版本,官方可能会为这种情况提供警告,最终将演化为报错:

data class Person(val name: String, val lastName: String)

fun process(person: Person) {
    // Warning: "surname" doesn't match property "name"
    // Warning: "firstName" doesn't match property "lastName"
    val (surname, firstName) = person
}

到那时,使用任意名称解构 data class 将无法完成。最终官方将彻底禁止基于 componentN 函数的解构,data class 也是少生成一部分代码。官方也将提供新的方式用于解构 data class,因此未来使用 data class 的代码将更加安全。

此功能将在主要在 Kotlin 2.2 中完成 译者吐槽:我感觉这处理不好会很难受啊,比如说 Pair 这种,很少会有人使用 val (first, second) = pair 吧,而是用更加富有语义的名称。看看未来的发展吧

Extensible Data Arguments | 函数参数整体传参

要引入这个语法,我们先来看一个背景:LazyColumn,这是 Jetpack Compose (确切地说,是 Compose Multiplatform)中的一个 Composable(UI 控件),用于显示惰性列表。使用起来大概如下:

LazyColumn {
    // 显示 100 个 Item
    items(100) { index ->
        Text("Item #$index")
    }
}

我们今天看看它的 API,作为一个 Composable,它有很多参数:

image-5.png

如果我们想加一个参数,又保持向后兼容性呢?我们就不得不创建一个新的 Overload,复制代码、复制 Doc。这就导致如果你点击看它的源码,能看到很多很多类似但只是一个参数不同的函数:

image-6.png

这样的问题在 Compose 的源码中比比皆是,各种文件一大部分就是为了保持向后兼容性的废弃代码。这样的代码维护起来是非常痛苦的。为此,Kotlin 计划引入了 dataarg,它允许我们把共性的参数提取出来,整体传入,就像这样:

// 提取出的共性参数,各种 LazyColumn/LazyGrid/Column/Row 都有,可以统一起来了
+ dataarg class ColumnSettings(
+    val contentPaddings: PaddingValues = PaddingValues(0.dp),
+    val reverseLayout: Boolean = false,
+    val verticalArrangement: Arrangement.Vertical = if (reverseLayout) Arrangement.Bottom else Arrangement.Top,
+    val horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+    val userScrollEnabled: Boolean = true,
+)

// 以 LazyColumn 为例,我们可以这样传参
@Composable
fun LazyColumn(
    modifier: Modifier = Modifier,
    state: LazyListState = rememberLazyListState(),
+   dataarg args: ColumnSettings,
-   contentPaddings: PaddingValues = PaddingValues(0.dp),
-   reverseLayout: Boolean = false,
-   verticalArrangement: Arrangement.Vertical = if (reverseLayout) Arrangement.Bottom else Arrangement.Top,
-   horizontalAlignment: Alignment.Horizontal = Alignment.Start,
-   userScrollEnabled: Boolean = true,
    flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
    content: LazyListScope.() -> Unit
) {
    // ...
}

这大大节省了代码量,也让代码更加易读,同时保持良好的向后兼容性。

在调用处,我们仍然可以像以前一样传参:

LazyColumn(
    modifier = Modifier.fillMaxSize(),
    state = listState,
    // 这个 reverseLayout 属于 dataarg 的一部分
    reverseLayout = true,
) {
    // ...
}

此功能目前只是提案(KT-8214),将在 Kotlin 2.2 进入 Experimental 状态 译者:真不错,写 Compose 的我非常需要这各功能

Union Types for Errors | 联合错误类型

让我们来设想一个函数:last,它返回一个序列中符合条件的最后一个对象,如果找不到,就报 NoSuchElementException。你可能想象的写法是这样:

fun <T> Sequence<T>.last(predicate: (T) -> Boolean): T {
    var last: T? = null
    for (element in this) {
        if (predicate(element)) {
            last = element
        }
    }
    return last ?: throw NoSuchElementException("No element matching the predicate was found.")
}

看着很理想,但问题来了,如果条件是 { it == null } 呢?这样的写法明显不正确。为了实现能对判空的情况正确处理,我们必须加入一个 found 变量用来记录有没有找到:

fun <T> Sequence<T>.last(predicate: (T) -> Boolean): T {
    var last: T? = null
    var found = false
    for (element in this) {
        if (predicate(element)) {
            last = element
            found = true
        }
    }
    if (!found) {
        throw NoSuchElementException("No element matching the predicate was found.")
    }
    @Suppress("UNCHECKED_CAST")
    return last as T
}

这代码既复杂,又引入了一个不必要的类型转换(as T),显然不优雅。那还有更好的主意吗?让我们试着引入一个全局变量 NotFound,用它来表示一个未找到的情况

private object NotFound

fun <T> Sequence<T>.last(predicate: (T) -> Boolean): T {
    var result: Any? = NotFound
    for (element in this) {
        if (predicate(element)) {
            result = element
        }
    }
    if (result === NotFound) {
        throw NoSuchElementException("No element matching the predicate was found.")
    }
    return result as T 
}

但这也不够优雅,因为 result 的类型是 Any?,我们还是需要一个类型转换。为了补全这个类型系统,Kotlin 将引入 对 Errors 和 Exceptions 的联合类型,让我们可以这样写:

// 变成了 error object
private error object NotFound

fun <T> Sequence<T>.last(predicate: (T) -> Boolean): T {
    var result: T | NotFound = NotFound
    for (element in this) {
        if (predicate(element)) {
            result = element
        }
    }
    if (result === NotFound) {
        throw NoSuchElementException("No element matching the predicate was found.")
    }
    return result // 将会被智能转换为 T
}

但值得指出的是,这个联合类型专为 Errors 和 Exceptions 设计,用于区分成功和失败的情况。官方不打算引入更广泛的联合类型。联合类型会为类型检查器带来不确定性,新的运算符 ?.!. 也将引入。

此特性仍处于研究阶段

Explicit Backing Fields | 显式后备字段

让我们看下面的代码,这在 MVVM、MVI 模式中经常经常出现

class ViewModel {
    private val _city = MutableStateFlow("Beijing")
    // 通过 city 属性暴露出去,使其只读
    val city: StateFlow<String> = _city


    // 而更新则通过 updateXxx 函数
    fun updateCity(newCity: String) {
        _city.value = newCity
    }
}

这非常常见,官方说有超过 10w 个 GitHub 上的文件中有这种写法。这包括:

  • MutableLiveData -> LiveData (Android)
  • MutableStateFlow -> StateFlow (Kotlin Flow)
  • MutableList -> List (Kotlin Collections)
  • MutableState -> State (Compose)
  • ...

显然这表示需要些新语法来简化这个过程。为此,Kotlin 将引入 field 关键字的新用法。上述的变量定义将简化为:

class ViewModel {
    val city: StateFlow<String>
        field = MutableStateFlow("Beijing")
}

这将自动生成 private_city。当然,看到这个语法,你可能会问:

  • 如果我在 public 的 inline 函数中使用,它的可访问性是 public 还是 private?
  • 如果我从函数返回这个 field,它是什么呢?它应该是个 function 还是 getter?
  • ...

现在官方的想法是,将引入可选的名称来解决这些问题,譬如:

class ViewModel {
    val city: StateFlow<String>
        field mutableCity = MutableStateFlow("Beijing")

    val background: Color
        field = mutableStateOf(getBackgroundColor)
        get() = field.value
}

此特性将在 Kotlin 2.0 中 Experimental,2.2 中进一步变化


上述是未来即将到来的,除此之外,K2 作为一个全新的编译器,带来了很多能力上的优化,挑选部分列举如下:

Kotlin 2.0 已有的小优化

更强的 FIR

对于下列代码:

fun add(list: MutableList<Long>) {
    list[0] = list[0] + 1
}

我们给 list[0] 加 1,在这里,Kotlin 正确处理了 Int 到 Long 的转换。但是,如果我们写成:

fun add(list: MutableList<Long>) {
    list[0] += 1 // Error, 1L is required
}

在 Kotlin 1.x 中这会报错,因为它没有正确处理运算符合并(+=)加上类型转换。在 Kotlin 2.0 中,强大的新 FIR(Frontend Intermediate Representation)编译器将把其脱糖为类似下面的代码:

fun add(list: MutableList<Long>) {
    list.set(0, list.get(0).plus(1))
}

在这个简单的例子中,我们事实上用了三种运算符([] - get+ - plus=)和一次类型转换。如果有可空运算符参与,这个例子还可以变得更加复杂:

class Box(val list: MutableList<Long>)

fun add(box: Box?) {
    box?.list[0] += 1 // Error, 1L is required
    box?.list[0] += 1L // 在 Kotlin 1.x 中,仍然会报错
}

在 Kotlin 2.0 中,这个例子也能正确编译,它会被脱糖为:

box?.run {
    list.set(0, list.get(0).plus(1))
}

如各位所知,Kotlin 的强大来自它的可组合性(组合使用 运算符重载委托拓展函数 等),而新的 FIR 能让其更为一致和健壮。

更强大的智能类型转换

K2 现在有了新的控制流引擎,帮助我们推断代码行为。在 Kotlin 中,这也表现为更加智能的类型转换。让我们来看一些例子:

变量参与到智能类型转换中

class Animal

class Cat: Animal {
    fun meow() = println("Meow")
}

fun moew(animal: Animal) {
    if (animal is Cat) {
        animal.meow() // animal 被智能转换为 Cat,可以执行 meow
    }
}

这个例子是个非常基础的类型转换,但如果我们稍微变一下,把 animal is Cat 提取出来,就不行了:

fun moew(animal: Animal) {
    val isCat = animal is Cat
    if (isCat) {
        animal.meow() // 仅仅提取了个变量,就不行了。这一行在 Kotlin 1.x 中会报错
    }
}

造成这的原因是,旧有代码中,仅有少量类型的表达式会对类型转换做贡献,比如类型检查(Type Checks)和可空性检查(Null Checks),而变量不会。自 K2 起,局部变量也能参与到类型转换中,携带必要的控制流信息,最终使得上述代码能够正确编译。

而说到可组合性,上述特性对所有智能转换都有效,甚至包括 Contracts。

Contract(契约)是一种 Kotlin 面向编译器约定的一种规则,比如 isNotEmpty 这种函数,就会用 Contract 告诉编译器传入的参数经此函数后如果返回 true,那么它就不为空列表。更多介绍可以参考:juejin.cn/post/705678…

class Card(val title: String?)

fun cardName(card: Any): String {
    val cardWithNotNullTitle = card is Card && !card.title.isNullOrEmpty()

    return when {
        // 这里 cardWithNotNullTitle 贡献了两种规则:card 是 Card 类型,且 title 不为空,所以返回值直接判断应为 String
        cardWithNotNullTitle -> card.title
        else -> "无标题"
    }
}

如上。

可变内联 Lambda 闭包中的智能转换

下一个例子发生在内联 lambda 内部:

fun indexOfMax(array: IntArray): Int? {
    var maxIndex: Int? = null
    array.forEachIndexed { index, value ->
        // 在 Kotlin 1.x 中,即使 || 后面已经表示 maxIndex 非空,也无法省略这个 !! 的判断
        // Smart cast to 'Int' is impossible, 
        // 'maxIndex' is a local variable that is captured by a changing closure
        if (maxIndex == null || array[maxIndex!!] <= value) {
            maxIndex = index
        }
    }
    return maxIndex
}

在这个例子中,由于 maxIndex 定义在这个函数外,且在 lambda 内部使用,旧的 Kotlin 编译器会认为可能这个值会在别的地方被修改,无法推导出它非空。但事实上,我们知道,forEachIndexed 是一个内联函数,会在此处执行,且它无法被存储起来以修改。自 K2 起,Kotlin 编译器会认为所有的内联函数具有默认的 call in place(原地执行) Contract,能够正推断出 maxIndex 的非空性。

|| 运算符后的智能转换

假设我们有如下 Interface,它们组成了一个层级:

interface Status {
    fun signal()
}

interface Ok: Status
interface Postponed: Status // Postpone:推迟
interface Decline: Status // Decline:拒绝

我们定义一个函数,当 Status 是 Postponed 或 Decline 时,调用 signal 通知:

fun process(object: Any) {
    if (object is Postponed || object is Decline) {
        // 很遗憾,这里会报错。因为 Kotlin 1.x 仍然会认为 object 是 Any 类型
        object.signal() 
    }
}

在旧的版本中,编译器对于 || 运算符不会做智能转换。自 Kotlin 2.0.0 起,|| 将会尝试合并两个类型为其公共父类,上述的 object 将会被智能转换为 Status

除了这些,Kt 2.0 还有大量诸如此类的优化,如下图所示:

image-4.png

此处不再详细列出,感兴趣的可以参考 Release Page

观众提问

主题演讲中有一些很有意思的观众提问,演讲者也给出了很有针对性的回答,此处也一并列出(下文中的“我”和“我们”均指的是演讲者和 Kotlin 团队,由于提问和回复表述比较繁琐,译者做了简化和合并):

问:是否会提供迁移工具,帮助迁移到新的语法?

是的,尤其是对于简单的语法。总的来说,我们会在 IDEA Plugin 或者 Fleet 中提供一些工具,帮助你迁移到新的语法

问:为什么 Guards 选择了 if 而不是原先的逻辑运算符,比如 && 呢?那还能允许 || 这种。

事实上,最初我们确实想用 &&,那很直白,你完全不需要学新东西。但我们发现了一些问题,比如当你真的需要逻辑运算符的时候,整体有些混乱。因为这个 && 其实不是那个“逻辑和”,它在前面的条件成立时才执行,那可能会导致你认为的某些为 true 的条件实际上是 false。这也是为啥我们选择了明显的 if 以作区分

问:我们知道,Java 学习了很多 Kotlin 的特性,也在飞速发展;那 Kotlin 会不会从 Java 中带来一些东西呢?

好问题,大概来说,目前 Java 比 Kotlin 强的地方可能是它的模式匹配,它们引入了基于位置的模式匹配(译注:可参考此处),在 Kotlin 可能有一些前向兼容问题,但仍然非常强大。我们有点犹豫是否要为 Kotlin 也引入类似语法,我们正在观察 Java 的动向,如果开发者们普遍接受了这种基于命名的解构,我们可能会考虑引入去除基于位置解构这一部分的模式匹配。

问:我还想回到那个 guard 的示例上

fun render(status: Status): String = 
    when (status) {
        Status.Loading -> "Loading" 
        // 注意:下面的 && 变成了 if
        is Status.OK if status.data.isEmpty() -> "No data" 
        is Status.OK -> status.data.joinToString()
        is Status.Error if status.isCritical -> "Critical problem" 
        else -> "Unknown problem"
    }

这里你省略了前面的 status,但是后面的 status.data 之类的却还是要保留 status.,为什么不做一个隐式的 this 指向这个 status 呢,这样还可以进一步简化。

感谢,事实上,我们确实讨论过,我们甚至想过用 $ 这种来指代。但我们尝试了后发现这并不能带来很大帮助,而且有时候让代码太过晦涩了。因为有时候你需要用这个变量,有时候不需要。

问:为什么不考虑把联合类型推广到普遍情况呢,而不仅仅是限定于错误类型?您提到了一些问题是对于错误实现联合类型所可能面对的,既然都要面对,为什么不考虑一起做了呢?

好的,关键问题其实在于,我们希望创造一种独立的类型,用于表示错误,对于这种类型有可能实现联合类型。这种类型有一些方法比如 throw,因此可以与 Exception 兼容,并最终让所有的 Exception 和 Error 构建于其之上。至于推广,我们事实上有一个多项式复杂度的类型判定算法,这基于你有一个确定的主类型,以及一系列其他类型。我们得确保这个主类型是唯一的确定的。

问:当工作于一个大型的多模块应用时,我们有时发现跨模块的智能转换无法工作,而且有些库,比如提供不可变类型的,在智能转换时也不工作,有什么这方面的工作吗?

额,实话说,短期内我们确实没有跨模块的智能转换方面的计划。不过我们也确实需要更多的关于不可变性的特性,在未来确实有可能的

问:如果真的取消了不同名的解构,那如果我们需要,比如说解构两个同类的对象,怎么办呢?

对对对,非常有道理。我们会提供一些特殊语法来帮助重命名属性,就像调用函数时的命名参数一样,会有类似的东西的

问:对于新的显示后备字段,有办法让其参与到序列化中码,比如 Kotlinx.serialization?

是的,这也是我们让这些新语法与现有工具适配的一小部分,因为这些序列化工具,比如 Jackson,非常流行。简单地说,可以的。

问:对于联合错误类型,有办法做到像 Java 那样的多异常捕获吗?

是的,这也是这一特性力图解决的问题之一(现场鼓掌)

问:对于新的后备字段,有办法提供多个 Setters,为不同的传入参数类型提供不同的重载吗?

啊,这是关于更新不可变数据的一部分,是的,我们有类似的设计,你可以参考我门的 KEEP 仓库。有一个由 Arrow(?)提供的编译器插件,提供了类似 Java 的语法,可以开一个 Lambda 去做更新,但我们觉得这不是最佳方式。因为,比如说你想更新一个嵌套很深的值,你就需要写很多嵌套的 Lambda,这有点疯狂;同时这也让编译器很难只更新有更改的部分。

另外除了他重点演示的这些语法,还有一些别的小语法只给了名称但没有细说,此处也不再列举了。

社区讨论

既然谈到了语法,那不妨也聊一聊社区的声音。Kotlin的官方论坛 在这里也有很多想法,我们挑选两个上文被提到的话题,来看看社区的讨论。

以下内容均为社区讨论,不代表 JetBrains 官方立场。下文提到的 “现在” 均指 2024-05-28

Union Types | 联合类型

关于联合类型的讨论几乎是最热门的话题,而且持续时间也非常久。这个话题 从 2015 年 10 月开启,到现在有 127 条回复,10w+ 的浏览。当然,Kotlin 自己说不太可能引入更广泛的联合类型了,但是讨论说不定还是可以看看的。

大多数人希望参考类似 TS、Python 的联合类型,能设计出类似如下的代码:

fun foo(x: Int | String) {
    when (x) {
        is Int -> println(x + 1)
        // smart cast as String
        else -> println(x.length)
    }
}

译者本人确实非常期待这个特性,对于 Jetpack Compose 这种 UI 框架,能大幅度减少代码量。现有的各种控件为了处理不同类型的输入,必须要写一大堆函数,比如 Text:

@Composable
fun Text(
    text: String, // 接收 String 类型
    modifier: Modifier = Modifier,
    color: Color = ...,
    fontSize: TextUnit = ...,
    fontWeight: FontWeight = ...,
    fontStyle: FontStyle = ...,
    letterSpacing: TextUnit = ...,
    ...
)

@Composable
fun Text(
    text: AnnotatedString, // 接收 AnnotatedString 类型
    modifier: Modifier = Modifier,
    color: Color = ...,
    fontSize: TextUnit = ...,
    fontWeight: FontWeight = ...,
    fontStyle: FontStyle = ...,
    letterSpacing: TextUnit = ...,
    ...
)

如果有联合类型,我们就可以把这两个函数合并成一个:

@Composable
fun Text(
    text: String | AnnotatedString, // 接收 String 或 AnnotatedString 类型
    modifier: Modifier = Modifier,
    color: Color = ...,
    fontSize: TextUnit = ...,
    fontWeight: FontWeight = ...,
    fontStyle: FontStyle = ...,
    letterSpacing: TextUnit = ...,
    ...
)

整体代码就会简洁非常多(比 dataarg 还简洁)。这种情况在 Icon 这种也很明显,如果想混用 ImageVectorPainter,就必须写两个函数,对于封装的基础控件就得一层层往上套,如果有联合类型就好非常多。而且,同为 JVM 语言的 Scala 也有联合类型,这也证明了编译角度的可行性。

评论区的大家提出了各种用法的畅享,比如用于 typealias

typealias FlexibleDate = String | Long | Instant

// 接收不同的时间类型
fun bar( x : String | Long) { ... }

fun foo( y : FlexibleDate) {
   if (!(x is Instant)) { // so x is String | Long
       bar(x)
   }
}

或者归类一些错误:

object NoInternetConnectionException
object NoSignedInUserException
data class InternalException(val origin: Throwable)
data class UnknownException(val origin: Throwable)

typealias AddException =
    NoInternetConnectionException | NoSignedInUserException | UnknownException

typealias FindException =
    NoInternetConnectionException | NoSignedInUserException | InternalException | UnknownException

typealias FindAllException =
    NoInternetConnectionException | NoSignedInUserException | InternalException | UnknownException

甚至有人设计了在此基础上的运算符:

  • T | A — 类型 A 或 T
  • T & A — 类型 A 和 T
  • T & ~A — 类型 T 但不是 A
  • ~A — 不是 A 的任何类型

且有:

  • T = A | B | C,那么 T & ~A = B | C

基本上就像二进制运算符,但是用于类型。

也有人给出了 when 语句中的使用,这样可以按层级的提取公共项,然后聚合相同的代码:

return when(union) {
    is A,
    is B ->  when(union) {
        // smart-cast `union` to A|B union type
        // do something common for A & B
        when(union) {
            is A -> // do A-specific
            is B -> // do B-specific
            // don't handle `is C`
        }
    }
    is C -> // do C-specific
}

评论区的小伙伴儿也讨论了很多可能的编译实现,回复里也有官方以前的回复。唉,不过现在只能看着欣赏了。

Pattern Matching | 模式匹配

另一个热门话题是模式匹配,在问答环节也提到过。这个话题 从 2017 年 5 月开启,到现在有 28 条回复,5.7w+ 的浏览。

模式匹配是一种非常强大的特性,譬如在 Rust、Scala、Python 等语言中都有。它可以让我们在分支语句中直接解构获取对象的一部分。比如如下代码:

// 数据类,有 componentN 函数
data class Person(val name: String, val age: Int)

when(person) {
    Person("sam", age) -> println("这个匹配所有名为 sam 的人,并将 $age 作为参数传递")
    Person(name, _) -> println("这个匹配所有人,并将 $name 作为参数传递,忽略年龄")
}

在另一个 JVM 系语言 Scala 中,如果使用字面量,那么它将匹配该值,如果使用变量名,它将提取该值并将其分配给变量,如果在反引号中使用变量名,则它将匹配已定义变量的值(但该变量必须是“常量”)。

另外的经典例子出现在 Pair 和 Triple 上:

when (pair) {
    ("不及格", _) -> println("这个匹配所有第一个元素为不及格的 Pair")
    ("优秀", score) -> println("这个匹配所有第一个元素为优秀的 Pair,并将 $score 作为参数传递")
    (status, score) -> println("这个匹配所有 Pair,并将 $status$score 作为参数传递")
}

when (obj) {
    (true, "OK", _) -> println("这个匹配所有第一个元素为 true,第二个元素为 OK 的 Triple")
    (false, "OK", data) -> println("这个匹配所有第一个元素为 false,第二个元素为 OK 的 Triple,并将 $data 作为参数传递")
    (false, status, data) -> println("这个匹配所有 Triple,并将 $status$data 作为参数传递")
}

除了用在 when 语句,还有人发挥想象,提出了放在函数里:

fun foo(1) = 1 // 直接让 foo 传入参数为 1 时返回 1
fun foo(2) = 1

fun foo(n: Int) = foo(n-1) + foo(n-2) 
/**
    计算斐波那契数列
    等价于
    fun foo(n: Int) = when(n) {
        1, 2 -> 1
        else -> foo(n-1) + foo(n-2)
    }
 */

感觉蛮神奇的。

不过评论区有提到,JetBrains 官方可能认为这个特性太复杂,但ta也没有给具体链接。反正嘛,放在这权当畅想了,说了去掉基于位置的解构,也不知道真出来得是什么样子了。


以上就是本文全部内容。除新语法外,Kt 2.0.0 以及 Compose Multiplatform 1.6.10 的发布也有很多亮点(比如两倍的编译速度,CMP 对 iOS 进入 Beta、WasmJs 进入 Alpha 等)。作为一个用 Jetpack Compose 两年半CMP 半年的开发者,很期待看到它进一步成熟。此次 Keynote 中,Kotlin Project Lead 表示,Kt 2.0.0 经过了 JetBrains 上千万行的代码验证,非常稳定,也有公司(如 Meta)已经大规模迁移。这也是我在文章开头提到的,很建议去观看原视频。所以说,xdm,你们会尝试用起来吗?