Scala破冰之旅

作者: 刘光聪 | 来源:发表于2016-08-16 14:35 被阅读1150次

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

    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,同时也是57的倍数,要说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的测试框架,它也是作者偏爱的测试框架之一。

    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) }
    

    因为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操作。

    实现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
    }
    

    其中,traitScala实现对象组合的重要机制。

    实现特质

    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单键对象中的逻辑至MatcherAction,在此不再冗述。

    匹配器: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的星际之旅吧。

    相关文章

      网友评论

      本文标题:Scala破冰之旅

      本文链接:https://www.haomeiwen.com/subject/kdypsttx.html