Kotlin 實戰範例 (3) 基礎 (Null、相等、字串、註解)

Kotlin 實戰範例

Kotlin 在型別系統中直接針對 null 做處理,讓我們的程式碼更穩固;相等比較也和 Java 略有不同,但是更好用;字串模板讓我們在串接變數時更有效率。

3.1 安全的 Null

Null 的發明者 Tony Hoare 曾道歉說,這是他犯下的 十億美元的錯誤(The Billion Dollar Mistake),可見 null 有多危險。在 Java 中,只要存取 null reference 的成員就會引發 NullPointerException (空指標例外,簡稱 NPE),造成程式崩潰,不得不小心。

Kotlin 致力於消除危險的 null 參考 (reference) 引用,在型別系統的設計上已經可以避免大多數 NPE 的發生,除非我們明確地丟出 NPE,例如:throw NullPointerException() 、或是使用 !! 符號、或是由外部 Java 程式碼所造成等等。

3.1.1 Nullable 型別及 Non-Null 型別

在 Kotlin 中,型別系統區分成兩種,一種是可以接受 Null (nullable references) 的,在型別名稱後面加上問號 ? 來表示;另一種是不能接受 Null (non-null references) 的。

先來看看不能接受 Null 的例子:

var name: String = "Tony"
name = null // <-- 編譯錯誤

我們宣告的變數 name 的型別 String 不允許 null 值,當我們指派給它的值為 null 時,就會編譯失敗。這種設計的好處在於,我們在宣告變數時就能確定它是有值的,即使之後修改其值,也絕對不可能是 null。因為在編譯時就能發現這個問題,在程式中存取此變數時就不會有模棱兩可的情況。

接下來看看接受 Null (nullable references) 的例子:

var name: String? = "Tony"
name = null // <-- 編譯成功

當我們在型別名稱後面加上問號 ? ,就表示此變數接受 null 值,因此這個變數的值可能有兩種情況:值或 null。當我們在使用這個變數時就必須額外檢查 null ,否則編譯器不會讓這個程式碼通過,這樣我們的程式碼將會變得更加穩固。

現在,我們來看看這兩種型別對我們的程式造成了什麼影響:

val a: String = "Tony"
println(a.length)

val b: String? = "Tony"
println(b.length) // <-- 編譯失敗

我們可以毫不猶豫的存取變數 a 的屬性,並且知道它不會出問題 (引發 NPE);同樣的動作對於變數 b 則有風險,立即被編譯器發現而編譯失敗,我們必須檢查該變數的值是否為 null,編譯器才能讓我們繼續,接下來讓我們看看如何檢查 null

3.1.2 Null 的檢查

針對可能為 null 的變數,在存取之前要先用 if 檢查其值,確定不是 null 才能呼叫其成員,請看範例:

val b: String? = "Tony"
if (b != null) {
  println(b.length)
}

由於 if 中的條件是從左向右執行,因此我們也可以同時檢查 null 並呼叫其成員 (類別中的屬性或方法),請務必依照這個順序指定條件,請看範例:

val b: String? = "Tony"
if (b != null && b.isNotEmpty()) {
  println(b.toUpperCase())
}

3.1.3 安全呼叫成員

Kotlin 提供比 if 檢查更有效率的方式,使用 ?. 符號來安全呼叫成員,請看範例:

val b: String? = "Tony"
println(b?.length)

這樣是不是簡單、快速又方便。?. 的意思是「如果變數 b 有值就存取它的 length 屬性,如果是 null 就不要做任何動作」,這樣就不需要額外使用 if 來檢查了。程式碼不僅簡短,還能一眼就知道意圖。

有一點要注意的是,一旦我們使用了一個安全呼叫,在其後的呼叫鏈也必須使用安全呼叫,請看以下範例:

val b: String? = "Tony"
println(b?.toUpperCase()?.replace("Y", "")?.length)

這個安全呼叫鏈中,只要其中一個呼叫回傳 null ,就會中斷整個呼叫鏈,結果只會得到 null 而不會發生任何錯誤,如果沒有使用安全呼叫鏈,就必須使用 if 條件判斷,程式碼將變得冗長而不易閱讀。

3.1.4 貓王運算符號

使用「安全呼叫」其實有個小缺點,由於最後的結果值可能為 null ,所以最終我們還是得判斷結果值是不是 null,似乎永遠離不開 if 條件判斷。Kotlin 提供一個方便的貓王 (Elvis) 運算符號 ?: ,它可以讓我們在得到 null 結果時指定預設值,這樣就能讓整個安全呼叫鏈一氣呵成,請看範例:

val b: String? = null
println(b?.toUpperCase()?.replace("Y", "")?.length ?: -1)

註:被稱為 貓王運算符號 的原因是這個符號和 貓王的顏文字 ?;J 很像,所以就被這樣命名了。貓王本人照片

