我们与Kotlin的故事:从尝试到放弃

Posted 前端之巅

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了我们与Kotlin的故事:从尝试到放弃相关的知识,希望对你有一定的参考价值。

作者|Bartosz Walacik
译者|无明
编辑|覃云

Kotlin 现在很流行,它提供了编译时 null 安全,代码更加简洁。它比 Java 更好,你应该切换到 Kotlin,否则就只能坐以待毙。不过,在转向 Kotlin 之前,请先听听这个故事——在这个故事里,那些稀奇古怪的东西让我们忍无可忍,最后不得不使用 Java 重写整个项目。

我们尝试过 Kotlin,但现在开始使用 Java 10 重写代码。

我有一组自己最喜欢的 JVM 语言,/main 目录下的 Java 代码和 /test 目录下的 Groovy 代码是我最爱的组合。2017 年夏天,我的团队开始了一个新的微服务项目,和往常一样,我们讨论了要使用什么编程语言和技术。我们想尝试新的东西,所以决定试试 Kotlin。由于在 Kotlin 中找不到可替代 Spock 的测试框架,所以我们决定继续在 /test 目录中使用 Groovy(Spek 不如 Spock 好)。2018 年冬天,在使用 Kotlin 数月之后,我们总结了它的优势和劣势,并得出结论:Kotlin 导致我们生产力下降。于是,我们开始使用 Java 重写这个微服务。

原因如下:

  • 命名遮蔽(name shadowing)

  • 类型推断

  • 编译时 null 安全

  • 类字面量

  • 反向类型声明

  • Companion 对象

  • 集合字面量

  • Maybe 语法

  • 数据类

  • 公开类

  • 陡峭的学习曲线

命名遮蔽

Kotlin 的命名遮蔽对我来说是个最大的惊喜。比如下面这个函数:

fun inc(num : Int) {
    val num = 2
    if (num > 0) {
        val num = 3
    }
    println ("num: " + num)
}

当你调用 inc(1) 时会打印出什么?在 Kotlin 里,方法参数是按值传递,所以我们不能修改 num 参数。这样的设计是对的,因为方法参数本来就不应该被修改。不过,我们可以用相同的名字定义另一个变量,并将它初始化为任何想要的值。现在,在方法作用域内有两个名为 num 的变量。当然,现在一次只能访问一个 num 变量。所以从根本上说,num 的值被改变了。

我们还可以在 if 代码块中添加另一个 num(新的代码块作用域)。

在 Kotlin 中,调用 inc(1) 时会打印出 2,而在 Java 中,等效代码无法通过编译:

void inc(int num) {
    int num = 2; //error: variable 'num' is already defined in the scope
    if (num > 0) {
        int num = 3; //error: variable 'num' is already defined in the scope
    }
    System.out.println ("num: " + num);
}

命名遮蔽并非 Kotlin 独有,它在编程语言中是很常见的。在 Java 中,我们习惯用方法参数来遮蔽类字段:

public class Shadow {
    int val;

    public Shadow(int val) {
        this.val = val;
    }
}

Kotlin 中的命名遮蔽做得有点过了,这绝对是 Kotlin 团队的一个设计缺陷。IDEA 团队试图通过为每个被遮蔽的变量显示警告(“Name shadowed”)来解决此问题。两个团队都属于同一家公司,或许他们可以就遮蔽问题达成共识?我认为,IDEA 团队是对的,因为我想象不出遮蔽方法参数有什么用处。

类型推断

在 Kotlin 中,在使用 var 或 val 声明变量时,通常会让编译器根据右边的表达式猜出变量类型。我们称之为局部变量类型推断,这对程序员来说是一个很大的改进,我们因此可以在不影响静态类型检查的情况下简化代码。

例如,这行 Kotlin 代码:

var a = "10"

将由 Kotlin 编译器翻译成:

var a : String = "10"

这是 Kotlin 曾经比 Java 真正好的地方。我故意说“曾经”,那是因为 Java 10 现在也有了局部变量类型推断。

Java 10 中的类型推断:

var a = "10";

为了公平起见,我需要补充一点,Kotlin 在这方面仍然略胜一筹,因为在 Kotlin 中,可以在其他上下文中使用类型推断,例如,单行代码方法。

编译时 null 安全

null 安全类型是 Kotlin 的杀手级特性。在 Kotlin 中,类型默认是不可空的。如果你需要一个可空类型,需要添加?,例如:

val a: String? = null      // ok
val b: String = null       // compilation error

如果使用不带空值检查的可空变量,将无法通过编译,例如:

