1. 上一章:第 13 章
  2. 下一章:第 15 章
Kotlin:图解指南 • 第 14 章

抽象类和开放类

章节封面图片

第 12 章中,我们看到了如何使用接口创建子类型,在上一章中,我们看到了如何通过接口使用委托来在具体类之间共享通用代码。在本章中,我们将学习如何通过不同的方式使用开放类和抽象类来实现同样的功能。

建模汽车

让我们从建模一辆简单的汽车开始,它可以通过accelerate()函数来增加速度。

class Car {
    private var speed = 0.0
    private fun makeEngineSound() = println("Vrrrrrr...")

    fun accelerate() {
        speed += 1.0
        makeEngineSound()
    }
}

每次调用accelerate()函数时,汽车的速度都会增加1.01,并发出发动机的声音。

val myCar = Car()
myCar.accelerate()
Vrrrrrr...

这对于很多汽车来说效果很好,因为它们会发出”Vrrrrrr…”的声音。

一辆普通、日常的汽车。

但等等……老基顿开着他那辆老旧的噗噗作响的破车来了。它发出的不是流畅的”Vrrrrrr…”声,而是”噗-噗-噗”!

老基顿正驾驶着他的破车噗噗前行。

而里科正驾驶着他1969年的肌肉车呼啸而过!加油,里科!

里科驾驶着他的肌肉车呼啸而过。

嗯,看起来”Vrrrrrr…”对很多汽车来说是不错的声音,但我们需要让不同类型的汽车发出不同的发动机声音。我们如何在 Kotlin 中实现这一点?

这个问题听起来很熟悉。在第 12 章中,为了创建能够各自发出不同声音的多个动物类,我们创建了一个名为FarmAnimal的接口,以及每个具体动物的类,如ChickenPigCow

UML图,显示第12章中接口和类之间的关系。 «interface» FarmAnimal Chicken Pig Cow

如果这种方法对动物发声有效,它对汽车及其发动机声音也有效吗?我们能把Car类转换成像FarmAnimal这样的接口,并为不同类型的汽车创建子类型吗?

UML图,展示从Car接口创建汽车子类的潜在解决方案。 «interface» Car Clunker SimpleCar MuscleCar

嗯,可以把Car类转换成接口,但这会带来一些相当重大且不良的变更。下面是它作为接口时的样子,与原始类进行对比。你能发现其中的差异吗?

将Car接口的代码与原始Car类的代码进行比较。 interface Car { var speed : Double fun makeEngineSound () = println ( "Vrrrrrr" ) fun accelerate () { speed += 1.0 makeEngineSound () } } class Car { private var speed = 0.0 private fun makeEngineSound () = println ( "Vrrrrrr" ) fun accelerate () { speed += 1.0 makeEngineSound () } } New Car Interface Original Car Class

Car转换为接口时,我们需要进行以下修改:

  1. 可见性(Visibility) - 接口不能有private成员,所以我们不得不把speedmakeEngineSound()设为 public。这意味着Car外部的代码可以设置速度,而无需通过accelerate()函数。同样,也可以不加速就直接调用makeEngineSound()
  2. 状态(State) - 虽然接口可以声明属性,但它不能包含状态。换句话说,它本身不能包含该属性的。所以,我们不得不从speed属性中移除= 0.0。相反,实现类必须对其进行初始化。
  3. 实例化(Instantiation) - 接口不能被实例化。为了解决这个问题,我们需要引入一个实现Car接口的类,然后实例化那个类。

这些都是一些令人担忧的变更,所以如果我们可以创建子类型而不引入这些问题就好了。幸运的是,除了从接口创建子类型外,Kotlin 还允许我们从创建子类型。但我们不能从任意旧类创建子类型。默认情况下,类是final的,这意味着 Kotlin 不允许我们从它创建子类型。

相反,我们必须修改声明,以便 Kotlin 知道我们想要从它创建子类型。做到这一点的一种方式是使用抽象类

抽象类简介

抽象类很像接口,但它们可以包含私有函数、私有属性和状态。要创建一个抽象类,只需在声明时添加abstract修饰符。正如你在下面看到的,新的抽象类和原始类之间的唯一区别是开头的单词abstract

将抽象Car类的代码与原始Car类的代码进行比较。 abstract class Car { private var speed = 0.0 private fun makeEngineSound () = println ( "Vrrrrrr" ) fun accelerate () { speed += 1.0 makeEngineSound () } } class Car { private var speed = 0.0 private fun makeEngineSound () = println ( "Vrrrrrr" ) fun accelerate () { speed += 1.0 makeEngineSound () } } New Abstract Car Class Original Car Class

到目前为止,一切都很好!就像我们原来的Car代码一样,speed属性是私有的,初始化为0.0makeEngineSound()函数也是私有的。

不过,有一个问题。和上面的Car接口版本一样,我们不能直接实例化这个抽象类——我们只能实例化它的子类型。在本章后面,我们会解决这个问题,但现在,让我们看看如何从新的抽象类创建子类型!

扩展抽象类

现在我们有了这个抽象的Car类,我们可以从中创建一个子类型。当我们从抽象类创建子类型类时,我们通常说子类型类扩展(extends)了抽象类。这与从接口创建子类型类不同,在后一种情况下,我们说该类实现(implements)了接口。

UML图,比较类扩展与接口实现。 «interface» Car Clunker "Clunker 实现 Car 接口的" Car Clunker "Clunker 扩展 Car 类"

当从类创建子类型时,被扩展的类称为超类(superclass),扩展它的类称为子类(subclass)

UML图,标注指出超类和子类。 Car Clunker 子类 超类

那么,我们如何从Car创建子类?谢天谢地,对进行子类型化的语法与对接口进行子类型化的语法看起来非常相似!

将Car类子类型化的代码与Car接口子类型化的代码进行比较。 class Clunker : Car { } class Clunker : Car () { } Subtyping a Car Interface Subtyping a Car Class

这两者之间的唯一区别是括号。为什么对类进行子类型化时需要括号,而对接口进行子类型化时不需要?因为类有构造函数,但接口没有。这里的括号是在调用Car类的构造函数。

如果我们添加一个构造函数参数来表示acceleration的速率,就更容易理解了。

abstract class Car(private val acceleration: Double = 1.0) {
    private var speed = 0.0
    private fun makeEngineSound() = println("Vrrrrrr...")

    fun accelerate() {
        speed += acceleration
        makeEngineSound()
    }
}

现在,当我们创建Clunker子类时,我们可以传递一个比默认值1.0更小的加速度。既然我们在传递构造函数参数,就更容易看出我们正在用那些括号调用构造函数!

class Clunker : Car(0.25)

在这种情况下,我们将所有破车的加速度硬编码为0.25。如果我们不希望所有破车都具有相同的加速度,我们也可以将其作为Clunker类的构造函数参数,然后传递参数给Car类的构造函数。

将参数从子类构造函数传递到超类构造函数。 class Clunker (acceleration: Double) : Car (acceleration) val clunker = Clunker ( 0.25 )

将构造函数参数从子类(例如Clunker)传递给超类(例如Car)是非常常见的操作。请注意,Clunker构造函数中的acceleration不包含valvar关键字——我们只是将其传递给Car类,它会将其存储为属性。

继承

第 12 章中,我们学习了接口如何能够继承另一个接口的函数和属性。作为我们在那一章中使用的例子,FarmAnimal有名字并且可以发声,所以我们能够同时继承Named接口和Speaker接口。

UML和代码演示接口继承。 «interface» FarmAnimal + name: String + speak(): Unit «interface» Named + name: String «interface» Speaker + speak(): Unit interface Named { val name : String } interface Speaker { fun speak () } interface FarmAnimal : Named, Speaker

在上面的代码中,FarmAnimal接口继承了一些成员:

  • 它从Named接口获得了name属性。
  • 它从Speaker接口获得了speak()函数。

因此,尽管FarmAnimal接口没有显式声明这些成员,但它从其他接口继承了它们。

类似地,当扩展抽象类时,子类将从超类继承函数和属性。因此,Clunker子类包含一个名为accelerate()的函数,即使它没有在其类体中显式声明,这也是因为它从Car继承了此函数。

