CH04-抽象与自类型

为了能够拥有易于扩展和维护的应用,在软件工程中设计和编写高质量的代码是非常重要的。这些活动要求开发者能够熟悉并正确理解领域,同时应用的需求也能被清晰的定义。如果缺少了任何一项,那么想要编写好的程序则会称为一个挑战。

很多时候,工程师使用一些抽象来建模“世界”。这有助于代码的可扩展性和可维护性,并能避免重复,这在在很多情况下可能会成为错误的原因。好的代码通常有多个小的组件构成,它们会互相依赖或集成。有多种不同的方式来实现抽象和集成。我们将会在本章中关注以下概念:

  • 抽象类型
  • 多态
  • 自类型

这里所涉及的几个主题将非常有助于我们研究具体的设计模式。理解它们也能有助于理解那些基于它们的设计模式。此外,使用本章所涉及的这些概念本身就能有助于编写良好的代码。

泛型与抽象类型

参数化一个类的常用方式之一是使用值。这十分简单,可以通过向类的构造器参数传入不同的值来实现。在下面的例子中,我们可以向Person类的name参数传入不同的值来创建不同的实例:

case class Person(name:String)

以这种方式我们能够创建不同的实例并将他们加以区分。但这样既不有趣也不是“火箭科学”。更进一步,我们会关注一些更加有趣的参数化来帮助我们编写更好的代码。

泛型

泛型是另一种参数化类的方式。当我们编写一个要操作多种不同类型的功能时,泛型会很有用,同时我们能够简单的推迟到最后再选择具体类型。一个例子是开发者所熟知的集合类。比如List,可以保存任何类型的数据,我们可以拥有整形、双精度浮点型、字符串、自定义类等等类型的列表。即便如此,列表的实现仍然总会是一样的。

我们同样可以参数化方法。比如,如果我们想要实现加法,对于不同的数字类型不会有什么不同之处。因此,我们可以使用泛型并仅实现方法一次,而不需要重载来适应世界上的每个类型。

让我们看一些例子:

trait Adder{
  def sum[T](a: T, b: T)(implicit numeric:Numeric[T]): T = 
    numeric.plus(a, b)
}

上面这段代码可能有点难懂,它定义了一个可以用于numeric类型的sum方法。这实质上是一个专用(ad-hoc)泛型的表示,我们将会在本章的后续部分讨论。

下面的代码展示了如何泛型一个能够包含任何数据的类:

class Container[T](data: T) {
  def compare(other:T) = data.equals(other)
}

下面的片段展示了例子的用法:

object GenericsExample extends App with Adder {
  println(s"1 + 3 = ${sum(1, 3)}")
  println(s"1.2 + 6.7 = ${sum(1.2, 6.7)}")
  
  // compilation fails
  // System.out.println(s"abc + cde = ${sum("abc", "cde")}") 
  
  val intContainer = new Container(10)
  println(s"Comparing with int: ${intContainer.compare(11)}")
  
  val stringContainer = new Container("some text")
  println(s"Comparing with string: ${stringContainer.compare("some text")}")
}

运行程序将会得到如下输出:

1 + 3 = 4
1.2 + 6.7 = 7.9
Comparing with int: false
Comparing with string: true

抽象类型

另一种参数化类的方式是使用抽象类型。泛型在其他语言中都有对应的实现,比如 Java。但是 Java 中却不存在抽象类型。让我们看一下上面Container的例子如何转换为以抽象类型的方式实现:

trait ContainerAT{
  type T
  val data:T
  
  def conpare(other:T) = data.equals(other)
}

然后在类中使用这个特质:

class StringContainer(val data:String) extends ContainerAT {
  override type T = String
}

然后就可以使用与前面相同的方式来使用这个类:

object AbstractTypesExample extends App{
  val stringContainer = new StringContainer("some text")
  println(s"Comparing with string: ${stringContainer.compare("some text")}")
}

同样可以得到与预期一样的输出:

comparing with string: true

当然我们也可以以类似的方式应用于泛型的例子,只需要创建一个特质的实例然后指定类型参数。这意味着泛型和抽象类型为我们提供了两种方式来实现相同的一件事。

泛型与抽象类型

那为什么 Scala 中同时拥有泛型和抽象类型呢?它们有什么不同吗?或者如何选择使用哪一个呢?我们会在这里给你答案。

泛型和抽象类型是可以互换的。虽然可能需要额外的工作,但是我们能够使用泛型来提供抽象类型所带来的一切。如何选择取决于不同的因素,有的是个人偏好,比如有人是为了可读性,而有人则是为了类的不同用法。

