CH03-统一化

为了能够理解和编写好的 Scala 代码需要开发者熟知语言中不同的概念。到目前,我们已经在几个地方提到了 Scala 是真的很具有表现力。在某种程度上,这是因为有很多编程的概念被统一化了。在本章中,我们将会关注如下概念:

  • 函数与类
  • 代数数据类型和类层级
  • 模块与对象

函数与类

在 Scala 中,所有的值都是一个对象。函数作为第一类值,同时作为他们各自的类的对象。下面的图示展示了 Scala 中被统一的类型系统和实现方式。该图来自 http://www.scala-lang. org/old/sites/default/files/images/classhierarchy.png,它表示了模型的最新视图(有些类比如ScalaObject已经被移除)。

又可以发现,Scala 中并没有 Java 中的原始类型概念,所有的类型最终都是Any的子类型。

NAME

函数作为类

函数作为类实际上意味着如果他们仅仅是值的话,则可以被自由的传递给其他方法或类。这提高了 Scala 的表现力也让他相对与其他语言(比如 Java)更易于实现一些事情,比如回调。

函数字面值

让我们看一个例子:

class FunctionLiterals {
  val sum = (a:Int, b:Int) => a + b
}

object FunctionLiterals extends App{
  val obj = new FunctionLiterals
  println(s"3 + 9 = ${obj.sum(3, 9)}")
}

这里我们可以看到FunctionLiterals类的sum字段是如何被赋值为一个函数的。我们可以将任意函数赋值给一个变量,然后把它当做一个函数调用(实际上是调用了它的apply方法)。函数同样可以作为参数传递给其他方法。让我们向FunctionLiterals类中添加如下代码:

def runOperation(f: (Int, Int) => Int, a: Int, b:Int) = {
  f(a, b)
}

我们可以传递需要的函数到runOperation,像下面这样:

obj.runOperation(obj.sum, 10, 20)
obj.runOperation(Math.max, 10, 20)

没有语法糖的函数

上个例子中我们只是使用了一些语法糖。为了能够理解实际上发生了什么,我们将会展示函数的字面被转换成了什么。他们基本上表示为扩展FunctionN特质,这里 N 是函数参数的数量。函数字面量的实现会通过apply方法被调用(一旦一个类或对象拥有apply方法,则可以通过一对小括号来调用它并传入对应需要的参数)。让我们看一下等同于上个例子的实现:

class SumFunction extends Function2[Int, Int, Int] {
  override def apply(v1:Int, v2:Int): Int = v1 + v2
}

class FunctionObjects {
  val sum = new SumFunction
  
  def runOperation(f:(Int, Int) => Int, a: Int, b: Int): Int = f(a, b)
}

object FunctionObjects extends App{
  val obj = new FunctionObjects
  println(s"3 + 9 = ${obj.sum(3, 9)}")
  println(s"Calling run operation: ${obj.runOperation(obj.sum, 10, 20)}")
  println(s"Using Math.max: ${obj.runOperation(Math.max, 10, 20)}")
}

增加的表现力

像你在例子中看到的一样,统一化的类和函数提升了表现力,你可以轻松实现不同的任务,比如回调、惰性参数求值、集中的异常处理等等,而无需编写额外的代码和逻辑。此外,函数可以作为类意味着我们可以扩展它们以提供能多的功能。

代数数据类型

代数数据类型和类层级是 Scala 中的另一种统一化。在其他的函数式语言中都拥有不同的方式来创建自定义的代数数据类型。在 Scala 中,这是通过类层级来实现的,称为case classobject。让我们看一下 ADT 到底是什么,他们是什么类型,以及如何定义它们。

ADTs

代数数据类型只是一种组合类型,它会组合一些已存在的类型或仅表示一些新类型。它们中仅包含数据而非像常规类一样包含任何数据之上的功能。比如,一周之中的一天,或者一个表示 RGB 颜色的类——他们没有任何功能,仅仅是包含类一些信息。下面的几个小节将会带你洞悉 ADT 是什么以及它们是什么类型。

概括 ADT

整体 ADT 是一种我们可以简单的枚举一个类型所有可能的值并为每个值提供一个单独的构造器。作为一个例子,我们考虑一下一年的所有月份。一年中仅有 12 个月,而且不会改变:

sealed abstract trait Month
case object January extends Month
case object February extends Month 
case object March extends Month 
case object April extends Month 
case object May extends Month 
case object June extends Month 
case object July extends Month 
case object August extends Month 
case object September extends Month 
case object October extends Month 
case object November extends Month 
case object December extends Month

object MonthDemo extends App {
  val month:Month = February
  println(s"The current month is: $month")
}

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

The current month is: February

上面代码中的Month是密闭的(使用 sealed 关键字声明),因为我们不想在当前文件之外被扩展。你可以发现,我们将不同的月份定义为对象,因为没有动机让他们成为分开的实例。值就是他们本身,同时也不会改变。

产品 ADT

在产品 ADT 类型中,我们无法枚举所有可能的值。手写他们通常会非常多。我们不能为每个单独的值提供各自的构造器。

比如颜色,有不同的颜色模型,不过最常见的是 RGB。它将主要颜色的不同值进行组合(红绿蓝)以表示其他颜色。假如每种颜色的值区间为 0 - 255,这意味着要表示所有可能的颜色,我们需要 256 的三次方个构造器。因此我们可以使用一个产品 ADT:

sealed case class REB(red: Int, green:Int, blue:Int)

object RGBDemo extends App {
  val magenta = RGB(255, 0, 255)
  println(s"Magenta in RGB is: $magenta")
}

可以发现,对于产品 ADT 来说,所有不同的值拥有同一个构造器。

混合 ADT

混合 ADT 表示一个概况和产品的组合。这意味我们可以拥有特定的值构造器,不过这些值构造器同时接收参数以包装其他类型。

让我们看一个例子。加入我们正在编写一个绘图程序:

sealed abstract trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(height:Double, width:Double) extends Shape

我们拥有不同的形状。这个例子中展示了一个概况 ADT,因为我们拥有CircleRectangle两个特定的值构造器。同时我们也拥有一个产品 ADT,因为这两个特定的值构造器同时接收额外的参数以表示更多的类型。

我们把这个类展开一点。当绘制形状时,我们需要知道其位置。因此我们可以添加一个 Point 类来持有 x 和 y 坐标:

case class Point(x:Double, y:Double)

sealed abstract trait Shape
case class Circle(centre:Point, radius:Double) extends Shape
case class Rectangle(topLeft:Point, height:Double, width:Double) extends Shape

希望这有助于理解 Scala 中的 ADT 是什么以及如何使用它们。

统一化

在所有上面的例子之后,可以明显的发现类层级和 ADT 是被统一化的而且看起来是同一件事。这给语言添加了一个更高级别的灵活性,使相其对于其他函数式语言中的建模则更加容易。

模式匹配

模式匹配常用于 ADT。当基于 ADT 的值来做什么事的时候它会使代码更清晰易读,相对于if-else语句也更易扩展。你可能会认为这些语句在有些场景会难以处理,特别是某个数据类型拥有多种可能的值时。

对值进行模式匹配

在之前月份的例子中,我们仅拥有月份的名字。我们或许需要他们的数字,否则电脑将不知道这是什么。下面展示了做法:

object Month {
  def toInt(month: Month): Int = month match { 
    case January => 1 
    case February => 2 
    case March => 3 
    case April => 4 
    case May => 5 
    case June => 6 
    case July => 7 
    case August => 8 
    case September => 9 
    case October => 10 
    case November => 11 
    case December => 12
  }
}

你可以看到我们基于他们来匹配不同的值,并最终返回这行正确的值。现在可以这样使用该方法:

println(s"The current month is: $month and it's number ${Month.toInt(month)}")

和预期一样,我们的应用会生成如下输出:

The current month is: February and it's number 2

实际上因为我们指定了基特质为密闭约束,因此在我们的代码之外没人能够扩展它们,同时我们拥有一个没有遗漏的模式匹配。不完整的模式匹配将会充满问题。作为实验,如果我们将二月份注释掉并编译我们的代码,将会得到如下警告:

Warning:(19, 5) match may not be exhaustive.
It would fail on the following input: February
	month match {
	^

运行这个例子将会证明这个警告的正确性,当传入参数使用February时我们的代码将会失败。为了完整,我们可以添加一个默认的情况:

case _ => 0

对产品类型进行模式匹配

当模式匹配用于产品或混合 ADT 时将会展示其强大力量。在这种情况下,我们可以匹配这些数据类型的实际值。让我们展示如何实现一个功能来计算面积的值:

object Shape{
  def area(shape: Shape):Double = shape match {
    case Circle(Point(x, y), radius) => Math.PI * Match.pow(radius, 2)
    case Rectangle(_, h, w) => h * w
  }
}

在匹配时可以忽略我们不关心的值。对于面积,我们实际上不需要位置信息。在上面的代码中,我们仅仅展示了匹配的两种方式。下划线_操作符可以用于match语句的任何位置,它仅仅会忽略所处位置的值。在这之后,直接使用我们的例子:

object ShapeDemo extends App {
  val circle = Circle(Point(1, 2), 2.5)
  val rect = Retangle(Point(6, 7), 5, 6)
  
  println(s"The circle area is: ${Shape.area(circle)}")
  println(s"The rectangle area is: ${Shape.area(rect)}")
}

可以得到类似下面的输出:

The circle area is: 19.634954084936208 
The rectangle area is: 30.0

在模式匹配时,我们甚至可以使用常量来代替变量来作为 ADT 的构造器参数。这使语言更加强大并支持我们实现更加复杂的逻辑,并且看起来也会很不错。你可以根据上面的例子进行试验以便更深入理解模式匹配的工作原理。

模式匹配常用语 ADT,它有助于实现清晰、可扩展及无遗漏的代码。

模块与对象

模块是组织程序的一种方式。它们是可互换的和可插拔的代码块,有明确定义的接口和隐藏的实现。在 Java 中,模块以包的形式组织。在 Scala 中,模块是对象,就像其他的一切一样。这意味它们可以被参数化、被扩展、像参数一样传递,等等。

Scala 的模块可以提供必要环境以便使用。

使用模块

我们已经总结了 Scala 中模块也是对象而且同样被统一化了。这意味着我们可以在我们的应用中传递一个完整的模块。这可以很有用,然而,下面展示一个模块看起来是什么样子:

trait Tick {
  trait Ticker {
    def count(): Int
    def tick(): Unit
  }
  def ticker:Ticker
}

这里,Tick仅是我们一个模块的一个接口。下面是它的实现:

trait TickUser extends Tick {
  class TickUserImpl extends Ticker{
    var curr = 0
    override def cont(): Int = curr
    override def tick():Unit = curr = curr + 1
  }
  object ticker extends TickUserImpl
}

这个TickUser实际上是一个模块。它实现了Tick并在其中隐藏了代码。我们创建了一个单例对象来提供实现。对象的名字和Tick中的方法名一样。这样混入该特质的时候则能满足其实现的需求。

类似的,我们可以像下面这样定义一个接口和实现:

trait Alarm {
  trait Alarmer {
    def trigger(): Unit
  }
  def alarm:Alarmer
}

然后是实现:

trait AlarmUser extends Alarm with Tick{
  class AlarmUserImpl extends Alarmer {
    override def trigger(): Unit = {
      if(trigger.count() % 10 == 0){
        println(s"Alarm triggered at ${ticker.count()}!")
      }
    }
  }
  object alarm extends AlarmUserImpl
}

有意思的是我们在AlarmUser同时扩展了两个模块。这展示了模块可以怎样互相基于对方。最终,我们可以像下面这样使用这些模块:

object ModuleDemo extends App with Alarmer with TickUser{
  println("Running the ticker. Should trigger the alarm every 10 times.")
  (1 to 100).foreach {
    case i => 
      ticker.tick()
      alarm.trigger()
  }
}

为了让ModuleDemo能够使用AlarmUser模块,编译器会要求混入TickUser或任何混入了Tick的任何模块。这提供了一种可能性来插拔一个不同的功能。

下面是程序的输出:

Running the ticker. Should trigger the alarm every 10 times. Alarm triggered at 10!
Alarm triggered at 20!
Alarm triggered at 30!
Alarm triggered at 40!
Alarm triggered at 50!
Alarm triggered at 60!
Alarm triggered at 70!
Alarm triggered at 80!
Alarm triggered at 90!
Alarm triggered at 100!

Scala 中的模块可以像其他对象一样传递。他们是可扩展的、可互换的,并且实现是被隐藏的。