UML和代码演示类继承。 Clunker + accelerate(): Unit Car - speed: Double - acceleration: Double - makeEngineSound(): Unit + accelerate(): Unit abstract class Car( private val acceleration ) { private var speed = 0.0 private fun makeEngineSound () = fun accelerate () { /*...*/ } } class Clunker(acceleration ) : Car (acceleration)

值得一提的是,从技术上讲,它还包含accelerationspeedmakeEngineSound(),但由于这些是private的,它们对子类不可见。我们稍后将看到如何解决这个问题。

接口和实现

要理解继承,区分类的两个部分可能会有帮助:

  1. 类的可见函数和属性签名(即它们的名称、参数类型和返回类型)构成了它的接口。这个术语可能会造成混淆,因为像Kotlin这样的语言也包含一个名为interface的代码元素,我们曾在第 12 章中介绍过。因此,当我们谈论”类的接口”时,并不总是清楚我们是在谈论类的公共表面区域,还是类声明中实际的interface
  2. 函数或属性主体中的代码称为其实现

为了帮助可视化这一点,让我们看看我们早期编写的类的代码,即第 4 章中的一个Circle类。

class Circle(
    var radius: Double
) {
    private val pi: Double = 3.14

    fun circumference() = 2 * pi * radius
}

现在,让我们使用完全相同的代码,但将实现部分缩进到右侧,以便帮助区分接口实现

区分Circle类的外部接口和内部实现。 class Circle( var radius : Double ) { private val pi : Double = 3.14 fun circumference ()       = 2 * pi * radius } Interface Implementation
  • 我们可以将接口想象为一个类从”外部”看到的样子——它的名称、属性及其类型、函数及其参数和返回类型,等等。
  • 另一方面,实现是类从”内部”看到的样子——其函数和属性对外不可见的部分,其函数和属性的主体,等等。从本质上讲,它是类的内部工作原理。

因此,当我们说一个类实现一个interface(这里指的是代码元素)时,我们真正意思是它为该interface声明的每个函数和属性提供了一个实现——即主体中的代码。

`Speaker`接口和`Cow`类的代码,区分接口和实现。 interface Speaker { fun speak () } class Cow : Speaker { override fun speak ()     = println ( "Moo!" ) } Speaker interface Cow's Interface Cow's Implementation

当一个类从interface或类继承时,它到底继承的是什么?接口,还是实现?

  • 在某些情况下,它只继承接口——即函数和属性签名。例如,当一个interface不包含默认实现时,类继承接口,但必须提供自己的实现,如上面Cow代码所示。
  • 在其他情况下,它还继承这些函数和属性的实现。例如,当接口包含默认实现时,继承类可以继承该实现,如以下代码所示。
interface Speaker {
    fun speak() = println("...")
}

class Cow : Speaker

稍后我们将看到,在扩展抽象类时,这两点同样适用。

当子类从其超类继承实现时,它也有机会替换或增强超类提供的实现。这被称为重写(overriding)2这就是我们如何自定义Clunker发动机声音的方式!接下来让我们看看重写。

重写成员

就像使用委托一样,你可以重写抽象类中的函数和属性,以专门化子类的行为——比如给Clunker一个特殊的发动机声音!不过,我们不能简单地添加override关键字,否则会出现编译器错误。

class Clunker(acceleration: Double) : Car(acceleration) {
    override fun makeEngineSound() = println("putt-putt-putt")
}
Error

这里的问题是makeEngineSound()在超类中有一个private可见性修饰符,如上面清单 14.3所示。当函数或属性是private时,它是如此私有,以至于连它自己的子类都无法看到它!我们可以用不同的可见性修饰符来修复这个问题。

受保护的可见性

超类中标记为private的函数在子类中不可见。如果看不到它,就无法重写它!当然,一个选择是移除private修饰符,这会使它成为一个公共函数,但如果我们这样做,就可以在不调用accelerate()函数的情况下发出发动机声音,如下所示:

val car = Clunker(0.25)
car.makeEngineSound()

