带你读《Kotlin核心编程》之三:面向对象-阿里云开发者社区

开发者社区> 华章出版社> 正文

带你读《Kotlin核心编程》之三:面向对象

简介: 本书不是一本简单介绍Kotlin语法应用的图书,而是一部专注于帮助读者深入理解Kotlin的设计理念,指导读者实现Kotlin高层次开发的实战型著作。书中深入介绍了Kotlin的核心语言特性、设计模式、函数式编程、异步开发等内容,并以Android和Web两个平台为背景,演示了Kotlin的实战应用。

点击查看第一章
点击查看第二章

第3章

面向对象
通过对上一章的阅读,相信你对Kotlin的基础语法已经有了一定的了解,本章我们会开启Kotlin中面向对象的大门。在Java中,也许你已经厌烦了重载多个构造方法去初始化一个类,或者又因设计了错误的继承关系而导致结构混乱。另外,你也肯定见识过Java中各种模板化的代码,这让程序变得臃肿。
很庆幸,在Kotlin中你将没有这些烦恼,它用合理的语言设计帮我们处理了可能会遇到的麻烦,比如方法默认参数、更严格的限制修饰符等。最后,你还将接触到Kotlin中编译器生成的更多样化的密封类、数据类,这为Kotlin从面向对象到函数式架起了另一条桥梁。我们会在下一章中进一步介绍它们的高级应用。

3.1 类和构造方法

Java是一门假设只用面向对象进行程序设计的编程语言,在Kotlin中对象思想同样非常重要(虽然它是多范式语言)。本节我们会从对象这个概念入手,结合一个鸟的例子,来学习在Kotlin中如何简洁地声明一个类和接口。

3.1.1 Kotlin中的类及接口

对象是什么?我们肯定再熟悉不过了,任何可以描述的事物都可以看作对象。我们以鸟为例,来分析它的组成。

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

1. Kotlin中的类
对象是由状态和行为组成的,我们可以通过它们描述一个事物。下面我们就用Kotlin来抽象一个Bird类:
// Kotlin中的一个类
class Bird {

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

}
是不是一点也不陌生?我们依然可以使用熟悉的class结构体来声明一个类。但是,Kotlin中的类显然也存在很多不同。作为对照,我们把上述代码反编译成Java的版本,然后分析它们具体的差异。
public final class Bird {

private final double weight = 500.0D;
@NotNull
private final String color = "blue";
private final int age = 1;

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

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

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

public final void fly() {
}

}
可以看出,虽然Kotlin中类声明的语法非常近似Java,但也存在很多不同:
1)不可变属性成员。正如我们在第2章介绍过,Kotlin支持用val在类中声明引用不可变的属性成员,这是利用Java中的final修饰符来实现的,使用var声明的属性则反之引用可变。
2)属性默认值。因为Java的属性都有默认值,比如int类型的默认值为0,引用类型的默认值为null,所以在声明属性的时候我们不需要指定默认值。而在Kotlin中,除非显式地声明延迟初始化,不然就需要指定属性的默认值。
3)不同的可访问修饰符。Kotlin类中的成员默认是全局可见,而Java的默认可见域是包作用域,因此在Java版本中,我们必须采用public修饰才能达相同的效果。我们会在下一节讲解Kotlin中不同的访问控制。
2.可带有属性和默认方法的接口
在看过类对比之后,我们继续来看看Kotlin和Java中接口的差异。这一次,我们先来看一个Java 8版本的接口:
// Java 8中的接口
public interface Flyer {

public String kind();

default public void fly() {
    System.out.println("I can fly");
}

}
众所周知,Java 8引入了一个新特性—接口方法支持默认实现。这使得我们在向接口中新增方法时候,之前继承过该接口的类则可以不需要实现这个新方法。接下来再来看看在Kotlin中如何声明一个接口:
// Kotlin中的接口
interface Flyer {

val speed: Int
fun kind()
fun fly() {
    println("I can fly")
}

}
同样,我们也可以用Kotlin定义一个带有方法实现的接口。同时,它还支持抽象属性(如上面的speed)。然而,你可能知道,Kotlin是基于Java 6的,那么它是如何支持这种行为的呢?我们将上面Kotlin声明的接口转换为Java代码,提取其中关键的代码:
public interface Flyer {

int getSpeed();

void kind();

void fly();

public static final class DefaultImpls {
    public static void fly(Flyer $this) {
        String var1 = "I can fly";
        System.out.println(var1);
    }
}

}
我们发现Kotlin编译器是通过定义了一个静态内部类DefaultImpls来提供fly方法的默认实现的。同时,虽然Kotlin接口支持属性声明,然而它在Java源码中是通过一个get方法来实现的。在接口中的属性并不能像Java接口那样,被直接赋值一个常量。如以下这样做是错误的:
interface Flyer {

val height = 1000 //error Property initializers are not allowed in interfaces

}
Kotlin提供了另外一种方式来实现这种效果:
interface Flyer {

val height
    get() = 1000

}
可能你会对这种语法感到不习惯,但这与Kotlin实现该机制的背景有关。我们说过,Kotlin接口中的属性背后其实是用方法来实现的,所以说如果我们要为变量赋值常量,那么就需要编译器原生就支持方法默认实现。但Kotlin是基于Java 6的,当时并不支持这种特性,所以我们并不能像Java那样给一个接口的属性直接赋值一个常量。我们再来回味下在Kotlin接口中如何定义一个普通属性:
interface Flyer {

val height: Long

}
它同方法一样,若没有指定默认行为,则在实现该接口的类中必须对该属性进行初始化。
总的来说,Kotlin的类与接口的声明和Java很相似,但它的语法整体上要显得更加简洁。好了,现在我们定义好了Bird类,接下来再来看看如何用它创建一个对象吧。

3.1.2 更简洁地构造类的对象

需要注意的是,Kotlin中并没有我们熟悉的new关键字。你可以这样来直接声明一个类的对象:
val bird = Bird()
当前我们并没有给Bird类传入任何参数。现实中,你很可能因为需要传入不同的参数组合,而在类中创建多个构造方法,在Java中这是利用构造方法重载来实现的。
class Bird {

private double weight;
private int age;
private String color;

public Bird(double weight, int age, String color) {
    this.weight = weight;
    this.age = age;
    this.color = color;
}

public Bird(int age, String color) {
    this.age = age;
    this.color = color;
}

public Bird(double weight) {
    this.weight = weight;
}
...

}
我们发现Java中的这种方式存在两个缺点:

  • 如果要支持任意参数组合来创建对象,那么需要实现的构造方法将会非常多。
  • 每个构造方法中的代码会存在冗余,如前两个构造方法都对age和color进行了相同的赋值操作。

Kotlin通过引入新的构造语法来解决这些问题,我们来看看它具体是如何做的。
1.构造方法默认参数
要解决构造方法过多的问题,似乎也很简单。在Kotlin中你可以给构造方法的参数指定默认值,从而避免不必要的方法重载。我们现在用Kotlin来改写上述的例子:
class Bird(val weight: Double = 0.00, val age: Int = 0, val color: String = "blue")
// 可以省略{}
竟然用一行代码就搞定了。我们可以实现与Java版本等价的效果:
val bird1 = Bird(color = "black")
val bird2 = Bird(weight = 1000.00, color = "black")
需要注意的是,由于参数默认值的存在,我们在创建一个类对象时,最好指定参数的名称,否则必须按照实际参数的顺序进行赋值。比如,以下最后一个例子在Kotlin中是不允许的:
image.png
如之前所言,我们在Bird类中可以用val或者var来声明构造方法的参数。这一方面代表了参数的引用可变性,另一方面它也使得我们在构造类的语法上得到了简化。
为什么这么说呢?事实上,构造方法的参数名前当然可以没有val和var,然而带上它们之后就等价于在Bird类内部声明了一个同名的属性,我们可以用this来进行调用。比如我们前面定义的Bird类就类似于以下的实现:
class Bird(

    weight: Double = 0.00, // 参数名前没有val
    age: Int = 0,
    color: String = "blue") {

val weight: Double
val age: Int
val color: String

init {
    this.weight = weight // 构造方法参数可以在init语句块被调用
    this.age = age
    this.color = color
}

}
2. init语句块
Kotlin引入了一种叫作init语句块的语法,它属于上述构造方法的一部分,两者在表现形式上却是分离的。Bird类的构造方法在类的外部,它只能对参数进行赋值。如果我们需要在初始化时进行其他的额外操作,那么我们就可以使用init语句块来执行。比如:
class Bird(weight: Double, age: Int, color: String) {

init {
    println("do some other things")
    println("the weight is ${weight}")
}

}
如你所见,当没有val或var的时候,构造方法的参数可以在init语句块被直接调用。其实它们还可以用于初始化类内部的属性成员的情况。如:
class Bird(weight: Double = 0.00, age: Int = 0, color: String = "blue") {

val weight: Double = weight //在初始化属性成员时调用weight
val age: Int = age
val color: String = color

}
除此之外,我们并不能在其他地方使用。以下是一个错误的用法:
class Bird(weight: Double, age: Int, color: String) {

fun printWeight() {
    print(weight) // Unresolved reference: weight
}

}
事实上,我们的构造方法还可以拥有多个init,它们会在对象被创建时按照类中从上到下的顺序先后执行。看看以下代码的执行结果:
class Bird(weight: Double, age: Int, color: String) {

val weight: Double
val age: Int
val color: String

init {
    this.weight = weight
    println("The bird's weight is ${this.weight}.")
    this.age = age
    println("The bird's age is ${this.age}.")
}

init {
    this.color = color
    println("The bird's color is ${this.color}.")
}

}

fun main(args: Array) {

val bird = Bird(1000.0, 2, "bule")

}
// 运行结果
The bird's weight is 1000.0.
The bird's age is 2.
The bird's color is bule.
可以发现,多个init语句块有利于我们进一步对初始化的操作进行职能分离,这在复杂的业务开发(如Android)中显得特别有用。
再来思考一种场景,现实中我们在创建一个类对象时,很可能不需要对所有属性都进行传值。其中存在一些特殊的属性,比如鸟的性别,我们可以根据它的颜色来进行区分,所以它并不需要出现在构造方法的参数列表中。
有了init语句块的语法支持,我们很容易实现这一点。假设黄色的鸟儿都是雌性,剩余的都是雄鸟,我们就可以如此设计:
class Bird(val weight: Double, val age: Int, val color: String) {

val sex: String

init {
    this.sex = if (this.color == "yellow") "male" else "female"
}

}
我们再来修改下需求。这一次我们并不想在init语句块中对sex直接赋值,而是调用一个专门的printSex方法来进行,如:
class Bird(val weight: Double, val age: Int, val color: String) {

val sex: String

fun printSex() {
    this.sex = if (this.color == "yellow") "male" else "female"
    println(this.sex)
}

}

fun main(args: Array) {

val bird = Bird(1000.0, 2, "bule")
bird.printSex()

}
// 运行结果
Error:(2, 1) Property must be initialized or be abstract
Error:(5, 8) Val cannot be reassigned
结果报错了,主要由以下两个原因导致:

  • 正常情况下,Kotlin规定类中的所有非抽象属性成员都必须在对象创建时被初始化值。
  • 由于sex必须被初始化值,上述的printSex方法中,sex会被视为二次赋值,这对val声明的变量来说也是不允许的。

第2个问题比较容易解决,我们把sex变成用var声明,它就可以被重复修改。关于第1个问题,最直观的方法是指定sex的默认值,但这可能是一种错误的性别含义;另一种办法是引入可空类型(我们会在第5章具体介绍),即把sex声明为“String?”类型,则它的默认值为null。这可以让程序正确运行,然而实际上也许我们又不想让sex具有可空性,而只是想稍后再进行赋值,所以这种方案也有局限性。
3.延迟初始化:by lazy和lateinit
更好的做法是让sex能够延迟初始化,即它可以不用在类对象初始化的时候就必须有值。在Kotlin中,我们主要使用lateinit和by lazy这两种语法来实现延迟初始化的效果。下面来看看如何使用它们。
如果这是一个用val声明的变量,我们可以用by lazy来修饰:
class Bird(val weight: Double, val age: Int, val color: String) {

val sex: String by lazy {
    if (color == "yellow") "male" else "female"
}

}
总结by lazy语法的特点如下:

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

lazy的背后是接受一个lambda并返回一个 Lazy 实例的函数,第一次访问该属性时,会执行lazy对应的Lambda表达式并记录结果,后续访问该属性时只是返回记录的结果。
另外系统会给lazy属性默认加上同步锁,也就是LazyThreadSafetyMode.SYNCHRON IZED,它在同一时刻只允许一个线程对lazy属性进行初始化,所以它是线程安全的。但若你能确认该属性可以并行执行,没有线程安全问题,那么可以给lazy传递LazyThreadSafetyMode.PUBLICATION参数。你还可以给lazy传递LazyThreadSafetyMode.NONE参数,这将不会有任何线程方面的开销,当然也不会有任何线程安全的保证。比如:
val sex: String by lazy(LazyThreadSafetyMode.PUBLICATION) {

//并行模式
if (color == "yellow") "male" else "female"

}
val sex: String by lazy(LazyThreadSafetyMode.NONE) {

//不做任何线程保证也不会有任何线程开销
if (color == "yellow") "male" else "female"

}
与lazy不同,lateinit主要用于var声明的变量,然而它不能用于基本数据类型,如Int、Long等,我们需要用Integer这种包装类作为替代。相信你已经猜到了,利用lateinit我们就可以解决之前的问题,就像这样子:
class Bird(val weight: Double, val age: Int, val color: String) {

lateinit var sex: String // sex 可以延迟初始化

fun printSex() {
    this.sex = if (this.color == "yellow") "male" else "female"
    println(this.sex)
}

}

fun main(args: Array) {

val bird = Bird(1000.0, 2, "bule")
bird.printSex()

}
// 运行结果
female
Delegates.notNull
你可能比较好奇,如何让用var声明的基本数据类型变量也具有延迟初始化的效果,一种可参考的解决方案是通过Delegates.notNull,这是利用Kotlin中委托的语法来实现的。我们会在后续介绍它的具体用法,当前你可以通过一个例子来认识这种神奇的效果:
var test by Delegates.notNull()
fun doSomething() {

  test = 1
  println("test value is ${test}")
  test = 2

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

3.1.3 主从构造方法

我们似乎遗漏了另一些常见的情况。有些时候,我们可能需要从一个特殊的数据中来获取构造类的参数值,这时候如果可以定义一个额外的构造方法,接收一个自定义的参数会显得特别方便。
同样以鸟为例,先把之前的Bird类简化为:
class Bird(age: Int) {

val age: Int

init {
    this.age = age
}

}
假设当前我们知道鸟的生日,希望可以通过生日来得到鸟的年龄,然后创建一个Bird类对象。如何实现?
第1种方案是在别处定义一个工厂方法,如:
import org.joda.time.DateTime

fun Bird(birth: DateTime) = Bird(getAgeByBirth(birth))
应该在哪里声明这个工厂方法呢?这种方式的缺点在于,Bird方法与Bird类在代码层面的分离显得不够直观。
一种改进方案是在Bird类的伴生对象中定义Bird方法。我们会在后续的节中介绍这种技术。
显然我们可以像Java那样新增一个构造方法来解决这个问题。其实Kotlin也支持多构造方法的语法,然而与Java的区别在于,它在多构造方法之间建立了“主从”的关系。现在我们来用Kotlin中的多构造方法实现这个例子:
import org.joda.time.DateTime

class Bird(age: Int) {

val age: Int

init {
    this.age = age
}

constructor(birth: DateTime) : this(getAgeByBirth(birth)) {
    ...
}

}
来看看这个新的构造方法是如何运作的:

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

通过this关键字来调用要委托的构造方法。如果一个类存在主构造方法,那么每个从构造方法都要直接或间接地委托给它。比如,可以把从构造方法A委托给从构造方法B,再将从构造方法B委托给主构造方法。举个例子:
import org.joda.time.DateTime import org.joda.time.Years
class Bird(age: Int) { val age: Int init { this.age = age } constructor(timestamp: Long): this(DateTime(timestamp)) //构造函数A constructor(birth: DateTime): this(getAgeByBirth(birth)) //构造函数B }
fun getAgeByBirth(birth: DateTime): Int { return Years.yearsBetween(birth, DateTime.now()).years }
现在你应该对Kotlin中的主从构造方法有了一定的了解了。其实,从构造方法的设计除了解决我们以上的场景之外,还有一个很大的作用就是可以对某些Java的类库进行更好地扩展,因为我们经常要基于第三方Java库中的类,扩展自定义的构造方法。如果你从事过Android开发肯定了解,典型的例子就是定制业务中特殊的View类。比如以下的代码:
class KotlinView : View {

constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : 

super(context, attrs, defStyleAttr) {

    ...
}

}
可以看出,利用从构造方法,我们就能使用不同参数来初始化第三方类库中的类了。

3.2 不同的访问控制原则

在构造完一个类的对象之后,你需要开始思考它的访问控制了。在Java中,如果我们不希望一个类被别人继承或修改,那么就可以用final来修饰它。同时,我们还可以用public、private、protected等修饰符来描述一个类、方法或属性的可见性。对于Java的这些修饰符,你可能已经非常熟悉,其实在Kotlin中与其大同小异。最大的不同是,Kotlin在默认修饰符的设计上采用了与Java不同的思路。通过本节的内容你会发现,Kotlin相比Java,对一个类、方法或属性有着不一样的访问控制原则。

3.2.1 限制修饰符

当你想要指定一个类、方法或属性的修改或者重写权限时,你就需要用到限制修饰符。我们知道,继承是面向对象的基本特征之一,继承虽然灵活,但如果被滥用就会引起一些问题。还是拿之前的Bird类举个例子。Shaw觉得企鹅也是一种鸟类,于是他声明了一个Penguin类来继承Bird。
open class Bird {

open fun fly() {
    println("I can fly.")
}

}

class Penguin : Bird() {

override fun fly() {
    println("I can't fly actually.")
}

}
首先,我们来说明两个Kotlin相比Java不一样的语法特性:

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

其次,你肯定注意到了Penguin类重写了父类中的fly方法,因为虽然企鹅也是鸟类,但实际上它却不会飞。这个其实是一种比较危险的做法,比如我们修改了Bird类的fly方法,增加了一个代表每天能够飞行的英里数的参数:miles。
open class Bird {

open fun fly(miles: Int) {
    println("I can fly ${miles} miles daily.")
}

}
现在如果我们再次调用Penguin的fly方法,那么就会出错,错误信息提示fly重写了一个不存在的方法。
Error:(8, 4) 'fly' overrides nothing
事实上,这是我们日常开发中错误设计继承的典型案例。因为Bird类代表的并不是生物学中的鸟类,而是会飞行的鸟。由于没有仔细思考,我们设计了错误的继承关系,导致了上述的问题。子类应该尽量避免重写父类的非抽象方法,因为一旦父类变更方法,子类的方法调用很可能会出错,而且重写父类非抽象方法违背了面向对象设计原则中的“里氏替换原则”。
什么是里氏替换原则?
对里氏替换原则通俗的理解是:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4个设计原则:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法;
  • 子类可以增加自己特有的方法;
  • 当子类的方法实现父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松;
  • 当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

然而,实际业务开发中我们常常很容易违背里氏替换原则,导致设计中出问题的概率大大增加。其根本原因,就是我们一开始并没有仔细思考一个类的继承关系。所以《Effective Java》也提出了一个原则:“要么为继承做好设计并且提供文档,否则就禁止这样做”。
1.类的默认修饰符:final
Kotlin站在前人肩膀上,吸取了它们的教训,认为类默认开放继承并不是一个好的选择。所以在Kotlin中的类或方法默认是不允许被继承或重写的。还是以Bird类为例:
class Bird {

val weight: Double = 500.0
val color: String = "blue"
val age: Int = 1
fun fly() {}

}
这是一个简单的类。现在我们把它编译后转换为Java代码:
public final class Bird {

private final double weight = 500.0D;
private final String color = "blue";
private final int age = 1;

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

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

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

public final void fly() {
}

}
我们可以发现,转换后的Java代码中的类,方法及属性前面多了一个final修饰符,由它修饰的内容将不允许被继承或修改。我们经常使用的String类就是用final修饰的,它不可以被继承。在Java中,类默认是可以被继承的,除非你主动加final修饰符。而在Kotlin中恰好相反,默认是不可被继承的,除非你主动加可以继承的修饰符,那便是之前例子中的open。
现在,我们给Bird类加上open修饰符:
open class Bird {

val weight: Double = 500.0
val color: String = "red"
val age: Int = 1
fun fly() {}

}
大家可以想象一下,这个类被编译成Java代码应该是怎么样的呢?其实就是我们最普通定义Java类的代码:
public class Bird {

...

}
此外,也正如我们所见,如果我们想让一个方法可以被重写,那么也必须在方法前面加上open修饰符。这一切似乎都是与Java相反着的。那么,这种默认final的设计真的就那么好吗?
2.类默认final真的好吗
一种批评的声音来自Kotlin官方论坛,不少人诟病默认final的设计会给实际开发带来不便。具体表现在:

  • 与某些框架的实现存在冲突。如Spring会利用注解私自对类进行增强,由于Kotlin中的类默认不能被继承,这可能导致框架的某些原始功能出现问题。
    - 更多的麻烦还来自于对第三方Kotlin库进行扩展。就统计层面讨论,Kotlin类库肯定会比Java类库更倾向于不开放一个类的继承,因为人总是偷懒的,Kotlin默认final可能会阻挠我们对这些类库的类进行继承,然后扩展功能。

Kotlin论坛甚至举行了一个关于类默认final的喜好投票,略超半数的人更倾向于把open当作默认情况。相关帖子参见:https://discuss.kotlinlang.org/t/classes-final-by-default/166
以上的反对观点很有道理。下面我们再基于Kotlin的自身定位和语言特性重新反思一下这些观点。
1)Kotlin当前是一门以Android平台为主的开发语言。在工程开发时,我们很少会频繁地继承一个类,默认final会让它变得更加安全。如果一个类默认open而在必要的时候忘记了标记final,可能会带来麻烦。反之,如果一个默认final的类,在我们需要扩展它的时候,即使没有标记open,编译器也会提醒我们,这个就不存在问题。此外,Android也不存在类似Spring因框架本身而产生的冲突。
2)虽然Kotlin非常类似于Java,然而它对一个类库扩展的手段要更加丰富。典型的案例就是Android的Kotlin扩展库android-ktx。Google官方主要通过Kotlin中的扩展语法对Android标准库进行了扩展,而不是通过继承原始类的手段。这也揭示了一点,以往在Java中因为没有类似的扩展语法,往往采用继承去对扩展一个类库,某些场景不一定合理。相较而言,在Kotlin中由于这种增强的多态性支持,类默认为final也许可以督促我们思考更正确的扩展手段。
除了扩展这种新特性之外,Kotlin中的其他新特性,比如Smart Casts结合class的final属性也可以发挥更大的作用。
Kotlin除了可以利用final来限制一个类的继承以外,还可以通过密封类的语法来限制一个类的继承。比如我们可以这么做:
sealed class Bird {

open fun fly() = "I can fly"
class Eagle : Bird()

}
Kotlin通过sealed关键字来修饰一个类为密封类,若要继承则需要将子类定义在同一个文件中,其他文件中的类将无法继承它。但这种方式有它的局限性,即它不能被初始化,因为它背后是基于一个抽象类实现的。这一点我们从它转换后的Java代码中可以看出:
public abstract class Bird {

@NotNull
public String fly() {
    return "I can fly";
}

private Bird() {
}

// $FF: synthetic method
public Bird(DefaultConstructorMarker $constructor_marker) {
    this();
}

public static final class Eagle extends Bird {
    public Eagle() {
        super((DefaultConstructorMarker) null);
    }
}

}
密封类的使用场景有限,它其实可以看成一种功能更强大的枚举,所以它在模式匹配中可以起到很大的作用。有关模式匹配的内容将会在下一章讲解。
总的来说,我们需要辩证地看待Kotlin中类默认final的原则,它让我们的程序变得更加安全,但也会在其他场合带来一定的不便。最后,关于限制修饰符,还有一个abstract。abstract大家也不陌生,它若修饰在类前面说明这个类是抽象类,修饰在方法前面说明这个方法是一个抽象方法。Kotlin中的abstract和Java中的完全一样,这里就不过多阐述了。Kotlin与Java的限制修饰符比较如表3-1所示。

image.png

3.2.2 可见性修饰符

除了限制类修饰符之外,还有一种修饰符就是可见性修饰符。下面我们就来看看Kotlin中的可见性修饰符。
若你想要指定类、方法及属性的可见性,那么就需要可见性修饰符。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的默认修饰符是default,它只允许包内访问,但是我们很多时候厌烦了每次都要加public,虽然通常编辑器会自动帮我们加上,但是总觉得这是一个多余的声明。所以,Kotlin可能考虑了这方面因素,将可见修饰符默认指定为public,而不需要显式声明。
上面说到了Java中默认修饰符是default,它的作用域是包内可访问。那么Kotlin中有类似的修饰符吗?
Kotlin中有一个独特的修饰符internal,和default有点像但也有所区别。internal在Kotlin中的作用域可以被称作“模块内访问”。那么到底什么算是模块呢?以下几种情况可以算作一个模块:

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

总的来说,一个模块可以看作一起编译的Kotlin文件组成的集合。那么,Kotlin中为什么要诞生这么一种新的修饰符呢?Java的包内访问不好吗?
Java的包内访问中确实存在一些问题。举个例子,假如你在Java项目中定义了一个类,使用了默认修饰符,那么现在这个类是包私有,其他地方将无法访问它。然后,你把它打包成一个类库,并提供给其他项目使用,这时候如果有个开发者想使用这个类,除了copy源代码以外,还有一个方式就是在程序中创建一个与该类相同名字的包,那么这个包下面的其他类就可以直接使用我们前面的定义的类。伪代码如下:
image.png
该类默认只允许com.dripower的包内可见,但是我们在项目中可以这么做:
image.png
这样我们便可以直接访问该类了。
而Kotlin默认并没有采用这种包内可见的作用域,而是使用了模块内可见,模块内可见指的是该类只对一起编译的其他Kotlin文件可见。开发工程与第三方类库不属于同一个模块,这时如果还想使用该类的话只有复制源码一种方式了。这便是Kotlin中internal修饰符的一个作用体现。
在Java程序中,我们很少见到用private修饰的类,因为Java中的类或方法没有单独属于某个文件的概念。比如,我们创建了Rectangle.java这个文件,那么它里面的类要么是public,要么是包私有,而没有只属于这个文件的概念。若要用private修饰,那么这个只能是其他类的内部类。而Kotlin中则可以用private给单独的类修饰,它的作用域就是当前这个Kotlin文件。比如:
package com.dripower.car

class BMWCar(val name: String) {

private val bMWEngine = Engine("BMW")
fun getEngine(): String {
    return bMWEngine.engineType()//error:Cannot access'enging Type': it is Protected in Engine
}

}

private class Engine(val type: String) {

fun engineType(): String {
    return "the engine type is $type"
}

}
除了private修饰符的差别,Kotlin中的protected修饰符也与Java有所不同。Java中的protected修饰的内容作用域是包内、类及子类可访问,而在Kotlin中,由于没有包作用域的概念,所以protected修饰符在Kotlin中的作用域只有类及子类。我们对上面的代码稍加修改:
package com.dripower.car

class BMWCar(val name: String) {

private val bMWEngine = Engine("BMW")
fun getEngine(): String {
    return bMWEngine.engineType()//error:Cannot access'enging Type'it is Protected in Engine
}

}

private open class Engine(val type: String) {

protected open fun engineType(): String {
    return "the engine type is $type"
}

}

private class BZEngine(type: String) : Engine(type) {

override fun engineType(): String {
    return super.engineType()  
}

}
我们可以发现同一包下的其他类不能访问protected修饰的内容了,而在子类中可以。
总结一下,可见性修饰符在Kotlin与Java中大致相似,但也有自己的很多特殊之处。这些可见性修饰符比较如表3-2所示。

image.png


在了解了Kotlin中的可见修饰符后,我们来思考一个问题:前面已经讲解了为什么要诞生internal这个修饰符,那么为什么Kotlin中默认的可见性修饰符是public,而不是internal呢?
关于这一点,Kotlin的开发人员在官方论坛进行了说明,这里我做一个总结:Kotlin通过分析以往的大众开发的代码,发现使用public修饰的内容比其他修饰符的内容多得多,所以Kotlin为了保持语言的简洁性,考虑多数情况,最终决定将public当作默认修饰符。

3.3 解决多继承问题

上面我们讨论了很多关于继承的问题,下面我们来看一个更有意思的问题:多继承。
继承与实现是面向对象程序设计中不变的主题。众所周知,Java是不支持类的多继承的,Kotlin亦是如此。为什么它们要这样设计呢?现实中,其实多继承的需求经常会出现,然而类的多继承方式会导致继承关系上语义的混淆。本节我们会展示多继承问题的所在,以及如何通过Kotlin的语法来设计多种不同的多继承解决方案,从而进一步了解Kotlin的语言特性。

3.3.1 骡子的多继承困惑

如果你了解C++,应该知道C++中的类是支持多重继承机制的。然而,C++中存在一个经典的钻石问题—骡子的多继承困惑。我们假设Java的类也支持多继承,然后模仿C++中类似的语法,来看看它到底会导致什么问题。
abstract class Animal {

abstract public void run();

}

class Horse extends Animal { //马

@Override
public void run() {
    System.out.println("I am run very fast");
}

}

class Donkey extends Animal { //驴

@Override
public void run() {
    System.out.println("I am run very slow");
}

}

class Mule extends Horse, Donkey { //骡子

...

}
这是一段伪代码,我们来分析下这段代码具体的含义:

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

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

image.png


所以钻石问题也被称为菱形继承问题。可以发现,类的多重继承如果使用不当,就会在继承关系上产生歧义。而且,多重继承还会给代码维护带来很多的困扰:一来代码的耦合度会很高,二来各种类之间的关系令人眼花缭乱。
于是,Kotlin跟Java一样只支持类的单继承。那么,面对多重继承的需求,我们在Kotlin中该如何解决这个问题呢?

3.3.2 接口实现多继承

一个类实现多个接口相信你肯定不会陌生,这是Java经常干的事情。Kotlin中的接口与Java很相似,但它除了可以定义带默认实现的方法之外,还可以声明抽象的属性。我们的第1个方案,就来看看如何用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 eat() {
    println("I can eat")
}

override fun fly() {
    println("I can fly")
}

override fun kind() = super<Flyer>.kind()

}

fun main(args: Array) {

val bird = Bird("sparrow")
println(bird.kind())

}
// 运行结果
flying animals
如你所见,Bird类同时实现了Flyer和Animal两个接口,但由于它们都拥有默认的kind方法,同样会引起上面所说的钻石问题。而Kotlin提供了对应的方式来解决这个问题,那就是super关键字,我们可以利用它来指定继承哪个父接口的方法,比如上面代码中的super.kind()。当然我们也可以主动实现方法,覆盖父接口的方法。如:
override fun kind() = "a flying ${this.name}"
那么最终的执行结果就是:
a flying sparrow
通过这个例子,我们再来分析下实现接口的相关语法:
1)在Kotlin中实现一个接口时,需要实现接口中没有默认实现的方法及未初始化的属性,若同时实现多个接口,而接口间又有相同方法名的默认实现时,则需要主动指定使用哪个接口的方法或者重写方法;
2)如果是默认的接口方法,你可以在实现类中通过“super”这种方式调用它,其中T为拥有该方法的接口名;
3)在实现接口的属性和方法时,都必须带上override关键字,不能省略。
除此之外,你应该还注意到了,我们通过主构造方法参数的方式来实现Animal接口中的name属性。我们之前说过,通过val声明的构造方法参数,其实是在类内部定义了一个同名的属性,所以我们当然还可以把name的定义放在Bird类内部。
class Bird(name: String) : Flyer, Animal {

override val name: String // override不要忘记

init {
    this.name = name
}

}
name的赋值方式其实无关紧要。比如我们还可以用一个getter对它进行赋值。
class Bird(chineseName: String) : Flyer, Animal {

override val name: String
    get() = translate2EnglishName(chineseName)

}
getter和setter
对于getter和setter相信很多Java程序员再熟悉不过了,在Java中通过这种方式来对一个类的私有字段进行取值和赋值的操作,通常用IDE来帮我们自动生成这些方法。但是在很多时候你会发现这种语法真是不堪入目。而Kotlin类不存在字段,只有属性,它同样需要为每个属性生成getter和setter方法。但Kotlin的原则是简洁明了的,既然都要做,那么为何我不幕后就帮你做好了呢?所以你在声明一个类的属性时,要知道背后Kotlin编译器也帮你生成了getter和setter方法。当然你也可以主动声明这两个方法来实现一些特殊的逻辑。还有以下两点需要注意:
1)用val声明的属性将只有getter方法,因为它不可修改;而用var修饰的属性将同时拥有getter和setter方法。
2)用private修饰的属性编译器将会省略getter和setter方法,因为在类外部已经无法访问它了,这两个方法的存在也就没有意义了。
总的来说,用接口模拟实现多继承是我们最常用的方式。但它有时在语义上依旧并不是很明确。下面我们就来看一种更灵活的方式,它能更完整地解决多继承问题。

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

我们要探讨的第2种方式就是用内部类模拟多继承的效果。我们知道,在Java中可以将一个类的定义放在另一个类的定义内部,这就是内部类。由于内部类可以继承一个与外部类无关的类,所以这保证了内部类的独立性,我们可以用它的这个特性来尝试解决多继承的问题。
在探讨这个问题之前,我们有必要来了解一下Kotlin中内部类的语法。如你所知,Java的内部类定义非常直观,我们只要在一个类内部再定义一个类,那么这个类就是内部类了,如:
public class OuterJava {

private String name = "This is Java's inner class syntax.";

class InnerJava {  //内部类
    public void printName()
    {
        System.out.println(name);
    }
}

}
现在我们尝试用类似的Kotlin代码来改写这段代码,看看有没有类似的效果。
class OuterKotlin {

val name = "This is not Kotlin's inner class syntax."

class ErrorInnerKotlin { // 其实是嵌套类
    fun printName() {
        print("the name is $name") //error
    }
}

}
// 运行结果
Error:(5, 32) Unresolved reference: name
怎么回事,这段代码竟然报错了?其实这里闹了乌龙,当前我们声明的并不是Kotlin中的内部类,而是嵌套类的语法。如果要在Kotlin中声明一个内部类,我们必须在这个类前面加一个inner关键字,就像这样子:
class OuterKotlin {

val name = "This is truely Kotlin's inner class syntax."

inner class InnerKotlin {
    fun printName() {
        print("the name is $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() {
    HorseC().runFast()
}

fun doLongTimeThing() {
    DonkeyC().doLongTimeThing()
}

private inner class HorseC : Horse()
private inner class DonkeyC : Donkey()

}
通过这个修改后的例子可以发现:
1)我们可以在一个类内部定义多个内部类,每个内部类的实例都有自己的独立状态,它们与外部对象的信息相互独立;
2)通过让内部类HorseC、DonkeyC分别继承Horse和Donkey这两个外部类,我们就可以在Mule类中定义它们的实例对象,从而获得了Horse和Donkey两者不同的状态和行为;
3)我们可以利用private修饰内部类,使得其他类都不能访问内部类,具有非常良好的封装性。
因此,可以说在某些场合下,内部类确实是一种解决多继承非常好的思路。

3.3.4 使用委托代替多继承

