50

从 Java 到 Kotlin,再从 Kotlin 回归 Java

 5 years ago
source link: https://www.oschina.net/translate/from-java-to-kotlin-and-back-again?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

由于此博客文章引起高度关注和争议,我们认为值得在Allegro上增加一些关于我们如何工作和做出决策的背景。Allegro拥有超过50个开发团队可以自由选择被我们PaaS所支持的技术。我们主要使用Java、Kotlin、Python和Golang进行编码。本文中提出的观点来自作者的经验。

Kotlin很流行,Kotlin很时髦。Kotlin为你提供了编译时null-safety和更少的boilerplate。当然,它比Java更好。你应该切换到Kotlin或作为码农遗老直到死亡。等等,或者你不应该如此?在开始使用Kotlin编写之前,请阅读一个项目的故事。关于奇技和障碍的故事变得如此令人讨厌,因此我们决定重写之。

我们尝试过Kotlin,但现在我们正在用Java10重写

我有我最喜欢的JVM语言集。Java的/main和Groovy的/test对我来说是组好的组合。2017年夏季,我的团队开始了一个新的微服务项目,我们就像往常一样谈论了语言和技术。在Allegro有几个支持Kotlin的团队,而且我们也想尝试新的东西,所以我们决定试试Kotlin。由于Kotlin中没有 Spock 的替代品,我们决定继续在/test中使用Groovy( Spek 没有Spock好用)。在2018年的冬天,每天与Kotlin相伴的几个月后,我们总结出了正反两面,并且得出Kotlin使我们的生产力下降的结论。我们开始用Java重写这个微服务。

这有几个原因:

名称遮掩

这是 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"

它曾经是胜过Java的真正优点。我故意说曾经是,因为——有个好消息——Java10 已经有这个功能了,并且Java10现在已经可以使用了。

Java10 中的类型涂端:

var a = "10";

公平的说,我需要补充一点,Kotlin在这个领域仍然略胜一筹。你也可以在其他上下文中使用类型推断,例如,单行方法。

更多关于Java10 中的 局部变量类型推断

编译时空值安全

Null-safe 类型是Kotlin的杀手级特征。 这个想法很好。 在Kotlin,类型是默认的非空值。 如果您需要一个可空类型,您需要添加?符号, 例如:

val a: String? = null      // ok

val b: String = null       // 编译错误

如果您在没有空检查的情况下使用可空变量,那么Kotlin将无法编译,例如:

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

一旦你有了这两种类型, non-nullable  T  和nullable  T?, 您可以忘记Java中最常见的异常——NullPointerException。 真的吗? 不幸的是,事情并不是那么简单。

当您的Kotlin代码必须与Java代码一起使用时,事情就变得很糟糕了(库是用Java编写的,所以我猜它经常发生)。 然后,第三种类型就跳出来了——T! 它被称为平台类型,它的意思是T或T?, 或者如果我们想要精确,T! 意味着具有未定义空值的 T类型 这种奇怪的类型不能用Kotlin来表示,它只能从Java类型推断出来。 T! 会误导你,因为它放松了对空的限制,并禁用了Kotlin的空值安全限制。

看看下面的Java方法:

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

现在,您想要从Kotlin调用format(string)。 您应该使用哪种类型来使用这个Java方法的结果? 好吧,你有三个选择。

第一种方法。 你可以使用字符串,代码看起来很安全,但是会抛出空指针异常。

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) ?: ""  // 
    println ("f.len : " + f.length)
}

第二种方法。 您可以使用String?, 然后你的程序就是空值安全的了。

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 的类型系统中所有这些类似scala的东西!,?和!!,实在是 太复杂了。 为什么Kotlin从Java的T类型推断到T! 而不是T?呢? 似乎Java互操作性破坏了Kotlin的杀手特性——类型推断。 看起来您应该显式地声明类型(如T?),以满足由Java方法填充的所有Kotlin变量。

字面量

在使用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 这么优秀的 IDE 中为 Kotlin 输入这样的变量名也十分不易。如果代码中存在很多 Repository,就很难在自动完成列表中找到匹配的那一个。换句话说,你得手工输入完整的变量名。

repository : MongoExperimentsRepository

伴生对象

一个 Java 程序员来到 Kotlin 阵营。

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

“没有。我是面向对象的,而静态成员不是面向对象的,” Kotlin回答。

“好吧,但我需要用于 MyClass 日志记录器,该怎么办?”

“没问题,可以使用伴生对象。” 

“伴生对象是什么鬼?”

“它是与类绑定的一个单例对象。你可以把日志记录器放在伴生对象中,” Kotlin 如此解释。

“明白了。是这样吗?”

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

“对!“

“好麻烦的语法,”这个程序看起来有些疑惑,“不过还好,现在我可以像这样——MyClass.logger——调用日志记录了吗?就像在 Java 中使用静态成员那样?”

“嗯……是的,但是它不是静态成员!它只是一个对象。可以想像那是一个匿名内部类的单例实现。而实际上,这个类并不是匿名的,它的名字是 Companion,你可以省略这个名称。明白吗?这很简单。”

我很喜欢 对象声明 的概念——单例是种很有用的模式。从从语言中去掉静态成员就不太现实了。我们在Java中已经使用了若干年的静态日志记录器,这是非常经典的模式。因为它只是一个日志记录器,所以我们并不关心它是否是纯粹的面向对象。只要它起作用,而且不会造成损害就好。

有时候,我们 必须 使用静态成员。古老而友好的 public static void main() 仍然是启动 Java 应用的唯一方式。在没有Google的帮助下尝试着写出这个伴生对象。

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

集合字面量

Java 中初始化列表需要大量的模板代码:

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 产生新语法来简化集合和映射表的字面表达。这样的语法在很多语言中都自然而便捷。

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)没有空(null)。它们提供 Maybe Monad(如果你不清楚 Monad,请阅读这篇由  Tomasz Nurkiewicz 撰写 文章 )。

在很久以前,Scala 就将 Maybe 作为 Option 引入 JVM 世界,然后在 Java 8 中被采用,成为 Optional。现在 Optional 广泛应用于 API 边界,用于处理可能含空值的返回类型。

Kotlin 中并没有与 Optional 等价的东西。看起来你应该使用 Kotlin 的可空类型封装。我们来研究一下这个问题。

通常,在使用 Optional 时,你会先进行一系列空安全的转换,最后来处理空值。

比如在 Java 中:

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

在 Kotlin 中也没问题,使用 let 功能:

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

可以吗?是的,但并不是这么简单。上面的代码可能会出错,从 parseInt() 中抛出 NPE。只有值存在的时候才能执行 Monad 风格的 map(),否则,null 只会简单的传递下去。这就是 map() 方便的原因。然后不幸的是,Kotlin 的 let 并不是这样工作的。它只是从左往右简单地执行调用,不在乎是否是空。

因此,要让这段代码对空安全,你必须在 let 前添加 ?:

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

现在,比如 Java 和 Kotlin 两个版本的可读性,你更喜欢哪一个?

想了解更多关于 Optional 的知识,可以阅读 Stephen Colebourne 的博客


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK