本文收录于 kotlin入门潜修专题系列,欢迎学习交流。
创作不易,如有转载,还请备注。
写在前面
生而知之者,上也;学而知之者,次也;困而学之,又其次也;困而不学,民斯为下矣。——与君共勉。
属性的定义
我们都知道在java类中定义的变量被称为成员变量,而kotlin中则称为属性。kotlin中的属性有两个关键字来定义var和val。var定义的属性表示是可变的,而val则不可变的即是个常量。kotlin中的属性定义如下:
class Person {
var state: String? = null//变量,允许为null,其关键字是具体类型后面跟个问号
var address: String = "hang zhou"//变量,不允许为null,其关键字后面只跟具体类型,没有问号
val name: String = "zhang san"//常量,值为'zhang san'
val city: String? = null//常量,值为null
}
前文有提到过kotlin中能有效解决空指针问题,那么它是如何做到的呢?实际上就是新增了一类定义类型,如上面的String?关键字,它和String是完全不同的两个类型,String?表示其修饰的变量或者常量可以为null,而String则不能为空。在使用的时候,如果编译器发现变量可能为null则会编译报错,告知用户需要进行处理,从而避免运行时的空指针异常。比如下面的例子:
class Person {
val name: String? = null//这里定义了一个可为null的变量
fun personName(){
println(name.length)//!!!编译错误!编译器会提示这么调用是不安全的,不允许这么做
println(name?.length)//正确,只有当name不为null的时候才打印name的length
println(name!!.length)//编译通过!注意这种写法只是暂时骗过了编译器,实际上在运行的时候,如果name为null会直接抛出异常!因此是否采用这种写法要根据场景进行判断。
}
}
kotlin中调用属性同java一样,如下所示:
val person:Person = Person()//生成一个Person对象
person.name//调用其name属性
Getters和Setters
前面展示了属性如何定义,实际上上面的定义并不完整,kotlin完整的定义属性的语法如下:
var <propertyName>[: <PropertyType>] [= <property_initializer>]
[<getter>]
[<setter>]
解释下上面符号的含义,var(或者val)即是属性定义的关键字,<>里面的是属性名,表示不可省略。而[ ]里面的表示是可以省略的。示例如下
1. [: <PropertyType>] 表示可以省略属性类型,这个时候编译器会根据初始值进行推断,示例如下:
class Person {
val name = "zhangsan"//编译正确,可以通过"zhangsan"自动推断出name为string类型
val age = 20//正确,通过20自动推断为Int类型
}
- [= <property_initializer>]表示可以省略初始化,但是这个省略只是暂时的,这些省略的变量终归还是需要在constructor中或者init块中进行。示例如下:
class Person {
val name: String?//常量name,编译正确,因为在init中紧接着进行了初始化
var state: String?//变量state,编译正确,因为在init中紧接着进行了初始化
var city: String?//!!!编译错误!,因为编译器没有找到对其进行了初始化
init {//这里在init中对name和state进行初始化,当然也可以在constructor中进行
name = ""//对name进行初始化
state = ""//对state进行初始化
}
}
- [<getter>]和[<setter>]表示属性的getter和setter是可以省略的,实际上在上面代码中,我们都省略了getter和setter,那么如果不省略getter和setter,属性定义的效果是怎么样的呢?示例如下:
class Person {
var name: String
get() {//这里为name定义了get方法
println("default value is zhangsan")
return "zhangsan"
}
set(value) {//为name定义set方法
println("set new value is $value")
}
}
//main方法,用于测试属性的getter和setter执行机制
@JvmStatic fun main(args: Array<String>) {
val person = Person()
println(person.name)//这里会触发name的get方法,因此会先执行name的get方法,打印'default value is zhangsan',然后才执行该打印语句,即打印出"zhangsan"
person.name = "lisi"//这里会触发name的set方法,因此会先调用set方法,打印值为"set new value is lisi
"
}
注意,上面只是演示了字段的getter和setter方法的定义,实际上并不会那么用(比如上面的写法, person.name = "lisi"将会永远失效,无法为person.name再赋值,因为每次获取值的时候都会执行get方法,而get方法只是机械性的返回了"zhangsan"),在实际使用中可以结合后备字段(backing fields)来进行值的变更,后面会介绍后备字段。
这里先介绍一个getter和setter的一种使用场景,示例如下:
class Person {
var name = "zhangsan"//定义了name字段
val nameLength: Int
get() = name.length//这里定义了nameLength的get方法,目的是获取name的长度。
}
@JvmStatic fun main(args: Array<String>) {
val person = Person()
println(person.nameLength)//打印'8'
person.name = "lisi"
println(person.namel)//打印'4'
}
对于上面的例子,可能会有朋友想到直接用属性赋值的方式行不行?如下所示
class Person {
var name = "zhangsan"
val nameLength = name.length//这里直接采用属性初始化的方式,没有提供get方法
}
@JvmStatic fun main(args: Array<String>) {
val person = Person()
println(person.nameLength)//打印'8'
person.name = "lisi"
println(person.nameLength)//!!!注意这里,依然打印'8'
}
从上面代码可以看出,采用属性赋值的方式实际上是一次性的,而采用get方法,则是在每次获取属性值的时候都会执行一次,因此可以在get中做一些计算性的逻辑。
属性的set方法还有一个用处,即用于改变属性的访问权限以及给属性加注解,这个时候不需要提供set方法的body实现,如下所示:
class Person {
var name = "zhangsan"//注意这里是var不是val
private set//注意,这里标识了set为private
var nameLength = name.length
@Inject set//这里使用注入注解进行了标注
}
@JvmStatic fun main(args: Array<String>) {
val person = Person()
println(person.name)//打印默认值'zhangsan'
person.name = "lisi"//!!!编译错误,因为name的set方法被标注为private,故无法再进行赋值
}
后备字段(Backing Fields)
后备字段相对于java是一个新的定义,听起来有点陌生的感觉,那么什么是后备字段?先回到前面的一段代码,如下所示:
class Person {
var name: String = ""
get() {//这里为name定义了get方法
return "zhangsan"//注意这里简单的返回了"zhangsan"
}
}
@JvmStatic fun main(args: Array<String>) {
val person = Person()
println(person.name)
person.name = "lisi"
println(person.name)
}
上面代码执行完成后打印如下:
zhangsan
zhangsan//注意这里,我们明明通过person.name = "lisi"进行了重新赋值,但打印的依然是默认的zhangsan值
上面的问题是显而易见的,因为我们定义了Person中name的get方法,所以每次读取name值的时候都会执行get,而get只是简单的返回了"zhangsan"。那是不是直接用name替换掉"zhangsan"就可以了?示例如下:
var name: String = ""
get() {//这里为name定义了get方法
return name//返回name
}
@JvmStatic fun main(args: Array<String>) {
val person = Person()
println(person.name)
person.name = "lisi"
println(person.name)
}
那么上面代码执行过后的结果是什么?打印lisi?no,上面的写法实际上是错误的,在运行时会造成无限递归,直到抛出java.lang.StackOverflowError栈溢出异常,为什么?
这是因为在我们每次获取person.name这个值的时候,都会执行其get方法,而在get方法中我们访问了name属性(即return name这条语句),这又会促使kotlin去调用name的get方法,如此反复直到栈溢出。实际上set方法的效果也是如此,比如我们想通过自定义set来改变name的值:
class Person {
var name: String = ""
set(value) {//定义set方法
name = value//这里原意是要给name赋值为一个新值即value
}
}
上面代码同样会抛出栈溢出异常,因为name=value
这条语句会无限触发name的get方法。那么我们怎么能在自定义属性的get和set方法的时候还能在外部修改其值呢?
这就是后备字段的作用。kotlin中后备字段的名子为field,通过field的使用可以有效解决上述问题。代码示例如下:
class Person {
var name: String = ""
get() {
return field//注意,我们这里直接返回了field
}
set(value) {
field = value//这里是将value赋值给了field
}
}
@JvmStatic fun main(args: Array<String>) {
val person = Person()
println(person.name)
person.name = "lisi"
println(person.name)
}
上述代码执行结果如下:
zhangsan
lisi
通过打印结果,我们可以很清楚的知道,这正是我们想要的效果。
注意:只有使用默认的getter(setter)以及显示使用field字段的时候,后备字段才会存在,下面这段代码就不存在后备字段。
val isEmpty: Boolean
get() = this.size == 0//这里显示定义了get方法,但没有通过后备字段field去引用。
后备属性(Backing Properties)
了解了后备字段之后,后备属性就非常简单了,看个例子就能明白:
class Person {
private var _age: Int? = null//后备属性
val age: Int
get() {
if (this._age == null) {
_age = 20
}
return _age ?: throw AssertionError("Set to null by another thread")
}
}
上面代码定义了一个后备属性_age,该属性是private的,意味着外界无法访问到,接着提供了一个外界可以访问的age属性,在这里可以做一些操作,来满足我们的需求。
编译时常量(Compile-Time Constants)
编译时常量,这个在java中也有体现,只不过并没有一个显示的关键字来区分什么是编译时常量。所谓编译时常量,是指那些在编译期间就能够确定值的常量,这个值将会直接被编译到代码的使用处;与之对应的则还有运行时常量,这个值只有在运行的时候才会知道,编译的时候是无法确定其值的,只有在运行的时候才能得到其值,并放入运行时常量池中,针对运行时常量,编译器只能保证其他代码段无法再对其进行修改赋值。关于二者的区别,可以先看一段java代码如下所示:
public class Test {
private final static String str1 = "hello word!";
private final static String str2 = String.valueOf("hello word!");
}
很简单的一段代码,定义了两个我们“意识中”的常量:str1和str2,那么这两个有啥区别?大部分人可能都认为这两个没啥区别,都是常量!但实际上却不是这样的,来看下二者对应的字节码:
private final static Ljava/lang/String; str1 = "hello word!"
private final static Ljava/lang/String; str2
我们 很惊奇的发现,编译后的str1竟然直接被赋值了hello word!,而str2却没有(实际上str2会在类构造方法初始化的时候才进行赋值,也就是运行的时候才会被赋值),这就是编译时常量(str1)和运行时常量(str2)的区别!
理解了概念之后,来看下kotlin中的编译时常量和运行时常量实现的其他区别。
在kotlin中,编译时常量使用const修饰符修饰,它必须要满足以下条件:
——top-level或者object成员
——被原生类型或者String类型修饰的初始化变量
——没有自定义getter方法
这里,我在加一条
——只有用val修饰的才有可能使用const修饰
只看名词一头雾水,看例子就明白了:
class Person {
const var VAR = 1//编译错误,var变量无法使用const修饰
const val VAL = 2//编译错误,这里并没有违背只能使用val修饰这一项,但是不属于top-level级别的,也不属于object里面的
object Instance {//这里Instance使用了object关键字修饰,表示Instance是个单例,没错,kotlin已经为我们内置了单例,无需再像java那样来写单例了。
const var VAR = 1//编译错误,var变量无法使用const修饰
const val VAL = 2//编译正确,属于object类
}
}
const var VAR = 0//编译错误,var变量无法使用const修饰
const val VAL = 1//编译正确,属于top-level级别的成员
const val POINT = Point(1, 2)//编译错误,const只能修饰String类型以及原生类型,Point显然是个自定义的类型
上面介绍了const能用的场景(const修饰的变量名建议大写),但是有个很大的疑问放在眼前:kotlin中既然已经提供了val修饰符为什么还要提供const修饰符?按理说kotlin中的val已经可以表示常量了,为什么还提供const?
为解决上述疑问,先来看个例子:
//下面代码属于kotlin代码,位于kotlin文件Person.kt中,属于top-level级别
const val NAME = "zhangsan"
val age = 20
//下面这段代码则是java代码,用于测试
public class Test {
public static void main(String[] args) {
//注意下面两种的调用方式
System.out.println(PersonKt.NAME);//这里注意:kotlin文件会默认生成kotlin文件名+Kt的java类文件,所以可以这样使用
System.out.println(PersonKt.getAge());
//!!!下面这种编译不会通过!
System.out.println(PersonKt.age);
}
}
上面代码演示了用const修饰的字段和单纯val修饰的字段的区别:使用const修饰的字段可以直接通过类名+字段名来调用,类比于java的public static final变量,而只用val修饰的字段,则只能通过get方法的形式调用!
那么,const所起的作用难道是java中的public static的作用(val对应于java中的final)?是为了标识该字段是公有静态字段?
这句话只说对了一半,实际上确实是因为const修饰的缘故NAME才会变成公有的字段(即public字段),这是kotlin的实现机制。但是并不是因为const修饰才会产生static变量,这些可以通过查看kotlin生成的字节码来得以确认。
使用idea系列ide的用户可以通过 Tools ——>Kotlin ——>Show Kotlin ByteCode 选项来查看字节码,针对上面的代码(即Person.kt中的 const val NAME = "zhangsan"
val age = 20)其生成的字节码如下:
public final class com/test/PersonKt {//Person位于com.test包下,PersonKt是kotlin生成的与之对应的java类文件
//注意下面两个字段NAME和age的字节码。
// access flags 0x19
public final static Ljava/lang/String; NAME = "zhangsan"//kotlin实际上为NAME生成了public final static修饰的java字段
@Lorg/jetbrains/annotations/NotNull;() // invisible
// access flags 0x1A
private final static I age = 20//kotlin实际上为age生成了private final static修饰的java字段
//注意这里生成的getAage方法
// access flags 0x19
public final static getAge()I
L0
LINENUMBER 4 L0
GETSTATIC com/test/PersonKt.age : I
IRETURN
L1
MAXSTACK = 1
MAXLOCALS = 0
// access flags 0x8
static <clinit>()V//kotlin生成的静态构造方法
L0
LINENUMBER 4 L0
BIPUSH 20
PUTSTATIC com/test/PersonKt.age : I
RETURN
MAXSTACK = 1
MAXLOCALS = 0
// compiled from: Person.kt
}
字节码文件很长,但是不妨碍我们理解其中的意思,这里只需要关注几个点即可:
- 从字节码中可以看出,kotlin为NAME和age都生成了final static标识,只不过NAME是public 而age是private,这也就解释了前面为什么可以通过类名来访问NAME而无法通过类名来访问age了。
- kotlin同时为age生成了一个public final static修饰的方法,这就解释了可以通过getAge访问age的原因了。
- 至于字节码其他的作用我们这里暂时不阐述,后续会慢慢阐述。
总之,关于const val和val可以总结如下:
const val和val都会生成对应于java的static final修饰的字段,而const val会以public修饰,val则会以private修饰。与此同时,编译器还会为val字段生成get方法,以便外部访问。
延迟初始化(Late-Initialized)
通常情况下,非空属性的定义必须要进行初始化(或在构造方法中进行初始化),有些时候,这对于我们来说可能并不符合需求,比如junit单元测试中,我们一般会在setUp方法中进行变量初始化;比如android开发中我们常在onCreate方法对控件进行初始化;再比如使用依赖注入框架的时候我们只需要定义字段不需要立刻初始化等等。kotlin针对这种场景设计了延迟初始化的机制,使用关键字lateinit修饰即可,示例如下:
class Test {
lateinit var people: People//注意这里,使用了lateinit修饰,表示不必立即进行初始化,也不必在构造方法中进行初始化,可以在后面某个时机进行初始化。
fun setup() {//模拟junit的setup方法,在这里完成了对people的初始化
people = People()
}
fun test(){
people.toString()
}
}
针对lateinit修饰符有一下几点说明:
- lateinit修饰符无法修饰val类型的属性
- lateinit修饰符无法修饰空类型属性(即具体类型后面加问号的类型,如String?、Int?等等)
- lateinit修饰符只能修饰位于class body中的属性,而不能修饰位于构造方法中的属性。
- lateinit修饰符无法修饰自定义get或set方法的属性
- lateinit修饰符无法修饰方法中的本地变量(住:kotlin1.2开始支持修饰本地变量和top-level属性)
- lateinit修饰符无法修饰原生类型(Int、Long等等)
- lateinit修饰符表示该属性不需要额外进行判空检查。
- 访问lateinit修饰的属性的时候,如果该属性还没有完成初始化,那么kotlin就会明确抛出异常。
属性重载
关于属性重载的内容已经在kotlin入门潜修之类和对象篇—继承这篇文章进行过阐述,这里不做重复。