三、详解:类

144 阅读11分钟

类型检测与转换

Kotlin中使用is替代了JAVA中的instanceof,判断后在对应代码块内自动转换对应类型。

val test = Test()
if(test is Test) { //返回true
    test.toString() //自动转换类型
}

Kotlin中使用as声明强制转换,转换失败则会抛出异常;如果允许强转失败,则应该使用as?,转换失败返回null

fun test(obj: Any) {
    val testObj1 = obj as Test//转换失败抛出异常
    val testObj2 = obj as? Test//转换失败返回null
}

类型别名

类型别名为现有类型提供替代名称。你可以使用一个新的命名来表示一个类或定义一类方法:

class TestCls {
    class A {}
}
typealias TA = TestCls.A

//括号内为方法参数,箭头后为方法返回
typealias typeString = (type: Int) -> String
typealias genericString<T> = (type: T) -> String

类型别名并没有创建新的类型,在编译时编译器会将别名替换成原本的类型,它有助于缩短较⻓的泛型类型。

构造函数

在Kotlin中的⼀个类有且仅有⼀个主构造函数,可以没有或有多个次构造函数。

  1. 主构造函数 主构造函数是类头的⼀部分,它跟在类名与可选的类型参数后。
class Person constructor(name: String)

如果主构造函数没有任何注解或者可⻅性修饰符,可以省略constructor关键字。如果没有声明主构造函数,编译器会默认生成一个可见性为public的无参数主构造函数。

主构造函数内的参数可在初始化代码块和类代码块内声明的属性初始化器中使⽤,添加var/val修饰则成为全局变量。

//如下name为初始化参数,仅在初始化阶段可用;age和height为Person类内的全局变量
class Person(name: String, private val age: Int, var height: Int)

在继承时,子类必须委托调用父类的构造函数,不可省略。如果父类没有声明构造函数,则委托给无参数构造函数;如果父类有声明构造函数,则必须委托给声明的构造函数之一。

class Student(name: String) : Person(name)
  1. 次级构造函数 在类代码块中使用constructor前缀声明的构造函数为次构造函数。每个次构造函数都必须直接或间接委托给主构造函数。如果当前类有声明主构造函数,则通过this关键字直接或间接委托给当前类的主构造函数,否则通过super关键字委托给当父类的主构造函数,如果父类没有声明构造函数则省略委托,编译器会委托给默认创建的无参数主构造函数; | | 当前类有声明主构造函数 | 当前类没有声明主构造函数 | | --- | --- | --- | | 有父类 | this委托给当前类主构造函数,主构造函数委托父类构造函数 | super委托给父类构造函数 | | 没父类 | this委托给当前类主构造函数 | 省略委托声明 |

  2. 初始化代码块 在类代码块内使用init修饰的代码即为此类文件的初始化代码块。

class Student(name: String) : Person(name) {
    init {
        System.out.println("init...")
    }
}

初始化代码块在主构造函数执行之后,次构造函数执行之前执行,如果存在多个,按照声明顺序执行。

接口

Kotlin的接⼝可以既包含抽象⽅法的声明也包含实现。它可以有属性但必须声明为抽象或提供访问器实现。

interface MyInterface {
    val prop: Int // 抽象的
    val propertyWithImplementation: String
        get() = "foo"
    fun foo() {
        print(prop)
    }
}
class Child : MyInterface {
    override val prop: Int = 29
}

解决覆盖冲突:当实现多继承时,可能会遇到同⼀⽅法继承多个实现的问题,此时需要显示声明具体实现类。

interface A {
    fun foo() { print("A") }
    fun bar()
}
interface B {
    fun foo() { print("B") }
    fun bar() { print("bar") }
}
class C : A {
    override fun bar() { print("bar") }
}
class D : A, B {
    override fun foo() {
        super<A>.foo()
        super<B>.foo() //'<B>'不能省略
    }
    override fun bar() {
        super<B>.bar() //'<B>'可以省略
    }
}

嵌套类与内部类

将一个类或接口嵌套在另一个类或接口中定义,则称之为嵌套类,相当于Java中的静态内部类。

class A {
    class B {}
    interface C {}
}
interface D {
    class E {}
    interface F {}
}
var ab: A.B? = null
var ac: A.C? = null
var de: D.E? = null
var df: D.F? = null

