当前正在阅读
Kotlin程序设计基础
预计阅读时间: 4 小时
  知识库提供的所有文档均为本站版权所有,禁止任何未经授权的个人或企业发布、传播、售卖本站提供的文档,如经发现,本站有权起诉侵权方并追究法律责任。
image-20230821184810141

Kotlin程序设计高级篇

在学习了前面的内容之后,相信各位小伙伴应该对Kotlin这门语言有了一些全新的认识,我们已经了解了大部分的基本内容,从本章开始,就是对我们之前所学的基本内容的进一步提升。

泛型

在前面我们学习了最重要的类和对象,了解了面向对象编程的思想,注意,非常重要,面向对象是必须要深入理解和掌握的内容,不能草草结束。在本章节,我们还会继续深入,从泛型开始,再到我们的集合类学习,循序渐进。

什么是泛型

为了统计学生成绩,要求设计一个Score对象,包括课程名称、课程号、课程成绩,但是成绩分为两种,一种是以优秀、良好、合格 来作为结果,还有一种就是 60.0、75.5、92.5 这样的数字分数,可能高等数学这门课是以数字成绩进行结算,而计算机网络实验这门课是以等级进行结算,这两种分数类型都有可能出现,那么现在该如何去设计这样的一个Score类呢?

现在的问题就是,成绩可能是String类型,也可能是Int类型,如何才能更好的去存可能出现的两种类型呢?

kotlin 复制代码
class Score(var name: String, var id: String, var value: Any) {
    //因为Any是所有类型的父类,因此既可以存放Int也能存放String
}

fun main() {
    Score("数据结构与算法基础", "EP074512", "优秀")  //文字和数字都可以存
    Score("计算机操作系统", "EP074533", 95)
}

虽然这样看起来很不错,但是Any毕竟是所有类型的顶级父类,在编译阶段并不具有良好的类型判断能力,很容易出现以下的情况:

kotlin 复制代码
fun main() {
    val score = Score("数据结构与算法基础", "EP074512", "优秀")
  	...
    val a: Int = score.value as Int   //获取成绩需要进行强制类型转换
}

使用Any类型作为引用虽然可以做到任意类型存储,但是对于使用者来说,由于是Any类型,所以说并不能直接判断存储的类型到底是String还是Int,取值只能进行强制类型转换,显然无法在编译期确定类型是否安全,项目中代码量非常之大,进行类型比较又会导致额外的开销和增加代码量,如果不经比较就很容易出现类型转换异常,代码的健壮性有所欠缺。

所以说这种解决办法虽然可行,但并不是最好的方案,我们需要使用一个更好的东西来实现: 泛型

泛型其实就一个待定类型,我们可以使用一个特殊的名字表示泛型,泛型在定义时并不明确是什么类型,而是需要到使用时才会确定对应的泛型类型,Kotlin中的类可以具有类型参数:

kotlin 复制代码
class Score<T>(var name: String)
//这里的T就是一个待定的类型,同样是这个类具有的,我们称为泛型参数

可以看到,它相比普通的类型,仅仅多了一个<T>表示类型参数,那么如何使用呢?

kotlin 复制代码
fun main() {
  	//在创建对象时,再来明确使用的是什么类型,同样使用尖括号填写
    val score = Score<Int>("数据结构与算法")
}

既然可以做到使用时明确,那现在我们应该怎么去设计这个类呢?

kotlin 复制代码
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的类型也会自动匹配:

kotlin 复制代码
val score = Score("数据结构与算法基础", "EP074512", "优秀")  //自动匹配为String类型

而泛型类型在类内部使用时,由于无法确定具体类型,也只能当做Any类去使用:

image-20231224145801613

因为泛型本身就是对某些待定类型的简单处理,如果都明确要使用什么类型了,那大可不必使用泛型。还有,不能通过这个不确定的类型变量就去直接创建对象:

image-20231224145942619

还有,由于泛型在创建时就已经确定,因此即使都是Score类,由于类型参数的不同也会导致不通用:

image-20231224150118514

有了泛型之后,我们再来使用一些类型就非常方便了,并且泛型并不是每个类只能存在一个,我们可以一次性定义多个类型参数:

kotlin 复制代码
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的类型,后面就没必要再写一次了,直接使用下划线运算符进行推断即可
}

感觉使用场景应该比较少,了解就行。

当然,不只是类,包括接口、抽象类,都是可以支持泛型的:

kotlin 复制代码
interface Test<T> {
    
}

