Kotlin核心编程知识点-02-面向对象

1.类和构造方法

Java 是一门面向对象进行程序设计的语言,同样在 Kotlin 中对象思想非常重要。

下面我们从对象这个概念入手,结合例子来学习 Kotlin 中声明类和接口。

1.1.Kotlin 中的类及接口

任何可以描述的事物都可以看作是对象。下面以鸟为例,分析它的组成。

  • 状态:形状、颜色等部件都可以看作是鸟的静态属性,大小、年龄可以看作是鸟的动态属性,对象的状态就是由这些属性来表现的。
  • 行为:飞行、进食、鸣叫等动作可以看作鸟的行为。

1.1.1.Kotlin 中的类

对象是由状态和行为组成的,这些可以描述一个事物。下面就用 Kotlin 来抽象一个 Bird 类:

class Bird {
    val weight: Double = 500.0
    val color: String = "blue"
    val age: Int = 1
    // 全局可见
    fun fly() {}
}

使用熟悉的 class 结构体声明一个类,但是,Kotlin 中的类和 Java 存在很多不同。上述代码转为 Java 代码后差不多如下:

public class Bird {
    private final double weight = 500.0;
    private final String color = "blue";
    private final int age = 1;

    public final double getWeight() {
        return weight;
    }

    public final String getColor() {
        return color;
    }

    public final int getAge() {
        return age;
    }

    public final void fly() {
        // 实现飞行的逻辑
    }
}

可以看出 Kotlin 中类的声明和 Java 的很近似,但也存在很多不同:

  1. 不可变属性成员:正如上一篇文章Kotlin核心编程知识点-01-基础语法中介绍那样,用 val 在类中声明引用不可变的属性成员,使用 var 声明的属性则反之引用可变。
  2. 属性默认值:Java 的属性都有默认值,比如 int 类型的默认值是0,引用类型默认值是 null,所以在声明属性的时候可以不指定默认值。但是 Kotlin 中,除非显式的声明延迟初始化,不然就需要指定属性的默认值。
  3. 不同的可访问修饰符:Kotlin 类中的成员默认是全局可见的(相当于Java的public),而 Java 的默认可见域是包作用域。后续会说明 Kotlin 中的不同访问控制。

1.1.2.可带有属性和默认方法的接口

再看 Kotlin 和 Java 中接口的差异。先看 Java的:

public interface Flyer {
	public String kind();
	// 方法实现
	default public void fly() {
		System.out.println("我可以飞行");
	}
}

Java 中实现了这个接口的子类,可以不实现 fly 方法,但是需要实现 kind 方法。再看 Kotlin 中声明接口:

interface Flyer {
    val speed: Int
    fun kind()
    fun fly() {
        println("我可以飞行")
    }
}

Kotlin 同样可以定义带有方法实现(fly方法)的接口,同时,还支持抽象属性(speed属性)。同时,虽然Kotlin 支持接口中声明属性,但是实际中却不能像 Java 接口那样直接赋值一个常量,如下是错误的:

interface Flyer {
	// 报错:Property initializers are not allowed in interfaces
    val speed = 6
 
}

Kotlin 提供了另一种方式实现赋值常量的方式,如下:

interface Flyer {
    val speed: Int
    get() = 6
}

确实很别扭,这与 Kotlin 实现该机制的北京有关,是否有更好的改进写法?可以评论出来。

在说明一下接口中定义普通属性,例如上面 Kotlin 接口 Flyer 中的 val speed: Int,这同方法一样,如果没有指定默认行为,则实现该接口的类中必须对属性进行初始化。

接口定义好了,下面看如何创建一个对象。

1.2.更简洁地构造类的对象

Kotlin 中,没有我们熟悉的 Java 中的 new 关键字,声明一个类的对象如下:

val bird = Bird()

当前创建对象Bird,没有传入任何参数。而实际中,很可能需要传入不同参数组合来创建多个构造方法,就像 Java 中利用重载的方式构建多个构造函数那样。

但是 Java 中的重载多构造函数,会存在两个缺点:

  1. 如果要支持任意参数组合来创建对象,那么需要实现的构造方法将会非常多。
  2. 每个构造方法中的代码会存在冗余,例如第一个构造函数是 name,age,address,第二个是 name,age。这两个构造函数都对 name 和 age 进行了相同的赋值操作。

而 Kotlin 的构造语法会解决这些问题,我们来看一下。

1.2.1.构造方法默认参数

Kotlin 中,可以给构造方法的参数指定默认值,从而避免不必要的方法重载。例如:

class Bird(val weight: Double = 0.00, val color: String = "blue", val age: Int = 0) {
        // ...
}

是不是很简单,然后我们通过构造方法创建对象:

// 避免了 Java 需要多构造函数的问题
val bird1 = Bird(color = "black")
val bird2 = Bird(weight = 500.0, age = 1)

需要注意,由于构造参数默认值的存在,在创建对象的时候,最好指定参数的名称,就像是上面这样,否则必须按照实际参数的顺序进行赋值,比如下面这样的最后一个示例是不允许的:

// bird1和bird2是可以的,因为符合参数实际顺序和类型
val bird1 = Bird(500.0)
val bird2 = Bird(500.0, "red")
// 不被允许
val bird3 = Bird(500.0, 1)

另外,我们在 Bird 类中可以使用 val 或者 var 来声明构造函数的参数,这一方面代表了参数的引用可变性,另一方面也在构造类的语法上得到了简化。

事实上,构造方法的参数名前可以没有 val 或者 var ,但是带上之后就等价于在 Bird 类内部声明了一个同名的属性,我们可以用 this 来进行调用。比如刚才定义的 Bird 类(1.2.1.构造方法默认参数中的Bird类)就类似于如下的实现:

class Bird (weight: Double = 0.00, color: String = "blue", age: Int = 0) {
	val weight: Double
	val color: String
	val age: Int
	init {
		// 构造方法的参数可以在init语句块被调用
		this.weight = weight
		this.color = color
		this.age = age
	}
}

1.2.2.init 语句块

Kotlin 中的 init 语句块的语法,属于上述构造方法的一部分,两者的表现形式上确是分离的。

Bird 类的构造方法在类的外部(类名和方法体之间),它只能对参数进行赋值。如果需要在初始化时进行其他的额外操作,就可以使用 init 语句块来执行,如下:

class Bird(weight: Double, color: String, age: Int) {
    init{
        println("do some other things")
        println("the weight is ${weight}")
    }
}

如上,当没有 val 或者 var 的时候,构造方法的参数可以在 init 语句块中直接调用,也可以用于初始化类内部的属性成员情况,如下:

class Bird(weight: Double, color: String, age: Int) {
    val weight: Double = weight
    val color: String = color
    val age: Int = age
}

除此之外,不能在其他地方使用,如下是一个错误的:

class Bird(weight: Double, color: String, age: Int) {
    fun printlnColor() {
    	// 报错:Unresolved reference: color
        println(color)
    }
}

事实上,构造方法可以拥有多个 init 语句块,会在创建对象时按照类中从上到下的顺序先后执行,这有利于对初始化的操作进行职能分离以及复杂的业务开发。

再考虑一个场景,在创建一个类的时候,可能不需要对所有的属性都进行传值,例如有的特殊属性需要根据其他的属性来区分赋值,所以这样的特殊属性不需要出现在构造方法的参数中,而因为 init 语句块的支持,可以实现这一点,例如我们假设 Bird 如果颜色 red 的都是雌性,否则都是雄性,可以如下设计:

class Bird(val weight: Double, val color: String, val age: Int) {
    val sex: String
    init {
        sex = if (color == "red") "female" else "male"
    }
}

再修改一下需求,不想在 init 语句块中对 sex 属性赋值,而是调用另一个专门的 handleSex 方法来进行,如下:

class Bird(val weight: Double, val color: String, val age: Int) {

	// 报错1:Property must be initialized or be abstract
    val sex: String

    fun handleSex(){
    	// 报错2:Val cannot be reassigned
        this.sex = if(color == "red") "female" else "male"
    }

}

如上,两个地方报错了,原因如下:

  • Kotlin 规定类中的所有非抽象属性成员都必须在对象创建的时候被初始化。
  • 由于 sex 必须被初始化,上述的 handleSex 方法中,sex 被视为二次赋值(理解为修改),这对 val 声明的变量 sex 来说是冲突、不被允许的。

上面两个报错中,第二个报错比较容易解决,把 sex 的修饰 val 改为 var 即可。但是第一个报错的地方,最直观简单的方法就是指定 sex 的默认值,但这可能很别扭,也是一种错误的性别含义。另一个方法就是引入可空类型(后续会讲解到),把 sex 声明为 String?类型的,则默认值为 null,虽然可以正常运行,但是我们又不想让 sex 具有可空性,而只是想稍后再赋值而已,所以这个方法也有局限性。

所以,更好的做法是让 sex 能够延迟初始化,也就是可以不用在类对象初始化的时候就有值,在 Kotlin 中,主要使用 lateinit 和 by lazy 两种语法实现延迟初始化的效果,继续往下看。

1.2.3.延迟初始化:by lazy 和 lateinit

接着上述问题,如果 sex 是一个 val 声明的变量,我们可以使用 by lazy 来修饰,如下:

class Bird(val weight: Double, val color: String, val age: Int) {
    val sex: String by lazy {
        if (color == "red") "female" else "male"
    }
}

// 正常初始化
val b1 = Bird(500.0, "blue", 1)

总结 by lazy 的语法如下:

  • 该变量必须是引用不可变的,而不能通过 var 来声明
  • 再被首次调用的时候,才会进行赋值操作,一旦赋值,后续将不能被改变。

lazy 的背后是接收一个 lambda 并返回一个 Lazy 实例的函数,第一次访问该属性时候,会执行 lazy 对应的 lambda 表达式并记录结果,后续访问该属性只是返回记录的结果。

另外系统会给 lazy 属性默认加上同步锁,所以 lazy 是线程安全的。

与 lazy 不同,lateinit 主要用于 var 声明的变量,但是它不能用于基本数据类型,如 Int、Long 等,需要用 Integer 这种包装类作为替代。所以可以使用 lateinit 修改刚才报错的问题(第二个报错的地方那里,var修饰sex),如下:

class Bird(val weight: Double, val color: String, val age: Int) {
	// 延迟初始化
    lateinit var sex: String
    // 修改赋值 sex
    fun handleSex() {
        this.sex = if (color == "red") "female" else "male"
    }
}
...
// 初始化 Bird
val bird: Bird = Bird(10.0, "red", 1)
// 调用方法赋值 sex
bird.handleSex()
// 打印结果: female
println(bird.sex)

如何让 var 声明的基本数据类型变量也具有延迟初始化的效果呢?可参考的解决方案是通过 Delegates.notNull<T>,这是利用 Kotlin 中的委托语法实现的,后续会讲到,这里了解一下即可。

总之,Kotlin 不主张用 Java 中的构造方法重载,来解决多个构造参数组合调用的问题。取而代之的方案是利用构造参数默认值及用 val、var 来声明构造参数的语法,以更简洁地构造一个类对象。那么,这是否可以说明在Kotlin中真的只需要一个构造方法呢?

1.3.主从构造方法

Kotlin 其实也是支持多构造方法的语法,但是和 Java 有区别,Kotlin 在多构造函数之间建立了“主从”的关系。