我们只希望在加速时让汽车发出发动机声音。如果我们能让makeEngineSound()函数对子类可见,但对任何其他代码不可见,那就太好了。对于这些情况,Kotlin提供了另一个名为protected的可见性修饰符。让我们更新makeEngineSound()使其为protected

abstract class Car(private val acceleration: Double = 1.0) {
    private var speed = 0.0
    protected fun makeEngineSound() = println("Vrrrrrr...")

    fun accelerate() {
        speed += acceleration
        makeEngineSound()
    }
}

标记为protected的函数或属性将对当前类(例如Car)及其子类(例如Clunker)可见,但对其他所有代码都不可见。通过这种方式,makeEngineSound()现在在Clunker子类中可见。我们准备好重写它了吗?

class Clunker(acceleration: Double) : Car(acceleration) {
    override fun makeEngineSound() = println("putt-putt-putt")
}
Error

我们仍然遇到编译器错误!还记得类默认是finalfinal的。换句话说,它不能在子类中被重写,除非我们明确声明允许这样做。有两种方法可以做到这一点。

抽象函数和属性

第一种方法是将abstract修饰符添加到函数或属性。当函数被标记为abstract时……

  • 不能在抽象类中实现,而且……
  • 必须在子类中实现……除非子类本身也是抽象的!

因此,让我们从makeEngineSound()中移除函数体,并添加abstract修饰符。

abstract class Car(private val acceleration: Double = 1.0) {
    private var speed = 0.0
    protected abstract fun makeEngineSound() // no body allowed here!

    fun accelerate() {
        speed += acceleration
        makeEngineSound()
    }
}

通过这种方式,我们终于可以重写makeEngineSound()函数了:

class Clunker(acceleration: Double) : Car(acceleration) {
    override fun makeEngineSound() = println("putt-putt-putt")
}

现在我们可以实例化并加速一辆破车……

val clunker = Clunker(0.25)
clunker.accelerate()

……这发出了那跟随老基顿四处游走的"噗-噗-噗"声音!

putt-putt-putt

再次强调,将函数或属性标记为abstract意味着每个非抽象子类必须实现它。但是,如果您希望CarmakeEngineSound()有默认实现,以便子类型不必强制重写它呢?为此,我们必须使用不同的修饰符,我们将在接下来探讨。

开放函数和属性

允许子类重写函数或属性的第二种方法是将它标记为open。开放成员可以在超类中具有默认实现,这样子类就不须提供自己的实现。但如果有需要,它们也可以提供。让我们把makeEngineSound()函数改为open而不是abstract,并再次添加函数体。

abstract class Car(private val acceleration: Double = 1.0) {
    private var speed = 0.0
    protected open fun makeEngineSound() = println("Vrrrrrr...")

    fun accelerate() {
        speed += acceleration
        makeEngineSound()
    }
}

做了这个更改后,我们可以再次运行清单 14.13中的代码,我们会得到完全相同的结果,因为Clunker仍然重写了makeEngineSound()函数。

让我们介绍另一个不重写它的子类。

class SimpleCar(acceleration: Double) : Car(acceleration)

当我们实例化它并调用accelerate()时……

val car = SimpleCar(1.2)

car.accelerate()

……它将使用默认的发动机声音”Vrrrrrr…”。

Vrrrrrr...

因此,总而言之,抽象类可以被其他类扩展。它们的函数和属性可以是:

  • abstract,在这种情况下,它们在抽象类中没有函数体,但子类必须实现它们。
  • open,在这种情况下,它们在抽象类中有函数体,但子类可以重写它们。
  • Final(即既不是abstract也不是open),在这种情况下,子类不能重写它们。

我们的代码仍然存在一个问题。如前所述,像接口一样,抽象类不允许你直接实例化它。

val myCar = Car()
Error

相反,你必须实例化它的一个子类。要解决这个问题,我们可以将Car设为一个抽象类,而不是考虑将它设为一个开放类

开放类简介

开放类是一种可以同时被扩展并且直接实例化的类。我们可以通过简单地将关键字abstract替换为关键字open,将Car类从抽象类更改为开放类:

open class Car(private val acceleration: Double = 1.0) {
    // ...
}

通过这个简单的更改,我们现在可以直接实例化Car了。

val myCar = Car()

不过,有个问题——虽然开放类可以包含open或final的函数和属性,但不能包含任何abstract的成员。不过这是有道理的——想象一下,如果这个开放的Car类有一个名为honk()的抽象函数,它自然没有函数体。现在,如果我们实例化汽车并调用honk(),我们可能会期望发生什么?

再次强调,开放类不能包含abstract成员。接下来,让我们看看如何使用可见性修饰符来给予子类对超类函数和属性的特殊访问权。

属性 getter 和属性 setter 的可见性修饰符

让我们创建另一个Car的子类。这是一辆肌肉车,它的发动机声音取决于它行驶的速度。不幸的是,当我们尝试引用speed变量时,会出现编译器错误:

class MuscleCar : Car(5.0) {
    override fun makeEngineSound() = when {
        speed < 10.0 -> println("Vrooooom")
        speed < 20.0 -> println("Vrooooooooom")
        else         -> println("Vrooooooooooooooooooom!")
    }
}
Error

问题是,在Car类中,speed属性有一个private可见性修饰符。

open class Car(private val acceleration: Double = 1.0) {
    private var speed = 0.0
    // ...
}

正如我们之前看到的,我们可以使用protected修饰符,以便子类可以访问speed属性。

open class Car(private val acceleration: Double = 1.0) {
    protected var speed = 0.0
    // ...
}

做了这个更改后,我们来自清单 14.20的MuscleCar代码现在可以正常编译了!

不过先别高兴得太早。通过这个更改,子类现在可以绕过accelerate()函数,直接将速度设置为任何他们想要的值!

class Clunker(acceleration: Double) : Car(acceleration) {
    override fun makeEngineSound() {
        println("putt-putt-putt")
        speed = 999.0 // Yikes! Shouldn't be able to increase the
                        // speed without calling accelerate()!
    }
}

我们真正想要的是让子类获取speed值,但阻止它们设置它。幸运的是,在Kotlin中,属性的getter可以有不同的可见性修饰符与它的setter。语法一开始可能看起来有点不自然,但它是这样的:

open class Car(private val acceleration: Double = 1.0) {
    protected var speed = 0.0
        private set
    // ...
}

这段代码表示:

  • speed属性是protected的,因此Car的子类可以获取它的值。
  • speed属性的setter可见性是private的,这意味着只有Car类本身可以设置该值。

另外,如果你喜欢把所有内容放在一行上,可以用分号将它们分隔开,如下所示:

open class Car(private val acceleration: Double = 1.0) {
    protected var speed = 0.0; private set
    // ...
}

值得一提的是,这是我可能在Kotlin中使用分号的两种情况之一。另一种是在枚举类中添加函数时。

结合接口和抽象类/开放类

正如我们在第 12 章中看到的,一个类可以实现多个接口。也可以实现接口并且扩展一个类。要做到这一点,只需用逗号分隔接口和/或超类的名称,如下所示:

class NamedCar(override val name: String) : Car(3.0), Named

接口和抽象类/开放类之间最关键的区别是子类只能扩展一个。你想实现多少接口就实现多少,但你只能有一个超类。3这就是为什么接口可以比抽象类和开放类灵活得多。

那么,什么时候应该使用接口,什么时候应该使用抽象类或开放类呢?

比较接口、抽象类和开放类

在接口、抽象类和开放类之间,创建子类型有很多选择,很难知道哪种选择最适合不同的情况。这样的软件设计决策是许多书籍(和许多争论!)的主题。

虽然软件分析和设计不在本书的范围内,但仍然值得总结每个选项的重要特征,所以我包含了以下方便的图表来帮助你朝着正确的方向前进!

特征 接口 抽象类 开放类
可以继承吗?
可以多重继承吗?
可以直接实例化吗?
可以包含未实现的成员吗?
可以包含默认实现吗?

子类和替换