让我们看一个例子来尝试理解泛型与抽象类型可以在合适以及如何使用。在这个例子中我们会使用打印机。大家都知道它们有多种类型——纸质打印机、3D 打印机等等。每种都是用不同的材料来打印,比如墨粉、墨水或塑料,同时它们也会于打印到不同的媒介上——纸或甚至是空气中。我们可以使用抽象类型来描述这些:

abstract class PrintData
abstract class PrintMaterial
abstract class PrintMedia
trait Printer{
  type Data <: PrintData
  type Material <: PrintMaterial
  type Media <: PrintMedia
  
  def print(data:Data, material:Material, media: Media) = 
    s"Printing $data with $material material on $media media."
}

为了能够调用这个print方法,我们需要拥有不同的媒介、数据类型及原料:

case class Paper() extends PrintMedia
case class Air() extends PrintMedia
case class Text() extends PrintData
case class Model() extends PrintData
case class Toner() extends PrintMaterial
case class Plastic() extends PrintMaterial

现在让我们创建两个具体的打印机实现,一个激光打印机,一个 3D 打印机:

class LaserPrinter extends Printer {
  type Media = Paper
  type Data = Text
  type Material = Toner
}

class ThreeDPrinter extends Printer {
  type Media = Air
  type Data = Model
  type Material = Plastic
}

在上面的代码中,我们实际上已经给出了数据类型、媒介,以及打印机可以使用的材料的说明。我们不能要求 3D 打印机使用墨粉来打印,或者激光打印机直接打印在空气中。下面是如何使用这两个打印机:

object PrinterExample extends App{
  val laser = new LaserPrinter
  val threeD = new ThreeDPrinter
  
  println(laser.print(Text(), Toner(), Paper()))
  println(threeD.print(Model(), Plastic(), Air()))
}

这段代码拥有很好的可读性,它支持我们轻松的指定具体类。使事物更易于建模。有意思的是将其转换为泛型的方式实现则会是这样:

trait GenericPrinter[Data <:PrintData, Material <: PrintMaterial, Media <: PrintMedia] {
  def print(data: Data, material: Material, media: Media) =
    s"Printing $data with $material material on $media media."
}

这个特质很容易被描述,可读性和逻辑性在这里也没有得到损害。然而,我们必须以如下方式来表示具体类:

class GenericLaserPrinter[Data <: Text, Material <: Toner, Media <: Paper] extends GenericPrinter[Data, Material, Media]

class GenericThreeDPrinter[Data <: Model, Material <: Plastic, Media <: Air] extends GenericPrinter[Data, Material, Media]

这会让具体类的定义变得相当长,开发者也更可能犯错。下面的片段展示了如何使用这些类来创建实例:

val genericLaser = new GenericLaserPrinter[Text, Toner, Paper]
val genericThreeD = new GenericThreeDPrinter[Model, Plastic, Air]
println(genericLaser.print(Text(), Toner(), Paper()))
println(genericThreeD.print(Model(), Plastic(), Air()))

你会发现每次在创建这些实例的时候都需要指定类型。假如我们拥有更多的泛型类型,或者一些类型本身又是基于泛型,比如集合。这很快会变得冗长,而且让人难以理解这些代码的实际用途。

另一方面,使用泛型能够允许我们复用GenericPrinter而不必为不同的打印机表示进行显式的子类化。然而这也存在逻辑错误的风险:

class GenericPrinterImpl[Data <: PrintData, Material <: PrintMaterial, Media <: PrintMedia] extends GenericPrinter[Data, Material, Media]

如果像相面这样使用则会有犯错的危险:

val wrongPrinter = new GenericPrinterImpl[Model, Toner, Air]
println(wrongPrinter.print(Model(), Toner(), Air()))

应用建议

上面的例子展示了使用泛型和抽象类型的简单比较。两种都是有用的概念;然而,清晰的知道他们具体的用法对于在需要的场景选择正确的一个是很重要的。下面的一些技巧能够帮助你做出正确的选择:

  • 泛型:
    • 如果你仅需要类型实例化。一个好的示范是标准的集合类。
    • 如果你正在创建一族类型。
  • 抽象类:
    • 如果你想允许别人能够通过特质混入类型。
    • 如果你需要在一些允许互换的场景拥有更好的可读性。
    • 如果你想在客户端代码中隐藏类型定义。

多态的三种形式

多态是任何使用面向对象编程语言的开发者都知道的东西。

多态帮助我们编写通用的代码以进行复用并应用到多样性的类型中。

知道有多种不同类型的多态是很重要的,这节中我们将会讨论它们。

子类型多态

