從Java到 Kotlin,再?gòu)腒otlin回歸Java
由于此博客文章引起高度關(guān)注和爭(zhēng)議,我們認(rèn)為值得在Allegro上增加一些關(guān)于我們?nèi)绾喂ぷ骱妥龀鰶Q策的背景。Allegro擁有超過(guò)50個(gè)開(kāi)發(fā)團(tuán)隊(duì)可以自由選擇被我們PaaS所支持的技術(shù)。我們主要使用Java、Kotlin、Python和Golang進(jìn)行編碼。本文中提出的觀點(diǎn)來(lái)自作者的經(jīng)驗(yàn)。
Kotlin很流行,Kotlin很時(shí)髦。Kotlin為你提供了編譯時(shí)null-safety和更少的boilerplate。當(dāng)然,它比Java更好。你應(yīng)該切換到Kotlin或作為碼農(nóng)遺老直到死亡。等等,或者你不應(yīng)該如此?在開(kāi)始使用Kotlin編寫(xiě)之前,請(qǐng)閱讀一個(gè)項(xiàng)目的故事。關(guān)于奇技和障礙的故事變得如此令人討厭,因此我們決定重寫(xiě)之。
我們嘗試過(guò)Kotlin,但現(xiàn)在我們正在用Java10重寫(xiě)
我有我最喜歡的JVM語(yǔ)言集。Java的/main和Groovy的/test對(duì)我來(lái)說(shuō)是組好的組合。2017年夏季,我的團(tuán)隊(duì)開(kāi)始了一個(gè)新的微服務(wù)項(xiàng)目,我們就像往常一樣談?wù)摿苏Z(yǔ)言和技術(shù)。在Allegro有幾個(gè)支持Kotlin的團(tuán)隊(duì),而且我們也想嘗試新的東西,所以我們決定試試Kotlin。由于Kotlin中沒(méi)有 Spock 的替代品,我們決定繼續(xù)在/test中使用Groovy( Spek 沒(méi)有Spock好用)。在2018年的冬天,每天與Kotlin相伴的幾個(gè)月后,我們總結(jié)出了正反兩面,并且得出Kotlin使我們的生產(chǎn)力下降的結(jié)論。我們開(kāi)始用Java重寫(xiě)這個(gè)微服務(wù)。
這有幾個(gè)原因:
- 名稱(chēng)遮蔽
- 類(lèi)型推斷
- 編譯時(shí)空指針安全
- 類(lèi)文字
- 反向類(lèi)型聲明
- 伴侶對(duì)象
- 集合文字
- 也許? 不
- 數(shù)據(jù)類(lèi)
- 公開(kāi)課
- 陡峭的學(xué)習(xí)曲線(xiàn)
名稱(chēng)遮掩
這是 Kotlin 讓我感到***驚喜的地方??纯催@個(gè)函數(shù):
- fun inc(num : Int) {
- val num = 2
- if (num > 0) {
- val num = 3
- }
- println ("num: " + num)
- }
當(dāng)你調(diào)用inc(1)的時(shí)候會(huì)輸出什么呢?在Kotlin中方法參數(shù)是一個(gè)值,所以你不能改變num參數(shù)。這是好的語(yǔ)言設(shè)計(jì),因?yàn)槟悴粦?yīng)該改變方法的參數(shù)。但是你可以用相同的名稱(chēng)定義另一個(gè)變量,并按照你想要的方式初始化。現(xiàn)在,在這個(gè)方法級(jí)別的范圍中你擁有兩個(gè)叫做num的變量。當(dāng)然,同一時(shí)間你只能訪(fǎng)問(wèn)其中一個(gè)num,所以num的值會(huì)改變。將軍,無(wú)解了。
在if主體中,你可以添加另一個(gè)num,這并不令人震驚(新的塊級(jí)別作用域)。
好的,在Kotlin中,inc(1)輸出2。但是在Java中,等效代碼將無(wú)法通過(guò)編譯。
- 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);
- }
名稱(chēng)遮蔽不是Kotlin發(fā)明的。這在編程語(yǔ)言中著很常見(jiàn)。在Java中,我們習(xí)慣用方法參數(shù)來(lái)遮蔽類(lèi)中的字段。
- public class Shadow {
- int val;
- public Shadow(int val) {
- this.val = val;
- }
- }
在Kotlin中,遮蔽有點(diǎn)過(guò)分了。當(dāng)然,這是Kotlin團(tuán)隊(duì)的一個(gè)設(shè)計(jì)缺陷。IDEA團(tuán)隊(duì)試圖把每一個(gè)遮蔽變量都通過(guò)簡(jiǎn)潔的警告來(lái)向你展示,以此修復(fù)這個(gè)問(wèn)題:Name shadowed。兩個(gè)團(tuán)隊(duì)都在同一家公司工作,所以或許他們可以相互交流并在遮蔽問(wèn)題上達(dá)成一致共識(shí)?我感覺(jué)——IDEA是對(duì)的。我無(wú)法想象存在這種遮蔽了方法參數(shù)的有效用例。
類(lèi)型推斷
在Kotlin中,當(dāng)你申明一個(gè)var或者val時(shí),你通常讓編譯器從右邊的表達(dá)式類(lèi)型中猜測(cè)變量類(lèi)型。我們將其稱(chēng)做局部變量類(lèi)型推斷,這對(duì)程序員來(lái)說(shuō)是一個(gè)很大的改進(jìn)。它允許我們?cè)诓挥绊戩o態(tài)類(lèi)型檢查的情況下簡(jiǎn)化代碼。
例如,這段Kotlin代碼:
- var a = "10"
將由Kotlin編譯器翻譯成:
- var a : String = "10"
它曾經(jīng)是勝過(guò)Java的真正優(yōu)點(diǎn)。我故意說(shuō)曾經(jīng)是,因?yàn)?mdash;—有個(gè)好消息——Java10 已經(jīng)有這個(gè)功能了,并且Java10現(xiàn)在已經(jīng)可以使用了。
Java10 中的類(lèi)型涂端:
- var a = "10";
公平的說(shuō),我需要補(bǔ)充一點(diǎn),Kotlin在這個(gè)領(lǐng)域仍然略勝一籌。你也可以在其他上下文中使用類(lèi)型推斷,例如,單行方法。
更多關(guān)于Java10 中的 局部變量類(lèi)型推斷 。
編譯時(shí)空值安全
Null-safe 類(lèi)型是Kotlin的殺手級(jí)特征。 這個(gè)想法很好。 在Kotlin,類(lèi)型是默認(rèn)的非空值。 如果您需要一個(gè)可空類(lèi)型,您需要添加?符號(hào), 例如:
- val a: String? = null // ok
- val b: String = null // 編譯錯(cuò)誤
如果您在沒(méi)有空檢查的情況下使用可空變量,那么Kotlin將無(wú)法編譯,例如:
- println (a.length) // compilation error
- println (a?.length) // fine, prints null
- println (a?.length ?: 0) // fine, prints 0
一旦你有了這兩種類(lèi)型, non-nullable T 和nullable T?, 您可以忘記Java中最常見(jiàn)的異常——NullPointerException。 真的嗎? 不幸的是,事情并不是那么簡(jiǎn)單。
當(dāng)您的Kotlin代碼必須與Java代碼一起使用時(shí),事情就變得很糟糕了(庫(kù)是用Java編寫(xiě)的,所以我猜它經(jīng)常發(fā)生)。 然后,第三種類(lèi)型就跳出來(lái)了——T! 它被稱(chēng)為平臺(tái)類(lèi)型,它的意思是T或T?, 或者如果我們想要精確,T! 意味著具有未定義空值的 T類(lèi)型 。 這種奇怪的類(lèi)型不能用Kotlin來(lái)表示,它只能從Java類(lèi)型推斷出來(lái)。 T! 會(huì)誤導(dǎo)你,因?yàn)樗潘闪藢?duì)空的限制,并禁用了Kotlin的空值安全限制。
看看下面的Java方法:
- public class Utils {
- static String format(String text) {
- return text.isEmpty() ? null : text;
- }
- }
現(xiàn)在,您想要從Kotlin調(diào)用format(string)。 您應(yīng)該使用哪種類(lèi)型來(lái)使用這個(gè)Java方法的結(jié)果? 好吧,你有三個(gè)選擇。
***種方法。 你可以使用字符串,代碼看起來(lái)很安全,但是會(huì)拋出空指針異常。
- fun doSth(text: String) {
- val f: String = Utils.format(text) // compiles but assignment can throw NPE at runtime
- println ("f.len : " + f.length)
- }
你需要用增加判斷來(lái)解決這個(gè)問(wèn)題:
- 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做了令人難以置信的局部變量類(lèi)型推斷呢?
- 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
- }
壞主意。 這個(gè)Kotlin的代碼看起來(lái)很安全,也可以編譯通過(guò),但是允許空值在你的代碼中不受約束的游走,就像在Java中一樣。
還有一個(gè)竅門(mén),!! 操作符。 使用它來(lái)強(qiáng)制推斷f類(lèi)型為String類(lèi)型:
- fun doSth(text: String) {
- val f = Utils.format(text)!! // throws NPE when format() returns null
- println ("f.len : " + f.length)
- }
在我看來(lái), Kotlin 的類(lèi)型系統(tǒng)中所有這些類(lèi)似scala的東西!,?和!!,實(shí)在是 太復(fù)雜了。 為什么Kotlin從Java的T類(lèi)型推斷到T! 而不是T?呢? 似乎Java互操作性破壞了Kotlin的殺手特性——類(lèi)型推斷。 看起來(lái)您應(yīng)該顯式地聲明類(lèi)型(如T?),以滿(mǎn)足由Java方法填充的所有Kotlin變量。
類(lèi) 字面量
在使用Log4j或Gson之類(lèi)的Java庫(kù)時(shí),類(lèi) 字面量 是很常見(jiàn)的。
在 Java 中,我們用.class后綴來(lái)寫(xiě)類(lèi)名:
- Gson gson = new GsonBuilder().registerTypeAdapter(LocalDate.class, new LocalDateAdapter()).create();
在 Groovy 中,類(lèi)字面量被簡(jiǎn)化為本質(zhì)。 你可以省略.class,不管它是Groovy還是Java類(lèi)都沒(méi)關(guān)系。
- def gson = new GsonBuilder().registerTypeAdapter(LocalDate, new LocalDateAdapter()).create()
Kotlin區(qū)分了Kotlin和Java類(lèi),并為其準(zhǔn)備了不同的語(yǔ)法形式:
- val kotlinClass : KClass<LocalDate> = LocalDate::class
- val javaClass : Class<LocalDate> = LocalDate::class.java
所以在 Kotlin ,你不得不寫(xiě):
- val gson = GsonBuilder().registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()).create()
這真是丑爆了。
相反順序的類(lèi)型聲明
在C系列編程語(yǔ)言中,有一個(gè)標(biāo)準(zhǔn)的聲明類(lèi)型的方式。即先寫(xiě)出類(lèi)型,再寫(xiě)出聲明為該類(lèi)型的東西(變量、字段、方法等)。
在 Java 中如下表示:
- int inc(int i) {
- return i + 1;
- }
在 Kotlin 中則是相反順序的表示:
- fun inc(i: Int): Int {
- return i + 1
- }
這讓人覺(jué)得惱火,因?yàn)椋?/p>
首先,你得書(shū)寫(xiě)或者閱讀介于名稱(chēng)和類(lèi)型之間那個(gè)討厭的冒號(hào)。這個(gè)多余的字母到底起什么作用?為什么要把名稱(chēng)和類(lèi)型 分隔開(kāi) ?我不知道。不過(guò)我知道這會(huì)加大使用Kotlin的難度。
第二個(gè)問(wèn)題。在閱讀一個(gè)方法聲明的時(shí)候,你***想知道的應(yīng)該是方法的名稱(chēng)和返回類(lèi)型,然后才會(huì)去了解參數(shù)。
在 Kotlin 中,方法的返回類(lèi)型遠(yuǎn)在行末,所以可能需要滾動(dòng)屏幕來(lái)閱讀:
- private fun getMetricValue(kafkaTemplate : KafkaTemplate<String, ByteArray>, metricName : String) : Double {
- ...
- }
另一種情況,如果參數(shù)是按分行的格式寫(xiě)出來(lái)的,你還得去尋找返回類(lèi)型。要在下面這個(gè)方法定義中找到返回類(lèi)型,你需要花多少時(shí)間?
- @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
- }
- ...
- }
關(guān)于相反順序的 第三個(gè)問(wèn)題 是限制了IDE的自動(dòng)完成功能。在標(biāo)準(zhǔn)順序中,因?yàn)槭菑念?lèi)型開(kāi)始,所以很容易找到類(lèi)型。一旦確定了類(lèi)型,IDE 就可以根據(jù)類(lèi)型給出一些與之相關(guān)的變量名稱(chēng)作為建議。這樣就可以快速輸入變量名,不像這樣:
- MongoExperimentsRepository repository
即時(shí)在 Intellij 這么優(yōu)秀的 IDE 中為 Kotlin 輸入這樣的變量名也十分不易。如果代碼中存在很多 Repository,就很難在自動(dòng)完成列表中找到匹配的那一個(gè)。換句話(huà)說(shuō),你得手工輸入完整的變量名。
- repository : MongoExperimentsRepository
伴生對(duì)象
一個(gè) Java 程序員來(lái)到 Kotlin 陣營(yíng)。
“嗨,Kotlin。我是新來(lái)的,有靜態(tài)成員可用嗎?”他問(wèn)。
“沒(méi)有。我是面向?qū)ο蟮?,而靜態(tài)成員不是面向?qū)ο蟮模?rdquo; Kotlin回答。
“好吧,但我需要用于 MyClass 日志記錄器,該怎么辦?”
“沒(méi)問(wèn)題,可以使用伴生對(duì)象。”
“伴生對(duì)象是什么鬼?”
“它是與類(lèi)綁定的一個(gè)單例對(duì)象。你可以把日志記錄器放在伴生對(duì)象中,” Kotlin 如此解釋。
“明白了。是這樣嗎?”
- class MyClass {
- companion object {
- val logger = LoggerFactory.getLogger(MyClass::class.java)
- }
- }
“對(duì)!“
“好麻煩的語(yǔ)法,”這個(gè)程序看起來(lái)有些疑惑,“不過(guò)還好,現(xiàn)在我可以像這樣——MyClass.logger——調(diào)用日志記錄了嗎?就像在 Java 中使用靜態(tài)成員那樣?”
“嗯……是的,但是它不是靜態(tài)成員!它只是一個(gè)對(duì)象。可以想像那是一個(gè)匿名內(nèi)部類(lèi)的單例實(shí)現(xiàn)。而實(shí)際上,這個(gè)類(lèi)并不是匿名的,它的名字是 Companion,你可以省略這個(gè)名稱(chēng)。明白嗎?這很簡(jiǎn)單。”
我很喜歡 對(duì)象聲明 的概念——單例是種很有用的模式。從從語(yǔ)言中去掉靜態(tài)成員就不太現(xiàn)實(shí)了。我們?cè)贘ava中已經(jīng)使用了若干年的靜態(tài)日志記錄器,這是非常經(jīng)典的模式。因?yàn)樗皇且粋€(gè)日志記錄器,所以我們并不關(guān)心它是否是純粹的面向?qū)ο蟆V灰鹱饔?,而且不?huì)造成損害就好。
有時(shí)候,我們 必須 使用靜態(tài)成員。古老而友好的 public static void main() 仍然是啟動(dòng) Java 應(yīng)用的唯一方式。在沒(méi)有Google的幫助下嘗試著寫(xiě)出這個(gè)伴生對(duì)象。
- 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");
我們?nèi)匀辉诘却?Java 產(chǎn)生新語(yǔ)法來(lái)簡(jiǎn)化集合和映射表的字面表達(dá)。這樣的語(yǔ)法在很多語(yǔ)言中都自然而便捷。
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']
簡(jiǎn)單來(lái)說(shuō),簡(jiǎn)潔的集合字面量語(yǔ)法在現(xiàn)代編程語(yǔ)言中倍受期待,尤其是初始化集合的時(shí)候。Kotlin 提供了一系列的內(nèi)建函數(shù)來(lái)代替集合字面量: listOf()、mutableListOf()、mapOf()、hashMapOf(),等等。
Kotlin:
- val list = listOf("Saab", "Volvo")
- val map = mapOf("firstName" to "John", "lastName" to "Doe")
映射表中的鍵和值通過(guò) to 運(yùn)算符關(guān)聯(lián)在一起,這很好,但是為什么不使用大家都熟悉的冒號(hào)(:)?真是令人失望!
Maybe?不
函數(shù)式編程語(yǔ)言(比如 Haskell)沒(méi)有空(null)。它們提供 Maybe Monad(如果你不清楚 Monad,請(qǐng)閱讀這篇由 Tomasz Nurkiewicz 撰寫(xiě) 文章 )。
在很久以前,Scala 就將 Maybe 作為 Option 引入 JVM 世界,然后在 Java 8 中被采用,成為 Optional。現(xiàn)在 Optional 廣泛應(yīng)用于 API 邊界,用于處理可能含空值的返回類(lèi)型。
Kotlin 中并沒(méi)有與 Optional 等價(jià)的東西。看起來(lái)你應(yīng)該使用 Kotlin 的可空類(lèi)型封裝。我們來(lái)研究一下這個(gè)問(wèn)題。
通常,在使用 Optional 時(shí),你會(huì)先進(jìn)行一系列空安全的轉(zhuǎn)換,***來(lái)處理空值。
比如在 Java 中:
- public int parseAndInc(String number) {
- return Optional.ofNullable(number)
- .map(Integer::parseInt)
- .map(it -> it + 1)
- .orElse(0);
- }
在 Kotlin 中也沒(méi)問(wèn)題,使用 let 功能:
- fun parseAndInc(number: String?): Int {
- return number.let { Integer.parseInt(it) }
- .let { it -> it + 1 } ?: 0
- }
可以嗎?是的,但并不是這么簡(jiǎn)單。上面的代碼可能會(huì)出錯(cuò),從 parseInt() 中拋出 NPE。只有值存在的時(shí)候才能執(zhí)行 Monad 風(fēng)格的 map(),否則,null 只會(huì)簡(jiǎn)單的傳遞下去。這就是 map() 方便的原因。然后不幸的是,Kotlin 的 let 并不是這樣工作的。它只是從左往右簡(jiǎn)單地執(zhí)行調(diào)用,不在乎是否是空。
因此,要讓這段代碼對(duì)空安全,你必須在 let 前添加 ?:
- fun parseAndInc(number: String?): Int {
- return number?.let { Integer.parseInt(it) }
- ?.let { it -> it + 1 } ?: 0
- }
現(xiàn)在,比如 Java 和 Kotlin 兩個(gè)版本的可讀性,你更喜歡哪一個(gè)?