将一个类嵌套在另一个类中定义,并标记为inner时称之为内部类,相当于Java中非静态内部类。

class A {
    class B {}
}
var ab: A.B? = null //上面例子中的其他3种情况都不可以

两者主要差别:

  1. 嵌套类支持接口,内部类只能是类;
  2. 内部类持有外部类引用,嵌套类没有;

Kotlin中对象表达式来表示匿名内部类实例:

val runnable = object : Runnable {
    override fun run() {
        System.out.println("Runnable")
    }
}

使用object关键字代表创建实例,:后面表示继承的类型。对于JVM平台, 如果是只有单个抽象⽅法的实例,可以使⽤带接⼝类型前缀的lambda表达式创建它:

val runnable = Runnable { System.out.println("Runnable") }

泛型

Kotlin的泛型用法与Java基本一样,使用形参定义类型范围,创建对象后再确定类型。

  1. 泛型约束 通过标明泛型父类,明确约束上界,这与Java中使用extends关键字一样。
fun <T : Comparable<T>> sort(list: List<T>){}

冒号之后指定的类型就是泛型上界,表明只有Comparable<T>的⼦类型可以替代T。如果没有声明则默认上界为Any?。在尖括号中只能指定⼀个上界,如果有多个泛型上界则需啊哟使用where子句。

fun <T> copyWhenGreater(list: List<T>, threshold: T): List<String>
    where T : CharSequence,
          T : Comparable<T> {
    return list.filter { it > threshold }.map { it.toString() }
}
  1. 型变 泛型默认不发生型变的,Java中使用通配符类型参数来完成协变(? extends xxx)或逆变(? super xxx),在具体使用时确定型变;Kotlin中使用out T表示协变,使用in T表示逆变,在具体声明T类型的位置确定型变。
//kotlin在class定义时声明协变或者逆变
class Test<out T>() 
fun run() {
    var t1: Test<Number> = Test<Int>() //可行,可接受Number或Number子类 
    var t2: Test<Int> = Test<Number>() //报错,可接受Int或Int子类
}
fun <T> test(test: Test<T>) {} //使用处均不用再声明型变
//Java中在使用处声明协变或者逆变
static class Test<T> {}
public void run(){
    Test<? extends Number> t1 = new Test<Integer>(); //可行,可接受Number或Number子类 
    Test<Number> t2 = new Test<Integer>(); //报错,仅接受Number类,此种写法不型变
}
public void test(Test<? extends Number> test){} //使用处声明型变

使用协变时仅支持读取(get),使用逆变时仅支持写入(add),可以将其改写为更⾼级的抽象:消费者in,⽣产者out。如果同时需要读写,只能使用不型变泛型。

协变在初始化时完成赋值,其内部保存内容类型是明确的,而再向内添加时内容类型无法确定,所以禁止写入;

逆变不关心存储内容的类型(都按照超类存储),但是取出时无法页确定返回内容的类型,所以禁止读取;

  1. 星号投射 有些时候,你可能想表示你并不知道类型参数的任何信息,但是仍然希望能够安全地使用它。即在使用是忽略数据类型校验,对应Java中使用无界类型通配符?,Kotlin中使用星号投影*

  2. 类型擦除 Kotlin与Java都会在编译阶段擦除泛型声明,仅保留泛型原始类型信息用于替代泛型的使用。如果泛型设置了边界,那么原始类型就等于第一个边界的类型,否则使用超类替换(Any)。

//由于类型擦除以下两个方法内的参数都变成了List<Any>,编译报错
fun prinitItem(list:List<String>){}
fun prinitItem(list:List<Int>){}
//声明一个有边界的泛型类型
class Test<T : Number> {
    var content: T? = null
}
//类型擦除后保留泛型原始类型信息Number,替换使用点
class Test {
    var content: Number? = null
}

而编译器会在必要的时候进行强制类型转换。

数据类

使用data class声明一个数据类。

  1. 数据类类不能有以下修饰符:abstract,inner,open,sealed
  2. 数据类必须声明主构造函数,主构造函数要至少有一个参数,主构造函数中的所有参数必须使用valvar声明为全局属性;
  3. 编译器会⾃动根据数据类中所有属性生成常用方法实现:equals()、hashCode()、toString()、copy()、componetN();如果在数据类体中有显式实现声明equals()、hashCode()、toString()这些方法,或者这些函数在⽗类中有final实现,则编译器不会⽣成这些函数。如果数据类父类实现了componetN(),数据类仍会尝试复写,复写失败则会报错。数据类内不允许显示声明copy()componetN()

