Rant about Kotlin

/ 0评 / 0

Sometimes, I do prefer Java over Kotlin. The thing is never the same.

Kotlin 是一门好的语言,前提是你可以用 IDEA Ultimate. 在 Kotlin 的早期,我们都认为这是一款革命性的语言。但现在看来,或许 Kotlin 的动机比我们想象的要更复杂一些。

我是一个写 Java 的人,至少以前是。我将以从 Java 看 Kotlin 的视角分析 Kotlin 让人头大的地方。Kotlin sucks. Here's why.

不恰当的变量名影射 (Variable Shadowing)

考虑这样一个 Kotlin 函数:

fun doThing(x: Int) {
  val x = 2
  if (x > 0) {
    val x = 3
  }
  println(x)
}

猜猜 doThing(1) 会打印出什么值?(答案是 2.)

Kotlin 的函数参数是不可变的,即你不应该向参数赋值。这是一个(差不多)好的设计,但是这个设计在你可以影射参数名的时候就显得非常成问题了。同样的代码,在 Java 下根本不可能编译:

void doThing(int x) {
  int x = 2; // <!- x 已经被声明
  if (x > 0) {
    int x = 3; // <!- x 已经被声明
  }
  System.out.println(x);
}

要知道,IDEA 的 linter 会告诉你:不恰当的变量名影射,说明他们也知道这是个问题。但是由于 Kotlin 早期设计这么激进的影射机制,现在希望撤掉这个功能估计是一个非常大的 breaking changes 从而不可能实现。 (请不要再来一个「通过编译参数控制是否应该报错」这种事情了。-Wall -Werror 已经够烦人了。)

自动类型推导 (Type Inference)

自动类型推导曾是一个非常有用的机制。事实上我最初开始使用 Kotlin 的最大原因就是它有自动类型推导。但是,Java 10 的出现捅了一句:现在我们也有自动类型推导了。

从现在开始,你可以写这样的代码了:

var myInt = 10;
var myString = "Java 10";
var myMap = new HashMap<String, String>();
var myThing = getTheThing();

比较有意思的是:var 不是一个关键词。所以之前用 var 做变量名的库不会突然就挂掉。var 是一个保留的类型名,如果你的代码都按照规范来写的话,应该是不会出现 var 类型的类的。Nothing breaks.

当然,Java 10 的自动类型推导比起 Kotlin 还是略显逊色。比如,它不支持从 Lambda 推导类型。但其实也无伤大雅。

类字面量 (Class Literal)

Kotlin 为了做到那些拓展,创建了 KClass 取代 Class. 这也导致了有趣的事情:::class 的类型是 KClass 而不是 Class. 怎么取到 Class 呢?::class.java. 当你使用日志工具的时候,你会不断地意识到 ::class.java 后面的那个小尾巴的存在。

编译期空指针检查

而那个小尾巴的存在真的是太讽刺了。Kotlin 最初宣称 100% 的 JVM 兼容,没错,它做到了,而且现在也还是。但问题是并不是所有人都愿意或者有能力去抛开他们熟悉的一切去学新语言的。当你反过来尝试交互的时候,就有一股喝了风油精的感觉。

有些时候,Kotlin 和 Java 的交互必须做出妥协。做出妥协的通常是 Java 一方,也罢。但 Kotlin 的一个核心功能——编译期的空指针检查,在和 Java 交互的时候必须做出奇怪的妥协。

举个例子,在 Java 下有这么一段历史代码:

class Util {
  public static String processText(String str) {
    if (str.isEmpty()) {
      return null;
    } else {
      return "[TAG] " + str;
    }
}

现在你希望在 Kotlin 中调用它:

fun doThing() {
  val x = Util.processText("")
  println(x)
}

猜猜 x 的类型是什么?是 String!. 上述代码在运行的时候会抛出空指针异常。没错,运行期的 NPE.

T! 类型是非常特殊的类型,它表示这个类型可以为空,也可以不为空。编译器不对带 ! 的类型做空检查。这是一种非常非常让人感觉迷惑的决定。似乎把所有 Java 方法都加 ? 要造成更大的问题,所以我们就不得不接受来自外部的值无法进行合适的编译期检查的现实。

好在,你仍然可以通过 JetBrains 的 annotation 库提示 IDEA 某个方法的正确类型究竟是什么,比如 @NotNull, @Nullable@Contract. 但这意味着跑去修改历史代码,工作量加倍大成功。

伴生对象 (Companion Object)

知道为啥 Kotlin 允许顶层函数么?为了 fun main() { ... }.

在 Kotlin 中,没有静态成员,因为静态成员不 OO. 但有些时候我们又真的需要静态成员这个概念。那怎么搞呢?答:创建一个伴生对象:

class A {
  companion object {
    val logger = getLogger(...)
  }
}

然后你就可以写 A.logger 来获取这个成员了。顺带一提,这个伴生对象实际上真的是个类,它在一开始就初始化为一个单例,而且它并不匿名,它叫 Companion, 不过你调用的时候可以不写它的名字。

好吧,那我希望在 Java 里面调用呢?

class JavaA {
  void doThing() {
    A.??? // <!- A 没有导出成员
  }
}

忘记说了,默认情况下,伴生对象内的所有成员都是私有成员。如果你需要暴露成员的话,用 @JvmField 修饰:

class A {
  companion object {
    @JvmField
    val logger = getLogger(...)
  }
}

然后,对于方法,用 @JvmStatic 修饰。以及,对于 lateinit var, 它们也会被暴露给 Java.

如果没有顶层函数,你猜猜 Kotlin 下要怎么写一个主函数入口?

class Main {
  companion object {
    @JvmStatic
    fun main(args: Array<String>) {
      // ..
    }
  }
}

这可真是刺激。

协程 (Coroutine)

如果说之前在交互性上的 Rant 只是一点点不方便而已,协程标志着 Kotlin 和其他语言的彻底决裂:我就没指望你用 Kotlin 以外的任何语言来写这个程序。

实际上协程并不是什么新鲜玩意。协程是最基本的代码块间合作的方式。事实上在写操作系统的时候,我们都必须先实现协程,再实现线程。不过由于 Java 里没有协程这个玩意,导致任何 suspended fun 都不可能直接从 Java 调用,而必须由 Kotlin 方提供包装方法。

对了,如果你真的很在意协程究竟是怎么一回事——它就是一个大的 switch, 以 Continuation 作为执行上下文和 switch 内跳转。真没啥特殊的。你以为它是线程?Kotlin 甚至根本不需要专门维护一个线程池去干这事,因为它只是最简单的单线异步模式——这也是为啥你可以创建一堆协程而不会带跨 JVM 的原因。在 JavaScript 里,这就是用了无穷多次的 Promise - async/await. 别忘了 JavaScript 可是单线程的。

Maybe 类型

如果说这份决裂做得再彻底一些,那么或许还是可以让人接受的。比如引入一些函数式里面最基本的玩意:Maybe 单子。要知道,Java 8 就引入了 Optional<T> 作为一种挽救 NPE 的尝试:

int parseAndInc(String x) {
  return Optional.ofNullable(x)
                 .map(Integer::parseInt)
                 .map(y -> y + 1)
                 .orElse(0);
}

你可能觉得用 let 不也行么?

fun parseAndInc(x: String?): Int {
  reutrn x.let { Integer.parseInt(it) }
          .let { it -> it + 1 } ?: 0
}

然而不行。这段代码会在 parseInt 处抛出 NPE. let 不是 map, 它不关心进来的值是 Just 还是 None. 它只负责传数据而已。而且,Kotlin 不会强制你用 x?.let, 哪怕它知道 x 可能为空,因为 let 不是 x 的成员,而是一个内联扩展方法。如果你需要它和 map 的行为保持一致,那么你需要自己打 ?.:

fun parseAndInc(x: String?): Int {
  return x?.let { Integer.parseInt(it) }
          ?.let { it -> it + 1 } ?: 0
}

比起 Java 的 map, 这语法反而更啰嗦了。

截至发稿时,Kotlin 也没有官方引入 Maybe 类型。

封闭数据类 (Sealed Data Class)

Kotlin 中,数据类型是不能被声明为 open 或者 abstract 的,即所有数据类型都是 final 的。这是件好事,直到你开始设计业务模型的时候你才会意识到:卧槽?我没法继承一个 data class?

这个事情发生的核心原因是:对于数据类,它的 equals() 方法是自动生成的。如果我们允许数据类被继承,那么在不违反 Liskov 原则的情况下不可能生成基于值的 equals() 方法。

所以,对于核心业务模型,你大概不太特别希望把它声明为一个 data class.

领域专用语言 (Domain Specific Language)

Do I have to do this?

自从 .kts 的引入,Kotlin 也可以被拿来设计领域专用语言了。但问题是,这些领域专用语言需要专用的插件才能被正确地进行语法分析、高亮和补全。这个用意有那么一点点的明显了吧。要知道,一些 DSL 的出现是为了做配置或者一些快速的小的修修补补。如果我们必须硬记 DSL 的每一处内容才能开始用的话,我有点怀疑这样做的必要性。

另:不要把配置和代码混在一起写。说的就是你,Gradle!

在一个理想的情况下,我们应该可以有一个独立的、开放的、统一的平台提供代码的语法分析、高亮、补全和洞察功能。事实上我们已经在推进了:语言服务协议 (LSP) 就是希望通过提供这样一个统一开放平台,接入不同的语言和不同的编辑器。截至发稿时,Kotlin 的语言服务器并不由 JetBrains 维护,且仍然处于不稳定状态。

域函数 (Scoped Function)

Kotlin 是一门能拔丝的语言。但问题是,如果这些语法糖的行为能稍微可预测一点,那么问题也不是很严重。但是,它的问题就是非常非常的严重。

还记得上面的 .let 么?如果我不允许你 Google, 你能写出它的签名是什么么?

inline fun <U, V> U.let(fn: (U) -> V): V

Yeah, everything is fine. 但紧接着是第二个问题:在没有类型提示的情况下,跟在后面的 { .. } 的类型是什么?对于标准库函数而言,倒还好说,它们的类型要么是 Function<U, V>, 要么是 Consumer<T>. 但有些时候你需要知道 <U, V>T 具体是什么(比如 thisit 的区别、函数返回值的类型等)。这个东西必须通过手册和 IDE 提示才能给出。对于那些文档成问题的第三方库……再结合一下 DSL... Terrible.

Kotlin tries to blend the functional part in, but fails. 高阶函数虽然是 FP 的一部分,但不支持 partial application 让它只是比 Java 8 甜了一点(少啰嗦了一点)而已。如果我真的需要 FP, 我为啥不用 Scala 呢?

更何况 Kotlin 它的很多用例并不 FP. 举个例子:

object Feed {
    private suspend fun <T> useHttpClient(block: suspend (HttpClient) -> T): T = HttpClient(OkHttp) {
        install(HttpTimeout) {
            socketTimeoutMillis = 60_000
            connectTimeoutMillis = 60_000
            requestTimeoutMillis = 180_000
        }
        BrowserUserAgent()
        ContentEncoding {
            gzip()
            deflate()
            identity()
        }
    }.use { block(it) }

