最近Java与kotlin语言之争又有点小热,大概是因为某位当初吹捧Java的大神来华兜售其kotlin新书有关,但是与此同时相反观点也是不断涌现,Allegro团队就在他们的博客发表这篇文章,从Java到Kotlin,然后又回到Java的"折腾"过程。
我们过去尝试Kotlin,但现在我们正在用Java 10重写。
我有我最喜欢的一组JVM语言:Java是主打,Groovy用于测试,这是表现最好的二人组合。2017年夏季,我的团队开始了一个新的微服务项目,和往常一样,我们谈论了语言和技术。在Allegro有几个已经采用了Kotlin的团队,我们想尝试新兴事物,所以我们决定这次试一下Kotlin。由于Kotlin没有对应的Spock框架,我们决定还是使用Groovy /test (Spek不如Spock)。在2018年采取Kotlin几个月后,我们总结了正反优缺点,并得出Kotlin反而使我们的生产力下降的结论。我们开始用Java将这个微服务重写。
这是为什么呢?
从以下几个方面谈原因:
Name shadowing名称遮蔽
这是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的真正优势。我故意说是因为 - Java 10也已经有了这个功能,而且现在Java 10已经可以使用了。
Java 10中的类型推断:
var a = "10";
公平地说,我需要补充一点,Kotlin在这个领域仍然略胜一筹。您也可以在其他上下文中使用类型推断,例如,单行方法。
编译时空指针安全Null-safe
Null-safe类型是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
一旦你有这两种类型,不可为空T和可为空T?,你可以远离甚至忘记Java中最常见的空指针异常--NullPointerException。真的吗?不幸的是,事情并不这么简单。
当你的Kotlin代码必须与Java代码相处时,事情会变得很糟糕(库是用Java编写的,所以我猜这种情况经常会发生)。然后,第三种类型跳入 - T!。它被称为平台类型,那么它到底意味着T或T?呢。或者,如果我们想要精确,T!意味着T具有未定义的可空性。这种奇怪的类型不能在Kotlin中表示,它只能从Java类型推断出来。 T!却可能会误导你,因为它对空值放松并失效Kotlin的Null-safe。
考虑下面的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) // 会通过编译但是运行时会抛NPE
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-safe:
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的杀手功能 - 类型推断。看起来你应该为所有Kotlin会导入的Java变量显式声明类型为T?。
类字面量Class literals
使用类似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::class
val javaClass : Class = 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, 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 {
val bootstrapServer = if (cloudMetadata.datacenter == "dc1") {
bootstrapServersDc1
}
...
}
反转符号的第三个问题是IDE的自动完成得不好。在标准符号中,您从类型名称开始,并且很容易找到类型。一旦你选择了一个类型,一个IDE会给你提供一些关于变量名的建议,这些变量名是从选定的类型派生的 所以你可以快速输入这样的变量:
MongoExperimentsRepository repository
在Kotlin中输入这个变量在IntelliJ中是很难的,而这是有史以来最伟大的IDE。如果您有多个存储库,则在自动完成列表中找不到正确的选项。这意味着必须使用手工输入完整的变量名称。
一位Java程序员来到Kotlin。
“嗨,Kotlin。我是新来的,我可以使用静态成员吗?“他问。
“不。我是面向对象的,静态成员不是面向对象的,“Kotlin回答。
“好吧,但我需要logger的MyClass,我该怎么办?”
“没问题,用Companion object呢。”
“哪个是Companion object吗?”
“这是单独的对象,绑定到你的类。把你的logger放到Companion object中就可以了,“Kotlin解释说。
“我懂了。这样对吗?”
class MyClass {
companion object {
val logger = LoggerFactory.getLogger(MyClass::class.java)
}
}
“是的!”
“很详细的语法,”程序员看起来很疑惑,“但是没关系,现在我可以像这样调用我的logger - MyClass.logger就像Java中的一个静态成员?”
“嗯......是的,但它不是静态成员!这里只有对象。把它看作是已经实例化为单例的匿名内部类。事实上,这个类并不是匿名的,它的名字是Companion,但你可以省略这个名字。看到吗?这很简单。“
我很欣赏对象声明的概念 - 单例很有用。但是从语言中删除静态成员是不切实际的。在Java中,我们使用静态logger多年。这很经典。它只是一个记录器,所以我们不关心面向对象的纯度。它的工作原理并不会造成没有任何问题。
有时候,你必须使用静态。旧版本public static void main()仍然是启动Java应用程序的唯一方式。试着写这companion object的咒语,不要使用谷歌搜索。
class AppRunner {
companion object {
@JvmStatic fun main(args: Array) {
SpringApplication.run(AppRunner::class.java, *args)
}
}
}
集合字面量Collection literals
在Java中,初始化列表需要很多仪式:
import java.util.Arrays;
...
List strings = Arrays.asList("Saab", "Volvo");
初始化过程非常冗长,很多人使用Guava:
import com.google.common.collect.ImmutableMap;
...
Map 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")
在mao中,键和值与to运算符配对,这很好,但为什么不使用众所周知的符号“:”呢,令人失望。
Maybe?不
函数式语言(如Haskell)没有空值。相反,他们提供Maybe monad(如果你不熟悉单子,请阅读Tomasz Nurkiewicz的文章)。
Maybe很久以前就被Scala引入JVM世界作为Option,然后在Java 8中被采用为Optional。现在,Maybe是在API边界处理返回类型中的空值的非常流行的方式。
Kotlin中没有可选的等价物。看来你应该使用裸Kotlin的可空类型。让我们来调查这个问题。
通常情况下,当你有一个Optional的时候,你想要应用一系列无效的转换,并在和处理null。
例如,在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。monadic风格map()仅在值存在时执行。否则,null就会通过。这就是为什么map()这么方便。不幸的是,Kotlin's let不会那样工作。它只是从左侧的所有内容中调用,包括空值。
所以为了使这个代码无效,你必须let在每个代码之前添加问号“?”:
fun parseAndInc(number: String?): Int {
return number?.let { Integer.parseInt(it) }
?.let { it -> it + 1 } ?: 0
}
现在,比较Java和Kotlin版本的可读性。你更倾向哪个?
数据类
数据类 是Kotlin在实现值对象Value Objects(又名DTO)时减少Java中不可避免的僵化样板仪式的有效方法。
例如,在Kotlin中,你只写了一个Value Object很简单:
data class User(val name: String, val age: Int)
而Kotlin 会产生良好的实施方法:equals(),hashCode(),toString(),和copy()。
在实现简单的DTO时它非常有用。但请记住,数据类带有严重的局限性 - 它们是final的。您无法扩展Data类或将其抽象化。所以,你可能不会在核心领域模型中使用它们。(banq注:DDD领域驱动设计中核心领域模型有值对象类型)
这个限制不是Kotlin的错。在equals()没有违反Liskov原则的情况下,没有办法产生正确的基于价值的数据。这就是为什么Kotlin不允许Data类继承的原因。
开放类
在Kotlin中,类默认是final的。如果你想扩展一个类,你必须添加open修饰符。
继承语法如下所示:
open class Base
class Derived : Base()
Kotlin将extends关键字更改为:运算符,该运算符已用于将变量名称与其类型分开。回到C ++语法?对我来说这很混乱。
这里有争议的是,默认情况下,class是final的。也许Java程序员会过度使用继承。也许你应该在考虑继承类之前考虑三次。但我们生活在框架世界,框架爱AOP。Spring使用库(cglib,jassist)为您的bean生成动态代理。Hibernate扩展你的实体以启用延迟加载。
如果你使用Spring,你有两种选择。你可以把open放在所有bean类的前面(这很枯燥),或者使用这个棘手的编译器插件:
buildscript {
dependencies {
classpath group: 'org.jetbrains.kotlin', name: 'kotlin-allopen', version: "$versions.kotlin"
}
}
陡峭的学习曲线
如果你认为你可以快速学习Kotlin,因为你已经掌握了Java - 那么你错了。kotlin会让你陷入深渊。事实上,Kotlin的语法更接近Scala。这是全押下注。你将不得不忘记Java并切换到完全不同的语言。
相反,学习Groovy是一个愉快的旅程。Groovy会牵着你的手。Java代码是正确的Groovy代码,因此您可以通过将文件扩展名从更改.java为.groovy。每次您学习新的Groovy功能时,您都可以决定。你喜欢它还是喜欢用Java方式?棒极了。
最后的想法
学习新技术就像投资。我们投入时间,然后从技术上得到应该得到回报。我不是说Kotlin是一种糟糕的语言。我只是说在我们的案例中,成本超过了收益。
请不要太认真,但我希望你会喜欢它。
网友评论