正如在第 12 章提到的,我们可以在 Kotlin 代码期望超类型的任何地方使用子类型。这不仅对接口成立,对抽象类和开放类也是如此。因此,我们可以将变量的类型显式指定超类(例如Car),而实际上赋值的是子类(例如MuscleCar)的实例。

val car: Car = MuscleCar()

调用函数时同样适用。

fun drive(car: Car) {
    // ...
}

drive(MuscleCar())

如果一个函数有类型为Car的参数,它会很乐意接收MuscleCar,因为——根据定义——子类至少拥有与其超类相同的所有函数和属性。它可能比其超类更多,但绝不会更少。

这种在期望超类型的地方使用子类型的能力,以及子类型重写函数和属性的能力,被称为多态(polymorphism)4。这是一个很大的词,除了软件开发,它可能对你来说没有任何意义(除非你恰好是一位生物学家),但它仍然值得了解,因为它被认为是面向对象编程的支柱之一。

类层次结构

到目前为止,我们创建的每个子类都是 final 类,但子类本身也完全可以是抽象类或开放类。例如,根本不能开的破车可能被归类为”废车”。为了适应这种情况,我们可以将Clunker设为一个开放类,并用一个新的名为Junker的类来扩展它。

open class Clunker(acceleration: Double) : Car(acceleration) {
    override fun makeEngineSound() = println("putt-putt-putt")
}

class Junker : Clunker(0.0)

现在,Clunker既是Car的子类又是Junker的超类。一旦你有了超过几个类,用UML类图来可视化不同类之间的关系会很有帮助,像这样:

Car及其子类型的UML类图。 Car Clunker Junker MuscleCar

这种可视化使人们很容易看到这些类如何在类层次结构中关联,一般的类在顶部,越往下图,类就越具体。类层次结构的深度由层次结构中有多少层类决定。在上面的图表中,我们看到有三层深度。

一般来说,将类层次结构的深度限制在只有几层是一个好主意。否则,就很难跟踪哪些超类提供不同的函数和属性,哪些子类依赖它们,以及它们以什么方式依赖它们。

Any 类型

超类型和子类型并不局限于我们自己的类和接口。Kotlin标准库中的许多类实现接口并扩展抽象类或开放类。例如,像IntDoubleFloat这样的基本数字类型都是名为Number的抽象类的子类。

Number及其子类型的UML类图。 Int Float Double Number

事实上,你写的每一个Kotlin 类都至少有一个超类。例如,早在清单 4.1中,我们就创建了最简单的类:

class Circle

尽管这个类没有显式地扩展一个类,它仍然隐式地扩展了一个名为Any的开放类。这个类是 Kotlin 类层次结构的顶端,所以即使是不相关的类也都有Any类作为共同点。

一个 UML 类图,显示 Car、Circle 和 Number 类层次结构,都最终汇总到 Any 类。 Number Int Float Double Car Clunker Junker MuscleCar Circle Any

Any类提供了一些所有其他类都继承的基本函数——equals()hashCode()toString()。这三个函数可以被重写,但我们不太经常需要这样做,因为Kotlin有一种特殊的类会为我们重写这些函数,并使用我们通常需要的实现。我们将在下一章中学习所有关于这方面的内容,届时我们将探索数据类

总结

恭喜你完成了这充实的一章!以下是你学到的内容:

下一章中,我们将学习数据类。届时见!

感谢James Lorenzen@gbagd24审阅本章!


  1. 为简单起见,我没有包含速度单位。如果有帮助的话,你可以随意想象speed的单位是公里/小时、英里/小时、米/秒或任何其他你喜欢的单位! ↩︎

  2. 你可能还记得,在上一章中我们使用类委托时做了同样的事情,”override”这个术语与我们当时使用的是一样的。 ↩︎

  3. 与许多其他编程语言一样,Kotlin 不允许多重类继承,因为当两个超类对同一函数有不同的实现时会产生歧义。有关这方面的更多信息,请参阅维基百科关于多重继承的文章中的菱形问题↩︎

  4. 更准确地说,这被称为子类型多态(subtype polymorphism)。还有另一种叫做”参数化多态”,我们通常称之为”泛型”。↩︎