3.1.5 危險的強制呼叫

有時候我們非常有自信的確定變數是有值的,不打算使用安全呼叫,這時我們可以使用雙驚嘆號 !! 來強制呼叫,不過我們必須瞭解,如果該變數是 null 的話就會引發 NPE,請看範例:

val b: String? = "Tony"
println(b!!.length)

使用這種方式非常不安全,請儘量避免或記得補捉 NPE。

3.1.6 安全轉型及預設值

在使用 as 轉型時,如果轉型失敗會丟出 ClassCastException ,所以建議使用安全的轉型 as?,而安全的轉型也可以搭配貓王運算符號來指定預設值,請看範例:

val a: Any = 123
println(a as? String ?: "無法轉型為字串")

這樣在轉型失敗時就會使用預設值。

3.2 相等比較

Kotlin 提供兩種類型的相等比較:

  • 參考相等 (或稱位址相等):即兩個物件指向同一個參考 (位址);使用連續三個等號 === 來比較參考相等,!== 則比較參考不相等。
  • 結構相等 (結構指存在該位址中的值):只判斷值是否相等,不管位址是否相等。使用 equals() 方法 來比較結構 (值),或是使用 == 符號;!= 則判斷不相等。

重要!Kotlin 的 == 和 Java 的 == 行為是不同的,Java 的 == 比較的是參考 (位址), equals() 方法則是比較值。Kotlin 使用 ==equals() 來比較值,使用 === 比較參考 (位址)。

3.2.1 結構的比較

當我們使用 == 符號時,實際上會轉換成如下的程式碼:

a?.equals(b) ?: (b === null)

如果 a 不是 null,會呼叫 equals() 方法來比較,如果 anull 則比較 b 是否為 null。所以 == 符號實際上做的事比 equals() 方法還多,因此除非必要,否則大多數時候都是使用 == 符號來做結構的比較。

另外,要比較某變數是否為 null 時,必須使用 === 比較其參考,但是我們不必明確的使用 ===,當我們使用 == 來比較是否為 null 時,Kotlin 會自動幫我們轉換為 ===

對於不同的數值型別,必須轉換成相同型別才能比較,或是使用 compareTo() 方法,請看範例:

val a: Int = 1
val b: Long = 1L
val c: Double = 1.0
println(a.toLong() == b) // true
println(a.toDouble() == c) // true
println(a.compareTo(b)) // 0
println(a.compareTo(c)) // 0

compareTo() 的結果,0 表示相等,-1 表示小於,1 表示大於。

針對數值這種在 Java 中被視為原始型別 (primitive type) 的值使用 === 符號是沒有必要的,它的結果等同於 ==

3.2.2 物件的比較

當我們建立一個物件時,會得到一個參考 (記憶體位址),要比較兩個物件是否屬於同一個參考,必須使用 ===,請看範例:

data class User(val name: String)
fun main() {
  val a = User("Tony")
  val b = User("Tony")
  val c = a // 同一個物件,即相同參考
  println(a === b) // false
  println(a === c) // true
  // 比較結構
  println(a == b) // true
}

這裡要注意的是,因為我們使用 Kotlin 提供的 data class 來建立類別,比較結構時才會相等,這是因為 data class 幫我們實做了等式判斷用的 equals() 方法,如果使用一般的類別而又沒有實作 equals() 方法,比較結構的結果等同於比較參考的結果。

在 Java 中想要單純比較物件的結構比較麻煩,必須自行實作 equals() 方法才行,簡單示範如下:

/* Java */
public class Jpp {
  public static void main(String[] args) {
    User a = new User("Tony");
    User b = new User("Tony");
    System.out.println(a.equals(b)); // true
    System.out.println(a == b); // false
  }
}

class User {
  private String name;
  User(String name) {
    this.name = name;
  }
  // 如果沒有實作以下方法,a.equals(b) 的結果將為 false
  @Override
  public boolean equals(Object obj) {
    try {
      return this.name.equals(((User)obj).name);
    } catch (Exception e) {
      return false;
    }
  }
}

在 Java 中,如果沒有實作自己版本的 equals() 方法,預設會使用從 Object 繼承而來的比對方式,這個比對方式只是簡單的用 == 來比對兩個物件的參考,我們看到範例中,使用 == 比對的結果就是 false,也就是說,在 Java 中比較兩個物件時,使用 equals() 方法或 == 的結果都一樣,只能自行實作比對邏輯。

實際上,我們還少說了 hashCode 的部分,根據規則,只要有實作 equals() 就必須同時實作 hashCode 。(這部分要解釋的東西太多,可以參考電子書的內容)



3.3 字串

3.3.1 字串模板