println (a.length)          // compilation error
println (a?.length)         // fine, prints null
println (a?.length ?: 0)    // fine, prints 0

一旦使用了这两种类型,不可空的 T 和可空的 T?,那么就可以避免出现 Java 中最常见的异常——NullPointerException。真的吗?事情并没有那么简单。

当需要将 Kotlin 代码和 Java 代码(库是用 Java 编写的,所以我猜经常会发生这种情况)混在一起时,事情就会变得很糟糕。于是,出现了第三种类型 T!。它被称为平台类型,代表 T 或 T?。或者更确切地说,T! 表示未定义可空性的 T。这种奇怪的类型无法在 Kotlin 中表示,只能从 Java 类型推断出来。T! 可能会误导你,因为它对空值放松了警惕,并禁用了 Kotlin 的 null 安全。

比如下面的 Java 方法:

public class Utils {
    static String format(String text) {
        return text.isEmpty() ? null : text;
    }
}

现在,你想在 Kotlin 中调用 format(String),那么应该使用哪种类型来使用此 Java 方法返回的结果?你有三个选择。

第一种方法,你可以使用 String,代码看起来很安全,但可能抛出 NPE。

fun doSth(text: String) {
    val f: String = Utils.format(text)// compiles but assignment can throw NPE at runtime
    println ("f.len : " + f.length)
}

你需要这样来解决这个问题:

fun doSth(text: String) {
    val f: String = Utils.format(text) ?: "" // safe with Elvis
    println ("f.len : " + f.length)
}

第二种方法,你可以使用 String?,这样就是 null 安全的:

fun doSth(text: String) {
    val f: String? = Utils.format(text)   // safe
    println ("f.len : " + f.length)       // compilation error, fine
    println ("f.len : " + f?.length)      // null-safe with ? operator
}

第三种方法,让 Kotlin 进行局部变量类型推断:

fun doSth(text: String) {
    val f = Utils.format(text)       // f type inferred as String!
    println ("f.len : " + f.length)  // compiles but can throw NPE at runtime
}

这段 Kotlin 代码看起来很安全,可以通过编译,但仍然会出现未检查的空值,就像在 Java 中那样。

还有一招,使用!! 操作符来强制推断 f 类型为 String:

fun doSth(text: String) {
    val f = Utils.format(text)!! // throws NPE when format() returns null
    println ("f.len : " + f.length)       
}

在我看来,Kotlin 类型系统中的!、? 和!! 太过复杂了。为什么 Kotlin 将 Java T 推断为 T! 而不是 T? 呢?Java 互操作性似乎损害了 Kotlin 的类型推断特性。看起来,我们似乎应该为所有通过 Java 方法赋值的 Kotlin 变量显式声明类型(如 T?)。

类字面量

在使用 Log4j 或 Gson 这些 Java 库时,经常会用到类字面量。

在 Java 中,我们在类名后面加上.class 后缀:

Gson gson = new GsonBuilder().registerTypeAdapter(LocalDate.class, new LocalDateAdapter()).create();

在 Groovy 中,类字面量被简化了,我们可以省略.class,不管它是 Groovy 类还是 Java 类:

def gson = new GsonBuilder().registerTypeAdapter(LocalDate, new LocalDateAdapter()).create()

而 Kotlin 则会区分 Kotlin 和 Java 类,并提供了语法规范:

val kotlinClass : KClass<LocalDate> = LocalDate::class
val javaClass : Class<LocalDate> = LocalDate::class.java

所以在 Kotlin 中,我们不得不这样写:

val gson = GsonBuilder().registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()).create()
反向类型声明

C 语言家族使用标准方法来声明类型。简单地说,就是先声明一个类型,然后指定其他部分(变量、字段、方法等)。

Java 中的标准表示法:

int inc(int i) {
    return i + 1;
}

Kotlin 中的反向表示法:

fun inc(i: Int): Int {
    return i + 1
}

这种方式令人感到讨厌,原因如下。

首先,我们需要在名称和类型之间键入冒号。这个额外字符的意义何在?为什么名称与它的类型要分隔开?我不知道。只能说,这让 Kotlin 更难用了。

其次,一般来说,在查看一个方法的声明时,我们会先看方法名和返回类型,然后再查看参数。

而在 Kotlin 中,方法的返回类型可能远在行尾,所以需要滚动到最后面:

private fun getMetricValue(kafkaTemplate : KafkaTemplate<String, ByteArray>, metricName : String) : Double {
    ...
}