子类在继承时,可以选择将父类的泛型参数给明确为某一类型,或是使用子类定义的泛型参数作为父类泛型参数的实参使用:

kotlin 复制代码
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())
}

除了在类上定义泛型之外,我们也可以在函数上定义:

kotlin 复制代码
//在函数名称前添加<T>来增加类型参数,之后函数的返回值或是参数都可以使用这个类型
fun <T> test(t: T): T = t

fun main() {
    val value: String = test("Hello World")  //调用函数时自动明确类型
}

甚至在使用函数类型的参数时,我们可以使用泛型来代表不确定的类型:

kotlin 复制代码
fun <T> test(func: (Int) -> T) : T {  //只要是有类型的地方都可以用T代替
 		...
}

fun <T> test2(func: T.() -> Unit) {  //甚至还可以是T类型的扩展函数
		...
}

在这之后,我们还会遇到更多官方提供的泛型函数,尤其是下一章的数组和集合部分。

官方高阶扩展函数

为了我们开发的便利,官方提供了一系列内置的高阶函数,大部分都是通过扩展函数形式定义,我们可以使用来简化我们的代码。

我们之前在使用时或许就已经发现了:

image-20231224174024496

那么怎么依靠它们来简化我们的代码呢?比如下面的代码:

kotlin 复制代码
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;
}

由于传入的是一个可空类型,这导致我们在使用时非常不方便,每次都需要进行判断,有没有更优雅一点的方式来处理呢?

kotlin 复制代码
fun test(student: Student?): Student? = student?.apply {
    this.name = "小明"
    this.age = 18
    this.hello()
}

太优雅了,同样的操作,原本繁杂的调用直接简化成了简单的几句代码,真是舒服啊!

我们来介绍一下这些函数时如何使用的,这里以apply为例,这个函数功能是简化我们对某个对象的操作并在最后返回对象本身,在Standard.kt中是这样定义的:

kotlin 复制代码
public inline fun <T> T.apply(block: T.() -> Unit): T {
    ...
    block()   //调用我们传入的函数
    return this   //返回当前T类型对象本身
}

可以看到,这个函数也是以扩展函数定义的T可以代表任何类型,所有的类都可以使用这个预设的扩展函数,并且它的参数是一个T.() -> Unit函数类型的,很明显这是一个高阶函数,并且最后一个参数就是函数类型,后续可以结合我们之前讲解的简化代码。

这个参数非常有意思,比如我们原来需要这样编写:

kotlin 复制代码
fun main() {
    val student: Student = Student("小明", 18)
    student.name = "大明"
    student.hello()
}

我们现在可以进行代码优化:

kotlin 复制代码
fun main() {
    Student("小明", 18).apply { 
        this.name = "大明"
    }.hello()
}

什么鬼,怎么突然就变得这么简单了?我们一个一个来看:

kotlin 复制代码
Student("小明", 18).apply{  }  //调用Apply后,我们需要传入一个Lambda表达式,也就是我们要如何操作这个对象

我们可以直接将对这个对象全部的操作搬进来,然后在一个Lambda里面就能完成,接着我们对这个对象的其他操作,可以直接在后续编写,因为返回的也是这个对象本身,所以,使用这些预设的高阶函数,在很多情况下都能省掉我们不少代码量。

这里我们来看几个比较常用的:

  1. let:用于执行一个lambda表达式并将得到的结果作为返回值返回。

    kotlin 复制代码
    //对当前对象进行操作,得到一个新的类型值并作为结果返回
    public inline fun <T, R> T.let(block: (T) -> R): R {
       	...
        return block(this)  //调用我们传入的函数,并将结果作为let返回值
    }
  2. also:用于执行一个lambda表达式并返回对象本身,跟apply功能一致像,但是采用的是it参数形式传递给Lambda当前对象。

    kotlin 复制代码
    //对当前对象进行操作,并返回当前对象本身
    public inline fun <T> T.also(block: (T) -> Unit): T {
        ...
        block(this)   //调用我们传入的函数
        return this   //返回当前T类型对象本身
    }
  3. run:用于执行一个lambda表达式并将得到的结果作为返回值返回,它跟let一样,使用this传递当前对象,可以看到接受的参数是一个扩展函数。

    kotlin 复制代码
    public inline fun <T, R> T.run(block: T.() -> R): R {
        ...
        return block()
    }

由此可见,let和run功能相近,apply和also功能相近,只是它们传递对象方式不同,所以说这个就别搞混了。

还有一个比较好用的是,有时候我们可能需要对象满足某些条件才处理,我们可以使用takeIf来完成:

kotlin 复制代码
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的使用就像下面这样:

kotlin 复制代码
fun main() {
    val str = "Hello World"
  	//判断字符串长度是否大于7,大于就返回一个重复一次的字符串,否则原样返回
    val myStr = str.takeIf { it.length > 7 }?.let { it + it } ?: str
}

一个很复杂的工作,可能需要很多行代码才能搞定,但是现在借助这些预设的高阶扩展函数,我们就可以以更简短的代码完成。

还有一个比较有意思的:

kotlin 复制代码
public inline fun <T, R> with(receiver: T, block: T.() -> R): R {
    ...
    return receiver.block()  //手动传入一个现有的变量,然后通过这个变量去调用传入的Lamdba
}

用起来就像这样:

kotlin 复制代码
fun main() {
    val str = "Hello World"
    val len = with(str) { this.length } 
}

除了我们上面提到的这些,其实在Standard.kt还提供了更多有意思的工具函数,由于篇幅有限,还请各位小伙伴自行探索。

协变与逆变*

注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。

我们在前面介绍了泛型的基本使用,实际上就是一个待定的类型,我们在使用时可以指定具体的类型,并在编译时检查类型是否匹配,保证运行时类型的安全性,就像下面这样:

kotlin 复制代码
class Test<T>(var data: T) 

fun main() {
    val test1: Test<String> = Test("Hello")
    val test2: Test<Int> = Test(10)
}

一旦泛型变量类型确定,后续将一直固定使用此类型,并且不兼容其他类型:

image-20231225000422558

但是现在存在这样一个问题,我们如果使用某个类型的父类呢,会不会出现类型不匹配的情况?

image-20231225000541465

可以看到,即使是Int类型的父类Number,也无法接收其子类类型的结果,这就很奇怪了,我们前面说过一个类可以被当做其父类使用(因为父类具有属性什么子类一定也有)会自动完成隐式类型转换,但是为什么到了泛型这里就不行了呢?

为了探究这个问题,我们先从几个概念开始说起,假设Int类型是Number类型的子类,正常情况下只能子类转换为父类,泛型类型Test<T>存在以下几种形变:

  • 协变 (Covariance):因为Int是Number的子类,所以Test<Int>同样是Test<Number>的子类,可以直接转换
  • 逆变(Contravariance):跟上面相反,Test<Number>可以直接转换为Test<Int>,前置是后者的子类
  • 抗变 (Invariant):Test<Int>Test<Number>没半毛钱关系,无法互相转换

而在Kotlin的泛型中,默认就是抗变的,即使两个类型存在父子关系,到编译器这里也不认账,但是实际上我们需要的可能是协变或是逆变,为了处理这种情况,Kotlin提供了两个关键字供我们使用:

  • out 关键字用于标记一个类型参数作为协变,可以实现子类到父类的转换。
  • in 关键字用于标记一个类型参数作为逆变,可以实现父类到子类的转换。

那么该怎么使用呢,非常简单:

kotlin 复制代码
fun main() {
    val test1: Test<Int> = Test(888)
  	//使用out关键字使得此类型协变,可以代表Number及其子类
    val test2: Test<out Number> = test1  //此时就可以正常接受子类Int了
}

虽然看上去非常难理解,但是简单来说,其实就是为类型添加一个可以转换子类的性质,out作用就是使类型支持协变,可以支持泛型从父类转换为子类,但是不能子类转父类,比如这里使用Any就没法成功接受。相反的,如果我们标记某个类型为in,那么这个类型就是逆变的,可以由父类向下转化:

kotlin 复制代码
fun main() {
    val test1: Test<Any> = Test(888)
  	//使用in关键字使得此类型逆变,可以代表Number及其父类
    val test2: Test<in Number> = test1  //Any是Number的父类,逆变
}

用树形图展示,关系如下:

image-20231224155321582
image-20231225004519670

在使用这种协变或逆变类型时,具体使用的类型就变得不确定了,导致不同的界限会有不同的效果,比如下面:

kotlin 复制代码
fun main() {
  	//协变类型在使用时会变成上界,因为无论子类是什么,都是继承自上界类型的
    val test: Test<out Number> = Test(888)
    var data: Number = test.data
}
kotlin 复制代码
fun main() {
  	//逆变类型在使用时由于没有上界,具体使用哪个父类也不清楚,所以只能是Any?类型了
    val test: Test<in Number> = Test(888)
    var data: Any? = test.data
}