    suspend fun feedUrl(url: String) = useHttpClient { client ->
        SyndFeedInput().build(XmlReader(client.get<InputStream>(url)))
    }
}

Look at this fucked up thing! Builder pattern has never been so unintuitive! 如果我挑任何一个大括号问你这玩意的签名是什么,你能在不看手册的情况下短时间内猜出答案么?(我并不要求你写出完全一样的签名,意思一致就可以。)

看看大括号里发生的各种副作用吧!注意到那个 = 了么?没错,后面的一串内容是表达式,类型是一个函数。但是它又长得那么像是一个函数声明,第一眼看上去的时候除非你熟悉这个库,否则就是无尽的迷惑。而且这个 Builder pattern 设计得还挺成问题,为什么超时设置需要用 install 而后面的诸多设定都是隐式地调用 this 进行的?

Kotlin 有些时候宣称自己是 Streamlined Java. 这简直是莫大的讽刺。Even Java knows about side-effects better than those Kotlin libraries.

总结

你可能注意到我吐槽的很多点都围绕着「Java 不能调用 Kotlin」和「你需要 IDE 才能写 Kotlin」。这是因为你完全可以从 JavaScript 调用 TypeScript. TypeScript 作为 JavaScript 的超集,是完全可以做到双向兼容的;而且 TypeScript 不挑 IDE, 你甚至可以用 vim 配合 Language Server 顺畅地写 TypeScript. 但 Kotlin, 你绝对需要 IDEA, 甚至是 Ultimate.

But who am I to judge? 编程语言只有两类:没人用的和都在骂的。萝卜青菜各有所爱。但能逼我水一篇文章的,Kotlin 还是头一个。或许是一开始我对它有过高的期待,当期待落空的时候,自然就觉得不彳亍。

Java sucks. Kotlin sucks. Python sucks. JavaScript sucks. TypeScript sucks. Everything sucks, but it works.

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

Your comments will be submitted to a human moderator and will only be shown publicly after approval. The moderator reserves the full right to not approve any comment without reason. Please be civil.