例如还是以 Bird 为例,改造 Bird 只有一个 age 属性,假设我们知道鸟的生日,希望通过生日得到鸟的年龄,然后构建一个 Bird 类对象,如何实现?如下:

class Bird(val age: Int) {
	// 定义一个从构造器,委托给主构造方法
    constructor(birth: LocalDateTime) : this(getAgeByBirth(birth)) {
        // ...
    }

}

fun getAgeByBirth(birth: LocalDateTime): Int {
    // 返回随机数模拟返回鸟的年龄
    return RandomUtil.randomInt(10)
}

那么这个从构造器是如何运作的:

  • 通过 constructor 方法定义了一个新的构造方法,这被称为从构造器,相应的,在类外部定义的构造方法被称为主构造方法。每个类最多存在一个主构造方法和多个从构造方法,如果主构造方法存在注解或者可见性修饰符,也必须像从构造方法一样加上 constructor 关键字,如下:
internal public Bird @Inject constructor(val: age Int) { ... }
  • 每个从构造方法由两部分组成,一部分是对其他构造方法的委托,另一部分是由花括号包裹的代码块。执行顺序上辉县执行委托的方法,然后执行自身代码块的逻辑。

通过 this 关键字来调用要委托的构造方法。如果一个类存在主构造方法,那么每个从构造方法都要直接或间接地委托给它。比如,可以把从构造方法 A 委托给从构造方法 B 再将从构造方法 B 委托给主构造方法。举个例子:

class Bird(val age: Int) {

    // 这是从构造方法B,委托给从构造方法A
    // 从构造方法B,参数是Long类型,调用函数getBirth获取LocalDateTime类型返回值,调用从构造方法A
    constructor(timestamp: Long) : this(getBirth(timestamp)) {

    }

    // 这是从构造方法A,委托给主构造方法
    // 从构造方法A,参数是LocalDateTime类型,调用函数getAgeByBirth获取Int类型返回值,调用主构造方法
    constructor(birth: LocalDateTime) : this(getAgeByBirth(birth)) {

    }

}
fun getBirth(timestamp: Long): LocalDateTime {
    // 模拟返回LocalDateTime
    return LocalDateTime.now()
}

fun getAgeByBirth(birth: LocalDateTime): Int {
    // 模拟返回Int
    return RandomUtil.randomInt(10)
}

2.不同的访问控制原则

在构造完一个类的对象之后,就要思考它的访问控制了。

在 Java 中,如果不希望一个类被继承或修改,那就可以用 final 来修饰它。同时,还可以用 public、private、protected 等修饰符来描述一个类、方法或属性的可见性。其实在 Kotlin 中与其大同小异。最大的不同是,Kotlin 在默认修饰符的设计上采用了与Java不同的思路,Kotlin 相比 Java 对一个类、方法或者属性有着不一样的访问控制原则。

2.1.限制修饰符

当想要指定一个类、方法或属性的修改或者重写权限时,就需要用到限制修饰符。

继承是面向对象的基本特征之一,继承虽然灵活,但如果被滥用就会引起一些问题。

还是拿之前的 Bird 类举个例子。有人觉得企鹅也是一种鸟类,于是他声明了一个 Penguin 类来继承 Bird。如下:

open class Bird {
    open fun fly() {
        println("i can fly")
    }
}
// 继承 Bird 类
class Penguin : Bird() {
    override fun fly() {
        println("i can't fly actually")
    }
}

这里首先说明 Kotlin 和 Java 不一样的语法特性:

  • Kotlin 中没有采用 Java 中的 extends 和 implements 关键词,而是使用“:”来代替类的继承和接口实现。
  • 由于 Kotlin 中类和方法默认是不可被继承或者重写的,所以必须加上 open 修饰符

2.1.1.类的默认修饰符:final

Kotlin 认为类默认开放继承并不是一个好的选择。所以 Kotlin 中的类和方法默认不允许被继承或者重写。还是以 Bird 为例:

class Bird {
    val weight: Double = 50.0
    val color: String = "blue"
    val age: Int = 0
    fun fly() {
        println("i can fly")
    }
}

Kotlin 中默认这个类和方法不允许被继承和重写的,类似于 Java 中的 final 修饰,除非主动加上可以继承或者重写的修饰符,也就是刚才用到的 open,如下:

// 加上 open,可以被继承
open class Bird {
    val weight: Double = 50.0
    val color: String = "blue"
    val age: Int = 0
    // 加上open,可以被重写
    open fun fly() {
        println("i can fly")
    }
}

2.2.可见性修饰符

可见性修饰符,可以指定类、方法以及属性的可见性,Kotlin 的可见性修饰符和 Java 中很类似,但又不同,如下:

  1. Kotlin 和 Java 的默认修饰符不同,Kotlin 的是 public,Java 的是 default。
  2. Kotlin 中有一个独特的 internal。
  3. Kotlin 可以在一个文件内单独声明方法以及常量,同样支持可见性修饰符。
  4. Java 中除了内部类可以用 private 修饰以外,其他类都不允许 private 修饰,而 Kotlin 可以。
  5. Kotlin 和 Java 中的 protected 的访问范围不同,Java 中是包、类以及子类可访问,而 Kotlin 只允许类以及子类。

首先来看默认修饰符。很多时候,在写类或者方法的时候都会省略它的修饰符,当然,在 Java 中我们很自然地会给类加上 public 修饰符,因为大多数类都可能需要在全局访问。而 Java 的默认修饰符是 defaut,它只允许包内访问,但每次都要加public 很厌烦,虽然通常编辑器会自动帮我们加上,但是总觉得这是一个多余的声明。

所以,Kotlin考虑了这方面因素,将可见修饰符默认指定为 public,而不需要显式声明。

