Scala破冰之旅

编程入门 行业动态 更新时间:2024-10-11 17:22:54

Scala破冰<a href=https://www.elefans.com/category/jswz/34/1770100.html style=之旅"/>

Scala破冰之旅

即使水墨丹青,何以绘出半妆佳人。
Scala是一门优雅而又复杂的程序设计语言,初学者很容易陷入细节而迷失方向。这也给我的写作带来了挑战,如果从基本的控制结构,再深入地介绍高级的语法结构,难免让人生厌。
为此,本文另辟蹊径,尝试通过一个简单有趣的例子,概括性地介绍 Scala常见的语言特性。它犹如一个迷你版的 Scala教程,带领大家一起领略 Scala的风采。

问题的提出

有一名体育老师,在某次离下课还有五分钟时,决定玩一个游戏。此时有 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,同时也是5和7的倍数,要说Fizz,而不能说BuzzWhizz
  6. 否则,就要说对应的数字。

形式化

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的测试框架,它也是作者最喜欢的测试框架之一。

package scalaspec.fizzbuzz
import org.scalatest.Matchers._
import org.scalatest._
class RuleSpec extends FunSpec {describe("World") {it ("should not be work" ) {true should be(false)}}
}

运行测试用例,用于验证环境。

第一个测试用例

it ("times(3) -> fizz" ) {new Times(3, "Fizz").apply(3 * 2) should be("Fizz")
}
它先建立了一个规则: new Times(3, "Fizz"),表示如果是 3的倍数,则输出 Fizz。此时,如果输入数字 3*2,断言预期的结果为 Fizz
如果使用Java
为了快速通过测试,可以做简单实现。如果使用 Java,Times实现大致如下。

public class Times(int n, String word) {private final int n;private final String word;public Times(int n, String word) {this.n = n;this.word = word;}public apply(int m) {return word;}
}
构造参数

从上述Java实现可以看出,当定义一个私有字段时,需要构造函数对它进行初始化。类似重复的「样板代码」在Scala中,可以在「主构造函数」中使用「构造参数」代替,彻底消除重复代码。

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

类型的后缀修饰
Scala将类型的修饰放在后面,以便实现风格的「一致性」,包括:
  • 变量的类型修饰
  • 函数返回值的类型修饰

def apply(m: Int): String = word

类型推演
关于 Scala类型推演,需要注意几点:
  • 函数原型后面不能略去=
apply函数原型后面的=不能略去,因为 Scala将函数也看成普通的表达式。否则会限制函数的类型推演的能力,编译器将一律推演函数返回值类型为 Unit(等价于 Java中的 void)。
  • 函数返回值常常略去return
例如, def apply(m: Int) = { return word }将产生编译错误,其需要明确地声明函数返回值的类型。所以,显式的 return语句的使用得不偿失,除非 return用于明确的提前中断。
  • 借助于类型推演的机制,变量、函数返回值的类型都可以略去;但是,当逻辑较为复杂时,代码的表达力将大打折扣

val i: Int = 0  // 类型修饰显得冗余

事实上,此处也可以略去 apply方法的返回值的类型修饰,它依然能够推演为 String类型。
def apply(m: Int) = word
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

至此,测试通过了,但apply实现被写死了。因为Times的逻辑较为简单,可以快速实现它。

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

