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<String>) {
    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<String>) {
    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>): Int {
    var res = 0
    for (el in list) {
        res *= el
        res += el
    }
    return res
}

这是我们非常熟悉的做法,以上代码中的res是个局部的可变变量,它与外界没有任何交互,非常安全可控。我们再来尝试用val实现:

fun cal(list: List<Int>): Int {
    fun recurse(listr: List<Int>, 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>): Int {
    return list.fold(0) { res, el -> res * el + el }
}

函数式API果然拥有极强的表达能力。

可见,在诸如以上的场合下,用var声明一个局部变量可以让程序的表达显得直接、易于理解。这种例子很多,即使是Kotlin的源码实现,尤其集合类遍历的实现方法,也大量使用了var。之所以采用这种命令式风格,而不是更简洁的函数式实现,一个很大的原因是因为var的方案有更好的性能,占用内存更少。所以,尤其针对数据结构,可能在业务中需要存储大量的数据,所以显然采用var是其更加适合的实现方案。

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/ez85rQHlVBIW5NsKghFB
  • 如有侵权,请联系 yunjia_community@tencent.com 删除。

扫码关注云+社区

领取腾讯云代金券