Java 中默认修饰符是 default,作用域是包内可访问,Kotlin 中的类似修饰符是独特的 internal,和 default 有点像但又不同。

internal 在 Kotlin 中的作用域被称为“模块内访问”,什么算模块内?如下:

  • 一个Eclipse项目
  • 一个IDEA项目
  • 一个Maven项目
  • 一个Grandle项目
  • 一组由一次Ant任务执行编译的代码

总的来说,一个模块可以看作一起编译的 Kotlin 文件组成的集合。而模块内可见指的是该类只对一起编译的其他 Kotlin 文件可见。

Kotlin 中,如果一个 kt 文件中的单独的类是 private 修饰的,那么它的作用域就是当前的这个 Kotlin 文件。

还有 protected 修饰符,Java 中修饰的内容作用域是包内、类以及子类可访问,而 Kotlin 没有包作用域的概念,所以 protected 修饰在 Kotlin 中的作用域只是类以及子类。

总结如下:

Kotlin修饰符含义与Java比较
publicKotlin的默认修饰符,全局可见与Java中的public效果相同
protected受保护修饰符,类以及子类可见含义一致,但Java中作用域除了类和子类,包内也可见
private私有修饰符,类内修饰只有本类可见,类外修饰文件内可见私用修饰符,只有类内可见
internal模块内可见

3.解决多继承问题

刚才讨论了继承的问题,下面来看多继承。继承与实现是面向对象程序设计中不变的主题。Java 是不支持类的多继承的,Kotlin 亦是如此。

其实多继承的需求经常会出现然而类的多继承方式会导致继承关系上语义的混淆。

这里我们会展示多继承问题的所在以及如何通过 Kotlin 的语法来设计多种不同的多继承解决方案,从而进一步了解Kotlin的语言特性。

3.1.骡子的多继承困惑

如果你了解 C++,就会知道 C++ 中的类是支持多重继承机制的。然而 C++ 中存在一个经典的钻石问题–骡子的多继承困惑。

我们假设Java的类也支持多继承,然后模仿 C++ 中类似的语法,来看看它到底会导致什么问题。

abstract class Animal {
	abstract public void run();
}

// 马,继承动物类
class Horse extends Animal {
	@Override
	public void run(){...}
}
// 驴,继承动物类
class Donkey extends Animal {
	@Override
	public void run(){...}
}
// 骡子
class Mule extends Horse, Donkey {
}

上面这些都是伪代码,分下一下:

  • 马和驴都继承了 Animal 类,实现了 run 方法。
  • 骡子是马和驴的杂交产物,它应该拥有两者的特性,于是 Mule 利用多继承同时继承了 Horse 和 Donkey。

但是有个问题,当我们打算在Mule中实现run方法的时候,问题就产生了: Mule 到底是继承 Horse的 run 方法,还是 Donkey 的 run 方法呢?这个就是经典的钻石问题。你可以通过继承关系图来更好地认识这个问题,如图:

在这里插入图片描述
所以钻石问题也被称为菱形继承问题。可以发现,类的多重继承如果使用不当,就会在继承关系上产生歧义。而且,多重继承还会给代码维护带来很多的困扰:一来代码的合度会很高,二来各种类之间的关系令人眼花缭乱。

于是,Kotlin跟Java一样只支持类的单继承。那么,面对多重继承的需求,我们在Kotlin中该如何解决这个问题呢?

3.2.接口实现多继承

一个类实现多个接口不陌生,这是Java经常干的事情。Kotlin 中的接口与 Java 很相似,但它除了可以定义带默认实现的方法之外,还可以声明抽象的属性。

现在来试试 Kotlin 中利用接口实现多继承的方案,如下:

interface Flyer {
    fun fly()
    fun kind() = "flying animals"
}

interface Animal {
    val name: String
    fun eat()
    fun kind() = "flying animals"
}

class Bird(override val name: String) : Flyer, Animal {
    override fun fly() {
        TODO("Not yet implemented")
    }

    override fun eat() {
        TODO("Not yet implemented")
    }
	// 指定继承哪个父接口的方法
	// 或者重写 kind 方法
    override fun kind() = super<Flyer>.kind()
}

fun main() {
    val bird = Bird("sparrow")
    // sparrow
    println(bird.name)
    // flying animals
    println(bird.kind())
}

如上,Bird 类同时实现了 Flyer 和 Animal 两个接口,但由于它们都拥有默认的 kind 方法,同样会引起上面所说的钻石问题。而 Kotlin 提供了对应的方式来解决这个问题那就是 super 关键字,我们可以利用它来指定继承哪个父接口的方法,比如上面代码中的 super<Flyer>.kind()。当然我们也可以主动实现方法,覆盖父接口的方法。如:

override fun kind() = "flying ... ${this.name}"

通过这个例子,我们再来分析一下 Kotlin 实现接口的相关语法:

  1. 在 Kotlin 中实现一个接口时,需要实现接口中没有默认实现的方法及未初始化的属性,若同时实现多个接口,而接口间又有相同方法名的默认实现时,则需要主动指定使用哪个接口的方法或者重写方法;
  2. 如果是默认的接口方法,可以在实现类中通过“super<T>”这种方式调用它,其中 T 为拥有该方法的接口名。
  3. 在实现接口的属性和方法时,都必须带上 override 关键字,不能省略。

除此之外,通过主构造方法参数的方式来实现 Anima l接口中的 name 属性。之前提过,通过 val 声明的构造方法参数,其实是在类内部定义了一个同名的属性,所以我们当然还可以把 name 的定义放在Bird 类内部。如下:

class Bird(name: String) : Flyer, Animal {

    override val name: String

    init {
        this.name = name
    }
   。。。
}

