Scala破冰之旅,控制抽象

根据正交设计的基本原则,如果设计出现重复的控制逻辑,可抽象出稳定的抽象;借助于Scala强大的可扩展能力,可以将「小括号」神奇地转换为「大括号」,让用户代码感觉是一种新的控制结构。

即使水墨丹青,何以绘出半妆佳人。

本文通过一个简单的例子,通过若干迭代,遵循正交设计的基本原则,灵活地应用重构,逐渐改进设计,以供参考。

Scala是一门优雅而又复杂的程序设计语言,初学者很容易陷入细节而迷失方向。这也给我的写作带来了挑战,如果从基本的控制结构,再深入地介绍高级的语法结构,难免让人生厌。

需求1:搜索目录下扩展名为.scala的所有文件

为此,本文另辟蹊径,尝试通过一个简单有趣的例子,概括性地介绍Scala常见的语言特性。它犹如一个迷你版的Scala教程,带领大家一起领略Scala的风采。

快速实现

object FileMatchers { def ends(file: File, ext: String) = { for (file <- file.listFiles if file.getName.endsWith yield filelist }}

需求2:搜索目录下名字包含Test的所有文件

问题的提出

有一名体育老师,在某次离下课还有五分钟时,决定玩一个游戏。此时有100名学生在上课,游戏的规则如下:

  1. 老师先说出三个不同的特殊数(都是个位数),比如3, 5, 7;让所有学生拍成一队,然后按顺序报数;

  2. 学生报数时,如果所报数字是「第一个特殊数(3)」的倍数,那么不能说该数字,而要说Fizz;如果所报数字是「第二个特殊数(5)」的倍数,要说Buzz;如果所报数字是「第三个特殊数(7)」的倍数,要说Whizz

  3. 学生报数时,如果所报数字同时是「两个特殊数」的倍数,也要特殊处理。例如,如果是「第一个(3)」和「第二个(5)」特殊数的倍数,那么也不能说该数字,而是要说FizzBuzz。以此类推,如果同时是三个特殊数的倍数,那么要说FizzBuzzWhizz

  4. 学生报数时,如果所报数字包含了「第一个特殊数」,那么也不能说该数字,而是要说Fizz。例如,要报13的同学应该说Fizz

  5. 如果数字中包含了「第一个特殊数」,需要忽略规则23,而使用规则4。例如要报35,它既包含3,同时也是57的倍数,要说Fizz,而不能说BuzzWhizz

