Kotlin是目前非常熱門的用於取代Java的流行語言。 Kotlin的編譯時安全性,簡潔明快,去掉Java繁瑣的臭裹腳布。是該切換到Kotlin呢?還是堅守Java繼續擼呢,這是每一個Java碼農需要思考的問題。那麼本文蟲蟲就給大家白一白。
我最喜歡的一組JVM語言棧:主推Java,Groovy 做測試。去年夏天,我們團隊開始了一個新的微服務項目,和往常一樣,除了擼碼,我們也爭論語言和技術。有很多人倡導Kotlin的團隊,所以我們想嘗嘗鮮,所以就決定試試Kotlin。由於沒有Spock對應Kotlin,我們還用Groovy做測試。去年冬天,在經過幾個月的日息相處之後,我們做了技術總結,最後的結論是Kotlin並沒有給我們代來什麼改進,反而拖慢了我們的進度。所以我們從入門到放棄,又回歸了Java 10。
命名隱藏(Name shadowing)
隱藏(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,相當於你的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做法可能是對的。我還無法想出一個用於映射方法參數的有效用例。
類型推斷(Type inference)
在Kotlin中,當你聲明一個var或val時,編譯器會通過右邊的表達式的類型來自動探測到變量的類型。我們稱之為局部變量類型推斷,這對程式設計師來說是一個很大的改進。它允許我們在不影響靜態類型檢查的情況下簡化代碼。
舉例如下:
var a ="10"
Kotlin編譯器會自動轉化成:
var a:String ="10"
這是也是Java的優勢。我故意說是因為,有了好消息,Java 10已經支持這個特性了。
Java 10中對應的語法為:
var a = "10";
公平來說,我們要說的是,儘管如此,Kotlin在這方面還是更勝一籌。你還可以在其他上下文中使用類型推斷,比如單行方法
關於Java10的局部類型推斷可以參考Java 10相關的文檔。
編譯時NULL安全(Compile time null-safety)
Null安全類型是Kotlin的殺手鐧,非常棒的功能。在Kotlin中,類型默認是不可空的。如果你需要一個可為空的類型,你需要在它的後面添加"?"來修飾它,例如:
val a: String? = null // 可以編譯
val b: String = null // 編譯錯誤
如果使用不帶空值檢查的可空變量,Kotlin編譯器就會報錯,例如:
println (a.length) // error
println (a?.length) //可編譯,列印為空
println (a?.length ?: 0) //可編譯,列印為 0
一旦你有這兩種類型,不可為空的T和可空的T?,你就可以不用在考慮Java中最常見的異常:"NullPointerException(NPE)"。真有這麼神奇?然而,事情沒有這麼簡單。
當你的Kotlin代碼必須與Java代碼混合使用時,情況會變得很糟糕(很多庫都是用Java編寫的,這種情況常見)。然後,第三種類型就來了,T!。它被稱為平臺類型,不知為什麼他可以是T或T?。或者更確切的說,T!意味著T具有未定義的可空性。這種奇怪的類型無法在Kotlin中表示,它只能從Java的類型推斷出來。它可能會誤導你,因為它對空值放鬆檢查,並且Kotlin的的NULL安全機制也對它失效了。
考慮下面的Java方法:
public class Utils {
static String format(String text) {
return text.isEmpty() ? null : text;
}
}
現在,你想調用Kotlin的格式(String)。你應該使用哪種類型來表示這個Java方法的返回結果呢? 你有三個選擇:
方法一。你可以使用String,代碼看起來很安全,但會拋出NPE異常。
fun chongchong(text: String) {
val f: String = Utils.format(text)
// 可編譯,但是運行時會拋出NPE異常
println ("f.len : " + f.length)
}
你需要用Elvis來修正它:
fun chongchong1(text: String) {
val f: String = Utils.format(text) ?: ""
// 用了Elvis符(?:)所以ok了
println ("f.len : " + f.length)
}
方法二 你可以使用String?,然後NULL安全:
fun chongchong2(text: String) {
val f: String? = Utils.format(text) // 安全
println ("f.len : " + f.length) // 運行時錯誤
println ("f.len : " + f?.length) // 通過添加?符號,null安全
}
方法三 你可以利用Kotlin的局部變量類型推斷:
fun chongchong3(text: String) {
val f = Utils.format(text) // f 推斷為String
println ("f.len : " + f.length)
// 可以編譯,但是可能運行時拋出NPE異常
}
這些代碼看起來是編譯時安全的,但是這會讓你代碼完全不會做NULL安全檢查,就像Java中的那樣。
還有一種更巧妙的辦法:!!操作符,用它來強制f的類型為String:
fun chongchong4(text: String) {
val f = Utils.format(text)!! // throws NPE when format() returns null
println ("f.len : " + f.length)
}
在我看來,Kotlin的類型系統的這些符號!,?和!!太複雜了。為什麼Kotlin從Java T推斷為T!而不是T?,和Java的互操作性是Kotlin的殺手功能類型推斷。我們應該為Java方法補充的所有Kotlin變量顯式聲明類型(如T?)。
類字面量(literal)
使用類似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()
反式類型聲明(Reversed type declaration)
在C語言系列程式語言中,我們有標準聲明類型的方法。簡而言之,首先進入一個類型,然後輸入一個類型的東西(變量,欄位,方法等)。
Java中的標準表示法:
int inc(int i) {
return i + 1;
}
Kotlin中的反向類型聲明:
fun inc(i: Int): Int {
return i + 1
}
這種反序的表示令人很討厭:
首先,你需要在名稱和類型之間鍵入並閱讀這個多餘的冒號。增加他的目的是什麼?為什麼名稱要與類型分開?最主要的是,這會增加很多工作量。
其次,當你讀取一個方法聲明時,你第一對名字和返回類型感興趣,然後才是參數。
在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中輸入這個變量非常困難。如果你有多個存儲庫,則在自動補全列表中找不到正確的項目。這意味著需要用手輸入完整的變量名稱。
repository : MongoExperimentsRepository
伴生對象(Companion object)
某Kotlin QQ群對話:
"嗨,我是新來的,我可以在Kotlin中使用靜態成員嗎?"
"沒有。Kotlin中一切皆對象的,靜態成員不是面向對象的。"
"哦,那麼我想對我的類搞個記錄器,怎麼做?"
"沒問題,你可以使用伴生對象。"
"什麼是伴生對象?"
"這是與你的類相關的單例對象。你可以在你的伴生對象中放記錄器"
"哦,知道了,謝謝"
伴生對象寫法例子:
class Chongchong {
companion object {
val logger = LoggerFactory.getLogger(Chongchong::class.java)
}
}
看起來很繁瑣的,但是現在可以很方便的用Chongchong.logger來調用這個記錄器,和 Java中的靜態成員類似。但它不是一個靜態成員。因為kotlin中只有對象。我們可以把它看作是已經實例化為單例的匿名內部類。實際上這個類又不是匿名的,它被命名為Companion,但是你可以省略這個名字。
單例很有用,但是完全拋棄Java的靜態成員有點不切實際的。在Java中,我們使用靜態記錄器多年。它只是一個記錄器,所以我們不關心他是否面向對象。它運行的很好,也沒有帶來什麼危害。
有的時候,你必須使用靜態。比如老的公共靜態方法void main()仍然是啟動Java應用程式的唯一方式。為他寫伴生對象沒有太大必要:
class AppRunner {
companion object {
@JvmStatic fun main(args: Array<String>) {
SpringApplication.run(AppRunner::class.java, *args)
}
}
}
集合字面量(Collection literals)
在Java中,初始化一個列表很繁瑣:
import java.util.Arrays;
...
List<String> strings = Arrays.asList("Chong", "CC");
初始化一個Map也是如此,所以很多人使用Guava:
import com.google.common.collect.ImmutableMap;
...
Map<String, String> string = ImmutableMap.of("firstName", " Chong", "lastName", "CC");
在Java中,我們仍然在等待新的語法來表達集合和map字面量。 該語法,在許多語言中都很自然方便。
比如:
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("Chong", "CC")
val map = mapOf("firstName" to " Chong ", "lastName" to "CC")
在map中,鍵和值通過to運算符做配對,這很好,但是為啥不用大家都熟悉的:呢?有點令人失望。
Maybe? 語法
函數式語言(如Haskell)沒有NULL值。而是提供Maybe語法。很久以前Scala就把這種語法引進到了JVM中,這就是Optional。然後在Java 8中被採用為。現在,Optional是在API邊界返回類型中的空值處理的非常流行的方式。
Kotlin中沒有相應的語法。一般來說應該用Kotlin的可空類型。
通常情況下,當你有一個可選的時候,你想要應用一系列NULL安全的轉換,並在末端處理NULL值。
例如,在Java中:
public int parseAndInc(String number) {
return Optional.ofNullable(number)
.map(Integer::parseInt)
.map(it -> it + 1)
.orElse(0);
}
你可以嗎?是的,但並不那麼簡單。上面的代碼會報錯,並且parseInt()也會引發NPE。
monadic風格的map()僅在存在值時執行。否則,就會傳遞null值。這就是為什麼map()非常方便。不幸的是,Kotlin中不是這樣的。它只是獲取左側的內容,包括空值。
所以為了使這個代碼null安全,你必須在每一個let之前添加?:
fun parseAndInc(number: String?): Int {
return number?.let { Integer.parseInt(it) }
?.let { it -> it + 1 } ?: 0
}
現在,比較Java和Kotlin版本的可讀性。你會更喜歡哪一個呢?
數據類(Data classes)
數據類是Kotlin在實現Java中值對象(又名DTO)的必須方法。
例如,在Kotlini中一個基本的數據類:
data class User(val name: String, val age: Int)
Kotlin就會自動生成equals(), hashCode(), toString(),和copy()的,這是非常很好的實現。
在實現簡單的DTO時數據類非常有用。但是需要注意,數據類有很大的局限性,它們是終態的(final)。我們無法擴展數據類或對其抽象。所以可能你無法在核心域模型中使用它們。
這個限制不是Kotlin的錯。因為沒有辦法在不違反Liskov原則的情況下生成正確的基於值的equals()。這就是為什麼Kotlin不允許數據類繼承的原因。
公開類(Open classes)
在Kotlin中,類默認是final的。如果你想擴展一個類,你必須給它添加open修飾符。
Kotlin的類繼承語法如下所示:
open class Base
class Derived : Base()
Kotlin將extends關鍵字更改為:運算符,該運算符已用於變量名稱與類型的分隔符。這是致敬C++語法?對我來說這有點混亂。
而且最具爭議的是,默認情況下,類是final的。這也許是由於Java程式設計師過度使用繼承。也許想讓你你應該在考慮擴展類之前再三考慮。但我們生活在富框架的世界,框架喜歡AOP。Spring使用庫(cglib,jassist)給你的bean生成動態代理。Hibernate擴展你的實體以啟用惰加載(lazy loading)。
如果你使用Spring,你有兩種選擇:把所有的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方式?