在学习了前面的内容之后,相信各位小伙伴应该对Kotlin这门语言有了一些全新的认识,我们已经了解了大部分的基本内容,从本章开始,就是对我们之前所学的基本内容的进一步提升。
在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们还会继续深入,从泛型开始,再到我们的集合类学习,循序渐进。
为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以优秀、良好、合格
来作为结果,还有一种就是 60.0、75.5、92.5
这样的数字分数,可能高等数学这门课是以数字成绩进行结算,而计算机网络实验这门课是以等级进行结算,这两种分数类型都有可能出现,那么现在该如何去设计这样的一个Score类呢?
现在的问题就是,成绩可能是String
类型,也可能是Int
类型,如何才能更好的去存可能出现的两种类型呢?
class Score(var name: String, var id: String, var value: Any) {
//因为Any是所有类型的父类,因此既可以存放Int也能存放String
}
fun main() {
Score("数据结构与算法基础", "EP074512", "优秀") //文字和数字都可以存
Score("计算机操作系统", "EP074533", 95)
}
虽然这样看起来很不错,但是Any毕竟是所有类型的顶级父类,在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况:
fun main() {
val score = Score("数据结构与算法基础", "EP074512", "优秀")
...
val a: Int = score.value as Int //获取成绩需要进行强制类型转换
}
使用Any类型作为引用虽然可以做到任意类型存储,但是对于使用者来说,由于是Any类型,所以说并不能直接判断存储的类型到底是String还是Int,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺。
所以说这种解决办法虽然可行,但并不是最好的方案,我们需要使用一个更好的东西来实现: 泛型
泛型其实就一个待定类型,我们可以使用一个特殊的名字表示泛型,泛型在定义时并不明确是什么类型,而是需要到使用时才会确定对应的泛型类型,Kotlin中的类可以具有类型参数:
class Score<T>(var name: String)
//这里的T就是一个待定的类型,同样是这个类具有的,我们称为泛型参数
可以看到,它相比普通的类型,仅仅多了一个<T>
表示类型参数,那么如何使用呢?
fun main() {
//在创建对象时,再来明确使用的是什么类型,同样使用尖括号填写
val score = Score<Int>("数据结构与算法")
}
既然可以做到使用时明确,那现在我们应该怎么去设计这个类呢?
class Score<T>(var name: String, var id: String, var value: T)
//我们在定义类型参数后,T就是一个待定类型,我们可以直接将value属性的类型定义为T
fun main() {
val score = Score<String>("数据结构与算法基础", "EP074512", "优秀")
//在使用时,使用<String>来明确Score的值类型,此时value的类型也会变成String
val value: String = score.value //得到的直接就是String类型的结果
}
泛型将数据类型的确定控制在了编译阶段,在编写代码的时候就能明确泛型的类型,如果类型不符合,将无法通过编译,同时,如果我们这里填入的参数明确是一个String类型的值,创建时不需要指定T的类型也会自动匹配:
val score = Score("数据结构与算法基础", "EP074512", "优秀") //自动匹配为String类型
而泛型类型在类内部使用时,由于无法确定具体类型,也只能当做Any类去使用:
因为泛型本身就是对某些待定类型的简单处理,如果都明确要使用什么类型了,那大可不必使用泛型。还有,不能通过这个不确定的类型变量就去直接创建对象:
还有,由于泛型在创建时就已经确定,因此即使都是Score类,由于类型参数的不同也会导致不通用:
有了泛型之后,我们再来使用一些类型就非常方便了,并且泛型并不是每个类只能存在一个,我们可以一次性定义多个类型参数:
class Test<K, V>(val key: K, val value: V)
多个不同的类型参数代表不同的类型,这些都可以在使用时明确,并且互不影响。
Kotlin还提供了下划线运算符可以自动推断类型:
kotlin 复制代码fun <K: Comparable<V>, V> test() { } //类型参数中第一个类型参数可以直接推断得到 fun main() { test<Int, _>() //由于前面的类型本身就是Comparable<Int>的子类,已经明确了V的类型,后面就没必要再写一次了,直接使用下划线运算符进行推断即可 }
感觉使用场景应该比较少,了解就行。
当然,不只是类,包括接口、抽象类,都是可以支持泛型的:
interface Test<T> {
}
子类在继承时,可以选择将父类的泛型参数给明确为某一类型,或是使用子类定义的泛型参数作为父类泛型参数的实参使用:
abstract class A<T> {
abstract fun test(): T
}
class B: A<String>() { //子类直接明确为String类型
override fun test(): String = "Hello World" //明确后所有用到泛型的地方都要变成具体类型
}
abstract class C<D>: A<D>() { //子类也有泛型参数D
abstract override fun test(): D
}
fun main() {
val b = B()
println(b.test())
}
除了在类上定义泛型之外,我们也可以在函数上定义:
//在函数名称前添加<T>来增加类型参数,之后函数的返回值或是参数都可以使用这个类型
fun <T> test(t: T): T = t
fun main() {
val value: String = test("Hello World") //调用函数时自动明确类型
}
甚至在使用函数类型的参数时,我们可以使用泛型来代表不确定的类型:
fun <T> test(func: (Int) -> T) : T { //只要是有类型的地方都可以用T代替
...
}
fun <T> test2(func: T.() -> Unit) { //甚至还可以是T类型的扩展函数
...
}
在这之后,我们还会遇到更多官方提供的泛型函数,尤其是下一章的数组和集合部分。
为了我们开发的便利,官方提供了一系列内置的高阶函数,大部分都是通过扩展函数形式定义,我们可以使用来简化我们的代码。
我们之前在使用时或许就已经发现了:
那么怎么依靠它们来简化我们的代码呢?比如下面的代码:
class Student(var name: String, var age: Int) {
fun hello() = println("大家好,我是$name")
}
fun test(student: Student?): Student? {
student?.name = "小明" //不优雅!!!!
student?.age = 18
student?.hello()
returun student;
}
由于传入的是一个可空类型,这导致我们在使用时非常不方便,每次都需要进行判断,有没有更优雅一点的方式来处理呢?
fun test(student: Student?): Student? = student?.apply {
this.name = "小明"
this.age = 18
this.hello()
}
太优雅了,同样的操作,原本繁杂的调用直接简化成了简单的几句代码,真是舒服啊!
我们来介绍一下这些函数时如何使用的,这里以apply为例,这个函数功能是简化我们对某个对象的操作并在最后返回对象本身,在Standard.kt中是这样定义的:
public inline fun <T> T.apply(block: T.() -> Unit): T {
...
block() //调用我们传入的函数
return this //返回当前T类型对象本身
}
可以看到,这个函数也是以扩展函数定义的T可以代表任何类型,所有的类都可以使用这个预设的扩展函数,并且它的参数是一个T.() -> Unit
函数类型的,很明显这是一个高阶函数,并且最后一个参数就是函数类型,后续可以结合我们之前讲解的简化代码。
这个参数非常有意思,比如我们原来需要这样编写:
fun main() {
val student: Student = Student("小明", 18)
student.name = "大明"
student.hello()
}
我们现在可以进行代码优化:
fun main() {
Student("小明", 18).apply {
this.name = "大明"
}.hello()
}
什么鬼,怎么突然就变得这么简单了?我们一个一个来看:
Student("小明", 18).apply{ } //调用Apply后,我们需要传入一个Lambda表达式,也就是我们要如何操作这个对象
我们可以直接将对这个对象全部的操作搬进来,然后在一个Lambda里面就能完成,接着我们对这个对象的其他操作,可以直接在后续编写,因为返回的也是这个对象本身,所以,使用这些预设的高阶函数,在很多情况下都能省掉我们不少代码量。
这里我们来看几个比较常用的:
let
:用于执行一个lambda表达式并将得到的结果作为返回值返回。
//对当前对象进行操作,得到一个新的类型值并作为结果返回
public inline fun <T, R> T.let(block: (T) -> R): R {
...
return block(this) //调用我们传入的函数,并将结果作为let返回值
}
also
:用于执行一个lambda表达式并返回对象本身,跟apply功能一致像,但是采用的是it参数形式传递给Lambda当前对象。
//对当前对象进行操作,并返回当前对象本身
public inline fun <T> T.also(block: (T) -> Unit): T {
...
block(this) //调用我们传入的函数
return this //返回当前T类型对象本身
}
run
:用于执行一个lambda表达式并将得到的结果作为返回值返回,它跟let一样,使用this传递当前对象,可以看到接受的参数是一个扩展函数。
public inline fun <T, R> T.run(block: T.() -> R): R {
...
return block()
}
由此可见,let和run功能相近,apply和also功能相近,只是它们传递对象方式不同,所以说这个就别搞混了。
还有一个比较好用的是,有时候我们可能需要对象满足某些条件才处理,我们可以使用takeIf来完成:
public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? {
...
return if (predicate(this)) this else null //传入一个用于判断的函数,根据结果返回对象本身或是null
}
public inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? {
...
return if (!predicate(this)) this else null //跟上面相反
}
对于takeIf的使用就像下面这样:
fun main() {
val str = "Hello World"
//判断字符串长度是否大于7,大于就返回一个重复一次的字符串,否则原样返回
val myStr = str.takeIf { it.length > 7 }?.let { it + it } ?: str
}
一个很复杂的工作,可能需要很多行代码才能搞定,但是现在借助这些预设的高阶扩展函数,我们就可以以更简短的代码完成。
还有一个比较有意思的:
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
...
return receiver.block() //手动传入一个现有的变量,然后通过这个变量去调用传入的Lamdba
}
用起来就像这样:
fun main() {
val str = "Hello World"
val len = with(str) { this.length }
}
除了我们上面提到的这些,其实在Standard.kt还提供了更多有意思的工具函数,由于篇幅有限,还请各位小伙伴自行探索。
注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。
我们在前面介绍了泛型的基本使用,实际上就是一个待定的类型,我们在使用时可以指定具体的类型,并在编译时检查类型是否匹配,保证运行时类型的安全性,就像下面这样:
class Test<T>(var data: T)
fun main() {
val test1: Test<String> = Test("Hello")
val test2: Test<Int> = Test(10)
}
一旦泛型变量类型确定,后续将一直固定使用此类型,并且不兼容其他类型:
但是现在存在这样一个问题,我们如果使用某个类型的父类呢,会不会出现类型不匹配的情况?
可以看到,即使是Int类型的父类Number,也无法接收其子类类型的结果,这就很奇怪了,我们前面说过一个类可以被当做其父类使用(因为父类具有属性什么子类一定也有)会自动完成隐式类型转换,但是为什么到了泛型这里就不行了呢?
为了探究这个问题,我们先从几个概念开始说起,假设Int类型是Number类型的子类,正常情况下只能子类转换为父类,泛型类型Test<T>
存在以下几种形变:
Test<Int>
同样是Test<Number>
的子类,可以直接转换Test<Number>
可以直接转换为Test<Int>
,前置是后者的子类Test<Int>
跟Test<Number>
没半毛钱关系,无法互相转换而在Kotlin的泛型中,默认就是抗变的,即使两个类型存在父子关系,到编译器这里也不认账,但是实际上我们需要的可能是协变或是逆变,为了处理这种情况,Kotlin提供了两个关键字供我们使用:
out
关键字用于标记一个类型参数作为协变,可以实现子类到父类的转换。in
关键字用于标记一个类型参数作为逆变,可以实现父类到子类的转换。那么该怎么使用呢,非常简单:
fun main() {
val test1: Test<Int> = Test(888)
//使用out关键字使得此类型协变,可以代表Number及其子类
val test2: Test<out Number> = test1 //此时就可以正常接受子类Int了
}
虽然看上去非常难理解,但是简单来说,其实就是为类型添加一个可以转换子类的性质,out
作用就是使类型支持协变,可以支持泛型从父类转换为子类,但是不能子类转父类,比如这里使用Any就没法成功接受。相反的,如果我们标记某个类型为in
,那么这个类型就是逆变的,可以由父类向下转化:
fun main() {
val test1: Test<Any> = Test(888)
//使用in关键字使得此类型逆变,可以代表Number及其父类
val test2: Test<in Number> = test1 //Any是Number的父类,逆变
}
用树形图展示,关系如下:
在使用这种协变或逆变类型时,具体使用的类型就变得不确定了,导致不同的界限会有不同的效果,比如下面:
fun main() {
//协变类型在使用时会变成上界,因为无论子类是什么,都是继承自上界类型的
val test: Test<out Number> = Test(888)
var data: Number = test.data
}
fun main() {
//逆变类型在使用时由于没有上界,具体使用哪个父类也不清楚,所以只能是Any?类型了
val test: Test<in Number> = Test(888)
var data: Any? = test.data
}
在使用out
和in
之后,类型的使用就可以更加灵活,但是这样会存在一定的安全隐患,比如下面的代码:
open class A
class B: A()
class C: A()
fun main() {
val test1: Test<B> = Test(B()) //这里存放的都是B类型的数据
val test2: Test<out A> = test1 //此时test2与test1是同一个对象,但是test2是out A
test2.data = C() //由于C是A的子类,按照正常情况来说可以直接用(但实际上这句会报错)
val data: B = test1.data //这下搞笑了,拿到的类型应该是C,结果接收的类型是B
}
为了解决这种情况,Kotlin对于out或in的类型进行了限制,比如设置了out的情况下:
属性的setter操作被限制,无法通过编译,因为这可能会导致不安全的操作发生,而in也是同理的:
fun main() {
val test1: Test<A> = Test(B()) //这里存的是B类型的对象
val test2: Test<in C> = test1 //直接使用in C接收得到
val data: C = test2.data //此时得到的结果应该也可以是C才对,那肯定是错的
}
因此,在使用in
时,属性的getter操作被限制,会提示类型不匹配,得到的类型也是Any? 无法通过编译,同样是因为可能存在不安全的操作。不仅仅是属性,包括所有函数的参数、返回值,都会受到限制:
fun main() {
val test1: Test<B> = Test(B())
val test2: Test<out A> = test1
test2.test(C()) //报错,因为这里存在消费行为
}
因此,对于in和out来说,协变和逆变的属性将其限制为了生产者和消费者:
out
修饰的泛型不能用作函数的参数,对应类型的成员变量setter也会被限制,只能当做一个生产者使用。in
修饰的泛型不能用作函数的返回值,对应类型的成员变量getter也会被限制,只能当做一个消费者使用。在了解了这么多泛型的知识之后,相信各位小伙伴已经感受到泛型的巧妙而又复杂的设计了。
最后,在有些时候,我们可能并不在乎到底使用哪一个类型,我们希望一个变量可以接受任意类型的结果,而不是去定义某一个特定的上界或下界。在Kotlin泛型中,星号(*
)代表了一种特殊的类型投影,可以代表任意类型:
fun main() {
var test: Test<*> = Test(888) //由于此时使用了*表示任意类型,无论类型如何变化,都可以被此变量接收
test = Test("Hello")
}
同样的,由于不确定具体类型,使用时只能是Any?类型,跟上面in的情况一样,这里就不做演示了,下一章我们还会继续探讨更多*
的默认情况。
注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。
前面我们介绍了协变和逆变,使得泛型的类型可以灵活变化使用,而我们在定义类的时候,在类型参数位置也可以进行限制。
比如有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,这又该怎么去实现呢?
//设定类型参数上界,必须是Number或是Number的子类
class Score<T : Number>(private val name: String, private val id: String, val value: T)
使用类似于继承的语法来完成类型的上界限制,定义后,使用时的具体类型只能是我们指定的上界类型或是上界类型的子类,不得是其他类型,否则一律报错:
在默认情况下,如果我们不指定,那么上界类型就是Any?,而现在,我们在使用时就只能将类型指定为Number的子类了。
如果我们需要设定多个上界,比如必须同时是某两个类型的子类(或接口实现)像这样多个约束设定,我们需要使用where
关键字:
class Score<T>(private val name: String, private val id: String, val value: T)
where T : Comparable<T>, T : Number
//where后跟上多个需要同时匹配的类型
fun main() {
//由于Int同时实现了Comparable接口以及继承自Number,所以满足多个条件,可以使用
var score: Score<Int> = Score("数据结构与算法", "EP710214", 6)
}
通过设定上界,能够更加规范类的使用。
有时候为了方便,我们也可以直接在类定义的时候直接将类型参数指定为out
或是in
来使得其协变或逆变:
interface Test<out T> {
fun test(): T //使用T类型作为返回值
}
interface Test<in T> {
fun test(t: T) //使用T类型作为参数
}
这样我们使用时就可以实现类型自动适应:
interface Test<out T> {
fun test(): T
}
fun test(test: Test<Int>) {
val a: Test<Number> = test //协变
}
同样的,我们前面说了在添加in
或out
后会限制相应的行为来保证类型的安全性,在定义类的一些函数或属性的时候都会得到警告:
在了解了类型界限相关内容之后,我们再来看看*
类型投影在不同情况下的默认类型,比如:
对于Foo<out T : TUpper>
,其中T
是与上界TUpper
的协变类型参数,Foo<*>
等价于Foo<out TUpper>
,就像下面这样:
class Test<out T : Number>(val data: T) //因为限制了out,因此作为生产者,这里只能使用val
fun main() {
val test: Test<*> = Test(10) //虽然使用了*表示不确定,但是由于类型参数本身存在上界
var data: Number = test.data //所以类型读取后可以直接当做上界类型Number使用
}
对于Foo<in T>
,其中T
是逆变类型参数,Foo<*>
等价于Foo<in Nothing>
,无法安全地将属性给到消费者消费:
class Test<in T> {
fun set(t: T) { } //因为限制了in,因此只能作为消费者,这里用函数的形式
}
fun main() {
val test: Test<*> = Test<Int>()
test.set(10) //编译错误,set中参数类型为Nothing,不允许任何值
}
对于Foo<T : TUpper>
,其中T
是具有上界TUpper
的抗变类型参数,在读取数据时Foo<*>
等价于Foo<out TUpper>
,写入数据时等价于Foo<in Nothing>
,就像这样:
class Test<T: Number>(var data: T)
fun main() {
val test: Test<*> = Test(10)
var data: Number = test.data //正常通过
test.data = 10 //编译错误,Setter for 'data' is removed by type projection
}
如果一个泛型类有多个类型参数,每个类型参数都可以独立使用*表示不确定,例如类型为interface Function<in T, out U>
,您可以使用以下星形投影:
Function<*, String>
等价于Function<in Nothing, String>
。Function<Int, *>
等价于Function<Int, out Any?>
。Function<*, *>
等价于Function<in Nothing, out Any?>
。泛型的使用可以很简单也可以很复杂,想要完全把这个搞明白还是需要多练多理解才能达到。
注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。
前面我们介绍了泛型的使用,以及各种高级功能,但是实际上,泛型的类型检查仅仅只存在于编译阶段,在源代码编译之后,实际上并不会保留任何关于泛型类型的内容,这便是类型擦除。
比如下面的类型:
class Test<T>(private var data: T) {
fun test(t: T) : T {
val tmp = data
data = t
return tmp
}
}
在编译时候,会自动擦除类型:
class Test(private var data: Any?) { //最后还是全部变成Any?类型了
fun test(t: Any?) : Any? {
val tmp = data
data = t
return tmp
}
}
如果存在上界,那么擦除后会是上界的类型:
class Test<T : Number>(private var data: T)
class Test(private var data: Number) //擦除后类型变成上界类型
由于在运行时不存在泛型的概念,因此,很多操作都是不允许的,比如类型判断:
class Test<T>(private var data: T) {
fun isType(obj: Any) : Boolean {
return obj is T //编译错误,由于类型擦除,运行时根本不存在T的类型
}
}
包括我们在使用这个泛型类时:
fun main() {
val test: Test<Int> = Test(10)
println(test is Test<Double>) //编译错误,由于类型擦除,无法判断具体的类型
println(test is Test) //编译通过,判断是不是这个类还是没问题的
}
因此,正是为了保证类型擦除之后程序能够安全运行,才有了上面这么多限制。
对于内联函数,泛型擦除的处理会有一些不同,得益于它的内联性质,内联函数的代码是在编译时期直接插入到调用处的,在编译之后具体类型必须要存在,否则会出现问题(因为类型可以明确)因此其泛型参数的具体类型信息是可用的,编译器可以使用这些信息来生成更具体的字节码。这意味着,对于内联函数的泛型参数,并不会像非内联函数那样发生类型擦除。
inline fun <T> test(value: T): T {
val value2 : T = value
return value2
}
fun main() {
val data: String = test("Hello World!")
}
内联函数编译后,类型直接保留:
fun main() {
val value: String = "Hello World!"
val value2: String = value //直接以String类型变量编译到程序中
val data: String = value2
}
Kotlin的内联函数还有一个功能是可以使用具化的类型参数(reified
关键字)具化类型参数允许在函数体内部检测泛型类型,因为这些类型信息会被编译器内嵌在调用点。但是,这只适用于内联函数,因为类型信息在编译时是可知的,并且实际类型会被编译到使用它们的地方,使用也很简单:
//添加reified关键字具化类型参数
inline fun <reified T> isType(value: Any): Boolean {
return value is T //这样就可以在函数里面使用这个类型了
}
fun main() {
println(isType<String>("666"))
}
具化类型参数仅适用于内联函数。
前面我们介绍了泛型,它可以实现在编写代码阶段的类型检查,现在我们就可以正式进入到数组的学习当中了。
假设出现一种情况,我们想记录100个数字,要是采用定义100个变量的方式可以吗?是不是有点太累了?这种情况我们就可以使用数组来存放一组相同类型的数据。
在Kotlin中,数组是Array类型的对象。
数组是相同类型数据的有序集合,数组可以代表任何相同类型的一组内容,其中存放的每一个数据称为数组的一个元素,我们来看看如何创建一个数组,在Kotlin中有两种创建方式:
arrayOf()
、arrayOfNulls()
以及emptyArray()
Array
构造函数创建。比如我们要创建一个包含5个数字的数组,那么我们可以像这样:
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6) //直接在arrayOf函数中添加每一个元素
这里得到的结果类型为Array,它是一个泛型类
public class Array<T> {
//构造函数,包括数组大小、元素初始化函数
public inline constructor(size: Int, init: (Int) -> T)
//重载[]运算符
public operator fun get(index: Int): T
public operator fun set(index: Int, value: T): Unit
//当前数组大小(可以看到是val类型的,一旦确定不可修改)
public val size: Int
//迭代运算重载(后面讲解)
public operator fun iterator(): Iterator<T>
}
可以看到,数组本质就是一个Array类型的对象,其类型参数就是我们存储的元素类型,由于使用构造函数创建数组稍微有些复杂,我们将其放到后面进行介绍。
注意: 数组在创建完成之后,数组容量和元素类型是固定不变的,后续无法进行修改。
fun main() {
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
array.size = 10 //编译错误,长度不可修改
val arr: Array<String> = array //编译错误,类型不匹配
}
既然现在创建好了数组,那么该如何去访问数组里面的内容呢?
fun main() {
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
println(array[0]) //使用[]运算符来访问指定下标上的元素
}
由于数组存放的是一组元素,我们在访问每个元素时需要告诉程序我们要访问的是哪一个,而每个元素都有一个自己的下标地址,下标从0开始从左往右依次递增排列,比如我们要访问第一个元素那么下标就是0,第三个元素下标就是2,以此类推:
fun main() {
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
println("数组中的第二个元素是${array[1]}")
}
注意,在使用数组时,我们只能访问数组可以访问的范围,如果我们获取一个范围之外的元素,会得到错误,比如当前的数组的大小是5那么也就只能包含5个元素,此时我们去访问第六个元素,显然是错误的:
println("数组中的第六个元素是${array[5]}") //已经超出可访问范围了
println("数组中的第?个元素是${array[-1]}") //下标从0开始,怎么可能有-1呢
我们也可以使用[]
修改数组中指定下标元素的值:
fun main() {
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
array[0] = 10 //修改第一个元素的值
println("数组中的第一个元素是${array[0]}")
}
还有一个要注意的是,我们直接打印这个数组对象并不能得到数组里面每个元素的值,而是一堆看不懂的东西:
具体原因可以通过学习Java后进行了解,如果各位小伙伴需要打印数组中的每一个元素,我们只能一个一个打印,可以使用一个for循环语句来完成:
fun main() {
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for (i in 0..<array.size) { //从0循环到array.size前一位
println(array[i]) //每一个依次打印即可
}
}
不过,在Kotlin中,这样编写并不优雅,我们有更好的方式去遍历数组中的每一个元素,在之前我们学习for循环语句时,谈到使用in来遍历一个可遍历的目标,而数组就是满足这个条件的,我们可以直接遍历它:
fun main() {
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for (element in array) {
println(element) //从第一个元素开始依次遍历,element就是每一个元素了
}
}
当然,如果我们还是希望按照数组的索引进行遍历,也可以使用:
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for (i in array.indices) { //indices返回的是数组的有效索引范围,这里就是0 ~ 4
println(array[i])
}
如果你想同时遍历索引和元素本身,也可以使用withIndex函数,它会生成一系列IndexedValue对象:
//关于data class我们会在下一篇中讲解
public data class IndexedValue<out T>(public val index: Int, public val value: T) //包含元素本身和索引
在使用forin时,我们也可以对待遍历的元素进行结构操作,当然,前提是这些对象类型支持解构,比如这里的IndexedValue就支持解构,所以我们可以在遍历时直接使用解构之后的变量进行操作:
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for ((index, item) in array.withIndex()) { //使用withIndex解构后可以同时遍历索引和元素
println("元素$item,位置: $index")
}
如果需要使用Lambda表达式快速处理里面的每一个元素,也可以使用forEach
高阶函数:
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
array.forEach { println(it) } //只带元素的
array.forEachIndexed { index, item -> //同时带索引和元素的
println("元素$item,位置: $index")
}
如果只是想打印数组里面的内容,快速查看,我们可以使用:
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
println(array.joinToString()) //使用joinToString将数组中的元素转换为字符串,默认使用逗号隔开:7, 3, 9, 1, 6
println(array.joinToString(" - ", "> ", " <")) //自定义分隔符,前后缀: > 7 - 3 - 9 - 1 - 6 <
println(array.joinToString(limit = 1, truncated = "...")) //甚至可以限制数量,多余的用自定义的字符串...代替: 7, ...
println(array.joinToString() { (it * it).toString() }) //自定义每一个元素转换为字符串的结果
我们接着来看一下如何使用构造函数来创建数组,首先构造函数时这样定义的:
/**
* size: 不必多说,数组的大小
* init: 初始化操作,这个操作会根据数组大小,循环调用传入的函数size次,并且将对应的下标作为参数,我们需要在函数中返回当前数组元素类型的结果,这样就会自动填充到数组的对应位置上
*/
public inline constructor(size: Int, init: (Int) -> T)
比如我们希望创建一个字符串数组:
fun main() {
val array: Array<String> = Array(5) { "我是元素$it" } //其中返回值为自定义的字符串,这样就会自动填充到对应位置
for (s in array) {
println(s)
}
}
利用这种特性,我们可以快速创建一个全是同一个值的数组:
val array: Array<Double> = Array(5) { 1.5 } // 1.5, 1.5, 1.5, 1.5 ...
还可以快速搞一个平方数数组:
val array: Array<Int> = Array(10) { it * it } // 0, 1, 4, 9, 16 ...
不过,其实一般情况下使用arrayOf
都可以解决大部分情况了,还有它的变种,大概介绍一下:
val array: Array<Int> = emptyArray<Int>() //创建容量为0的数组
val array: Array<Int?> = arrayOfNulls(10) //创建元素可空的数组
下一节课我们接着学习更多数组的操作。