万物皆是对象
Scala并没有像 Java一样,针对「基本类型」(例如 int),「数组类型」(例如 int[])定义特殊的语法,它将世间万物都看成对象。
其中, m % n等价于 m.%(n),而 %只不过是 Int的一个普通方法而已。
package scala
final abstract class Int private extends AnyVal {def %(x: Int): Int...
}
注意, Int的实现由编译器完成,后续章节将讲述 Int的修饰语法。
面向表达式
Scala是一门面向表达式的语言,它所有的程序结构都具有值,包括 if-else,函数调用等。其中, if (m % n == 0) word else ""类似于 Java中的三元表达式: m % n == 0 ? word : ""
因为两者功能重复,因此 Scala并没有提供三元表达式的特性。
使用样本类
可以将 Times设计为「样本类」。
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")
}
揭秘样本类
使用 case class定义的类称为「样本类」,它默认具有字段的 Getter方法,并天然地拥有 equals, hashCode等方法。
另外,「样本类」在其「伴生对象」中自动生成 apply的工厂方法。当生产对象时,可以略去 new关键字,使得语义更加简洁。
也就是说, case class Times...等价于:
class Times(val n: Int, val word: String) {def apply(m: Int): String =if (m % n == 0) word else ""override def equals(obj: Any): Boolean = ???override def hashCode(): Int = ???...
}
object Times {def apply(n: Int, word: String) = new Times(n, word)
}
在本书中,除非特别说明,否则???表示函数实现的占位表示,仅仅为了方便举例。
其中,???定义在scala.Predef中;因为Predef的所有成员被编译器默认导入,它对所有程序公开。

def ??? = throw new NotImplementedError
伴生对象
object Times常常称为 class Times的「伴生对象」。事实上,「伴生对象」中的方法,类似于 Java中的 static方法。但 Scala摒弃了 static的关键字,将面向对象的语义进行统一。
如果用 Java设计 case class Times,其实现类似于:
public class Times {private final int n;private final String word;public Times(int n, String word) {this.n = n;this.word = word;}public apply(int m) {return word;}// Getter方法public int n() {return n;}public String word() {return word;}// 自动生成equals, hashCode方法  @Overridepublic boolean equals(Object obj) {...}@Overridepublic int hashCode() {...}// 静态工厂方法public static Times apply(int n, String word) {return new Times(n, word);}...
}

实现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),不能略去 ()

提取抽象

至此,发现 Times, Contains, Default都具有相同的结构,可抽象出 Rule的概念。
trait Rule {def apply(n: Int): String
}
特质的功效
此处使用 trait定义了一个接口。事实上, trait不仅仅等价于 Javainterface,它是 Scala实现对象组合的重要机制。
实现特质
例如 Times实现 Rule特质,它使用 extends Rule语法混入该「特质」。
case class Times(n: Int, word: String) extends Rule {def apply(m: Int): String =if (m % n == 0) word else ""
}
以此类推, Contains, Default实现方式相同,不再重述。

实现AnyOf

接下来,实现具有两个之间具有「逻辑与」关系的复合规则。先建立一个简单的测试用例:
it ("times(3) && times(5) -> FizzBuzz" ) {AllOf(Times(3, "Fizz"), Times(5, "Buzz"))(3*5) should be("FizzBuzz")
}
为了快速通过测试,可以先打桩实现。

case class AllOf(rules: Rule*) extends Rule {def apply(n: Int): String = "FizzBuzz"
}
变长参数
rules: Rule*表示变长的 Rule列表,表示可以向 AllOf的构造函数传递任意多的 Rule实例。
事实上, rules: Rule*的真正类型为 scala.collection.mutable.WrappedArray[Rule],所以 rules: Rule*拥有普通集合类的一般特征,例如调用 map, foreach, foldLeft等方法。
快速实现AllOf
case class AllOf(rules: Rule*) extends Rule {def apply(n: Int): String = {var result = StringBuilder.newBuilderrules.foreach { r =>result.append(r(n))}result.toString}
}
使用foldLeft

case class AllOf(rules: Rule*) extends Rule {def apply(n: Int): String =rules.foldLeft("") { r => _ + r.apply(n) }
}

因为r在函数字面值中仅过一次,可以使用占位符代替。

case class AllOf(rules: Rule*) extends Rule {def apply(n: Int): String =rules.foldLeft("") { _ + _.apply(n) }
}
因为apply方法具有特殊的函数调用语义,可以进一步简化实现。
case class AllOf(rules: Rule*) extends Rule {def apply(n: Int): String =rules.foldLeft("") { _ + _(n) }
}

实现AnyOf

