即使水墨丹青,何以绘出半妆佳人。
Scala
是一门优雅而又复杂的程序设计语言,初学者很容易陷入细节而迷失方向。这也给我的写作带来了挑战,如果从基本的控制结构,再深入地介绍高级的语法结构,难免让人生厌。
为此,本文另辟蹊径,尝试通过一个简单有趣的例子,概括性地介绍Scala
常见的语言特性。它犹如一个迷你版的Scala
教程,带领大家一起领略Scala
的风采。
问题的提出
有一名体育老师,在某次离下课还有五分钟时,决定玩一个游戏。此时有100
名学生在上课,游戏的规则如下:
老师先说出三个不同的特殊数(都是个位数),比如
3, 5, 7
;让所有学生拍成一队,然后按顺序报数;学生报数时,如果所报数字是「第一个特殊数(
3
)」的倍数,那么不能说该数字,而要说Fizz
;如果所报数字是「第二个特殊数(5
)」的倍数,要说Buzz
;如果所报数字是「第三个特殊数(7
)」的倍数,要说Whizz
。学生报数时,如果所报数字同时是「两个特殊数」的倍数,也要特殊处理。例如,如果是「第一个(
3
)」和「第二个(5
)」特殊数的倍数,那么也不能说该数字,而是要说FizzBuzz
。以此类推,如果同时是三个特殊数的倍数,那么要说FizzBuzzWhizz
。学生报数时,如果所报数字包含了「第一个特殊数」,那么也不能说该数字,而是要说
Fizz
。例如,要报13
的同学应该说Fizz
。如果数字中包含了「第一个特殊数」,需要忽略规则
2
和3
,而使用规则4
。例如要报35
,它既包含3
,同时也是5
和7
的倍数,要说Fizz
,而不能说BuzzWhizz
;否则,直接说出要报的数字。
形式化
以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
;其他以此类推。
建立测试环境
首先搭建测试环境,建立反馈系统。这里使用scalatest
的测试框架,它也是作者偏爱的测试框架之一。
import org.scalatest.{FunSpec, Matchers}
class RuleSpec extends FunSpec with Matchers {
describe("World") {
it ("should not be work" ) {
true should be(false)
}
}
}
运行测试用例,与预期相符,测试失败;证明测试环境可工作,删除该用例,然后开启新的旅程。
第一个测试用例
先建立了一个规则:new Times(3, "Fizz")
,它表示如果是3
的倍数,则报Fizz
。此时,如果输入数字3*2
,断言预期的结果为Fizz
。
it ("times(3) -> Fizz" ) {
new Times(3, "Fizz").apply(3 * 2) should be("Fizz")
}
主构造函数
使用Scala
中,直接在类定义的首部直接定义「主构造函数」,可以消除重复的样板代码。
class Times(n: Int, word: String) {
def apply(m: Int): String = "Fizz"
}
类型的后缀修饰
Scala
将类型的修饰放在后面,以便实现风格的「一致性」,包括:
- 变量的类型修饰
- 函数返回值的类型修饰
def apply(m: Int): String = "Fizz"
类型推演
定义变量时,可以通过初始化值的类型推演出变量类型。
val i = 0
等价于
val i: Int = 0
事实上,当函数体比较短小时,可以一眼看出函数返回值类型,也可以略去函数返回值的类型。例如Times.apply
的返回值类型可以根据返回值自动推演为String
类型。
def apply(m: Int) = "Fizz"
等价于
def apply(m: Int): String = "Fizz"
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")
}
实现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) }
因为t
在foreach
的函数体内有且仅出现一次,可以使用占位符简化实现。
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, t
在foldLeft
的函数体中有且仅出现过一次,可以使用占位符代替。
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
操作。
实现AnyOf
接下来,实现具有两个之间具有「逻辑或」关系的复合规则。先建立一个简单的测试用例:
it ("times(3) -> Fizz || times(5) -> Buzz" ) {
AnyOf(Times(3, "Fizz"), Times(5, "Buzz"))(3*5) should be("Fizz")
}
为了快速通过测试,可以先打桩实现。
case class AnyOf(times: Times*) extends Rule {
def apply(n: Int): String = "Fizz"
}
链式调用
鉴于AllOf
的基础,可以快速地实现AnyOf
的逻辑。
case class AnyOf(times: Times*) {
def apply(m: Int): String =
times.map(t => t.apply(m))
.filterNot(s => s.isEmpty)
.headOption
.getOrElse("")
}
AnyOf.apply
将每一个Times
通过map
转换为字符串,然后找到第一个不为空的字符串为止。
此时,测试通过了。首先,可以使用占位符简化实现。
case class AnyOf(times: Times*) {
def apply(m: Int): String =
times.map(_.apply(m))
.filterNot(_.isEmpty)
.headOption
.getOrElse("")
}
其次,因为apply
具有特殊的语义,实现可以进一步简化。
case class AnyOf(times: Times*) {
def apply(m: Int): String =
times.map(_(m)).filterNot(_.isEmpty).headOption.getOrElse("")
}
提取Rule
至此,发现Times, Contains, Default, AllOf, AnyOf
都具有相同的结构,可抽象出Rule
的概念。
trait Rule {
def apply(n: Int): String
}
其中,trait
是Scala
实现对象组合的重要机制。
实现特质
Times
通过extends Rule
的方式实现Rule
特质。
case class Times(n: Int, word: String) extends Rule {
def apply(m: Int): String =
if (m % n == 0) word else ""
}
以此类推,Contains, Default, AllOf, AnyOf
实现方式相同,不再重述。
隐式树
AllOf, AnyOf
是一个「复合规则」,而Times, Contains, Default
表示「原子规则」。它们之间构成了一棵「隐式树」,它们的关键在于抽象的Rule
特质。
工厂方法
因为Times, Contains, Default, AnyOf, AllOf
都具有相同的句法结构,是一种典型的结构性重复设计,可以通过「工厂方法」消除它们之间的重复设计。
另外,为了简单函数调用的方式,可以使用Int => String
的一元函数代替Rule
特质。
重构测试用例
此时,可以定义一组新的测试用例集合,并使用describe
分离用例组,并通过显示地导入所依赖的类型,与既有的用例集共存,互不干扰。
切忌删除既有的
Rule
特质,以及Times, Contains, Default, AllOf, AnyOf
的实现,包括既有的测试用例;否则既有的测试用例失败,重构的安全网被撕破,将会让重构陷入一个极度危险的境界。总之,重构应该保持小步快跑的基本原则。
按照TDD
的规则,可以小步地,安全地逐一驱动实现各个工厂方法。
class RuleSpec extends FunSpec {
...
describe("fizz buzz whizz: using factory") {
import Rule.times
it ("times(3) -> fizz" ) {
times(3, "Fizz")(3 * 2) should be("Fizz")
}
}
}
实现工厂
times
的工厂方法也较容易实现,可以通过搬迁Times
的逻辑至此即可。
object Rule {
def times(n: Int, word: String): Int => String =
m => if (m % n == 0) word else ""
}
至此,times
实现通过测试。
小步快跑
以此类推,通过小步地TDD
的微循环,将其他工厂方法驱动实现出来。
class RuleSpec extends FunSpec {
...
describe("fizz buzz whizz: using factory") {
import Rule.{times, contains, default, allof, anyof}
it ("times(3) -> fizz" ) {
times(3, "Fizz")(3 * 2) should be("Fizz")
}
it ("contains(3) -> fizz" ) {
contains(3, "Fizz")(13) should be("Fizz")
}
it ("default rule" ) {
default(2) should be("2")
}
it ("times(3) && times(5) -> FizzBuzz" ) {
allof(times(3, "Fizz"), times(5, "Buzz"))(3*5) should be("FizzBuzz")
}
it ("times(3) -> Fizz || times(5) -> Buzz" ) {
anyof(times(3, "Fizz"), times(5, "Buzz"))(3*5) should be("Fizz")
}
}
}
最终,在Rule
伴生对象中实现了所有方法。
object Rule {
def times(n: Int, word: String): Int => String =
m => if (m % n == 0) word else ""
def contains(n: Int, word: String): Int => String =
m => if (m.toString.contains(n.toString)) word else ""
def default: Int => String =
m => m.toString
def anyof(rules: (Int => String)*): Int => String =
m => rules.foldLeft("") { _ + _(m) }
def allof(rules: (Int => String)*): Int => String =
m => rules.map(_(m)).filterNot(_.isEmpty).headOption.getOrElse("")
}
恭喜,通过所有测试。此时可以安全地删除Times, Contains, Default, AnyOf, AllOf
, trait Rule
,以及相关遗留的测试用例了。
类型别名
可以对Int => String
定义「类型别名」,消除类型的重复定义。
object Rule {
type Rule = Int => String
def times(n: Int, word: String): Rule =
m => if (m % n == 0) word else ""
def contains(n: Int, word: String): Rule =
m => if (m.toString.contains(n.toString)) word else ""
def default: Rule =
m => m.toString
def anyof(rules: Rule*): Rule =
m => rules.foldLeft("") { _ + _(m) }
def allof(rules: Rule*): Rule =
m => rules.map(_(m)).filterNot(_.isEmpty).headOption.getOrElse("")
}
至此,设计已经相当干净了。
微妙的重复
如果将default
稍微进行改造,很容易发现times, contains, default
之间存在微妙的重复结构。
def times(n: Int, word: String): Rule =
m => if (m % n == 0) word else ""
def contains(n: Int, word: String): Rule =
m => if (m.toString.contains(n.toString)) word else ""
def default: Rule =
m => if (true) m.toString else ""
它们各自拥有隐晦的「匹配规则」,当匹配成功时,执行相应的「转换规则」;其中,default
的「匹配规则」比较特殊,因为它总是匹配成功。
因此,三者实现可归结为一种统一的抽象行为:
n => if (matcher) action(n) else ""
提取原子
接下来开始消除times, contains, default
三者之间的重复逻辑。此时,先新建一组用例集合,使用describe
隔离新老用例集,显式地import
所依赖的类型,保证既有测试用例可用。然后按照TDD
微循环驱动实现三者之间共同的本质操作:atom
。
在
Rule.atom, Matcher.times, Action.to
可运行之前,切忌删除Rule.times
,及其相应的测试用例。
class RuleSpec extends FunSpec {
...
describe("using atom rule") {
import Rule.atom
import Matcher.times
import Action.to
it ("times(3) -> fizz" ) {
atom(times(3), to("Fizz"))(3 * 2) should be("Fizz")
}
}
}
快速通过
为了快速通过这个新的测试用例,可以快速搬迁times, to, atom
的代码实现。
object Matcher {
def times(n: Int): Int => Boolean = _ % n == 0
}
object Action {
def to(word: String): Int => String = _ => word
}
atom
也可以快速地实现,当给定一个整数m
,如果与matcher
匹配成功,则执行action
转换;否则返回空字符串。
def atom(matcher: Int => Boolean, action: Int => String): Rule =
m => if (matcher(m)) action(m) else ""
依次类推,可以逐一搬迁Rule
单键对象中的逻辑至Matcher
和Action
,在此不再冗述。
匹配器:Matcher
事实上,Matcher
是一个「一元函数」,入参为Int
,返回值为Boolean
,是一种典型的「谓词」。
从
OO
的角度看,always
是一个典型的Null Object
。
object Matcher {
type Matcher = Int => Boolean
def times(n: Int): Matcher = _ % n == 0
def contains(n: Int): Matcher = _.toString.contains(n.toString)
def always(bool: Boolean): Matcher = _ => bool
}
执行器:Action
Action
也是一个「一元函数」,入参为Int
,返回值为String
。其本质类似于map
操作,将定义域映射到值域。
从
OO
的角度看,nop
也是一个典型的Null Object
。
object Action {
type Action = Int => String
def to(str: String): Action = _ => str
def nop: Action = _.toString
}
规则库:Rule
使用类型别名,atom
的函数原型将更加清晰。
import Matcher.Matcher
import Action.Action
def atom(matcher: => Matcher, action: => Action): Rule =
m => if (matcher(m)) action(m) else ""
此时所有测试通过了,可以安全地删除Rule
伴生对象中times, contains, default
的实现,只保留atom, anyof, allof
三个核心的规则即可;同时,也可以删除遗留的测试用例集。
从
Rule
的概念中分离Matcher, Action
是「正交设计」的一个典范。它不仅让Rule
的职责更加单一,而且使得Rule, Matcher, Action
三个变化方向能够保持独立地变化,互不影响,相互正交。
最终,Rule
实现如下,它只保留了atom, anyof, allof
三个核心规则。
object Rule {
import Matcher.Matcher
import Action.Action
type Rule = Int => String
def atom(matcher: => Matcher, action: => Action): Rule =
m => if (matcher(m)) action(m) else ""
def anyof(rules: Rule*): Rule =
m => rules.map(_(m)).filterNot(_.isEmpty).headOption.getOrElse("")
def allof(rules: Rule*): Rule =
m => rules.foldLeft("") { _ + _(m) }
}
按名传递
matcher: => Matcher, action: => Action
是按照by-name
传递参数的,在实参传递形参过程中,并未对实参进行立即求值,而将求值推延至形参调用点。
也就是说,求值推延至if (matcher(m)) action(m)
语句才展开调用的。
隐式树
"Composition Everywhere".
Rule
是问题最核心的抽象,也是设计的灵魂所在。从语义上Rule
分为两种基本类型,并且它们之间形成了隐式的「树型」结构,体现了「组合式设计」的强大威力。
- 原子规则:
atom
- 复合规则:
anyof, anyof
事实上,任何复杂的软件系统本质上是由众多的「原子」构成,并通过「组合规则」组装起来,从而形成万千的世界,这正是「组合式设计」的精髓所在。
对于本例,atom
构成了系统最小的原子单位,anyof, allof
定义了组合的规则,从而完美地解决了这个问题。
构建DSL
基于Rule, Matcher, Action
的抽象,该问题可以使用DSL
进行描述,具有很强的表现力。
import Rule._
import Matcher._
import Action._
object Game {
def spec(n1: Int, n2: Int, n3: Int): Rule = {
val r_n1 = atom(times(n1), to("Fizz"))
val r_n2 = atom(times(n2), to("Buzz"))
val r_n3 = atom(times(n3), to("Whizz"))
val r3 = atom(contains(n1), to("Fizz"))
val r2 = allof(r_n1, r_n2, r_n3)
val rd = atom(always(true), nop);
anyof(r3, r2, rd)
}
}
应用程序
基于DSL
,构建应用程序也变得较为简单了。
object Main extends App {
def start(n: Int)(n1: Int, n2: Int, n3: Int): Unit = {
val saying = Game.spec(n1, n2, n3)
(1 to n) foreach { n => println(s"${n} -> ${saying(n)}") }
}
start(100)(3, 5, 7)
}
完备用例集
而对于测试用例,以3, 5, 7
为例,可以对测试用例进行整理,形成完备的用例集。此处使用「数据驱动」的方式组织用例,消除用例的重复代码,并改善表达力。
import org.scalatest.{Matchers, PropSpec}
import org.scalatest.prop.TableDrivenPropertyChecks
class RuleSpec extends PropSpec with TableDrivenPropertyChecks with Matchers {
val specs = Table(
("n", "expect"),
(3, "Fizz"),
(5, "Buzz"),
(7, "Whizz"),
(3 * 5, "FizzBuzz"),
(3 * 7, "FizzWhizz"),
((5 * 7) * 2, "BuzzWhizz"),
(3 * 5 * 7, "FizzBuzzWhizz"),
(13, "Fizz"),
(35/*5*7*/, "Fizz"),
(2, "2")
)
property("fizz buzz whizz") {
val spec = Game.spec(3, 5, 7)
forAll(specs) { spec(_) should be (_) }
}
}
语义模型
归纳上述设计,可以得到问题的语义模型。
Rule: Int => String
Matcher: Int => Boolean
Action: Int => String
其中,Rule
存在三种基本的类型:
Rule: atom | allof | anyof
三者之间构成了隐式的「树型结构」。
atom: (Matcher, Action) => String
allof: rule1 && rule2 ...
anyof: rule1 || rule2 ...
如果从OO
的角度看,该问题的领域模型如下图所示。
Github
FizzBuzzWhizz
的实现可以在Github上找到。
总结
本文通过对FizzBuzzWhizz
小游戏的设计和实现,首先尝试使用Scala
的面向对象技术,然后采用函数式的设计;过程采用TDD
小步快跑,演进式地完成了所有功能。
中间遇到了「特质」,「子类化多态」,「case类」,「类型别名」,「伴生对象」,「变长参数」,「惰性求值」,「高阶函数」,「柯里化」,「本地方法」等常用的技术。经过这个例子的实践,相信大家对Scala
有了一个大体的印象和感觉,接下来让我们开启Scala
的星际之旅吧。