在看完与Java类似的多继承解决思路后,我们再来看一种Kotlin中新引入的语法—委托。通过它我们也可以代替多继承来解决类似的问题。
关于委托,可能你会很熟悉。比如你非常了解委托模式,或者你是一名C#开发者,熟悉其中的delegate关键字。简单来说,委托是一种特殊的类型,用于方法事件委托,比如你调用A类的methodA方法,其实背后是B类的methodA去执行。
印象中,要实现委托并不是一件非常自然直观的事情。但庆幸的是,Kotlin简化了这种语法,我们只需通过by关键字就可以实现委托的效果。比如我们之前提过的by lazy语法,其实就是利用委托实现的延迟初始化语法。我们再来重新回顾一下它的使用:
val laziness: String by lazy {

// 用by lazy实现延迟初始化效果
println("I will have a value")
"I am a lazy-initialized string"

}
委托除了延迟属性这种内置行为外,还提供了一种可观察属性的行为,这与我们平常所说的观察者模式很类似。观察者模式在Android开发中应用很广,我们会利用委托在第9章中介绍它如何改善Android中的观察者模式。
接下来,我们来看看如何通过委托来代替多继承实现需求。请看下面的例子:
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(args: Array) {

val flyer = Flyer()
val animal = Animal()
val b = Bird(flyer, animal)
b.fly()
b.eat()

}
有人可能会有疑问:首先,委托方式怎么跟接口实现多继承如此相似,而且好像也并没有简单多少;其次,这种方式好像跟组合也很像,那么它到底有什么优势呢?主要有以下两点:
1)前面说到接口是无状态的,所以即使它提供了默认方法实现也是很简单的,不能实现复杂的逻辑,也不推荐在接口中实现复杂的方法逻辑。我们可以利用上面委托的这种方式,虽然它也是接口委托,但它是用一个具体的类去实现方法逻辑,可以拥有更强大的能力。
2)假设我们需要继承的类是A,委托对象是B、C、我们在具体调用的时候并不是像组合一样A.B.method,而是可以直接调用A.method,这更能表达A拥有该method的能力,更加直观,虽然背后也是通过委托对象来执行具体的方法逻辑的。

3.4 真正的数据类

通过前面的内容我们发现,Kotlin在解决多继承问题上非常灵活。但是有时候,我们并不想要那么强大的类,也许我们只是想要单纯地使用类来封装数据,类似于Java中的DTO(Data Transfer Object)的概念。但我们知道在Java中显得烦琐,因为通常情况下我们会声明一个JavaBean,然后定义一堆getter和setter。虽然IDE能帮我们自动生成这些代码,但是你很可能已经厌烦了这些冗长的代码了。下面就来看看Kotlin是如何改进这个问题的吧。

3.4.1 烦琐的JavaBean

首先我们先来回顾一下熟悉的JavaBean。当我们要定义一个数据模型类时,就需要为其中的每一个属性定义getter、setter方法。如果要支持对象值的比较,我们甚至还要重写hashcode、equals等方法。比如下面的例子:
public class Bird {

private double weight;
private int age;
private String color;

public void fly() {
}

public Bird(double weight, int age, String color) {
    this.weight = weight;
    this.age = age;
    this.color = color;
}

public double getWeight() {
    return weight;
}

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

public int getAge() {
    return age;
}

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

public String getColor() {
    return color;
}

public void setColor(String color) {
    this.color = color;
}

@Override
public boolean equals(Object o) {
    if (this == o)
        return true;
    if (!(o instanceof Bird))
        return false;

    Bird bird = (Bird) o;

    if (Double.compare(bird.getWeight(), getWeight()) != 0)
        return false;
    if (getAge() != bird.getAge())
        return false;
    return getColor().equals(bird.getColor());
}

@Override
public int hashCode() {
    int result;
    long temp;
    temp = Double.doubleToLongBits(getWeight());
    result = (int) (temp ^ (temp > > > 32));
    result = 31 * result + getAge();
    result = 31 * result + getColor().hashCode();
    return result;
}

@Override
public String toString() {
    return "Bird{" +
            "weight=" + weight +
            ", age=" + age +
            ", color='" + color + '\'' +
            '}';
}

}
这是一个只有3个属性的JavaBean,但代码量竟然足有60多行。可想而知,若是你想要更多的属性,那么一个JavaBean将会有多少代码量,而你的初衷无非就是想要有一个单纯封装数据的类而已,最后却变成了一堆样板式的代码。幸运的是,在Kotlin中我们将不再面对这个问题,它引入了data class的语法来改善这一情况。让我们来看看它到底是一个什么东西。

3.4.2 用data class创建数据类

data class顾名思义就是数据类,当然这不是Kotlin的首创的概念,在很多其他语言中也有相应的设计,比如Scala中的case class。为了搞明白数据类是什么,我们先把上面那段Java代码用Kotlin的data class来表示:
data class Bird(var weight: Double, var age: Int, var color: String)
第一眼看到代码是不是难以置信,这么一行代码就能表示上面60多行的Java代码吗?是的,是不是突然感觉Kotlin简直太人性化了,这一切无非只是添加了一个data关键字而已。事实上,在这个关键字后面,Kotlin编译器帮我们做了很多事情。我们来看看这个类反编译后的Java代码:
public final class Bird {

private double weight;
private int age;
@NotNull
private String color;

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

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

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

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

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

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

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

public final double component1() { //Java中没有
    return this.weight;
}

public final int component2() { //Java中没有
    return this.age;
}

@NotNull
public final String component3() { //Java中没有
    return this.color;
}

@NotNull
public final Bird copy(double weight, int age, @NotNull String color) { //Java中没有
    Intrinsics.checkParameterIsNotNull(color, "color");
    return new Bird(weight, age, color);
}

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

public String toString() {
    ...
}

public int hashCode() {
    ...
}

public boolean equals(Object var1) {
    ...
}

}
这段代码是不是和JavaBean代码很相似,同样有getter/setter、equals、hashcode、构造函数等方法,其中的equals和hashcode使得一个数据类对象可以像普通类型的实例一样进行判等,我们甚至可以像基本数据类型一样用==来判断两个对象相等,如下:
image.png
与此同时,我们还发现两个特别的方法:copy与componentN。对于这两个方法,很多人比较陌生,接下来我们来详细介绍它们。

3.4.3 copy、componentN与解构

我们继续来看上面代码中的一段:
public final Bird copy(double weight, int age, @NotNull String color) {

Intrinsics.checkParameterIsNotNull(color, "color");
return new Bird (weight, age, color);

}

public static Bird copy$default(Bird var0, double var1, int var3, String var4, int var5, Object var6) { //var0代表被copy的对象

if ((var5 & 1) != 0) {
    var1 = var0.weight;  //copy时若未指定具体属性的值,则使用被copy对象的属性值
}
if ((var5 & 2) != 0) {
    var3 = var0.age;
}
if ((var5 & 4) != 0) {
    var4 = var0.color;
}
return var0.copy(var1, var3, var4);

}
这段代码中的copy方法的主要作用就是帮我们从已有的数据类对象中拷贝一个新的数据类对象。当然你可以传入相应参数来生成不同的对象。但同时我们发现,在copy的执行过程中,若你未指定具体属性的值,那么新生成的对象的属性值将使用被copy对象的属性值,这便是我们平常所说的浅拷贝。我们来看下面这个例子:
Bird b1 = new Bird(20.0,1,"blue");
Bird b2 = b1;
b2.setColor("red");
System.out.println(b1.getColor()); //red
类似这样的代码很多人都写过,但这种方式会带来一个问题,明明是对一个新的对象b2做了修改,为什么还会影响老的对象b1呢?其实这只是一种表象而已。实际上,除了基本数据类型的属性,其他属性还是引用同一个对象,这便是浅拷贝的特点。
实际上copy更像是一种语法糖,假如我们的类是不可变的,属性不可以修改,那么我们只能通过copy来帮我们基于原有对象生成一个新的对象。比如下面的两个例子:
//声明的Bird属性可变
val b1 = Bird(20.0, 1, "blue")
val b2 = b1
b2.age = 2

