本文将伴随大家进入Kotlin语言的正式学习生涯中,希望大家不要半途而废哦!笔者将Kotlin用于Android开发中,因此将从Android开发的视角叙述相关内容,同时将与Java语言有所联系。
1. 数据类
1.1 数据类的定义
我们经常创建一些只保存数据的类。 在这些类中,一些标准函数往往是从数据机械推导而来的。在 Kotlin 中,这叫做数据类
并标记为data
:
1 | data class User(val name: String, val age: Int) |
编译器自动从主构造函数中声明的所有属性导出以下成员:
equals()
/hashCode()
对;toString()
格式是User(name=John, age=42)
;componentN()
函数 按声明顺序对应于所有属性;copy()
函数(见下文)。
为了确保生成的代码的一致性以及有意义的行为,数据类必须满足以下要求:
- 主构造函数需要至少有一个参数;
- 主构造函数的所有参数需要标记为
val
或var
; - 数据类不能是抽象、开放、密封或者内部的;
- (在1.1之前)数据类只能实现接口。
此外,成员生成遵循关于成员继承的这些规则:
- 如果在数据类体中有显式实现
equals()
、hashCode()
或者toString()
,或者这些函数在父类中有final
实现,那么不会生成这些函数,而会使用现有函数; - 如果超类型具有
open
的componentN()
函数并且返回兼容的类型, 那么会为数据类生成相应的函数,并覆盖超类的实现。如果超类型的这些函数由于签名不兼容或者是final
而导致无法覆盖,那么会报错; - 从一个已具
copy(……)
函数且签名匹配的类型派生一个数据类在Kotlin 1.2
中已弃用,并且在Kotlin 1.3
中已禁用。 - 不允许为
componentN()
以及copy()
函数提供显式实现。
自 1.1 起,数据类可以扩展其他类。
在 JVM
中,如果生成的类需要含有一个无参的构造函数,则所有的属性必须指定默认值。
1 | data class User(val name: String = "", val age: Int = 0) |
1.2 在类体中声明的属性
请注意,对于那些自动生成的函数,编译器只使用在主构造函数内部定义的属性。如需在生成的实现中排除一个属性,请将其声明在类体中:
1 | data class Person(val name: String) { |
在 toString()
、equals()
、 hashCode()
以及 copy()
的实现中只会用到 name
属性,并且只有一个 component
函数 component1()
。虽然两个Person
对象可以有不同的年龄,但它们会视为相等。
1 | val person1 = Person("John") |
1.3 复制
在很多情况下,我们需要复制一个对象改变它的一些属性,但其余部分保持不变。copy()
函数就是为此而生成。对于上文的User
类,其实现会类似下面这样:
1 | fun copy(name: String = this.name, age: Int = this.age) = User(name, age) |
这让我们可以写:
1 | val jack = User(name = "Jack", age = 1) |
1.4 数据类与解构声明
为数据类生成的Component
函数 使它们可在解构声明中使用:
1 | val jane = User("Jane", 35) |
2. 密封类
密封类用来表示受限的类继承结构:当一个值为有限几种的类型、而不能有任何其他类型时。在某种意义上,他们是枚举类的扩展:枚举类型的值集合也是受限的,但每个枚举常量只存在一个实例,而密封类的一个子类可以有可包含状态的多个实例。
要声明一个密封类,需要在类名前面添加sealed
修饰符。虽然密封类也可以有子类,但是所有子类都必须在与密封类自身相同的文件中声明。(在 Kotlin 1.1
之前, 该规则更加严格:子类必须嵌套在密封类声明的内部)。
1 | sealed class Expr |
(上文示例使用了 Kotlin 1.1
的一个额外的新功能:数据类扩展包括密封类在内的其他类的可能性。 )
一个密封类是自身抽象的,它不能直接实例化并可以有抽象(abstract
)成员。
密封类不允许有非private
构造函数(其构造函数默认为private
)。
请注意,扩展密封类子类的类(间接继承者
)可以放在任何位置,而无需在同一个文件中。
使用密封类的关键好处在于使用when
表达式 的时候,如果能够验证语句覆盖了所有情况,就不需要为该语句再添加一个else
子句了。当然,这只有当你用when
作为表达式(使用结果)而不是作为语句时才有用。
1 | fun eval(expr: Expr): Double = when(expr) { |
3. 泛型
与 Java 类似,Kotlin 中的类也可以有类型参数:
1 | class Box<T>(t: T) { |
一般来说,要创建这样类的实例,我们需要提供类型参数:
1 | val box: Box<Int> = Box<Int>(1) |
但是如果类型参数可以推断出来,例如从构造函数的参数或者从其他途径,允许省略类型参数:
1 | val box = Box(1) // 1 具有类型 Int,所以编译器知道我们说的是 Box<Int>。 |
3.1 声明处型变
我们可以标注 Source
的类型参数 T
来确保它仅从Source<T>
成员中返回(生产),并从不被消费。 为此,我们提供out
修饰符:
1 | interface Source<out T> { |
一般原则是:当一个类C
的类型参数 T
被声明为 out
时,它就只能出现在C
的成员的输出位置,但回报是 C<Base>
可以安全地作为 C<Derived>
的超类。
简而言之,他们说类 C
是在参数 T
上是协变的,或者说 T
是一个协变的类型参数。 你可以认为 C
是 T
的生产者,而不是 T
的消费者。
out
修饰符称为型变注解,并且由于它在类型参数声明处提供,所以我们称之为声明处型变。
另外除了 out
,Kotlin 又补充了一个型变注释:in
。它使得一个类型参数逆变:只可以被消费而不可以被生产。逆变类型的一个很好的例子是 Comparable
:
1 | interface Comparable<in T> { |
我们相信 in
和out
两词是自解释的(因为它们已经在 C# 中成功使用很长时间了), 因此上面提到的助记符不是真正需要的。
3.2 使用处型变
3.2.1 类型投影
将类型参数 T
声明为 out
非常方便,并且能避免使用处子类型化的麻烦,但是有些类实际上不能限制为只返回 T
! 一个很好的例子是 Array
:
1 | class Array<T>(val size: Int) { |
该类在 T
上既不能是协变的也不能是逆变的。这造成了一些不灵活性。考虑下述函数:
1 | fun copy(from: Array<Any>, to: Array<Any>) { |
这个函数应该将项目从一个数组复制到另一个数组。让我们尝试在实践中应用它:
1 | val ints: Array<Int> = arrayOf(1, 2, 3) |
这里我们遇到同样熟悉的问题:Array <T>
在T
上是不型变的,因此 Array <Int>
和 Array <Any>
都不是另一个的子类型。为什么? 再次重复,因为 copy
可能做坏事,也就是说,例如它可能尝试写一个 String
到from
, 并且如果我们实际上传递一个 Int
的数组,一段时间后将会抛出一个 ClassCastException
异常。
那么,我们唯一要确保的是 copy()
不会做任何坏事。我们想阻止它写到 from
,我们可以:
1 | fun copy(from: Array<out Any>, to: Array<Any>) { …… } |
这里发生的事情称为类型投影:我们说from
不仅仅是一个数组,而是一个受限制的(投影的)数组:我们只可以调用返回类型为类型参数 T
的方法,如上,这意味着我们只能调用 get()
。这就是我们的使用处型变的用法,并且是对应于 Java
的 Array<? extends Object>
、 但使用更简单些的方式。
你也可以使用in
投影一个类型:
1 | fun fill(dest: Array<in String>, value: String) { …… } |
Array<in String>
对应于 Java 的 Array<? super String>
,也就是说,你可以传递一个 CharSequence
数组或一个 Object
数组给 fill()
函数。
3.2.2 星投影
有时你想说,你对类型参数一无所知,但仍然希望以安全的方式使用它。 这里的安全方式是定义泛型类型的这种投影,该泛型类型的每个具体实例化将是该投影的子类型。
Kotlin 为此提供了所谓的星投影语法:
- 对于
Foo <out T : TUpper>
,其中T
是一个具有上界TUpper
的协变类型参数,Foo <*>
等价于Foo <out TUpper>
。 这意味着当T
未知时,你可以安全地从Foo <*>
读取TUpper
的值。 - 对于
Foo <in T>
,其中T
是一个逆变类型参数,Foo <*>
等价于Foo <in Nothing>
。 这意味着当T
未知时,没有什么可以以安全的方式写入Foo <*>
。 - 对于
Foo <T : TUpper>
,其中 T 是一个具有上界 TUpper 的不型变类型参数,Foo<*>
对于读取值时等价于Foo<out TUpper>
而对于写值时等价于Foo<in Nothing>
。
如果泛型类型具有多个类型参数,则每个类型参数都可以单独投影。 例如,如果类型被声明为 interface Function <in T, out U>
,我们可以想象以下星投影:
Function<*, String>
表示Function<in Nothing, String>
;Function<Int, *>
表示Function<Int, out Any?>
;Function<*, *>
表示Function<in Nothing, out Any?>
。
注意:星投影非常像 Java 的原始类型,但是安全。
3.3 泛型函数
不仅类可以有类型参数。函数也可以有。类型参数要放在函数名称之前:
1 | fun <T> singletonList(item: T): List<T> { |
要调用泛型函数,在调用处函数名之后指定类型参数即可:
1 | val l = singletonList<Int>(1) |
可以省略能够从上下文中推断出来的类型参数,所以以下示例同样适用:
1 | val l = singletonList(1) |
4. 枚举类
枚举类的最基本的用法是实现类型安全的枚举:
1 | enum class Direction { |
每个枚举常量都是一个对象。枚举常量用逗号分隔。
4.1 初始化
因为每一个枚举都是枚举类的实例,所以他们可以是这样初始化过的:
1 | enum class Color(val rgb: Int) { |
4.2 匿名类
枚举常量还可以声明其带有相应方法以及覆盖了基类方法的匿名类。
1 | enum class ProtocolState { |
如果枚举类定义任何成员,那么使用分号将成员定义中的枚举常量定义分隔开。
枚举条目不能包含内部类以外的嵌套类型(已在Kotlin 1.2
中弃用)。
4.3 在枚举类中实现接口
一个枚举类可以实现接口(但不能从类继承),可以为所有条目提供统一的接口成员实现,也可以在相应匿名类中为每个条目提供各自的实现。只需将接口添加到枚举类声明中即可,如下所示:
1 | enum class IntArithmetics : BinaryOperator<Int>, IntBinaryOperator { |
4.4 使用枚举常量
Kotlin 中的枚举类也有合成方法允许列出定义的枚举常量以及通过名称获取枚举常量。这些方法的签名如下(假设枚举类的名称是 EnumClass
):
1 | EnumClass.valueOf(value: String): EnumClass |
如果指定的名称与类中定义的任何枚举常量均不匹配,valueOf()
方法将抛出 IllegalArgumentException
异常。
自 Kotlin 1.1
起,可以使用 enumValues<T>()
与 enumValueOf<T>()
函数以泛型的方式访问枚举类中的常量 :
1 | enum class RGB { RED, GREEN, BLUE } |
每个枚举常量都具有在枚举类声明中获取其名称与位置的属性:
1 | val name: String |
枚举常量还实现了 Comparable
接口, 其中自然顺序是它们在枚举类中定义的顺序。
5. 内联类
内联类仅在
Kotlin 1.3
之后版本可用,目前还是实验性的,本文中简单介绍一下内联类,详细使用等稳定后再总结
有时候,业务逻辑需要围绕某种类型创建包装器。然而,由于额外的堆内存分配问题,它会引入运行时的性能开销。此外,如果被包装的类型是原生类型,性能的损失是很糟糕的,因为原生类型通常在运行时就进行了大量优化,然而他们的包装器却没有得到任何特殊的处理。
为了解决这类问题,Kotlin 引入了一种被称为 内联类
的特殊类,它通过在类的前面定义一个 inline
修饰符来声明:
1 | inline class Password(val value: String) |
内联类必须含有唯一的一个属性在主构造函数中初始化。在运行时,将使用这个唯一属性来表示内联类的实例(关于运行时的内部表达请参阅下文):
1 | // 不存在 'Password' 类的真实实例对象 |
这就是内联类的主要特性,它灵感来源于 inline
这个名称:类的数据被 “内联”到该类使用的地方(类似于内联函数中的代码被内联到该函数调用的地方)。