從Java到 Kotlin,再從Kotlin回歸Java
由于此博客文章引起高度關注和爭議,我們認為值得在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 兩個版本的可讀性,你更喜歡哪一個?