或者,如果参数按照行进行了格式化,则可能需要通过搜索才能找到返回类型。你需要花多少时间才能找到此方法的返回类型?

@Bean
fun kafkaTemplate(
        @Value("\${interactions.kafka.bootstrap-servers-dc1}") bootstrapServersDc1: String,
        @Value("\${interactions.kafka.bootstrap-servers-dc2}") bootstrapServersDc2: String,
        cloudMetadata: CloudMetadata,
        @Value("\${interactions.kafka.batch-size}") batchSize: Int,
        @Value("\${interactions.kafka.linger-ms}") lingerMs: Int,
        metricRegistry : MetricRegistry
): KafkaTemplate<String, ByteArray> {

    val bootstrapServer = if (cloudMetadata.datacenter == "dc1") {
        bootstrapServersDc1
    }
    ...
}

反向声明的第三个问题,IDE 对它的自动完成支持得不是很好。在标准的表示法中,可以很容易地根据类型名找到类型。在选定了类型后,IDE 会提供一系列候选变量名,这些变量名是从选定的类型派生出来的,所以你可以快速输入变量:

MongoExperimentsRepository repository

但即使是在 IntelliJ 中输入这个变量也是很费事的。如果你有多个 repository,则在自动完成列表中找不到正确的可选项,这意味需要手动输入完整的变量名。

repository : MongoExperimentsRepository

Companion 对象

一位 Java 程序员来到 Kotlin 面前。

“嗨,Kotlin。我是新来的,可以使用静态成员吗?“他问。

“不行,我是面向对象的,而静态成员不是面向对象的。“Kotlin 回答道。

“好吧,但我需要 MyClass 的 logger 对象,我该怎么办?”

“没问题,你可以使用 Companion 对象。”

“什么是 Companion 对象?”

“它是与类绑定的单例对象,可以把你的 logger 放在 Companion 对象中。“Kotlin 解释说“我懂了,是这样吗?”

class MyClass {
    companion object {
        val logger = LoggerFactory.getLogger(MyClass::class.java)
    }
}

“是的!”

“非常繁琐的语法,”程序员似乎感到困惑,“但没关系,现在我可以这样调用 logger——MyClass.logger,就像 Java 中的静态成员一样?”

“嗯……是的,但它不是一个静态成员!这里只有对象。你可以把它看作是已经实例化为单例对象的匿名内部类,但实际上这个类不是匿名的,它叫作 Companion,不过你可以忽略这个名字。是不是很简单?“

通过单例来声明对象的做法很管用,但是从语言中移除静态成员是不切实际的。在 Java 中,我们一直使用静态的 logger 对象。它只是一个 logger 而已,这个时候我们没有必要关心它是不是面向对象的,而且它并不会带来任何坏处。

有时候,我们必须使用 static,比如 public static void main() 仍然是启动 Java 应用程序的唯一方式。试着不使用谷歌搜索写出下面的 Companion 对象吧。

class AppRunner {
    companion object {
        @JvmStatic fun main(args: Array<String>) {
            SpringApplication.run(AppRunner::class.java, *args)
        }
    }
}
集合字面量

在 Java 中,初始化一个 List 需要很多代码:

import java.util.Arrays;
...
List<String> strings = Arrays.asList("Saab", "Volvo");

而初始化一个 Map 更加繁琐,所以很多人使用 Guava 来代替:

import com.google.common.collect.ImmutableMap;
...
Map<String, String> string = ImmutableMap.of("firstName", "John", "lastName", "Doe");

我们仍然在等待新的 Java 语法,可以简化集合和 Map 字面量的声明。而在其他很多语言中,已经有了便利的语法。

javascript

const list = ['Saab', 'Volvo']
const map = {'firstName': 'John', 'lastName' : 'Doe'}

Python:

list = ['Saab', 'Volvo']
map = {'firstName': 'John', 'lastName': 'Doe'}

Groovy:def list = ['Saab', 'Volvo']def map = ['firstName': 'John', 'lastName': 'Doe']

简单来说,整洁的集合字面量语法是我们对现代编程语言的期待,特别是如果这门语言是从头开始创建的。Kotlin 提供了一堆内置函数:listOf()、mutableListOf()、mapOf()、hashMapOf() 等。

Kotlin:

val list = listOf("Saab", "Volvo")
val map = mapOf("firstName" to "John", "lastName" to "Doe")

键和值通过 to 操作符配对,这样很好,但为什么不使用众所周知的冒号呢?

Maybe 语法