枚举类

每个枚举常量都是一个对象,声明枚举常量时以逗号分隔;

enum class Direction { NORTH, SOUTH, WEST, EAST } 

枚举常量可以在初始化时赋值;

enum class Color(val rgb: Int) {
    RED(0xFF0000), GREEN(0x00FF00), BLUE(0x0000FF) 
}

枚举还支持以声明自己的抽象方法,或者继承接口,并使用匿名类复写相应方法;

enum class ProtocolState :Closeable{
    WAITING {
        override fun signal() = TALKING
        override fun close() {}
    },
    TALKING {
        override fun signal() = WAITING
        override fun close() {}
    };
    abstract fun signal(): ProtocolState
}

另外枚举实现了Comparable接口,其中⾃然顺序是它们在枚举类中定义的顺序。可使用 Enum.values()获取全部枚举常量,使用Enum.valueOf(name:String)通过名称获取枚举常量,如果匹配失败则抛出异常。

密封类

使用sealed class声明一个密封类,或使用sealed interface(Kotlin 1.5新增)声明一个密封接口。密封类或密封接口都是抽象的,用来表示受限的类继承结构,在某种意义上,他们是枚举类的扩展;

早期的密封类,子类型必须是密封类的内部类或者写在同一个文件内;到了Kotlin 1.5,约束进一步放宽,只要保证子类和父类在同一个module且是同一个包名下即可。

早期的密封类构造函数是private修饰的,到了1.5版本,构造函数的修饰符改为了protect,但是不允许子类复写为public。而在实际编译后,密封类的构造函数被改为private修饰,但编译器会提供新的公有方法用于创建实例。这也是为什么可以同一个module且是同一个包名下声明子类,而其他位置不行的原因。

内联类

使用value class(旧版使用inline class)声明内联类。当前1.5版本要求同时使用@JvmInline声明为Java内联类。 主要特性:

  1. 内联类必须含有唯⼀的⼀个不可更改属性,并在主构造函数中初始化;
  2. 内联类可以继承接口,但不能继承类;
  3. 编译器会为每个内联类保留⼀个包装器。在运⾏时,会优先使⽤内部唯⼀属性来替代内联类的实例;然⽽,有时候使⽤包装器是必要的,⼀般来说,只要将内联类⽤作另⼀种类型,它们就会被装箱。
interface I
@JvmInline
value class Foo(val i: Int) : I
fun asInline(f: Foo) {}
fun <T> asGeneric(x: T) {}
fun asInterface(i: I) {}
fun asNullable(i: Foo?) {}
fun <T> id(x: T): T = x
fun main() {
    val f = Foo(42)
    asInline(f)// 拆箱操作: ⽤作 Foo 本身
    asGeneric(f) // 装箱操作: ⽤作泛型类型 T
    asInterface(f) // 装箱操作: ⽤作类型 I
    asNullable(f) // 装箱操作: ⽤作不同于 Foo 的可空类型 Foo?
    // 在下⾯这⾥例⼦中,'f'⾸先会被装箱(当它作为参数传递给'id'函数时)
    // 然后⼜被拆箱,最后,'c'等于被拆箱后的内部表达(也就是 '42'),和'f'⼀样
    val c = id(f)
}

内联类被设计用来提高运行效率,其原理为利用Jvm对常量(尤其是基本类型)使用的优化,所以当内联类拆箱使用时效率较高,但如果频繁拆箱、装箱则会额外消耗效能。

由于内联类被编译为其基础类型,因此可能会导致各种模糊的错误:

@JvmInline
value class UInt(val x: Int)
// 在 JVM 平台上被表示为'public final void compute(int x)'
fun compute(x: Int) { }
// 同理,在 JVM 平台上也被表示为'public final void compute(int x)'!
fun compute(x: UInt) { }

为了缓解这种问题,⼀般会通过在函数名后⾯拼接⼀些稳定的哈希码来重命名函数:

fun compute(x: UInt) 将会被表示为 public final void compute-<hashcode>(int x)

或者使用@JvmName指定一个新的名称。

@JvmName("computeUInt") fun compute(x: UInt) { }