接下来,实现具有两个之间具有「逻辑或」关系的复合规则。先建立一个简单的测试用例:
it ("times(3) -> Fizz || times(5) -> Buzz" ) {AnyOf(Times(3, "Fizz"), Times(5, "Buzz"))(3*5) should be("Fizz")
}
为了快速通过测试,可以先打桩实现。
case class AnyOf(rules: Rule*) extends Rule {def apply(n: Int): String = "Fizz"
}
快速实现AnyOf

case class AnyOf(rules: Rule*) extends Rule {def apply(n: Int): String =rules.map(_(n)).filterNot(_.isEmpty).headOption.getOrElse("")
}

测试用例通过了。

提供工厂方法

因为 Times, Contains, Default, AnyOf, AllOf都具有相同的句法结构,是一种典型的结构性重复设计,可以通过「工厂方法」消除它们之间的重复设计。
另外,为了简单函数调用的方式,可以使用Int => String的一元函数代替Rule特质。
重构测试用例
此时,可以定义一组新的测试用例集合,并使用describe分离用例组,并通过显示地导入所依赖的类型,与既有的用例集共存,互不干扰。
切忌删除既有的Rule特质,以及Times, Contains, Default, AllOf, AnyOf的实现,包括既有的测试用例;否则既有的测试用例失败,重构的安全网被撕破,将会让重构陷入一个极度危险的境界。