这是一种每个开发者都知道的多态,它与在具体类中覆写方法相关。考虑下面简单的层级:

abstract class Item {
  def pack:String
}

class Fruit extends Item {
  override def pack:String = "I'm a fruit and I'm packed in a bag."
}

class Drink extends Item {
  override def pack: String = "I'm a drink and I'm packed in a bottle."
}

现在我们拥有一个装满物品的购物篮,对每一个都进行pack调用:

object SubtypePolymorphismExample extends App {
  val shoppingBasket:List[Item] = List(
    new Fruit, new Drink
  )
  shoppingBasket.foreach(i => println(i.pack))
}

你会发现,这里我们可以使用一个抽象类型并且无需思考它们具体的类型直接调用pack方法即可。多态会注意打印正确的值。我们的输出会像下面这样:

I'm a fruit and I'm packed in a bag. 
I'm a drink and I'm packed in a bottle.

子类化多提通过extends关键字使用继承来表示。

参数式多态

函数式编程中的参数化多态即为我们上节中展示的泛型。泛型既是参数化多态,我们已经见过,它支持我们定义基于任何类型或给定类型的子集类型的方法和数据结构。而具体的类型则可以在后续的阶段指定。

参数式多态使用类型参数表示。

专用(ad-hoc)多态

专用多态类似于参数式多态;然而在这种情况下,参数的类型是很重要的,因为具体实现会依赖于这些参数。它会在编译时被分析,不像子类型多态是在运行时。这多少类似于函数重载。

我们在本章的前面部分看到过它的一个例子,我们创建了一个Adder特质可以对不同的类型求和。让我们一步一步定义一个更加精密的例子,期望这样有助于连接它是如何工作的。我们的目标是让sum方法可以对任何类别的类型(之前是数字类型)求和:

trait Adder[T]{
  def sum(a:T, b:T): T
}

下一步,我们创建一个使用sum方法的对象并向外界暴露:

object Adder {
  def sum[T: Adder](a: T, b: T): T = 
    implicitly[Adder[T]].sum(a, b)
}

我们看到的上面这段代码是 Scala 中的一些语法糖,implicitly表示拥有一个隐式转换可以将T转换为Adder[T]。现在我们可以编写如下程序:

object AdhocPolymorphismExample extends App{
  import Adder._
  println(s"The sum of 1 + 2 is ${sum(1, 2)}")
  println(s"The sum of abc + def is ${sum("abc", "def")}")
}

如果我们尝试编译运行这个程序,将会得到如下错误:

Error:(15, 51) could not find implicit value for evidence parameter 
of type com.ivan.nikolov.polymorphism.Adder[Int]
	System.out.println(s"The sum of 1 + 2 is ${sum(1, 2)}")
												  ^
Error:(16, 55) could not find implicit value for evidence parameter 
of type com.ivan.nikolov.polymorphism.Adder[String]
	System.out.println(s"The sum of abc + def is ${sum("abc", "def")}")
													  ^

这表示我们的代码不知道如何将整数或字符串隐式转换为Adder[Int]Adder[String]。我们需要做的是定义这些转换以告诉程序sum方法该如何做。我们的Adder对象看起来会是这样:

object Adder {
  def sum[T: Adder](a: T, b: T): T = implicitly[Adder[T]].sum(a, b)
  
  implicit val int2Adder:Adder[Int] = new Adder[Int] {
    override def sum(a:Int, b:Int):Int = a + b
  }
  
  implicit val string2Adder:Adder[String] = new Adder[String] {
    override def sum(a:String, b:String):Int = s"$a concatenated with $b"
  }
}

现在如果允许上面的程序则会得到如下输出:

The sum of 1 + 2 is 3
The sum of abc + def is abc concatenated with def

同样,如果你记得本章开头的例子,我们是不能对字符串求和的。现在你会发现,我们可以提供不同的实现,因为我们定义了一个转换为Adder方式,因此使用任何类型都是没有问题的。

专用泛型支持我们在不修改基类的基础上扩展代码。这在使用外部库时将会很有帮助,比如因为某些原因我们无法修改原始代码。它很强大而且在编译时求解,这可以确保我们的程序会和预期的一样运行。另外,它可以支持我们为无法访问的类型(比如这里的 Int、String)提供功能。

为多个类型提供功能

如果我们回头看本章的开头,我们为数字类型定义了一个Adder,会发现后面最终的实现会要求我们为不同的数字类型单独定义不同的操作,比如 Long、Double等等。有没有方式来实现本章开头的哪种效果呢?当然有,就像下面这样:

implicit def numeric2Adder[T: Numeric]:Adder[T] = new Adder[T]{
  override def sum(a: T, b: T) = implicitly[Numeric[T]].plus(a, b)
}

我们仅仅定义了另一个隐式转换(将Numeric[T]转换为Adder[T]),它会为我们处理好一切。现在可以像下面这样使用:

println(s"The sum of 1.2 + 6.5 is ${sum(1.2, 6.5)}")

专用多态通过隐式混入行为来表示。这是类型类设计模式的主要构建方式,后续章节中将会详细解释。

例子中,首先定义一个泛型接口,然后通过传入具体类型参数来创建不同类型的具体子类实例,从而调用支持不同类型的实例方法。只是这个过程是通过隐式转换完成的。

自类型

好代码的其中一个特性是关注点分离。开发者需要致力于使类与其方法的职责仅有一个。这有助于测试和维护,而且更有助于简化代码的理解。记得,简单的总是最好的

然而,在编写实际软件时总是无法避免,为了实现某些功能我们需要在一个类的实例中持有别的类的实例。换句话说,一旦我们的对构件块进行了清晰的分离,为了实现功能它们之间则会存在依赖。我们这里所讨论的总结起来实际上就是依赖注入。自类型提供了一种方法来以更加优雅的方式来处理这些依赖。本节中,我们将讨论它们的用法及优点。

使用自类型

自类型支持我们在应用中更简便的拆分代码,然后再在其他地方指定那些需要的部分。例子会让一切变得清晰,因此让我们看一个实例。假如我们想要往数据库持久化信息:

trait Persister[T] {
  def persist(data:T)
}

persist方法会对数据做一些转换并存储到我们的数据库。当然,我们的代码写的很好,因此数据库实现是互相分离的。我们拥有以下几种数据库:

trait Database[T] {
  def save(data: T)
}

trait MemoryDatabase[T] extends Database[T] {
  val db:mutable.MutableList[T] = mutable.MutableList.empty
  override def save(data:T):Unit = {
    println("Saving to in memory database.")
    db.+=:(data)
  }
}

trait FileDatabase[T] extends Database[T] = {
  override def save(data:T):Unit = {
    println("Saving to file.")
  }
}

我们拥有一个特质及一些具体的数据库实现。那么如何把数据库传递给Persister呢?它应该能够调用数据库中的save方法。可能会有以下几种方式:

  • Persister中扩展Database。这样虽然可行,但是会让Persister也变成了Database的实例,我们并不希望这样。后面会解释原因。
  • Persister中拥有一个Database变量,然后使用它。
  • 使用自类型。

为了能够观察自类型是如何工作的,因此使用自类型的方式。我们的Persister接口将会变成下面这样:

trait Persister[T] { this: Database[T] =>
  def persist(data:T):Unit = {
    println("Calling persist.") 
    save(data)
  }
}

现在我们访问了数据库中的方法并在Persister之内调用了save方法。

为自类型命名

在上面的代码中,我们使用this: Database[T] =>语句将自类型包括进来。这样支持我们像使用自身的方法一样直接使用被引入类型的方法。另一种替代的方式是使用self: Database[T] =>。有很多例子中使用了后面的方式,这样可以避免当我们需要在嵌套的特质或类定义中使用this而引起的混乱。然而这种方式需要在调用被注入的方法时使用self来引用。

自类型会要求任何混入Persister类同时混入Database,否则编译将会失败。让我们创建一些持久化到内存和数据库的类:

class FilePersister[T] extends Persister[T] with FileDatabase[T]
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T]

最终,我们可以在应用中使用它们:

object PersisterExample extends App {
  val fileStringPersister = new FilePersister[String]
  val memoryIntPersister = new MemoryPersister[Int]
  
  fileStringPersister.persist("something")
  fileStringPersister.persist("something else")
  
  memoryIntPersister.persist(100)
  memoryIntPersister.persist(123)
}

自类型与继承所做的事是不同的,它需要一些代码的存在,因此也能支持我们对功能进行很好的拆分。这可以很大的改变的对程序的维护、重构和理解。

使用多个组件

在真实的应用中,可能需要使用自类型来对多个组件做出要求。让我们在例子中展示一个Histoty特质,它能够追踪改变并回滚到某个点。不过这里仅做一些打印:

trait History {
  def add():Unit = {
    println("Action added to history.")
  }
}

我们需要在Persister中使用它,看起来像是这样:

trait Persister[T] { this: Database[T] with History =>
  def persist(data:T):Unit = {
    println("Calling persist.")
    save(data)
    add()
  }
}

