Categories
程式開發

Kotlin核心編程:val 和 var 的使用規則


編者按:本文節選自華章科技出版的 《Kotlin核心編程》一書中的部分章節。

與Java另一點不同在於,Kotlin聲明變量時,引入了val和var的概念。 var很容易理解,JavaScript等其他語言也通過該關鍵字來聲明變量,它對應的就是Java中的變量。那麼val又代表什麼呢?

如果說var代表了varible(變量),那麼val可看成value(值)的縮寫。但也有人覺得這樣並不直觀或準確,而是把val解釋成varible+final,即通過val聲明的變量具有Java中的final關鍵字的效果,也就是引用不可變。

提示 我們可以在IntelliJ IDEA或Android Studio中查看val語法反編譯後轉化的Java 代碼,從中可以很清楚地發現它是用final實現這一特性的。

val的含義:引用不可變

val的含義雖然簡單,但依然會有人迷惑。部分原因在於,不同語言跟val相關的語言特性存在差異,從而容易導致誤解。

我們先用val聲明一個指向數組的變量,然後嘗試對其進行修改。

>>> val x = intArrayOf(1, 2, 3)
>>> x = intArrayOf(2, 3, 4)
error: val cannot be reassigned
>>> x[0] = 2
>>> println(x[0])
2

因為引用不可變,所以x不能指向另一個數組,但我們可以修改x指向數組的值。

如果你熟悉Swift,自然還會聯想到let,於是我們再把上面的代碼翻譯成Swift的版本。

let x = [1, 2, 3]
x = [2, 3, 4]
Swift:: Error: cannot assign to value: 'x' is a 'let' constant
x[0] = 2
Swift:: Error: cannot assign through subscript: 'x' is a 'let' constant

這下連引用數組的值都不能修改了,這是為什麼呢?

其實根本原因在於兩種語言對數組採取了不同的設計。在Swift中,數組可以看成一個 值類型,它與變量x的引用一樣,存放在棧內存上,是不可變的。而Kotlin這種語言的設計思路,​​更多考慮數組這種大數據結構的拷貝成本,所以存儲在堆內存中。

因此,val聲明的變量是只讀變量,它的引用不可更改,但並不代表其引用對像也不可變。事實上,我們依然可以修改引用對象的可變成員。如果把數組換成一個Book類的對象,如下編寫方式會變得更加直觀:

class Book(var name: String) {  // 用var声明的参数name引用可被改变
    fun printName() {
        println(this.name)
    }
}

fun main(args: Array) {
    val book = Book("Thinking in Java") // 用val声明的book对象的引用不可变
    book.name = "Diving into Kotlin"
    book.printName() // Diving into Kotlin
}

首先,這裡展示了Kotlin中的類不同於Java的構造方法,我們會在第3章中介紹關於它具體的語法。其次,我們發現var和val還可以用來聲明一個類的屬性,這也是Kotlin中一種非常有個性且有用的語法,你還會在後續的數據類中再次接觸到它的應用。

優先使用val來避免副作用

在很多Kotlin的學習資料中,都會傳遞一個原則:優先使用val來聲明變量。這相當正確,但更好的理解可以是:盡可能採用val、不可變對象及純函數來設計程序。關於純函數的概念,其實就是沒有副作用的函數,具備引用透明性,我們會在第10章專門探討這些概念。由於後續的內容我們會經常使用副作用來描述程序的設計,所以我們先大概了解一下什麼是副作用。

簡單來說,副作用就是修改了某處的某些東西,比方說:

  • 修改了外部變量的值。
  • IO操作,如寫數據到磁盤。
  • UI操作,如修改了一個按鈕的可操作狀態。

來看個實際的例子:我們先用var來聲明一個變量a,然後在count函數內部對其進行自增操作。

var a = 1
fun count(x: Int) {
    a = a + 1
    println(x + a)
}
>>> count(1)
3
>>> count(1)
4

在以上代碼中,我們會發現多次調用count(1)得到的結果並不相同,顯然這是受到了外部變量 a 的影響,這個就是典型的副作用。如果我們把var換成val,然後再執行類似的操作,編譯就會報錯。

val a = 1
>>> a = a + 1
error: val cannot be ressigned

這就有效避免了之前的情況。當然,這並不意味著用val聲明變量後就不能再對該變量進行賦值,事實上,Kotlin也支持我們在一開始不定義val變量的取值,隨後再進行賦值。然而,因為引用不可變,val聲明的變量只能被賦值一次,且在聲明時不能省略變量類型,如下所示:

fun main(args: Array) {
    val a: Int
    a = 1
    println(a) // 运行结果为 1
}

不難發現副作用的產生往往與 可變數據共享狀態 有關,有時候它會使得結果變得難以預測。比如,我們在採用多線程處理高並發的場景,“並發訪問”就是一個明顯的例子。然而,在Kotlin編程中,我們推薦優先使用val來聲明一個本身不可變的變量,這在大部分情況下更具有優勢:

  • 這是一種防禦性的編碼思維模式,更加安全和可靠,因為變量的值永遠不會在其他地方被修改(一些框架採用反射技術的情況除外);
  • 不可變的變量意味著更加容易推理,越是複雜的業務邏輯,它的優勢就越大。

回到在Java中進行多線程開發的例子,由於Java的變量默認都是可變的,狀態共享使得開發工作很容易出錯,不可變性則可以在很大程度上避免這一點。當然,我們說過,val只能確保變量引用的不可變,那如何保證引用對象的不可變性?你會在第6章關於只讀集合的介紹中發現一種思路。

var的適用場景

一個可能被提及的問題是:既然val這麼好,那麼為什麼Kotlin還要保留var呢?

事實上,從Kotlin誕生的那一刻就決定了必須擁抱var,因為它兼容Java。除此之外,在某些場景使用var確實會起到不錯的效果。舉個例子,假設我們現在有一個整數列表,然後遍曆元素操作後獲得計算結果,如下:

fun cal(list: List): Int {
    var res = 0
    for (el in list) {
        res *= el
        res += el
    }
    return res
}

這是我們非常熟悉的做法,以上代碼中的res是個局部的可變變量,它與外界沒有任何交互,非常安全可控。我們再來嘗試用val實現:

fun cal(list: List): Int {
    fun recurse(listr: List, res: Int): Int {
        if (listr.size > 0) {
            val el = listr.first()
            return recurse(listr.drop(1), res * el + el)
        } else {
            return res
        }
    }
    return recurse(list, 0)
}

這就有點尷尬了,必須利用遞歸才能實現,原本非常簡單的邏輯現在變得非常不直觀。當然,熟悉Kotlin的朋友可能知道List有一個fold方法,可以實現一個更加精簡的版本。

fun cal(list: List): Int {
    return list.fold(0) { res, el -> res * el + el }
}

函數式API果然擁有極強的表達能力。

可見,在諸如以上的場合下,用var聲明一個局部變量可以讓程序的表達顯得直接、易於理解。這種例子很多,即使是Kotlin的源碼實現,尤其集合類遍歷的實現方法,也大量使用了var。之所以採用這種命令式風格,而不是更簡潔的函數式實現,一個很大的原因是因為var的方案有更好的性能,佔用內存更少。所以,尤其針對數據結構,可能在業務中需要存儲大量的數據,所以顯然採用var是其更加適合的實現方案。

圖書簡介https://item.jd.com/12519581.html?dist=jd

Kotlin核心編程:val 和 var 的使用規則 1

相關閱讀

Scala複合但不復雜,簡單卻不容易

Kotlin核心編程:Kotlin,改良的 Java