//声明的Bird属性不可变
val b1 = Bird(20.0, 1, "blue")
val b2 = b1.copy(age = 2) //只能通过copy
copy更像提供了一种简洁的方式帮我们复制一个对象,但它是一种浅拷贝的方式。所以在使用copy的时候要注意使用场景,因为数据类的属性可以被修饰为var,这便不能保证不会出现引用修改问题。
接下来我们来看看componentN方法。简单来说,componentN可以理解为类属性的值,其中N代表属性的顺序,比如component1代表第1个属性的值,component3代表第3个属性的值。那么,这样设计到底有什么用呢?我们来思考一个问题,我们或多或少地知道怎么将属性绑定到类上,但是对如何将类的属性绑定到相应变量上却不是很熟悉。比如:
val b1 = Bird(20.0, 1, "blue")
//通常方式
val weight = b1.weight
val age = b1.age
val color = b1.color
//kotlin进阶
val (weight, age, color) = b1
看到Kotlin的语法相信你一定会感到兴奋,因为你可能写过类似下面的代码:
String birdInfo = "20.0,1,bule";
String[] temps = birdInfo.split(",");
double weight = Double.valueOf(temps[0]);
int age = Integer.valueOf(temps[1]);
String color = temps[2];
这样代码有时真的很烦琐,我们明明知道值的情况,却要分好几步来给变量赋值。很幸运,Kotlin提供了更优雅的做法:
val (weight, age, color) = birdInfo.split(",");
这个语法很简洁也很直观。那么这到底是一种什么魔法呢?其实原理也很简单,就是解构,通过编译器的约定实现解构。
当然Kotlin对于数组的解构也有一定限制,在数组中它默认最多允许赋值5个变量,因为若是变量过多,效果反而会适得其反,因为到后期你都搞不清楚哪个值要赋给哪个变量了。所以一定要合理使用这一特性。
在数据类中,你除了可以利用编译器帮你自动生成componentN方法以外,甚至还可以自己实现对应属性的componentN方法。比如:
data class Bird(var weight: Double, var age: Int, var color: String) {

var sex = 1
operator fun component4(): Int {  //operator关键字
    return this.sex
}

constructor(weight: Double, age: Int, color: String, sex: Int) : this(weight, age, color) {
    this.sex = sex
}

}

fun main(args: Array) {

val b1 = Bird(20.0, 1, "blue", 0)
val (weight, age, color, sex) = b1
…

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

public val first: A,
public val second: B)

//Triple
public data class Triple(

public val first: A,
public val second: B,
public val third: C)

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

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

val weightT = triple.first
val ageT = triple.second
val colorT = triple.third

//当然我们也可以利用解构
val (weightP, ageP) = Pair(20.0, 1)
val (weightT, ageT, colorT) = Triple(20.0, 1, "blue")
数据类中的解构基于componentN函数,如果自己不声明componentN函数,那么就会默认根据主构造函数参数来生成具体个数的componentN函数,与从构造函数中的参数无关。

3.4.4 数据类的约定与使用

前面主要讲解了Kotlin中数据类的创造意图,接下来我们来看看如何设计一个数据类,并且合理地使用它。
如果你要在Kotlin声明一个数据类,必须满足以下几点条件:

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

数据类在语法上是如此简洁,以至于它可以像Map一样,作为数据结构被广泛运用到业务中。然而,数据类显然更灵活,因为它像一个普通类一样,可以把不同类型的值封装在一处。我们把数据类和when表达式结合在一起,就可以提供更强大的业务组织和表达能力。我们会在下一章重点介绍它的高级应用。
数据类的另一个典型的应用就是代替我们在Java中的建造者模式。正如你所知,建造者模式主要化解Java中书写一大串参数的构造方法来初始化对象的场景。然而由于Kotlin中的类构造方法可以指定默认值,你可以想象,依靠数据类的简洁语法,我们就可以更方便地解决这个问题。同样,当前并不会介绍其具体的使用,我们会在第9章来深入讨论这种方案。

3.5 从static到object

阅读本书到现在,你肯定发现了一个有趣的现象—没有任何一段Kotlin代码中出现过static这个关键字。在Java中,static是非常重要的特性,它可以用来修饰类、方法或属性。然而,static修饰的内容都是属于类的,而不是某个具体对象的,但在定义时却与普通的变量和方法混杂在一起,显得格格不入。
在Kotlin中,你将告别static这种语法,因为它引入了全新的关键字object,可以完美地代替使用static的所有场景。当然除了代替使用static的场景之外,它还能实现更多的功能,比如单例对象及简化匿名表达式等。

3.5.1 什么是伴生对象

按照规则,先来看一个可比较的Java例子:
public class Prize {

private String name;
private int count;
private int type;

public Prize(String name, int count, int type) {
    this.name = name;
    this.count = count;
    this.type = type;
}

static int TYPE_REDPACK = 0;
static int TYPE_COUPON = 1;

static boolean isRedpack(Prize prize) {
    return prize.type == TYPE_REDPACK;
}

public static void main(String[] args) {
    Prize prize = new Prize("红包", 10, Prize.TYPE_REDPACK);
    System.out.println(Prize.isRedpack(prize));
}

}
这是很常见的Java代码,也许你已经习惯了。但是如果仔细思考,会发现这种语法其实并不是非常好。因为在一个类中既有静态变量、静态方法,也有普通变量、普通方法的声明。然而,静态变量和静态方法是属于一个类的,普通变量、普通方法是属于一个具体对象的。虽然有static作为区分,然而在代码结构上职能并不是区分得很清晰。
那么,有没有一种方式能将这两部分代码清晰地分开,但又不失语义化呢?Kotlin中引入了伴生对象的概念,简单来说,这是一种利用companion object两个关键字创造的语法。
伴生对象
顾名思义,“伴生”是相较于一个类而言的,意为伴随某个类的对象,它属于这个类所有,因此伴生对象跟Java中static修饰效果性质一样,全局只有一个单例。它需要声明在类的内部,在类被装载时会被初始化。
现在我们就来改写一个伴生对象的版本:
class Prize(val name: String, val count: Int, val type: Int) {

companion object {
    val TYPE_REDPACK = 0
    val TYPE_COUPON = 1

    fun isRedpack(prize: Prize): Boolean {
        return prize.type == TYPE_REDPACK
    }
}

}

fun main(args: Array) {

val prize = Prize("红包", 10, Prize.TYPE_REDPACK)
print(Prize.isRedpack(prize))

}
可以发现,该版本在语义上更清晰了。而且,companion object用花括号包裹了所有静态属性和方法,使得它可以与Prize类的普通方法和属性清晰地区分开来。最后,我们可以使用点号来对一个类的静态的成员进行调用。
伴生对象的另一个作用是可以实现工厂方法模式。我们在前面讲解过如何使用从构造方法实现工厂方法模式,然而这种方式存在以下缺点:

  • 利用多个构造方法语意不够明确,只能靠参数区分。
  • 每次获取对象时都需要重新创建对象。