  6. 否则,直接说出要报的数字。

重复

object FileMatcher { def ends(file: File, ext: String) = { for (file <- file.listFiles if file.getName.endsWith yield file } def contains(file: File, query: String) = { for (file <- file.listFiles if file.getName.contains yield file }}

需求3:搜索目录下名字正则匹配特定模式的所有文件

形式化

3, 5, 7为例,该问题可形式化地描述为:

r1: times(3) => Fizz || 
    times(5) => Buzz ||
    times(7) => Whizz

r2: times(3) && times(5) && times(7) => FizzBuzzWhizz ||
    times(3) && times(5) => FizzBuzz  ||
    times(3) && times(7) => FizzWhizz ||
    times(5) && times(7) => BuzzWhizz

r3: contains(3) => Fizz

rd: others => string of others

spec: r3 || r2 || r1 || rd

其中,times(3) => Fizz表示:当要报的数字是3的倍数时,则说Fizz;其他以此类推。

再现重复

object FileMatcher { def ends(file: File, ext: String) = { for (file <- file.listFiles if file.getName.endsWith yield file } def contains(file: File, query: String) = { for (file <- file.listFiles if file.getName.contains yield file } def matches(file: File, regex: String) = { for (file <- file.listFiles if file.getName.matches yield file }}

建立测试环境

首先搭建测试环境,建立反馈系统。这里使用scalatest的测试框架,它也是作者偏爱的测试框架之一。

import org.scalatest.{FunSpec, Matchers}

class RuleSpec extends FunSpec with Matchers {
  describe("World") {
    it ("should not be work" ) {
      true should be(false)
    }
  }
}

运行测试用例,与预期相符,测试失败;证明测试环境可工作,删除该用例,然后开启新的旅程。

提取抽象

消除消除上述实现的重复,最重要的是提取公共的关注点:
Matcher: (String, String) => Boolean

object FileMatcher { private def list(file: File, query: String, matcher: (String, String) => Boolean) = { for (file <- file.listFiles if matcher(file.getName, query)) yield file } def ends(file: File, ext: String) = list(file, ext, (fileName, ext) => fileName.endsWith def contains(file: File, query: String) = list(file, query, (fileName, query) => fileName.contains def matches(file: File, regex: String) = list(file, regex, (fileName, regex) => fileName.matches}

第一个测试用例

先建立了一个规则:new Times(3, "Fizz"),它表示如果是3的倍数,则报Fizz。此时,如果输入数字3*2,断言预期的结果为Fizz

it ("times(3) -> Fizz" ) {
  new Times(3, "Fizz").apply(3 * 2) should be("Fizz")
}

类型推演

借助于Scala强大的类型推演能力,可以得到更为简洁的函数字面值。

object FileMatcher { private def list(file: File, query: String, matcher: (String, String) => Boolean) = { for (file <- file.listFiles if matcher(file.getName, query)) yield file } def ends(file: File, ext: String) = list(file, ext, _.endsWith def contains(file: File, query: String) = list(file, query, _.contains def matches(file: File, regex: String) = list(file, regex, _.matches}
主构造函数

使用Scala中,直接在类定义的首部直接定义「主构造函数」,可以消除重复的样板代码。

class Times(n: Int, word: String) {
  def apply(m: Int): String = "Fizz"
}

类型别名

list的参数由于类型修饰,显得有点过长而影响阅读;可以通过「类型别名」的机制缩短函数的类型修饰符,以便改善表达力。

object FileMatcher { private type Matcher = (String, String) => Boolean private def list(file: File, query: String, matcher: Matcher) = { for (file <- file.listFiles if matcher(file.getName, query)) yield file } def ends(file: File, ext: String) = list(file, ext, _.endsWith def contains(file: File, query: String) = list(file, query, _.contains def matches(file: File, regex: String) = list(file, regex, _.matches}
类型的后缀修饰

Scala将类型的修饰放在后面,以便实现风格的「一致性」,包括:

  • 变量的类型修饰
  • 函数返回值的类型修饰

def apply(m: Int): String = "Fizz"

简化参数

简化参数传递,消除不必要的冗余,是简单设计基本原则之一。

object FileMatcher { private type Matcher = String => Boolean private def list(file: File, matcher: Matcher) = { for (file <- file.listFiles if matcher(file.getName)) yield file } def ends(file: File, ext: String) = list(file, _.endsWith def contains(file: File, query: String) = list(file, _.contains def matches(file: File, regex: String) = list(file, _.matches}
类型推演

定义变量时,可以通过初始化值的类型推演出变量类型。

val i = 0

等价于

val i: Int = 0

事实上,当函数体比较短小时,可以一眼看出函数返回值类型,也可以略去函数返回值的类型。例如Times.apply的返回值类型可以根据返回值自动推演为String类型。

def apply(m: Int) = "Fizz"

等价于

def apply(m: Int): String = "Fizz"

替换for comprehension

可以通过定制「高阶函数」替代语法较为复杂的「for
comprehension」,以便改善表达力。

object FileMatcher { private type Matcher = String => Boolean private def list(file: File, matcher: Matcher) = file.listFiles.filter(f => matcher(f.getName)) def ends(file: File, ext: String) = list(file, _.endsWith def contains(file: File, query: String) = list(file, _.contains def matches(file: File, regex: String) = list(file, _.matches}
apply方法

apply方法是一个特殊的方法,它可以简化方法调用的形式,使其行为更贴近函数的语义。在特殊的场景下,能够改善代码的表达力。

it ("times(3) -> Fizz" ) {
  new Times(3, "Fizz").apply(3 * 2) should be("Fizz")
}

等价于:

it ("times(3) -> fizz" ) {
  new Times(3, "Fizz")(3 * 2) should be("Fizz")
}

柯里化

应用「柯里化」,漂亮的「大括号」终于登上了舞台。

object FileMatcher { private type Matcher = String => Boolean def list(file: File)(matcher: Matcher) = file.listFiles.filter(f => matcher(f.getName)) def ends(file: File, ext: String) = list { _.endsWith } def contains(file: File, query: String) = list { _.contains } def matches(file: File, regex: String) = list { _.matches }}

实现Times

因为Times的逻辑较为简单,可以快速实现它。

class Times(n: Int, word: String) {
  def apply(m: Int): String = 
    if (m % n == 0) word else ""
}
万物皆是对象

Scala并没有针对「基本类型」(例如int),「数组类型」(例如int[])定义特殊的语法,它将世间万物都看成对象。

其中,m % n等价于m.%(n),而%只不过是Int的一个普通方法而已。

面向表达式

Scala是一门面向表达式的语言,它所有的程序结构都具有值,包括if-else表达式。更有甚则,函数调用也可以认为是表达式求值的过程,函数原型末尾的=号更显式地表达了这个意图。

使用case类

可以将Times设计为case类。

case class Times(n: Int, word: String) {
  def apply(m: Int): String =
    if (m % n == 0) word else ""
}

当构造一个Times实例时,可以使用其「伴生对象」提供的工厂方法,从而略去new关键字,简化代码实现。

it ("times(3) -> fizz" ) {
  Times(3, "Fizz")(3 * 2) should be("Fizz")
}

实现Contains

有了Times实现的基础,可以很轻松地实现Contains的测试用例。

it ("contains(3) -> fizz" ) {
  Contains(3, "Fizz")(13) should be("Fizz")
}

依次类推,Contains可以快速实现为:

case class Contains(n: Int, word: String) {
  def apply(m: Int): String =
    if (m.toString.contains(n.toString)) word else ""
}

此时,测试通过了。

省略括号

m.toString等价于m.toString()。按照惯例,如果函数没有副作用,则可以略去小括号;相反,如果产生副作用,则显式地加上小括号用于警示。

如果函数定义时就没有使用小括号,用于表达函数是无副作用的;此时如果用户画蛇添足,添加多余的小括号,将产生编译错误。

实现默认规则

对于默认规则,它只是简单地将输入的数字转变为字符串表示形式。

it ("default rule" ) {
  Default()(2) should be("2")
}

其中,Default可以快速实现为:

case class Default() {
  def apply(m: Int): String = m.toString
}
定制伴生对象

上述实现中,case class Default(),及其调用点Default()(2),不能略去()。这非常讨厌,可以自行定制伴生对象的apply工厂方法,改善表达力。

class Default {
  def apply(m: Int) = m.toString
}

object Default {
  def default = new Default
}

这里使用了default替代apply的工厂方法,一方面消除了函数参数个数的歧义,另一方面保证了原有的语义。此时,可以删除测试用例中冗余的()

import Default._

it ("default rule" ) {
  default(2) should be("2")
}

值得庆幸的是,default并非Scala的保留字。

实现AllOf

接下来,实现具有两个之间具有「逻辑与」关系的复合规则。先建立一个简单的测试用例:

it ("times(3) && times(5) -> FizzBuzz" ) {
  AllOf(Times(3, "Fizz"), Times(5, "Buzz"))(3*5) should be("FizzBuzz")
}

为了快速通过测试,可以先打桩实现。

case class AllOf(times: Times*) extends Rule {
  def apply(n: Int): String = "FizzBuzz"
}
变长参数

times: Times*表示变长的Times列表,表示可以向AllOf的构造函数传递任意多的Times实例。

事实上,times: Times*的真正类型为scala.collection.mutable.WrappedArray[Times],所以times: Times*拥有普通集合类的一般特征,例如调用map, foreach, foldLeft等方法。

快速实现AllOf

case class AllOf(times: Times*) {
  def apply(n: Int): String = {
    val result = new StringBuilder
    times.foreach ( (t: Times) =>
      result.append(t.apply(n))
    )
    result.toString
  }
}
高阶函数

一般地,可以传递或返回「函数值」的函数常称为「高阶函数」。例如foreach就是一个高阶函数,它通过传递(t: Times) => result.append(t.apply(n))的函数值实现容器的遍历。

其中,该函数字面值的类型为Function1[Times, StringBuilder],表示参数为Times,返回值为StringBuilder的一元函数。

对于此例子,如果你偏爱大括号,可以使用大括号替代小括号。

times.foreach { (t: Times) => 
  result.append(t.apply(n))
}

借助类型推演,还可以去除t的类型修饰。

times.foreach { t => result.append(t.apply(n)) }

其中,apply有特殊的调用语义,因此代码可以更简洁。

times.foreach { t => result.append(t(n)) }

甚至,可以略去一些冗余的语法符号。

times foreach { t => result append t(n) }

因为tforeach的函数体内有且仅出现一次,可以使用占位符简化实现。

times foreach { result append _(n) }
使用foldLeft

事实上,上述AllOf.apply实现可以简化为函数式中常见的「规约」操作。

case class AllOf(times: Times*) {
  def apply(n: Int): String =
    times.foldLeft("") { (acc, t) => acc + t.apply(n) }
}

因为acc, tfoldLeft的函数体中有且仅出现过一次,可以使用占位符代替。

case class AllOf(times: Times*) {
  def apply(n: Int): String =
    times.foldLeft("") { _ + _.apply(n) }
}

同样地,因为apply方法具有特殊的函数调用语义,可以进一步简化实现。

case class AllOf(times: Times*) {
  def apply(n: Int): String = 
    times.foldLeft("") { _ + _(n) }
}
剖析foldLeft

foldLeft实现在TraversableOnce特质中。

trait TraversableOnce[+A] {
  ...
  def foreach[U](f: A => U): Unit

  def foldLeft[B](z: B)(op: (B, A) => B): B = {
    var result = z
    foreach(x => result = op(result, x))
    result
  }
}

foldLeft使用函数式中一个重要的技术:「柯里化」。其中,z为迭代的初始值,op: (B, A) => B中第一个参数为「收集参数」,然后遍历容器中的所有元素,并依次实施op操作。

发表评论

电子邮件地址不会被公开。 必填项已用*标注