总之,重构应该保持小步快跑的基本原则。
按照 TDD的规则,可以小步地,安全地逐一驱动实现各个工厂方法。
class RuleSpec extends FunSpec {...describe("Rule using factory method") {import Rule._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("Rule using factory method") {import Rule._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" ) {anyof(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.toStringdef 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,Rule特质,以及相关的遗留的测试用例了。
类型别名
可以对 Int => String定义「类型别名」,消除类型的重复定义。

object Rule {type Rule = Int => Stringdef 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.toStringdef anyof(rules: Rule*): Rule =m => rules.foldLeft("") { _ + _(m) }def allof(rules: Rule*): Rule =m => rules.map(_(m)).filterNot(_.isEmpty).headOption.getOrElse("")
}

至此,设计已经较为干净了。但发现 times, contains, default之间存在微妙的重复结构。它们各自拥有隐晦的「匹配规则」,当匹配成功时,执行相应的「转换规则」。
其中, default的匹配规则、转换规则都比较特殊;因为它总是匹配成功,转换时简单地讲数字转换为字符串表示的形式。。

提取匹配器

先提取抽象的「匹配器」概念: Matcher。事实上, Matcher是一个「一元函数」,入参为 Int,返回值为 Boolean,是一种典型的「谓词」。
OO的角度看, always是一个典型的 Null Object实现模式。
object Matcher {type Matcher = Int => Booleandef times(n: Int): Matcher = _ % n == 0def contains(n: Int): Matcher = _.toString.contains(n.toString)def always(bool: Boolean): Matcher = _ => bool
}

执行器:Action

然后再提取抽象的「执行器」概念: Action。事实上, Action也是一个「一元函数」,入参为 Int,返回值为 String。其本质类似于 map操作,将定义域映射到值域。
OO的角度看,nop也是一个典型的Null Object实现模式。
object Action {type Action = Int => Stringdef to(str: String): Action = _ => strdef nop: Action = _.toString
}

原子操作

至此,可以提取 times, contains, default三者之间的共公抽象: atom
def atom(matcher: => Matcher, action: => Action): Rule =m => if (matcher(m)) action(m) else ""
它表示的语义为:给定一个整数 m,如果与 Matcher匹配成功,则执行 Action转换;否则返回空字符串。
新建一组用例集合
此时,新建一组用例集合,并使用 atom的原子接口,并使用 describe隔离新老用例集,显式地 import所依赖的类型,保证既有测试用例可用。
Rule.atom, Matcher, Action可运行之前,切忌删除Rule中既有的times, contains, default,及其相应的测试用例。
class RuleSpec extends FunSpec {...describe("using atom rule") {import Rule.{anyof, anyof, atom}import Matcher._import Action._val r1_3 = atom(times(3), to("Fizz"))val r1_5 = atom(times(5), to("Buzz"))it ("times(3) -> fizz" ) {r1_3(3 * 2) should be("Fizz")}val r3 = atom(contains(3), to("Fizz"))it ("contains(3) -> fizz" ) {r3(13) should be("Fizz")}val rd = atom(always(true), nop)it ("default rule" ) {rd(2) should be("2")}it ("times(3) && times(5) -> FizzBuzz" ) {anyof(r1_3, r1_5)(3*5) should be("FizzBuzz")}it ("times(3) -> Fizz || times(5) -> Buzz" ) {anyof(r1_3, r1_5)(3*5) should be("Fizz")}}
}
测试用例通过。

规则库

Composition Everywhere
此时,可以安全地删除 Ruletimes, contains, default的实现,及其遗留的测试用例集。 Rule最终实现为:
object Rule {type Rule = Int => Stringimport Matcher.Matcherimport Action.Actiondef atom(matcher: => Matcher, action: => Action): Rule =n => if (matcher(n)) action(n) else ""def anyof(rules: Rule*): Rule =n => rules.map(_(n)).filterNot(_.isEmpty).headOption.getOrElse("")def allof(rules: Rule*): Rule =n => rules.foldLeft("") { _ + _(n) }
}
RuleFizzBuzzWhizz最核心的抽象,也是设计的灵魂所在。从语义上 Rule分为2种基本类型,并且两者之间形成了隐式的「树型」结构,体现了「组合式设计」的强大威力。
  • 原子规则:atom
  • 复合规则: anyof, anyof
Rule也是一个「一元函数」,入参为 Int,返回值为 String。其中, def atom(matcher: => Matcher, action: => Action)的入参使用 by-name的「惰性求值」特性。

完备用例集

针对于 FizzBuzzWhizz问题,以 3, 5, 7为例,其完备的用例集可以如下描述。此处使用表格驱动的方式组织用例,消除大量的重复代码,并改善其表达力。
import org.scalatest._
import prop._class RuleSpec extends PropSpec with TableDrivenPropertyChecks with Matchers {import Rule._import Matcher._import Action._val spec = {val r1_3 = atom(times(3), to("Fizz"))val r1_5 = atom(times(5), to("Buzz"))val r1_7 = atom(times(7), to("Whizz"))val r1 = anyof(r1_3, r1_5, r1_7)val r2 = anyof(allof(r1_3, r1_5, r1_7),allof(r1_3, r1_5),allof(r1_3, r1_7),allof(r1_5, r1_7))val r3 = atom(contains(3), to("Fizz"))val rd = atom(always(true), nop);anyof(r3, r2, r1, rd)}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") {forAll(specs) { spec(_) should be (_) }}
}

语义模型

归纳上述设计,可以得到 FizzBuzzWhizz的语义模型。
Rule:    Int => String
Matcher: Int => Boolean
Action:  Int => String
其中, Rule存在三种基本的类型:
Rule: atom | allof | anyof
三者之间构成了隐式的「树型结构」。
atom: (Matcher, Action) => String
allof: rule1 && rule2 ...
anyof: rule1 || rule2 ...

总结

本文通过对 FizzBuzzWhizz的小游戏的设计和实现,首先尝试使用 Scala的面向对象实现,然后采用函数式的设计;采用 TDD的方式,演进式地完成功能的实现。

中间也曾遇到了「样本类」,「类型别名」,「伴生对象」等常用的技术。相信经过本文的实践,你应该对Scala有了一个大体的影响和感觉,接下来让我们开启Scala的愉快之旅吧。


本文作者:刘光聪(简书作者)

原文链接:
著作权归作者所有,转载请联系作者获得授权,并标注“简书作者”。

更多推荐

Scala破冰之旅

本文发布于:2024-03-13 01:31:26,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1732881.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:之旅   破冰   Scala

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!