你会发现,伴生对象也是实现工厂方法模式的另一种思路,可以改进以上的两个问题。
class Prize private constructor(val name: String, val count: Int, val type: Int) {

companion object {
    val TYPE_COMMON = 1
    val TYPE_REDPACK = 2
    val TYPE_COUPON = 3
    val defaultCommonPrize = Prize("普通奖品", 10, Prize.TYPE_COMMON)

    fun newRedpackPrize(name: String, count: Int) = Prize(name, count, Prize.TYPE_REDPACK)
    fun newCouponPrize(name: String, count: Int) = Prize(name, count, Prize.TYPE_COUPON)
    fun defaultCommonPrize() = defaultCommonPrize  //无须构造新对象
}

}

fun main(args: Array) {

val redpackPrize = Prize.newRedpackPrize("红包", 10)
val couponPrize = Prize.newCouponPrize("十元代金券", 10)
val commonPrize = Prize.defaultCommonPrize()

}
总的来说,伴生对象是Kotlin中用来代替static关键字的一种方式,任何在Java类内部用static定义的内容都可以用Kotlin中的伴生对象来实现。然而,它们是类似的,一个类的伴生对象跟一个静态类一样,全局只能有一个。这让我们联想到了什么?没错,就是单例对象,下面我们会介绍如何用object更优雅地实现Java中的单例模式。

3.5.2 天生的单例:object

单例模式最大的一个特点就是在系统中只能存在一个实例对象,所以在Java中我们必须通过设置构造方法私有化,以及提供静态方法创建实例的方式来创建单例对象。比如,现在我们要创建一个数据库配置的单例对象:
public class DatabaseConfig {

private String host;
private int port;
private String username;
private String password;

private static DatabaseConfig databaseConfig = null;

private static String DEFAULT_HOST = "127.0.0.1";
private static int DEFAULT_PORT = 3306;
private static String DEFAULT_USERNAME = "root";
private static String DEFAULT_PASSWORD = "";

private DatabaseConfig(String host, int port, String username, String password) {
    this.host = host;
    this.port = port;
    this.username = username;
    this.password = password;
}

static DatabaseConfig getDatabaseConfig() {
    if (databaseConfig != null) {
        return databaseConfig;
    } else {
        return new DatabaseConfig(DEFAULT_HOST, DEFAULT_PORT, DEFAULT_USERNAME, DEFAULT_PASSWORD);
    }
}

}
这是用Java实现的一个最基本单例模式的精简例子(省略了多线程以及多种参数创建单例对象的方法)。它依赖static关键字,同时还必须将构造方法私有化。
在Kotlin中,由于object的存在,我们可以直接用它来实现单例,如下所示:
object DatabaseConfig {

var host: String = "127.0.0.1"
var port: Int = 3306
var username: String = "root"
var password: String = ""

}
是不是特别简洁呢?由于object全局声明的对象只有一个,所以它并不用语法上的初始化,甚至都不需要构造方法。因此,我们可以说,object创造的是天生的单例,我们并不需要在Kotlin中去构建一个类似Java的单例模式。由于DatabaseConfig的属性是用var声明的String,我们还可以修改它们:
DatabaseConfig.host = "localhost"
DatabaseConfig.poet = 3307
由于单例也可以和普通的类一样实现接口和继承类,所以你可以将它看成一种不需要我们主动初始化的类,它也可以拥有扩展方法,有关扩展的内容将会在后面章节讲解。单例对象会在系统加载的时候初始化,当然全局就只有一个。那么,object声明除了表现在单例对象及上面的说的伴生对象之外,还有其他的作用吗?它还有一个作用就是替代Java中的匿名内部类。下面我们就来看看它是如何做的。

3.5.3 object表达式

写Java的时候很多人肯定被它的匿名内部类弄得很烦燥,有时候明明只有一个方法,却要用一个匿名内部类去实现。比如我们要对一个字符串列表排序:
List list = Arrays.asList("redpack", "score", "card");
Collections.sort(list, new Comparator(){

@Override
public int compare(String s1, String s2){
    if(s1 == null)
    return -1;
    if(s2 == null)
    return 1;
    return s1.compareTo(s2);
}

});
并不是说匿名内部类这个方式不好,只不过方法内掺杂类声明不仅让方法看起来复杂,也不易阅读理解。而在Kotlin中,可以利用object表达式对它进行改善:
val comparator = object : Comparator {

override fun compare(s1: String?, s2: String?): Int {
    if (s1 == null)
        return -1
    else if (s2 == null)
        return 1
    return s1.compareTo(s2)
}

}
Collections.sort(list, comparator)
简单来看,object表达式跟Java的匿名内部类很相似,但是我们发现,object表达式可以赋值给一个变量,这在我们重复使用的时候将会减少很多代码。另外,我们说过object可以继承类和实现接口,匿名内部类只能继承一个类及实现一个接口,而object表达式却没有这个限制。
用于代替匿名内部类的object表达式在运行中不像我们在单例模式中说的那样,全局只存在一个对象,而是在每次运行时都会生成一个新的对象。
其实我们知道,匿名内部类与object表达式并不是对任何场景都适合的,Java 8引入的Lambda表达式对有些场景实现起来更加适合,比如接口中只有单个方法的实现。而Kotlin天然就支持Lambda表达式,关于Lambda的相关知识可以回顾一下第2章中的内容。现在我们可以将上面的代码用Lambda表达式的方式重新改造一下:
val comparator = Comparator { s1, s2 ->

if (s1 == null)
    return@Comparator -1  //我们已经在第2章中接触过这种语法了
else if (s2 == null)
    return@Comparator 1
s1.compareTo(s2)

}
Collections.sort(list, comparator)
使用Lambda表达式后代码变得简洁很多。
对象表达式与Lambda表达式哪个更适合代替匿名内部类?
当你的匿名内部类使用的类接口只需要实现一个方法时,使用Lambda表达式更适合;当匿名内部类内有多个方法实现的时候,使用object表达式更加合适。

3.6 本章小结

(1)Kotlin类与接口
Kotlin的类与接口的声明方式虽然有很多相似的地方,但相对来说Kotlin的语法更加简洁,同时它还提供了一些语法特性来帮我们简化代码,比如方法支持默认实现、构造参数支持默认值。另外Kotlin还引入主从构造方法、init语句块等语法来实现与Java构造方法重载同等的效果。
(2)Kotlin中的修饰符
Kotlin中的限制类修饰符相对Java来说更加严格,默认是final。而可见性修饰符则更加开放,默认是public,并提供了一个独特的修饰符internal,即模块内可见。
(3)多继承问题
探究类多继承问题的所在,并用多种方式在Kotlin中实现多继承的效果。我们还将进一步学习Kotlin的语法特性,比如内部类与嵌套类、委托等。
(4)数据类
学习数据类的语法,让你只关心真正的数据,而不是一堆烦琐的模板代码。此外,剖析了数据类的实现原理,来了解它的高级语法特性,比如copy、解构声明等,并学习如何合理地使用它。
(5)object
object声明的内容可以看成没有构造方法的类,它会在系统或者类加载时进行初始化。学习如何在Kotlin中通过companion object关键字实现Java中static的类似效果。使用object可以直接创建单例,而无须像Java那样必须利用设计模式。此外,可以用object表达式代替简化使用匿名内部类的语法。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:

华章出版社

官方博客
官网链接