name的赋值方式无关紧要,比如可还可以用一个 getter 进行赋值。

这里说明一下 Kotlin 中的 getter 和 setter。

对于 getter 和 setter ,在 Java中 通过这种方式来对一个类的私有字段进行取值和赋值的操作,通常用 IDE 来帮我们自动生成这些方法。但是在很多时候你会发现这种语法真是不堪入目。而 Kotlin 类不存在字段,只有属性,它同样需要为每个属性生成 getter 和 setter 方法。但 Kotlin 的原则是简洁明了的,既然都要做,那么为何我不幕后就帮你做好了呢?所以在声明一个类的属性时,要知道背后 Kotlin 编译器也帮你生成了 getter 和 setter 方法。当然你也可以主动声明这两个方法来实现一些特殊的逻辑。还有以下两点需要注意:

  1. 用 val 声明的属性将只有 getter 方法,因为它不可修改。而用 var 修饰的属性将同时拥有 getter 和 setter 方法。
  2. 用 private 修饰的属性编译器将会省略 getter 和 setter 方法,因为在类外部已经无法访问它了,这两个方法的存在也就没有意义了。

总的来说,用接口模拟实现多继承是我们最常用的方式。但它有时在语义上依旧并不是很明确。下面我们就来看一种更灵活的方式,它能更完整地解决多继承问题。

3.3.内部类解决多继承问题的方案

要探讨的第2种方式就是用内部类模拟多继承的效果。在 Java 中可以将一个类的定义放在另一个类的定义内部,这就是内部类。由于内部类可以继承一个与外部类无关的类,所以这保证了内部类的独立性,我们可以用它的这个特性来尝试解决多继承的问题。

首先了解一下 Kotlin 中内部类的语法。如你所知 Java 的内部类定义非常直观,我们只要在一个类内部再定义一个类,那么这个类就是内部类了,如:

// Java 中的内部类定义
public class OuterJava {
	private String name = "this is java's inner class syntax";
	class InnerJava {
		public void printlnName(){
			System.out.println(name);
		}
	}
}

如上,使用 Kotlin 改写这段代码,如下:

class OuterKotlin {
    val name = "this is kotlin inner class syntax"
    class InnerKotlin {
        fun printlnName() {
        	// 报错:Unresolved reference: name
            println(name)
        }
    }
}

报错了,因为这不是 Kotlin 的内部类,而是嵌套类的语法,如果要在 Kotlin 中声明内部类,必须在所谓的内部类上加关键字 inner,就像这样:

class OuterKotlin {
    val name = "this is kotlin inner class syntax"
    // 加上关键字 inner
    inner class InnerKotlin {
        fun printlnName() {
            println(name)
        }
    }
}

这里说明一下 内部类 VS 嵌套类。

在 Java 中,我们通过在内部类的语法上增加一个 static 关键词,把它变成一个嵌套类。然而,Kotlin 则是相反的思路,默认是一个嵌套类,必须加上 inner 关键字才是一个内部类,也就是说可以把静态的内部类看成嵌套类。

内部类和嵌套类有明显的差别,具体体现在:内部类包含着对其外部类实例的引用,在内部类中我们可以使用外部类中的属性,比如上面例子中的 name 属性。而嵌套类不包含对其外部类实例的引用,所以它无法调用其外部类的属性。

好了,熟悉了内部类,现在回到之前的骡子的例子,然后用内部类改写,如下:

open class Horse {
    fun runFast() {
        println("i can run fast")
    }
}

open class Donkey {
    fun doLongTimeThing() {
        println("i can do some thing long time")
    }
}

class Mule {
    fun runFast() {
        HorseInner().runFast()
    }
    fun doLongTimeThing() {
        DonkeyInner().doLongTimeThing()
    }

    private inner class HorseInner : Horse()
    private inner class DonkeyInner : Donkey()
}

如上,分析:

  1. 可以在一个类内部定义多个内部类,每个内部类的实例都有自己的独立状态,
    它们与外部对象的信息相互独立。
  2. 通过让内部类 HorseInner 、DonkeyInner 分别继承 Horse 和 Donkey 这两个外部类,就可以在 Mule 类中定义它们的实例对象,从而获得了 Horse 和 Donkey 两者不同的状态和行为。
  3. 可以利用 private 修饰内部类,使得其他类都不能访问内部类,具有非常良好的封装性。

因此,可以说在某些场合下,内部类确实是一种解决多继承非常好的思路。

3.4.使用委托代替多继承

再看一种 Kotlin 中新引入的语法,委托!通过委托也可以解决多继承的问题。

关于委托,可能很熟悉。比如委托模式,或者你是一名 C# 开发者,熟悉其中的 delegate 关键字。

简单来说,委托是一种特殊的类型,用于方法事件委托,比如你调用 A 类的 methodA 方法,其实背后是 B 类的 methodA 去执行。

其他语言中要实现委托并不是一件非常自然直观的事情。但 Kotlin 简化了这种语法,我们只需通过 by 关键字就可以实现委托的效果。比如我们之前提过的 by lazy 语法其实就是利用委托实现的延迟初始化语法。我们再来重新回顾一下它的使用:

val sex: String by lazy {
    "i am a lazy string"
}

委托除了延迟属性这种内置行为外,还提供了一种可观察属性的行为,这与平常所说的观察者模式很类似。

接下来,看看如何通过委托来代替多继承实现需求。请看下面的例子:

interface CanFly {
    fun fly()
}

interface CanEat {
    fun eat()
}

open class Flyer : CanFly {
    override fun fly() {
        println("i can fly ... ")
    }
}

open class Animal : CanEat {
    override fun eat() {
        println("i can eat ... ")
    }
}