函数式语言(如 Haskell)没有空值,相反,它们提供了 Maybe monad(如果你对 monad 不熟悉,请阅读 Tomasz Nurkiewicz 的这篇文章 http://www.nurkiewicz.com/2016/06/functor-and-monad-examples-in-plain-java.html)。

在很早以前,Scala 就将 Maybe 语法引入到了 JVM 世界,也就是 Option,然后 Java 8 也推出了 Optional。现在,Optional 是处理 API 返回类型空值的一种非常流行的方式。

Kotlin 中没有 Optional,所以似乎应该用 Kotlin 的可空类型来代替。

通常情况下,当你有一个 Optional 时,想要进行一系列 null 安全的转换,并在最后处理 null。

例如,在 Java 中:

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

也许会有人说,在 Kotlin 可以使用 let 函数代替 map:

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

这样可以吗?可以的,但并没那么简单。上面的代码是错误的,parseInt() 会抛出 NPE。

只有当值存在时,monad 风格的 map() 才会被执行,null 会被忽略。可惜的是,Kotlin 的 let 函数与 map 不一样,它会从左侧调用所有的内容,包括 null。

所以,为了让代码变得 null 安全,必须在每个 let 前面添加?:

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

现在,比较 Java 和 Kotlin 版本的可读性,你更倾向哪个?

数据类

在实现 Value Object(也叫 DTO)时,Kotlin 使用数据类来减少样板代码,而在 Java 中,样板代码是不可避免的。

例如,在 Kotlin 中,你写了一个 Value Object:

data class User(val name: String, val age: Int)

Kotlin 负责生成 equals()、hashCode()、toString() 和 copy() 方法。

在实现简单的 DTO 时它非常有用,但请记住,数据类有严重的局限性——它们是 final 的。也就是说,我们无法扩展数据类或将其抽象化,所以你可能不会在核心领域模型中使用它们。

这个局限性不是 Kotlin 的错,因为我们没有办法在不违反替换原则的情况下正确生成基于值的 equals() 方法。这就是为什么 Kotlin 不允许继承数据类。

公开类

在 Kotlin 中,类默认是 final 的。如果想扩展一个类,必须添加 open 修饰符。

继承语法如下所示:

open class Base

class Derived : Base()

Kotlin 使用: 操作符代替 extends 关键字,还记得吗,这个操作符已经用于分隔变量名与类型。难道我们又回到了 C++ 语法?

颇具争议的是,在默认情况下,类是 final 的。但我们生活在一个满是框架的世界,而框架喜欢使用 AOP。 Spring 使用库(cglib、jassist)为 bean 生成动态代理,Hibernate 通过扩展实体类来实现延迟加载。

如果你使用 Spring,那么就有两种选择。你可以在所有的 bean 类前面加上 open(这很枯燥),或者使用这个编译器插件:

buildscript {
    dependencies {
        classpath group: 'org.jetbrains.kotlin', name: 'kotlin-allopen', version: "$versions.kotlin"
    }
}
陡峭的学习曲线

如果你认为你可以快速学习 Kotlin,因为你已经学过 Java,那么你错了。Kotlin 会让你陷入深渊。事实上,Kotlin 的语法更接近 Scala。你将不得不忘记 Java,切换到一个完全不同的语言。

相反,学习 Groovy 是一趟愉快的旅程。Java 代码与 Groovy 代码相得益彰,因此你可以从将.java 文件扩展名改为.groovy 开始。

最后的想法

学习新技术就像投资,我们投入时间,然后应该得到回报。我不是说 Kotlin 是一种糟糕的语言,但在我们的案例中,成本超过了收益。

  原文链接

https://allegro.tech/2018/05/From-Java-to-Kotlin-and-Back-Again.html

前端之巅

「前端之巅」是 InfoQ 旗下关注大前端技术的垂直社群。紧跟时代潮流,共享一线技术,欢迎关注。

  活动推荐

8月18日,我们将在一场面向技术人的区块链大会,大会关注目前区块链领域前沿技术与落地应用,将邀请国内外一线技术专家交流分享,和你一起探索区块链技术的更多可能!目前大会6折最后一周,火热招募中!扫描二维码进入官网查看大会议题。


以上是关于我们与Kotlin的故事:从尝试到放弃的主要内容,如果未能解决你的问题,请参考以下文章

OkHttpInterceptor 从 kotlin 拦截器导航到登录片段

使用绑定从片段访问父活动的 UI 元素

如何在kotlin中从一个片段转到另一个片段?

五分钟学会 Kotlin 语法

使用导航从工具栏菜单项单击在片段之间传递数据 - Kotlin

使用 Kotlin 更改片段中的按钮背景