编者按:本文节选自华章科技出版的 《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 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中一种非常有个性且有用的语法,你还会在后续的数据类中再次接触到它的应用。
在很多Kotlin的学习资料中,都会传递一个原则:优先使用val来声明变量。这相当正确,但更好的理解可以是:尽可能采用val、不可变对象及纯函数来设计程序。关于纯函数的概念,其实就是没有副作用的函数,具备引用透明性,我们会在第10章专门探讨这些概念。由于后续的内容我们会经常使用副作用来描述程序的设计,所以我们先大概了解一下什么是副作用。
简单来说,副作用就是修改了某处的某些东西,比方说:
来看个实际的例子:我们先用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章关于只读集合的介绍中发现一种思路。
一个可能被提及的问题是:既然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是其更加适合的实现方案。
领取专属 10元无门槛券
私享最新 技术干货