在使用outin之后,类型的使用就可以更加灵活,但是这样会存在一定的安全隐患,比如下面的代码:

kotlin 复制代码
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的情况下:

image-20231225020935723

属性的setter操作被限制,无法通过编译,因为这可能会导致不安全的操作发生,而in也是同理的:

kotlin 复制代码
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? 无法通过编译,同样是因为可能存在不安全的操作。不仅仅是属性,包括所有函数的参数、返回值,都会受到限制:

kotlin 复制代码
fun main() {
    val test1: Test<B> = Test(B())
    val test2: Test<out A> = test1
    test2.test(C())  //报错,因为这里存在消费行为
}

因此,对于in和out来说,协变和逆变的属性将其限制为了生产者和消费者:

  • 使用out修饰的泛型不能用作函数的参数,对应类型的成员变量setter也会被限制,只能当做一个生产者使用。
  • 使用in修饰的泛型不能用作函数的返回值,对应类型的成员变量getter也会被限制,只能当做一个消费者使用。

在了解了这么多泛型的知识之后,相信各位小伙伴已经感受到泛型的巧妙而又复杂的设计了。

最后,在有些时候,我们可能并不在乎到底使用哪一个类型,我们希望一个变量可以接受任意类型的结果,而不是去定义某一个特定的上界或下界。在Kotlin泛型中,星号(*)代表了一种特殊的类型投影,可以代表任意类型:

kotlin 复制代码
fun main() {
    var test: Test<*> = Test(888)  //由于此时使用了*表示任意类型,无论类型如何变化,都可以被此变量接收
    test = Test("Hello")
}

同样的,由于不确定具体类型,使用时只能是Any?类型,跟上面in的情况一样,这里就不做演示了,下一章我们还会继续探讨更多*的默认情况。

泛型界限*

注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。

前面我们介绍了协变和逆变,使得泛型的类型可以灵活变化使用,而我们在定义类的时候,在类型参数位置也可以进行限制。

比如有一个新的需求,现在没有String类型的成绩了,但是成绩依然可能是整数,也可能是小数,这时我们不希望用户将泛型指定为除数字类型外的其他类型,这又该怎么去实现呢?

kotlin 复制代码
//设定类型参数上界,必须是Number或是Number的子类
class Score<T : Number>(private val name: String, private val id: String, val value: T)

使用类似于继承的语法来完成类型的上界限制,定义后,使用时的具体类型只能是我们指定的上界类型或是上界类型的子类,不得是其他类型,否则一律报错:

image-20231225010418319

在默认情况下,如果我们不指定,那么上界类型就是Any?,而现在,我们在使用时就只能将类型指定为Number的子类了。

如果我们需要设定多个上界,比如必须同时是某两个类型的子类(或接口实现)像这样多个约束设定,我们需要使用where关键字:

kotlin 复制代码
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来使得其协变或逆变:

kotlin 复制代码
interface Test<out T> {
    fun test(): T   //使用T类型作为返回值
}

interface Test<in T> {
    fun test(t: T)  //使用T类型作为参数
}

这样我们使用时就可以实现类型自动适应:

kotlin 复制代码
interface Test<out T> {
    fun test(): T
}

fun test(test: Test<Int>) {
    val a: Test<Number> = test  //协变
}

同样的,我们前面说了在添加inout后会限制相应的行为来保证类型的安全性,在定义类的一些函数或属性的时候都会得到警告:

image-20231225022706721

在了解了类型界限相关内容之后,我们再来看看*类型投影在不同情况下的默认类型,比如:

  • 对于Foo<out T : TUpper>,其中T是与上界TUpper的协变类型参数,Foo<*>等价于Foo<out TUpper>,就像下面这样:

    kotlin 复制代码
    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>,无法安全地将属性给到消费者消费:

    kotlin 复制代码
    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>,就像这样:

    kotlin 复制代码
    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?>

泛型的使用可以很简单也可以很复杂,想要完全把这个搞明白还是需要多练多理解才能达到。

类型擦除*

注意: 这一部分相当有难度,请务必将前面的泛型概念理解到位,否则很难继续学习。

前面我们介绍了泛型的使用,以及各种高级功能,但是实际上,泛型的类型检查仅仅只存在于编译阶段,在源代码编译之后,实际上并不会保留任何关于泛型类型的内容,这便是类型擦除。