我们可以通过with关键字同时添加多个需求。然而,如果我们仅让代码做出这些改变,它并不会编译成功。原因是现在我们必须同时混入HistoryPersister中:

class FilePersister[T] extends Persister[T] with FileDatabase[T] with History
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History

然后再次运行代码,将会得到如下输出:

Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to in memory database. Action added to history. Calling persist.
Saving to in memory database. Action added to history.

组件冲突

在上面的例子中,我们拥有一个对History特质的需要,它拥有一个add方法。如果不同组件中的方法拥有相同的签名会怎样呢?让我们试一下:

trait Mystery {
  def add(): Unit = {
    println("Mystery added!")
  }
}

然后使用到Persister中:

trait Persister[T] { this:Database[T] with History with Mystery =>
  def persist(data:T):Unit = {
    println("Calling persist.")
    save(data)
    add()
  }
}

class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery 
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery

如果我们允许这个应用,将会得到如下错误信息:

Error:(47, 7) class FilePersister inherits conflicting members:
	method add in trait History of type ()Unit and
	method add in trait Mystery of type ()Unit
(Note: this can be resolved by declaring an override in class FilePersister.)
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery
^
Error:(48, 7) class MemoryPersister inherits conflicting members:
	method add in trait History of type ()Unit and
	method add in trait Mystery of type ()Unit
(Note: this can be resolved by declaring an override in class MemoryPersister.)class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery
							   ^

幸运的是这个错误消息已经包含了一些如何修复问题的信息。这跟我们一开始使用特质时遇到的完全是相同的问题,我们可以使用如下的方式修复:

class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery{
  override def add():Unit = super[History].add()
}
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery {
  override def add(): Unit = super[Mystery].add()
}

然后再次运行例子,将会得到预期的输出:

Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to file.
Action added to history. Calling persist.
Saving to in memory database. Mystery added!
Calling persist.
Saving to in memory database. Mystery added!

自类型与蛋糕模式

上面我们看到的例子都是单纯的依赖注入的例子。我们通过自类型要要求一个组件必须引入一些指定的组件。

自类型常用于依赖注入。他们是蛋糕设计模式的主要部分,本书后续部分我们将详细介绍。

蛋糕模式的实现完全依赖于自类型。它鼓励工程师编写小而简单的组件,然后声明并使用它们的依赖。当应用中所有的组件都编写完成后,可以在一个通用的组件中实例化它们以应用于实际的应用。蛋糕模式的一个好的优势是实际上会在编译时来检查所有的依赖是否都满足了。

在本书的后续部分,我们将使用一整个小节来讨论蛋糕模式,那里我们将讨论更多关于该模式是如何被连接在一起的细节,它的优势及缺陷,等等。

自类型与继承对比

在上一节中,我们讲到不希望使用继承的方式来访问Database的方法。这又是为何呢?如果我们让Persister扩展Database,这意味着Persister本省也变成了一个Database(is-a 关系)。然而这是不正确的。这里它只是使用一个数据库来实现其功能,而不能称为一个数据库。

继承将子类暴露给父级的实现细节。然而并非总是期望得到这样的结果。根据Design Patterns: Elements of Reusable Object-Oriented Software一书的作者所提倡的,开发者总是应该优先使用组合,而不是继承。

继承泄露了功能

如果我们使用了继承,同样会将我们不希望的功能泄露给子类。让我们看一下下面的代码:

trait DB {
  def connect():Unit = println("Connected.")
  def dropDatabase():Unit = println("Dropping!")
  def close():Unit = println("Closed.")
}
trait UserDB extends DB{
  def createUser(username:String):Unit = {
    connect()
    try {
      println(s"Creating a user: $username")
    } finally{
      close()
    }
  }
  
  def getUser(username:String):Unit = {
    connect()
    try{
      println(s"Getting a user: $username")
    } finally {
      close()
    }
  }
}

trait UserService extends UserDB{
  def bad():Unit = dropDatabase()
}

这会是一个真实的情况。因为这就是继承的工作方式,我们可以在UserService中访问dropDatabase。这是一些我们不希望发生的事情,而且可以通过自类型来修复。特质DB不需要变动,仅需要修改以下内容:

trair UserDB{ this:DB =>
  def createUser(username:String):Unit = {
    connect()
    try{
      println(s"Creating a user: $username")
    } finally close()
  }
  def getUser(username:String):Unit = {
    connect()
    try{
      println(s"Getting a user: $username")
    } finally close()
  }
}

trait UserService{ this: UserDB =>
  //...
}

这样,在UserService中就无法再访问到dropDatabase了。我们只能调用我们需要的方法,这也就是我们要真正实现的。