我們經常將變數和一段文字串接來表達完整的內容,在 Java 裡只能用 + 來串接,即使使用 String.format() 也不是那麼清楚、簡單,來看看 Java 的例子:

/* Java */
String name = "Tony";
int age = 5;
// 使用 +
System.out.println("My name is " + name + ", " + age + " years old.");
// 使用 String.format()
System.out.println(String.format("My name is %s, %d years old.", name, age));

我們得查一下文件才知道 %s 對應到字串、%d 對應到整數。

來看看 Kotlin 的字串模板怎麼用:

val name = "Tony"
val age = 5
println("My name is $name, $age years old.")

在字串中要取用任何變數的值,只要直接使用該變數的名稱,並在前面加上 $ 即可,這樣是不是方便多了,整個字串也非常容易閱讀。而且不止這樣,如果我們想要取用該變數的屬性或做任何的運算,只要額外使用大括號 {} 把它包圍即可,請看範例:

println("My name have ${name.length} characters.")
// [結果] My name have 4 characters.
println("1 + 2 = ${1 + 2}")
// [結果] 1 + 2 = 3

3.3.2 原生字串

有時候我們需要輸出一段經過排版的文字,包括縮排、斷行及空白等等內容,可以用一對三個雙引號 """ 來包圍該內容,在此區塊中的內容會如實呈現,請看範例:

fun main() {
  val code = """
    val names = arrayOf("Tony", "Tom", "John")
    for (name in names) {
      println(name)
    }
  """
  println(code)
}
/* [結果]

  val names = arrayOf("Tony", "Tom", "John")
  for (name in names) {
    println(name)
  }
*/

我們可以注意到輸出結果的上面多了一行空白,前面也有縮排,如果我們想在輸出結果中讓內容不要有多餘的縮排及空行,我們可以用 trimMargin() 函式來消除,請看範例:

fun main() {
  val code = """
  |val names = arrayOf("Tony", "Tom", "John")
  |for (name in names) {
  | println(name)
  |}
  """.trimMargin()
  println(code)
}

trimMargin() 可以讓我們指定行的開頭,預設使用 | 符號,輸出的內容會以這個符號當成行的起點。我們也可以換成使用其他符號,例如這樣 trimMargin(">");如果我們只是想要忽略縮排,可以直接使用 trimIndent() 即可,它不需要使用起頭符號,但是結果和上例一樣。我們一樣可以在原生字串中使用字串模板的符號來取用變數值。

註:Java 13 也會有相似的功能稱為 Text Blocks (Preview) 預覽版。

3.4 註解

註解可以用來說明程式碼的意圖,程式編譯時會忽略註解。

3.4.1 單行註解

以雙斜線 // 表示:

// 這行之後的都是註解

3.4.2 區塊註解

以一對 /**/ 包圍的區塊,註解的內容較多時使用:

/* 這個區塊
   都是註解 */

Java 的區塊註解不能是巢狀的,Kotlin 的可以:

/* 1111
  /* 2222 */
  3333
*/

3.4.3 文件式註解

文件式註解可以讓我們將註解輸出為說明文件。Kotlin 使用的程式碼文件稱為 KDoc,如同 JavaDoc,語法相同但多了自己特有的標籤,並且可以在行內標記 (inline markup) 中使用 Markdown 語法。文件式註解以一對 /***/ 包圍的區塊表示:

/**
  哈囉類別
  @author Tony
*/
class Hello {
  // ...
}

如果要產生說明文件,可以用 Dokka 文件產生工具來輸出程式碼文件。在 build.gradle 檔中加入 Dokka

plugins {
  id 'org.jetbrains.dokka' version '0.10.0'
}
repositories {
  jcenter() 
  // 或 maven { url 'https://dl.bintray.com/kotlin/dokka' }
}
dokka {
  outputFormat = 'html'
  outputDirectory = "$buildDir/kdoc"
}

然後在命令列執行:

./gradlew dokka

就會在 build/kdoc 目錄下產生 html 格式的文件。


繼續閱讀:Kotlin 實戰範例 (4) 基礎 (條件控制、循環執行)

完整內容可以參考電子書:Google Play Pubu 樂天 Kobo



本文網址:https://blog.tonycube.com/2020/08/kotlin-by-example-3-null-equals-string-comments.html
Tony Blog 撰寫,請勿全文複製,轉載時請註明出處及連結,謝謝 😀

我要留言

留言小提醒:
1.回覆時間通常在晚上,如果太忙可能要等幾天。
2.請先瀏覽一下其他人的留言,也許有人問過同樣的問題。
3.程式碼請先將它編碼後再貼上。(線上編碼:http://bit.ly/1DL6yog)
4.文字請加上標點符號及斷行,難以閱讀者恕難回覆。
5.感謝您的留言,您的問題也可能幫助到其他有相同問題的人。