class Bird(flyer: Flyer, animal: Animal) : CanFly by Flyer(), CanEat by Animal() {

}

fun main() {

    val flyer = Flyer()
    val animal = Animal();
    val bird = Bird(flyer, animal);

    bird.fly()
    bird.eat()

}

有人可能会有疑问:首先,委托方式怎么跟接口实现多继承如此相似,而且好像也并没有简单多少;其次,这种方式好像跟组合也很像,那么它到底有什么优势呢?主要有以下两点:

  1. 前面说到接口是无状态的,所以即使它提供了默认方法实现也是很简单的,不能实现复杂的逻辑,也不推荐在接口中实现复杂的方法逻辑。我们可以利用上面委托的这种方式,虽然它也是接口委托,但它是用一个具体的类去实现方法逻辑,可以拥有更强大的能力。
  2. 假设我们需要继承的类是A,委托对象是B、C、我们在具体调用的时候并不是像组合一样 A.B.method,而是可以直接调用A.method,这更能表达 A 拥有该 method 的能力,更加直观,虽然背后也是通过委托对象来执行具体的方法逻辑的。

4.真正的数据类

Kotlin 在解决多继承问题上非常灵活。但是有时候,我们并不想要那么强大的类,只是想要单纯地使用类来封装数据,类似于 Java 中的 DTO。但这在 Java 中很烦琐,因为声明一个JavaBean后,还要定义一堆 getter 和 setter 。虽然IDE (或者Lombok)能帮我们自动生成这些代码,但是你很可能已经厌烦了这些冗长的代码了。

下面就来看看Kotlin是如何改进这个问题的吧。

4.1.烦琐的 JavaBean

JavaBean,定义一个数据模型类时,就需要为其中的每一个属性定义 getter、setter 方法。如果要支持对象值的比较,我们甚至还要重写 hashcode、equals等方法。

而在 Kotlin 中我们将不再面对这个问题,它引人了 data class 的语法来改善这一情况。让我们来看看它。

4.2.用 data class 创建数据类

data class 顾名思义就是数据类,就像是 Scala 中的 case class。如下:

// data class
data class Bird(var weight: Double, var age: Int, var color: String)

这么一行代码就能表示一个 JavaBean,这一切无非只是添加了一个 data 关键字而已。编译后的代码中包含了 getter、setter、equals、hashcode、toString以及构造方法等。

事实上,在这个关键字后面,Kotlin 编译器帮我们做了很多事情。反编译后的 Java 代码如下:

public final class Bird {
   private double weight;
   @NotNull
   private String color;
   private int age;

   public final double getWeight() {
      return this.weight;
   }

   public final void setWeight(double var1) {
      this.weight = var1;
   }

   @NotNull
   public final String getColor() {
      return this.color;
   }

   public final void setColor(@NotNull String var1) {
      Intrinsics.checkNotNullParameter(var1, "<set-?>");
      this.color = var1;
   }

   public final int getAge() {
      return this.age;
   }

   public final void setAge(int var1) {
      this.age = var1;
   }

   public Bird(double weight, @NotNull String color, int age) {
      Intrinsics.checkNotNullParameter(color, "color");
      super();
      this.weight = weight;
      this.color = color;
      this.age = age;
   }

   public final double component1() {
      return this.weight;
   }

   @NotNull
   public final String component2() {
      return this.color;
   }

   public final int component3() {
      return this.age;
   }

   @NotNull
   public final Bird copy(double weight, @NotNull String color, int age) {
      Intrinsics.checkNotNullParameter(color, "color");
      return new Bird(weight, color, age);
   }

   // $FF: synthetic method
   public static Bird copy$default(Bird var0, double var1, String var3, int var4, int var5, Object var6) {
      if ((var5 & 1) != 0) {
         var1 = var0.weight;
      }

      if ((var5 & 2) != 0) {
         var3 = var0.color;
      }

      if ((var5 & 4) != 0) {
         var4 = var0.age;
      }

      return var0.copy(var1, var3, var4);
   }

   @NotNull
   public String toString() {
      return "Bird(weight=" + this.weight + ", color=" + this.color + ", age=" + this.age + ")";
   }

   public int hashCode() {
      int var10000 = Double.hashCode(this.weight) * 31;
      String var10001 = this.color;
      return (var10000 + (var10001 != null ? var10001.hashCode() : 0)) * 31 + Integer.hashCode(this.age);
   }

   public boolean equals(@Nullable Object var1) {
      if (this != var1) {
         if (var1 instanceof Bird) {
            Bird var2 = (Bird)var1;
            if (Double.compare(this.weight, var2.weight) == 0 && Intrinsics.areEqual(this.color, var2.color) && this.age == var2.age) {
               return true;
            }
         }

         return false;
      } else {
         return true;
      }
   }
}

观察上面的代码,发现两个特别的方法,copy 和 componentN。接下来说一下。

4.3.copy、componentN 与解构

继续观察刚才的 Kotlin 的 Bird 反编译为 Java 的代码,有这么一段:

   @NotNull
   public final Bird copy(double weight, @NotNull String color, int age) {
      Intrinsics.checkNotNullParameter(color, "color");
      return new Bird(weight, color, age);
   }

   // $FF: synthetic method
   public static Bird copy$default(Bird var0, double var1, String var3, int var4, int var5, Object var6) {
      if ((var5 & 1) != 0) {
         var1 = var0.weight;
      }

      if ((var5 & 2) != 0) {
         var3 = var0.color;
      }

      if ((var5 & 4) != 0) {
         var4 = var0.age;
      }

      return var0.copy(var1, var3, var4);
   }