比如下面的类型:

kotlin 复制代码
class Test<T>(private var data: T) {
    fun test(t: T) : T {
        val tmp = data
        data = t
        return tmp
    }
}

在编译时候,会自动擦除类型:

kotlin 复制代码
class Test(private var data: Any?) {  //最后还是全部变成Any?类型了
    fun test(t: Any?) : Any? {
        val tmp = data
        data = t
        return tmp
    }
}

如果存在上界,那么擦除后会是上界的类型:

kotlin 复制代码
class Test<T : Number>(private var data: T) 
kotlin 复制代码
class Test(private var data: Number)   //擦除后类型变成上界类型

由于在运行时不存在泛型的概念,因此,很多操作都是不允许的,比如类型判断:

kotlin 复制代码
class Test<T>(private var data: T) {
    fun isType(obj: Any) : Boolean {
        return obj is T   //编译错误,由于类型擦除,运行时根本不存在T的类型
    }
}

包括我们在使用这个泛型类时:

kotlin 复制代码
fun main() {
    val test: Test<Int> = Test(10)
    println(test is Test<Double>)   //编译错误,由于类型擦除,无法判断具体的类型
  	println(test is Test)  //编译通过,判断是不是这个类还是没问题的
}

因此,正是为了保证类型擦除之后程序能够安全运行,才有了上面这么多限制。

对于内联函数,泛型擦除的处理会有一些不同,得益于它的内联性质,内联函数的代码是在编译时期直接插入到调用处的,在编译之后具体类型必须要存在,否则会出现问题(因为类型可以明确)因此其泛型参数的具体类型信息是可用的,编译器可以使用这些信息来生成更具体的字节码。这意味着,对于内联函数的泛型参数,并不会像非内联函数那样发生类型擦除。

kotlin 复制代码
inline fun <T> test(value: T): T {
    val value2 : T = value
    return value2
}

fun main() {
    val data: String = test("Hello World!")
}

内联函数编译后,类型直接保留:

kotlin 复制代码
fun main() {
    val value: String = "Hello World!"
    val value2: String = value   //直接以String类型变量编译到程序中
    val data: String = value2
}

Kotlin的内联函数还有一个功能是可以使用具化的类型参数(reified 关键字)具化类型参数允许在函数体内部检测泛型类型,因为这些类型信息会被编译器内嵌在调用点。但是,这只适用于内联函数,因为类型信息在编译时是可知的,并且实际类型会被编译到使用它们的地方,使用也很简单:

kotlin 复制代码
//添加reified关键字具化类型参数
inline fun <reified T> isType(value: Any): Boolean {
    return value is T  //这样就可以在函数里面使用这个类型了
}

fun main() {
    println(isType<String>("666"))
}

具化类型参数仅适用于内联函数。

数组

前面我们介绍了泛型,它可以实现在编写代码阶段的类型检查,现在我们就可以正式进入到数组的学习当中了。

假设出现一种情况,我们想记录100个数字,要是采用定义100个变量的方式可以吗?是不是有点太累了?这种情况我们就可以使用数组来存放一组相同类型的数据。

image-20220922214604430

在Kotlin中,数组是Array类型的对象。

创建数组

数组是相同类型数据的有序集合,数组可以代表任何相同类型的一组内容,其中存放的每一个数据称为数组的一个元素,我们来看看如何创建一个数组,在Kotlin中有两种创建方式:

比如我们要创建一个包含5个数字的数组,那么我们可以像这样:

kotlin 复制代码
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)  //直接在arrayOf函数中添加每一个元素

这里得到的结果类型为Array,它是一个泛型类

kotlin 复制代码
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类型的对象,其类型参数就是我们存储的元素类型,由于使用构造函数创建数组稍微有些复杂,我们将其放到后面进行介绍。

注意: 数组在创建完成之后,数组容量和元素类型是固定不变的,后续无法进行修改。

kotlin 复制代码
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    array.size = 10   //编译错误,长度不可修改
    val arr: Array<String> = array  //编译错误,类型不匹配
}

既然现在创建好了数组,那么该如何去访问数组里面的内容呢?

kotlin 复制代码
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    println(array[0])   //使用[]运算符来访问指定下标上的元素
}

由于数组存放的是一组元素,我们在访问每个元素时需要告诉程序我们要访问的是哪一个,而每个元素都有一个自己的下标地址,下标从0开始从左往右依次递增排列,比如我们要访问第一个元素那么下标就是0,第三个元素下标就是2,以此类推:

kotlin 复制代码
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    println("数组中的第二个元素是${array[1]}")
}

注意,在使用数组时,我们只能访问数组可以访问的范围,如果我们获取一个范围之外的元素,会得到错误,比如当前的数组的大小是5那么也就只能包含5个元素,此时我们去访问第六个元素,显然是错误的:

kotlin 复制代码
println("数组中的第六个元素是${array[5]}")  //已经超出可访问范围了
println("数组中的第?个元素是${array[-1]}")  //下标从0开始,怎么可能有-1呢
image-20231225172109843

我们也可以使用[]修改数组中指定下标元素的值:

kotlin 复制代码
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    array[0] = 10  //修改第一个元素的值
    println("数组中的第一个元素是${array[0]}")
}

还有一个要注意的是,我们直接打印这个数组对象并不能得到数组里面每个元素的值,而是一堆看不懂的东西:

image-20231225172707649

具体原因可以通过学习Java后进行了解,如果各位小伙伴需要打印数组中的每一个元素,我们只能一个一个打印,可以使用一个for循环语句来完成:

kotlin 复制代码
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来遍历一个可遍历的目标,而数组就是满足这个条件的,我们可以直接遍历它:

kotlin 复制代码
fun main() {
    val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
    for (element in array) {
        println(element)   //从第一个元素开始依次遍历,element就是每一个元素了
    }
}

当然,如果我们还是希望按照数组的索引进行遍历,也可以使用:

kotlin 复制代码
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for (i in array.indices) {   //indices返回的是数组的有效索引范围,这里就是0 ~ 4
    println(array[i])
}

如果你想同时遍历索引和元素本身,也可以使用withIndex函数,它会生成一系列IndexedValue对象:

kotlin 复制代码
//关于data class我们会在下一篇中讲解
public data class IndexedValue<out T>(public val index: Int, public val value: T) //包含元素本身和索引

在使用forin时,我们也可以对待遍历的元素进行结构操作,当然,前提是这些对象类型支持解构,比如这里的IndexedValue就支持解构,所以我们可以在遍历时直接使用解构之后的变量进行操作:

kotlin 复制代码
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
for ((index, item) in array.withIndex()) {  //使用withIndex解构后可以同时遍历索引和元素
    println("元素$item,位置: $index")
}

如果需要使用Lambda表达式快速处理里面的每一个元素,也可以使用forEach高阶函数:

kotlin 复制代码
val array: Array<Int> = arrayOf(7, 3, 9, 1, 6)
array.forEach { println(it) }   //只带元素的
array.forEachIndexed { index, item ->   //同时带索引和元素的
    println("元素$item,位置: $index")
}

如果只是想打印数组里面的内容,快速查看,我们可以使用:

kotlin 复制代码
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() })   //自定义每一个元素转换为字符串的结果

我们接着来看一下如何使用构造函数来创建数组,首先构造函数时这样定义的:

kotlin 复制代码
/**
 * size: 不必多说,数组的大小
 * init: 初始化操作,这个操作会根据数组大小,循环调用传入的函数size次,并且将对应的下标作为参数,我们需要在函数中返回当前数组元素类型的结果,这样就会自动填充到数组的对应位置上
 */
public inline constructor(size: Int, init: (Int) -> T)

比如我们希望创建一个字符串数组:

kotlin 复制代码
fun main() {
    val array: Array<String> = Array(5) { "我是元素$it" }   //其中返回值为自定义的字符串,这样就会自动填充到对应位置
    for (s in array) {
        println(s)
    }
}
image-20231225174845632

利用这种特性,我们可以快速创建一个全是同一个值的数组:

kotlin 复制代码
val array: Array<Double> = Array(5) { 1.5 }  // 1.5, 1.5, 1.5, 1.5 ...

还可以快速搞一个平方数数组:

kotlin 复制代码
val array: Array<Int> = Array(10) { it * it }   // 0, 1, 4, 9, 16 ...

不过,其实一般情况下使用arrayOf都可以解决大部分情况了,还有它的变种,大概介绍一下:

kotlin 复制代码
val array: Array<Int> = emptyArray<Int>()   //创建容量为0的数组
val array: Array<Int?> = arrayOfNulls(10)   //创建元素可空的数组

下一节课我们接着学习更多数组的操作。

大纲 (于 2025年1月1日 更新)
正在加载页面,请稍后...