抛弃 Java 改用 Kotlin 的六个月后,我后悔了
正文
毫无疑问,Kotlin 目前很受欢迎,业界甚至有人认为其将取代 Java 的霸主地位。它提供了 Null 安全性,从这一点来说它确实比 Java 更好。那么是不是这就意味着开发者应该毫不犹豫地拥抱 Kotlin,否则就落伍了?
等等,或许事情并非如此。
在开始使用 Kotlin 编程之前,本文想要分享个故事给你。在这个故事中,作者最早使用 Kotlin 来编写一个项目,后来 Kotlin 的各种怪异模式以及一些其他障碍越来越让人厌烦,最终,他们决定重写这个项目。
以下为译文:
一直以来,我对基于 JVM 的语言都非常情有独钟。我通常会用 Java 来编写主程序,再用 Groovy 编写测试代码,两者配合使用得心应手。
2017年夏天,团队发起了一个新的微服务项目,和往常一样,我们需要对编程语言和技术进行选型。部分团队成员是 Kotlin 的拥护者,再加上我们都想尝试一下新的东西,于是我们决定用 Kotlin 来开发这个项目。由于 Spock 测试框架不支持 Kotlin,因此我们决定坚持使用 Groovy 来测试。
2018年春天,使用 Kotlin 开发几个月之后,我们总结了 Kotlin 的优缺点,最终结论表明 Kotlin 降低了我们的生产力。
于是我们使用 Java 来重写这个微服务项目。
那么 Kotlin 主要存在哪些弊端?下面来一一解释。
名称遮蔽
这是 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,因为作用域的原因 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 团队试图通过向每个遮蔽变量显示警告信息来解决这个问题。两个团队在同一家公司工作,或许他们可以互相交流并就遮蔽问题达成共识。我从个人角度赞成 IDEA 的做法因为我想不到有哪些应用场景需要遮蔽方法参数。
类型推断
在Kotlin中,当你声明一个var或是val,你通常会让编译器从右边的表达式类型中猜测变量类型。我们称之为局部变量类型推断,这对程序员来说是一个很大的改进。它允许我们在不影响静态类型检查的情况下简化代码。
例如,这个Kotlin代码:
var a = "10"
Kotlin 编译器会将其翻译成:
var a : String = "10"
Java 同样具备这个特性,Java 10中的类型推断示例如下:
var a = "10";
实话实说,Kotlin 在这一点上确实更胜一筹。当然,类型推断还可应用在多个场景。关于 Java 10中的局部变量类型推断,点击以下链接了解更多:
https://medium.com/@afinlay/java-10-sneak-peek-local-variable-type-inference-var-3022016e1a2b
Null 安全类型
Null 安全类型是 Kotlin 的杀手级功能。
这个想法很好,在 Kotlin 中,类型默认不可为空。如果你需要添加一个可为空的类型,可以像下列代码这样:
val a: String? = null // ok
val b: String = null // compilation error
假设你使用了可为空的变量但是并未进行空值检查,这在 Kotlin 将无法通过编译,比如:
println (a.length) // compilation error
println (a?.length) // fine, prints null
println (a?.length ?: 0) // fine, prints 0
那么是不是如果你同时拥有不可为空和可为空的变量,就可以避免 Java 中最常见的 NullPointerException 异常吗?事实并没有想象的简单。
当 Kotlin 代码必须调用 Java 代码时,事情会变得很糟糕,比如库是用 Java 编写的,我相信这种情况很常见。于是第三种类型产生了,它被称为平台类型。Kotlin 无法表示这种奇怪的类型,它只能从 Java 类型推断出来。它可能会误导你,因为它对空值很宽松,并且会禁用 Kotlin 的 NULL 安全机制。
看看下面这个 Java 方法:
public class Utils {
static String format(String text) {
return text.isEmpty() ? null : text;
}
}
假如你想调用 format(String)。应该使用哪种类型来获得这个 Java 方法的结果呢?你有三个选择。
第一种方法:你可以使用 String,代码看起来很安全,但是会抛出 NullPointerException 异常。
fun doSth(text: String) {
val f: String = Utils.format(text) // compiles but assignment can throw NPE at runtime
println ("f.len : " + f.length)
}
那你就需要用 Elvis 来解决这个问题:
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 的所有这些类似 scala 的类型系统过于复杂。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
Kotlin 尽管有 IntelliJ 这样强大的 IDE,输入变量仍然是很难的。如果你有多个存储库,在列表中很难实现正确的自动补全,这意味着你不得不手动输入完整的变量名称。
repository : MongoExperimentsRepository
伴生对象
一位 Java 程序员来到 Kotlin 面前。
“嗨,Kotlin。我是新来的,我可以使用静态成员吗?"他问。
“不行。我是面向对象的,静态成员不是面向对象的。” Kotlin 回答。
“好吧,但我需要 MyClass 的 logger,我该怎么办?”
“这个没问题,使用伴生对象即可。”
“那是什么东西?” “这是局限到你的类的单独对象。把你的 logger 放在伴生对象中。”Kotlin解释说。
“我懂了。这样对吗?”
class MyClass {
companion object {
val logger = LoggerFactory.getLogger(MyClass::class.java)
}
}
“正确!”
“很详细的语法,”程序员看起来很疑惑,“但是没关系,现在我可以像 MyClass.logger 这样调用我的 logger,就像 Java 中的一个静态成员?”
“嗯......是的,但它不是静态成员!这里只有对象。把它看作是已经实例化为单例的匿名内部类。事实上,这个类并不是匿名的,它的名字是 Companion,但你可以省略这个名字。看到了吗?这很简单。"
我很欣赏对象声明的概念——单例很有用。但从语言中删除静态成员是不切实际的。在 Java 中我们使用静态 Logger 很经典,它只是一个 Logger,所以我们不关心面向对象的纯度。它能够工作,从来没有任何坏处。
因为有时候你必须使用静态。旧版本 public static void main() 仍然是启动 Java 应用程序的唯一方式。
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");
初始化地图非常冗长,很多人使用 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)没有空值。相反,他们提供 Maybe monad(如果你不熟悉monad,请阅读 Tomasz Nurkiewicz 的这篇文章:http://www.nurkiewicz.com/2016/06/functor-and-monad-examples-in-plain-java.html)。
Maybe 很久以前就被 Scala 以 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 。map() 仅在有值时执行。否则,Null 就会跳过,这就是为什么 map() 如此方便。不幸的是,Kotlin 的 let 不会那样工作。它从左侧的所有内容中调用,包括空值。
为了保证这个代码 Null 安全,你必须在每个代码之前添加 let:
fun parseAndInc(number: String?): Int {
return number?.let { Integer.parseInt(it) }
?.let { it -> it + 1 } ?: 0
}
现在,比较 Java 和 Kotlin 版本的可读性。你更倾向哪个?
数据类
数据类是 Kotlin 在实现 Value Objects 时使用的方法,以减少 Java 中不可避免的样板问题。
例如,在 Kotlin 中,你只写一个 Value Object :
data class User(val name: String, val age: Int)
Kotlin 对 equals()、hashCode()、toString() 以及 copy() 有很好的实现。在实现简单的DTO 时它非常有用。但请记住,数据类带有严重的局限性。你无法扩展数据类或者将其抽象化,所以你可能不会在核心模型中使用它们。
这个限制不是 Kotlin 的错。在 equals() 没有违反 Liskov 原则的情况下,没有办法产生正确的基于价值的数据。
这也是为什么 Kotlin 不允许数据类继承的原因。
开放类
Kotlin 类默认为 final。如果你想扩展一个类,必须添加 open 修饰符。
继承语法如下所示:
open class Base
class Derived : Base()
Kotlin 将 extends 关键字更改为: 运算符,该运算符用于将变量名称与其类型分开。那么再回到 C ++语法?对我来说这很混乱。
这里有争议的是,默认情况下类是 final。也许 Java 程序员过度使用继承,也许应该在考虑扩展类之前考虑三次。但我们生活在框架世界,Spring 使用 cglib、jassist 库为你的 bean 生成动态代理。Hibernate 扩展你的实体以启用延迟加载。
如果你使用 Spring,你有两种选择。你可以在所有 bean 类的前面添加 open,或者使用这个编译器插件:
buildscript {
dependencies {
classpath group: 'org.jetbrains.kotlin', name: 'kotlin-allopen', version: "$versions.kotlin"
}
}
陡峭的学习曲线
如果你认为自己有 Java 基础就可以快速学习 Kotlin,那你就错了。Kotlin 会让你陷入深渊,事实上,Kotlin 的语法更接近 Scala。这是一项赌注,你将不得不忘记 Java 并切换到完全不同的语言。
相反,学习 Groovy 是一个愉快的过程。Java 代码是正确的 Groovy 代码,因此你可以通过将文件扩展名从 .java 更改为 .groovy。
最后的想法
学习新技术就像一项投资。我们投入时间,新技术让我们得到回报。但我并不是说 Kotlin 是一种糟糕的语言,只是在我们的案例中,成本远超收益。
以上内容编译自 From Java to Kotlin and Back Again,作者 Kotlin ketckup。
他是一名具有15年以上专业经验的软件工程师,专注于JVM 。在 Allegro,他是一名开发团队负责人,JaVers 项目负责人,Spock 倡导者。此外,他还是 allegro.tech/blog 的主编。
本文一出就引发了业内的广泛争议,Kotlin 语言拥护者 Márton Braun 就表示了强烈的反对。
Márton Braun 十分喜欢 Kotlin 编程,目前他在 StackOverflow 上 Kotlin 标签的最高用户列表中排名第三,并且是两个开源 Kotlin 库的创建者,最著名的是 MaterialDrawerKt。此外他还是 Autosoft 的 Android 开发人员,目前正在布达佩斯技术经济大学攻读计算机工程硕士学位。
以下就是他针对上文的反驳:
当我第一次看到这篇文章时,我就想把它转发出来看看大家会怎么想,我肯定它会是一个有争议的话题。后来我读了这篇文章,果然证明了它是一种主观的、不真实的、甚至有些居高临下的偏见。
有些人已经在原贴下进行了合理的批评,对此我也想表达一下自己的看法。
名称遮蔽
“IDEA 团队”(或者 Kotlin 插件团队)和“Kotlin 团队”肯定是同样的人,我从不认为内部冲突会是个好事。语言提供这个功能给你,你需要的话就使用,如果讨厌,调整检查设置就是了。
类型推断
Kotlin 的类型推断无处不在,作者说的 Java 10 同样可以简直是在开玩笑。
Kotlin 的方式超越了推断局部变量类型或返回表达式体的函数类型。这里介绍的这两个例子是那些刚刚看过关于 Kotlin 的第一次介绍性讲话的人会提到的,而不是那些花了半年学习该语言的人。
例如,你怎么能不提 Kotlin 推断泛型类型参数的方式?这不是 Kotlin 的一次性功能,它深深融入了整个语言。
编译时 Null 安全
这个批评是对的,当你与 Java 代码进行互操作时,Null 安全性确实被破坏了。该语言背后的团队曾多次声明,他们最初试图使 Java 可为空的每种类型,但他们发现它实际上让代码变得更糟糕。
Kotlin 不比 Java 更差,你只需要注意使用给定库的方式,就像在 Java 中使用它一样,因为它并没有不去考虑 Null 安全。如果 Java 库关心 Null 安全性,则它们会有许多支持注释可供添加。
也许可以添加一个编译器标志,使每种 Java 类型都可以为空,但这对 Kotlin 团队来说不得不花费大量额外资源。
类名称字面常量
:: class 为你提供了一个 KClass 实例,以便与 Kotlin 自己的反射 API 一起使用,而:: class.java为你提供了用于 Java 反射的常规 Java 类实例。
反向类型声明
为了清楚起见,颠倒的顺序是存在的,这样你就可以以合理的方式省略显式类型。冒号只是语法,这在现代语言中是相当普遍的一种,比如 Scala、Swift 等。
我不知道作者在使用什么 IntelliJ,但我使用的变量名称和类型都能够自动补全。对于参数,IntelliJ 甚至会给你提供相同类型的名称和类型的建议,这实际上比 Java 更好。
伴生对象
原文中说:
有时候你必须使用静态。旧版本 public static void main() 仍然是启动 Java 应用程序的唯一方式。
class AppRunner {
companion object {
@JvmStatic fun main(args: Array<String>) {
SpringApplication.run(AppRunner::class.java, *args)
}
}
}
实际上,这不是启动 Java 应用程序的唯一方式。你可以这样做:
fun main(args:Array <String>){ SpringApplication.run(AppRunner :: class.java,* args)}
或者这样:
fun main(args:Array <String>){ runApplication <AppRunner>(* args)}
集合字面量
你可以在注释中使用数组文字。但是,除此之外,这些集合工厂的功能非常简洁,而且它们是另一种“内置”到该语言的东西,而它们实际上只是库函数。
你只是抱怨使用:进行类型声明。而且,为了获得它不必是单独的语言结构的好处,它只是一个任何人都可以实现的功能。
Maybe
如果你喜欢 Optional ,你可以使用它。Kotlin 在 JVM 上运行。
对于代码确实这有些难看。但是你不应该在 Kotlin 代码中使用 parseInt,而应该这样做(我不知道你使用该语言的 6 个月中为何错过这个)。你为什么要明确地命名一个 Lambda 参数呢?
数据类
原文中说:
这个限制不是 Kotlin 的错。在 equals() 没有违反 Liskov 原则的情况下,没有办法产生正确的基于价值的数据。
这就是为什么 Kotlin 不允许数据类继承的原因。
我不知道你为什么提出这个问题。如果你需要更复杂的类,你仍然可以创建它们并手动维护它们的 equals、hashCode 等方法。数据类仅仅是一个简单用例的便捷方式,对于很多人来说这很常见。
公开类
作者再次鄙视了,对此我实在无话可说。
陡峭的学习曲线
作者认为学习 Kotlin 很难, 但是我个人并不这么认为。
最后的想法
从作者列举的例子中,我感觉他只是了解语言的表面。
很难想象他对此有投入很多时间。
原文:
https://allegro.tech/2018/05/From-Java-to-Kotlin-and-Back-Again.html
https://zsmb.co/on-from-java-to-kotlin-and-back-again/
译者:安翔,责编:郭芮
回复 【idea激活】即可获得idea的激活方式
回复 【Java】获取java相关的视频教程和资料
回复 【SpringCloud】获取SpringCloud相关多的学习资料
回复 【python】获取全套0基础Python知识手册
回复 【2020】获取2020java相关面试题教程
回复 【加群】即可加入终端研发部相关的技术交流群
阅读更多
如何准备一份完美的Java简历呢?
你真的了解 volatile 关键字吗?
Java 强、弱、软、虚,你属于哪一种?
图解 Java 垃圾回收机制,写得非常好!
不要重复发明轮子!
队友代码神注释,ORZ 留下两行泪
你还在 select * 吗?
相信自己,没有做不到的,只有想不到的
在这里获得的不仅仅是技术!
喜欢就给个“在看”