这里的 copy 方法主要作用就是从已有的数据类对象中拷贝一个新的数据类对象。当然你可以传入相应参数来生成不同的对象。但同时发现,在 copy 的执行过程中,若你未指定具体属性的值,那么新生成的对象的属性值将使用被 copy 对象的属性值,这便是我们平常所说的浅拷贝。

实际上 copy 更像是一种语法糖,假如我们的类是不可变的,属性不可以修改,那么我们只能通过 copy 来帮我们基于原有对象生成一个新的对象。比如下面的两个例子:

// 假如声明的bird不允许修改属性
val bird = Bird(20.0, "red", 1)
// 只能通过copy
val bird2 = bird.copy(color = "blue")

copy 更像提供了一种简洁的方式帮我们复制一个对象,但它是一种浅拷贝的方式。所以在使用copy 的时候要注意使用场景,因为数据类的属性可以被修饰为 var,这便不能保证不会出现引用修改问题。

继续看看 componentN 方法。简单来说,componentN 可以理解为类属性的值,其中 N 代表属性的顺序,比如 component1 代表第1个属性的值,component3 代表第3个属性的值。那么,这样设计到底有什么用呢?思考一个问题,我们或多或少地知道怎么将属性绑定到类上,但是对如何将类的属性绑定到相应变量上却不是很熟悉。比如:

data class Bird(var weight: Double, var color: String, var age: Int)

fun main() {

    val bird = Bird(20.0, "red", 1)

    // 通常做法
    val weight = bird.weight
    val color = bird.color
    val age = bird.age
    // kotlin 进阶
    val (weight, color, age) = bird
    
}

再看一个,Java 中,你肯定这么写过代码:

final String value = "code,name,tranmemo";
final String[] arr = value.split(",");
final String code = arr[0];
final String name = arr[1];
final String tranmemo = arr[2];

这样做确实有点麻烦,明明知道值的情况,却不得分好几步给变量赋值,而 Kotlin 只需要如下:

val value = "code,name,tranmemo"
val (code, name, tranmemo) = value.split(",")
println(code)
println(name)
println(tranmemo)

是不是很直观!点赞!!!!那么这到底是一种什么魔法呢?其实原理也很简单,就是
解构,通过编译器的约定实现解构。当然 Kotlin 对于数组的解构也有一定限制,在数组中它默认最多允许赋值5个变量因为若是变量过多,效果反而会适得其反,因为到后期你都搞不清楚哪个值要赋给哪个变量了。所以一定要合理使用这一特性。

在数据类中,你除了可以利用编译器帮你自动生成 componentN 方法以外,甚至还可以自己实现对应属性的 componentN 方法。

除了数组支持解构外,Kotlin 也提供了其他常用的数据类,让使用者不必主动声明这些数据类,它们分别是 Pair 和 Triple。其中 Pair 是二元组,可以理解为这个数据类中有两个属性。Triple 是三元组,对应的则是3个属性。我们先来看一下它们的源码:

// Pair
public data class Pair<out A, out B>(
    public val first: A,
    public val second: B
)  。。。

// Triple
public data class Triple<out A, out B, out C>(
    public val first: A,
    public val second: B,
    public val third: C
)

可以发现 Pair 和 Triple 都是数据类,它们的属性可以是任意类型,我们可以按照属性的顺序来获取对应属性的值。因此,我们可以这么使用它们:

    val pair = Pair(1, 2)
    val triple = Triple(1, 2, 3)

    // 利用属性顺序获取值
    val a = pair.first
    val b = pair.second

    val v1 = triple.first
    val v2 = triple.second
    val v3 = triple.third

    // 利用解构
    val (a1, a2) = pair
    val (s1, s2) = Pair("name", "test")
    val (b1, b2, b3) = triple

数据类中的解构基于 componentN 函数,如果自己不声明 componentN 函数,那么就会默认根据主构造函数参数来生成具体个数的 componentN 函数,与从构造函数中的参数无关。

4.4.数据类的约定与使用

接下来看看如何设计一个数据类并且合理地使用它。

在 Kotlin 声明一个数据类,必须满足以下几点条件:

  • 数据类必须拥有一个构造方法,该方法至少包含一个参数,一个没有数据的数据类是没有任何用处的。
  • 与普通的类不同,数据类构造方法的参数强制使用 var 或者 val 进行声明。
  • data class 之前不能用 abstract、open、sealed 或者 inner 进行修饰。
  • 在 Kotlin1.1 版本前数据类只允许实现接口,之后的版本既可以实现接口也可以继
    承类。

数据类的另一个典型的应用就是代替我们在 Java 中的建造者模式。后续会说明。

5.从 static 到 object

在 Java 中,static 是非常重要的特性,它可以用来修饰类、方法或属性。然而,static 修饰的内容都是属于类的,而不是某个具体对象的,但在定义时却与普通
的变量和方法混杂在一起,显得格格不人。

在 Kotlin 中,告别 static 这种语法,因为它引入了全新的关键字 object,可以完美地代替使用 static 的所有场景。当然除了代替使用 static 的场景之外,它还能实现更多的功能,比如单例对象及简化匿名表达式等。

5.1.什么是伴生对象

伴生对象顾名思义,“伴生”是相较于一个类而言的,意为伴随某个类的对象,它属于这个类所有,因此伴生对象跟 Java 中 static 修饰效果性质一样,全局只有一个单例。它需要声明在类的内部,在类被装载时会被初始化。

在 Java 中,一个类中可以同时存在静态变量、静态方法、普通变量、普通方法的声明。然而,静态变量和静态方法是属于一个类的,普通变量、普通方法是属于一个具体对象的。虽然有 static 作为区分,但在代码结构上职能并不是区分得很清晰。

Kotlin 中引人了伴生对象的概念,简单来说,这是一种利用 companion object 两个关键字创造的语法。

Kotlin 写一个伴生对象的示例:

class Person(val name: String, val age: Int) {

	// 声明伴生对象
    companion object {
        val MAX_AGE = 120
        val TYPE = "PERSON"

        fun printlnPerson(person: Person) {
            println("我是个人,我叫${person.name},我${person.age}岁了, 我可以活到${MAX_AGE}岁,我分类属于${TYPE}")
        }
    }

}

fun main() {
    val person = Person("tom", 30)
    Person.printlnPerson(person)
}

可以发现,companion object 用花括号包裹了所有静态属性和方法,使得它可以与 Person 类的普通方法和属性清晰地区分开来。最后,我们可以使用点号来对一个类的静态的成员进行调用。

伴生对象的另一个作用是可以实现工厂方法模式。这里不说明了。

5.2.天生的单例:object

单例模式最大的一个特点就是在系统中只能存在一个实例对象,所以在 Java 中必须通过设置构造方法私有化,以及提供静态方法创建实例的方式来创建单例对象。

Kotlin 中,由于 obiect 的存在,我们可以直接用它来实现单例,如下所示:

object DatabaseConfig {
    var host: String = "localhost"
    var port: Int = 3306
}

是不是特别简洁呢?由于 obiect 全局声明的对象只有一个,所以它并不用语法上的初始化,甚至都不需要构造方法。因此,obiect 创造的是天生的单例,我们并不需要在 Kotlin 中去构建一个类似 Java 的单例模式。由于 DatabaseConfg 的属性是用 var 声明的 String,我们还可以修改它们:

// 3306
println(DatabaseConfig.port)
// 修改
DatabaseConfig.port = 123
// 123
println(DatabaseConfig.port)

单例也可以和普通的类一样实现接口和继承类,所以可以将它看成一种不需要主动初始化的类,它也可以拥有扩展方法,有关扩展的内容将会在后面讲解。

单例对象会在系统加载的时候初始化,全局就只有一个。那么,object 声明除了表现在单例对象及上面的说的伴生对象之外,还有其他的作用吗?它还有一个作用就是替代 Java 中的匿名内部类。下面我们就来看看它是如何做的。

5.3.object 表达式

Java 中的匿名内部类很烦燥,有时明明只有一个方法却要用一个匿名内部类去实现。(后面有了 Lambda 之后简单了很多)

Kotlin 中,我们要对一个字符串列表排序,如下:

    val comparator = object : Comparator<String> {
        override fun compare(s1: String?, s2: String?): Int {
            if (s1 == null) {
                return -1
            } else if (s2 == null) {
                return 1
            }
            return s1.compareTo(s2)
        }
    }

    val list = listOf("5", "3", "4", "1")
	// 排序
    Collections.sort(list, comparator)
	// 打印结果:=> [1, 3, 4, 5]
    println("=> $list")

如上,objec 表达式跟 Java 的匿名内部类很相似,但是 object 表达式可以赋值给一个变量,这在重复使用的时候会减少很多代码。另外 object 可以继承类和实现接口,匿名内部类只能继承一个类及实现一个接口,而 object 表达式却没有这个限制。

用于代替匿名内部类的 obiect 表达式在运行中不像我们在单例模式中说的那样,全局只存在一个对象,而是在每次运行时都会生成一个新的对象。

匿名内部类与 object 表达式并不是对任何场景都适合的,Java8 引人的 Lambda 表达式对有些场景实现起来更加适合,比如接口中只有单个方法的实现。而 Kotlin 天然就支持 Lambda 表达式,现在将上面的代码用 Lambda 表达式的方式重新改造一下:

    val comparator = Comparator<String> { s1, s2 ->
        if (s1 == null) {
            return@Comparator -1
        } else if (s2 == null) {
            return@Comparator 1
        }
        s1.compareTo(s2)
    }

    val list = listOf("5", "3", "4", "1")
	// 排序
    Collections.sort(list, comparator)
    // 打印结果:=> [1, 3, 4, 5]
    println("=> $list")

使用 Lambda 表达式后代码更简洁了。

对象表达式与 Lambda 表达式哪个更适合代替匿名内部类?当你的匿名内部类使用的类接口只需要实现一个方法时,使用 Lambda 表达式更适合。当匿名内部类内有多个方法实现的时候,使用 obiect 表达式更加合适。

6.总结

6.1.Kotlin 类与接口

Kotlin 的类与接口的声明方式虽然和 Java 有很多相似的地方,但相对来说 Kotlin 的语法更加简洁,同时它还提供了一些语法特性来帮我们简化代码,比如方法支持默认实现、构造参数支持默认值。另外 Kotlin 还引人主从构造方法、init 语句块等语法来实现与 Java 构造方法重载同等的效果。

6.2.Kotlin 中的修饰符

Kotlin 中的限制类修饰符相对 Java 来说更加严格,默认是 final。而可见性修饰符则更加开放,默认是 public,并提供了一个独特的修饰符 internal,即模块内可见。

6.3.多继承问题

探究类多继承问题的所在,并用多种方式在 Kotlin 中实现多继承的效果。我们还将进一步学习 Kotlin 的语法特性,比如内部类与嵌套类、委托等。

6.4.数据类

数据类的语法,只关心真正的数据,而不是一堆烦琐的模板代码。此外,剖析了数据类的实现原理,来了解它的高级语法特性,比如copy、解构声明等,并学习如何合理地使用它。

6.5.object

object 声明的内容可以看成没有构造方法的类,它会在系统或者类加载时进行初始化。学习如何在 Kotlin 中通过companion object 关键字实现 Java 中 static 的类似效果。使用 obiect 可以直接创建单例,而无须像 Java 那样必须利用设计模式。此外,可以用 obiect 表达式代替简化使用匿名内部类的语法。

  • 14
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值