天天看點

面向 Java 開發人員的 Scala 指南: 建構電腦,第 1 部分

Scala 的 case 類和模式比對

Ted Neward, 主管, Neward & Associates

簡介: 特定于領域的語言已經成為一個熱門話題;很多函數性語言之是以受歡迎,主要是因為它們可以用于建構特定于領域的語言。鑒于此,在 面向 Java™ 開發人員的 Scala 指南 系列的第 8 篇文章中,Ted Neward 着手建構 一個簡單的電腦 DSL,以此來展示函數性語言的建構 “外部” DSL 的強大功能。他研究了 Scala 的一個新的特性:case 類,并重新審視一個功能強大的特性:模式比對。

檢視本系列更多内容

釋出日期: 2008 年 9 月 23 日 

級别: 初級 

其他語言版本: 英文 

通路情況 : 5536 次浏覽 

評論:  (檢視 | 添加評論 - 登入)

面向 Java 開發人員的 Scala 指南: 建構電腦,第 1 部分

 平均分 (1個評分)

為本文評分

上個月的文章發表後,我又收到了一些抱怨/評論,說我迄今為止在本系列中所用的示例都沒涉及到什麼實質性的問題。當然在學習一個新語言的初期使用一些小例子是很合理的,而讀者想要看到一些更 “現實的” 示例,進而了解語言的深層領域和強大功能以及其優勢,這也是理所當然的。是以,在這個月的文章中,我們來分兩部分練習建構特定于領域的語言(DSL)— 本文以一個小的電腦語言為例。

關于本系列

Ted Neward 将和您一起深入探讨 Scala 程式設計語言。在這個新的 developerWorks 系列 中, 您将深入了解 Sacla,并在實踐中看到 Scala 的語言功能。進行比較時,Scala 代碼和 Java 代碼将放在一起展示,但(您将發現)Scala 中的許多内容與您在 Java 程式設計中發現的任何内容都沒有直接關聯,而這正是 Scala 的魅力所在!如果用 Java 代碼就能夠實作的話,又何必再學習 Scala 呢?

特定于領域的語言

可能您無法(或沒有時間)承受來自于您的項目經理給您的壓力,那麼讓我直接了當地說吧:特定于領域的語言無非就是嘗試(再一次)将一個應用程式的功能放在它該屬于的地方 — 使用者的手中。

通過定義一個新的使用者可以了解并直接使用的文本語言,程式員成功擺脫了不停地處理 UI 請求和 功能增強的麻煩,而且這樣還可以使使用者能夠自己建立腳本以及其他的工具,用來給他們所建構的應用程式建立新的行為。雖然這個例子可能有點冒險(或許會惹來幾封抱怨的電子郵件),但我還是要說,DSL 的最成功的 例子就是 Microsoft® Office Excel “語言”,用于表達電子表格單元格的各種計算和内容。 甚至有些人認為 SQL 本身就是 DSL,但這次是一個旨在與關系資料庫互相動的語言(想象一下如果程式員要通過傳統 API 

read()

/

write()

 調用來從 Oracle 中擷取資料的話,那将會是什麼樣子)。

這裡建構的 DSL 是一個簡單的電腦語言,用于擷取并計算數學表達式。其實,這裡的目标是要 建立一個小型語言,這個語言能夠允許使用者來輸入相對簡單的代數表達式,然後這個代碼來為它求值并産生結果。 為了盡量簡單明了,該語言不會支援很多功能完善的電腦所支援的特性,但我不也不想把它的用途限定在教學上 — 該語言一定要具備足夠的可擴充性,以使讀者無需徹底改變該語言就能夠将它用作一個功能更強大的語言的核心。 這意味着該語言一定要可以被輕易地擴充,并要盡量保持封裝性,用起來不會有任何的阻礙。

關于 DSL 的更多資訊

DSL 這個主題的涉及面很廣;它的豐富性和廣泛性不是本文的一個段落可以描述得了的。想要了解更多 DSL 資訊的讀者可以查閱本文末尾列出的 Martin Fowler 的 “正在進展中的圖書”;特别要注意 關于 “内部” 和 “外部” DSL 之間的讨論。Scala 以其靈活的文法和強大的功能而成為最強有力的建構内部和外部 DSL 的語言。

換句話說,(最終的)目标是要允許客戶機編寫代碼,以達到如下的目的:

清單 1. 電腦 DSL:目标

// This is Java using the Calculator
String s = "((5 * 10) + 7)";
double result = com.tedneward.calcdsl.Calculator.evaluate(s);
System.out.println("We got " + result); // Should be 57
      

我們不會在一篇文章完成所有的論述,但是我們在本篇文章中可以學習到一部分内容,在下一篇文章完成全部内容。

從實作和設計的角度看,可以從建構一個基于字元串的解析器來着手建構某種可以 “挑選每個字元并動态計算” 的解析器,這的确極具誘惑力,但是這隻适用于較簡單的語言,而且其擴充性不是很好。如果語言的目标是實作簡單的擴充性,那麼在深入研究實作之前,讓我們先花點時間想一想如何設計語言。

根據那些基本的編譯理論中最精華的部分,您可以得知一個語言處理器(包括解釋器和編譯器)的基本運算至少由兩個階段組成:

  • 解析器,用于擷取輸入的文本并将其轉換成 Abstract Syntax Tree(AST)。
  • 代碼生成器(在編譯器的情況下),用于擷取 AST 并從中生成所需位元組碼;或是求值器(在解釋器的情況下),用于擷取 AST 并計算它在 AST 裡面所發現的内容。

擁有 AST 就能夠在某種程度上優化結果樹,如果意識到這一點的話,那麼上述差別的原因就變得更加顯而易見了;對于電腦,我們可能要仔細檢查表達式,找出可以截去表達式的整個片段的位置,諸如在乘法表達式中運算數 為 “0” 的位置(它表明無論其他運算數是多少,運算結果都會是 “0”)。

您要做的第一件事是為電腦語言定義該 AST。幸運的是,Scala 有 case 類:一種提供了豐富資料、使用了非常薄的封裝的類,它們所具有的一些特性使它們很适合建構 AST。

case 類

在深入到 AST 定義之前,讓我先簡要概述一下什麼是 case 類。case 類是使 scala 程式員得以使用某些假設的預設值來建立一個類的一種便捷機制。例如,當編寫如下内容時:

清單 2. 對 person 使用 case 類

case class Person(first:String, last:String, age:Int)
{

}
      

Scala 編譯器不僅僅可以按照我們對它的期望生成預期的構造函數 — Scala 編譯器還可以生成正常意義上的

equals()

toString()

 和 

hashCode()

 實作。事實上,這種 case 類很普通(即它沒有其他的成員),是以 case 類聲明後面的大括号的内容是可選的:

清單 3. 世界上最短的類清單

case class Person(first:String, last:String, age:Int)
      

這一點通過我們的老朋友 

javap

 很容易得以驗證:

清單 4. 神聖的代碼生成器,Batman!

C:\Projects\Exploration\Scala>javap Person
Compiled from "case.scala"
public class Person extends java.lang.Object implements scala.ScalaObject,scala.
Product,java.io.Serializable{
    public Person(java.lang.String, java.lang.String, int);
    public java.lang.Object productElement(int);
    public int productArity();
    public java.lang.String productPrefix();
    public boolean equals(java.lang.Object);
    public java.lang.String toString();
    public int hashCode();
    public int $tag();
    public int age();
    public java.lang.String last();
    public java.lang.String first();
}
      

如您所見,伴随 case 類發生了很多傳統類通常不會引發的事情。這是因為 case 類是要與 Scala 的模式比對(在 “集合類型”中曾簡短分析過)結合使用的。

使用 case 類與使用傳統類有些不同,這是因為通常它們都不是通過傳統的 “new” 文法構造而成的;事實上,它們通常是通過一種名稱與類相同的工廠方法來建立的:

清單 5. 沒有使用 new 文法?

object App
{
  def main(args : Array[String]) : Unit =
  {
    val ted = Person("Ted", "Neward", 37)
  }
}
      

case 類本身可能并不比傳統類有趣,或者有多麼的與衆不同,但是在使用它們時會有一個很重要的差别。與引用等式相比,case 類生成的代碼更喜歡按位(bitwise)等式,是以下面的代碼對 Java 程式員來說有些有趣的驚喜:

清單 6. 這不是以前的類

object App
{
  def main(args : Array[String]) : Unit =
  {
    val ted = Person("Ted", "Neward", 37)
    val ted2 = Person("Ted", "Neward", 37)
    val amanda = Person("Amanda", "Laucher", 27)

    System.out.println("ted == amanda: " +
      (if (ted == amanda) "Yes" else "No"))
    System.out.println("ted == ted: " +
      (if (ted == ted) "Yes" else "No"))
    System.out.println("ted == ted2: " +
      (if (ted == ted2) "Yes" else "No"))
  }
}

/*
C:\Projects\Exploration\Scala>scala App
ted == amanda: No
ted == ted: Yes
ted == ted2: Yes
 */
      

case 類的真正價值展現在模式比對中,本系列的讀者可以回顧一下模式比對(參見 本系列的第二篇文章, 關于 Scala 中的各種控制構造),模式比對類似 Java 的 “switch/case”,隻不過它的本領和功能更加強大。模式比對不僅能夠檢查比對構造的值,進而執行值比對,還可以針對局部通配符(類似局部 “預設值” 的東西)比對值,case 還可以包括對測試比對的保護, 來自比對标準的值還可以綁定于局部變量,甚至符合比對标準的類型本身也可以進行比對。

有了 case 類,模式比對具備了更強大的功能,如清單 7 所示:

清單 7. 這也不是以前的 switch

case class Person(first:String, last:String, age:Int);

object App
{
  def main(args : Array[String]) : Unit =
  {
    val ted = Person("Ted", "Neward", 37)
    val amanda = Person("Amanda", "Laucher", 27)

    System.out.println(process(ted))
    System.out.println(process(amanda))
  }
  def process(p : Person) =
  {
    "Processing " + p + " reveals that" +
    (p match
    {
      case Person(_, _, a) if a > 30 =>
        " they're certainly old."
      case Person(_, "Neward", _) =>
        " they come from good genes...."
      case Person(first, last, ageInYears) if ageInYears > 17 =>
        first + " " + last + " is " + ageInYears + " years old."
      case _ => 
        " I have no idea what to do with this person"
    })
  }
}

/*
C:\Projects\Exploration\Scala>scala App
Processing Person(Ted,Neward,37) reveals that they're certainly old.
Processing Person(Amanda,Laucher,27) reveals that Amanda Laucher is 27 years old
.
 */
      

清單 7 中發生了很多操作。下面就讓我們先慢慢了解發生了什麼,然後回到電腦,看看如何應用它們。

首先,整個 

match

 表達式被包裹在圓括号中:這并非模式比對文法的要求,但之是以會這樣是因為我把模式比對表達式的結果根據其前面的字首串聯了起來(切記,函數性語言裡面的任何東西都是一個表達式)。

其次,第一個 

case

 表達式裡面有兩個通配符(帶下劃線的字元就是通配符),這意味着該比對将會為符合比對的 

Person

 中那兩個字段擷取任何值,但是它引入了一個局部變量 

a

p.age

 中的值會綁定在這個局部變量上。這個 case 隻有在同時提供的起保護作用的表達式(跟在它後邊的 

if

 表達式)成功時才會成功, 但隻有第一個 

Person

 會這樣,第二個就不會了。第二個 

case

 表達式 在 

Person

 的 

firstName

 部分使用了一個通配符,但在 

lastName

 部分使用常量字元串 

Neward

 來比對,在 

age

部分使用通配符來比對。

由于第一個 

Person

 已經通過前面的 

case

 比對了,而且第二個 

Person

 沒有姓 

Neward

,是以該比對不會 為任何一個 

Person

 而被觸發(但是,

Person("Michael", "Neward", 15)

 會由于第一個 case 中的 guard 子句失敗而轉到第二個 case)。

第三個示例展示了模式比對的一個常見用途,有時稱之為提取,在這個提取過程中,比對對象 

p

 中的值為了能夠在 case 塊内使用而被提取到局部變量中(第一個、最後一個和 

ageInYears

)。最後的 case 表達式是普通 case 的預設值,它隻有在其他 case 表達式均未成功的情況下才會被觸發。

簡要了解了 case 類和模式比對之後,接下來讓我們回到建立電腦 AST 的任務上。

電腦 AST

首先,電腦的 AST 一定要有一個公用基類型,因為數學表達式通常都由子表達式組成;通過 “5 + (2 * 10)” 就可以很容易地看到這一點,在這個例子中,子表達式 “(2 * 10)” 将會是 “+” 運算的右側運算數。

事實上,這個表達式提供了三種 AST 類型:

  • 基表達式
  • 承載常量值的 Number 類型
  • 承載運算和兩個運算數的 BinaryOperator

想一下,算數中還允許将一進制運算符用作求負運算符(減号),将值從正數轉換為負數,是以我們可以引入下列基本 AST:

清單 8. 電腦 AST(src/calc.scala)

package com.tedneward.calcdsl
{
  private[calcdsl] abstract class Expr
  private[calcdsl]  case class Number(value : Double) extends Expr
  private[calcdsl]  case class UnaryOp(operator : String, arg : Expr) extends Expr
  private[calcdsl]  case class BinaryOp(operator : String, left : Expr, right : Expr)
   extends Expr
}
      

注意包聲明将所有這些内容放在一個包(

com.tedneward.calcdsl

)中, 以及每一個類前面的通路修飾符聲明表明該包可以由該包中的其他成員或子包通路。之是以要注意這個是因為需要擁有一系列可以測試這個代碼的 JUnit 測試;電腦的實際客戶機并不一定非要看到 AST。是以, 要将單元測試編寫成 

com.tedneward.calcdsl

 的一個子包:

清單 9. 電腦測試(testsrc/calctest.scala)

package com.tedneward.calcdsl.test
{
  class CalcTest
  {
    import org.junit._, Assert._
    
    @Test def ASTTest =
    {
      val n1 = Number(5)

      assertEquals(5, n1.value)
    }
    
    @Test def equalityTest =
    {
      val binop = BinaryOp("+", Number(5), Number(10))
      
      assertEquals(Number(5), binop.left)
      assertEquals(Number(10), binop.right)
      assertEquals("+", binop.operator)
    }
  }
}
      

到目前為止還不錯。我們已經有了 AST。

再想一想,我們用了四行 Scala 代碼建構了一個類型分層結構,表示一個具有任意深度的數學表達式集合(當然這些數學表達式很簡單,但仍然很有用)。與 Scala 能夠使對象程式設計更簡單、更具表達力相比,這不算什麼(不用擔心,真正強大的功能還在後面)。

接下來,我們需要一個求值函數,它将會擷取 AST,并求出它的數字值。有了模式比對的強大功能,編寫這樣的函數簡直輕而易舉:

清單 10. 電腦(src/calc.scala)

package com.tedneward.calcdsl
{
  // ...

  object Calc
  {
    def evaluate(e : Expr) : Double =
    {
      e match {
        case Number(x) => x
        case UnaryOp("-", x) => -(evaluate(x))
        case BinaryOp("+", x1, x2) => (evaluate(x1) + evaluate(x2))
        case BinaryOp("-", x1, x2) => (evaluate(x1) - evaluate(x2))
        case BinaryOp("*", x1, x2) => (evaluate(x1) * evaluate(x2))
        case BinaryOp("/", x1, x2) => (evaluate(x1) / evaluate(x2))
      }
    }
  }
}
      

注意 

evaluate()

 傳回了一個 

Double

,它意味着模式比對中的每一個 case 都必須被求值成一個 

Double

 值。這個并不難: 數字僅僅傳回它們的包含的值。但對于剩餘的 case(有兩種運算符),我們還必須在執行必要運算(求負、加法、減法等)前計算運算數。 正如常在函數性語言中所看到的,會使用到遞歸,是以我們隻需要在執行整體運算前對每一個運算數調用

evaluate()

 就可以了。

大多數忠實于面向對象的程式設計人員會認為在各種運算符本身以外 執行運算的想法根本就是錯誤的 — 這個想法顯然大大違背了封裝和多态性的原則。坦白說,這個甚至不值得讨論; 這很顯然違背 了封裝原則,至少在傳統意義上是這樣的。

在這裡我們需要考慮的一個更大的問題是:我們到底從哪裡封裝代碼?要記住 AST 類在包外是不可見的, 還有就是客戶機(最終)隻會傳入它們想求值的表達式的一個字元串表示。隻有單元測試在直接與 AST case 類合作。

但這并不是說所有的封裝都沒有用了或過時了。 事實上恰好相反:它試圖說服我們在對象領域所熟悉的方法之外,還有很多其他的設計方法也很奏效。 不要忘了 Scala 兼具對象和函數性;有時候 

Expr

 需要在自身及其子類上附加其他行為(例如,實作良好輸出的 

toString

 方法),在這種情況下可以很輕松地将這些方法添加到 

Expr

。函數性和面向對象的結合提供了另一種選擇,無論是函數性程式設計人員還是對象程式設計人員,都不會忽略到另一半的設計方法,并且會考慮如何結合兩者來達到一些有趣的效果。

從設計的角度看,有些其他的選擇是有問題的;例如,使用字元串來承載運算符就有可能出現小的輸入錯誤,最終會導緻結果不正确。 在生産代碼中,可能會使用(也許必須使用)枚舉而非字元串,使用字元串的話就意味着 我們可能潛在地 “開放” 了運算符,允許調用出更複雜的函數(諸如 abs、sin、cos、tan 等)乃至使用者定義的函數;這些函數是基于枚舉的方法很難支援的。

對所有設計和實作的來說,都不存在一個适當的決策方法,隻能承擔後果。後果自負。

但是這裡可以使用一個有趣的小技巧。某些數學表達式可以簡化,因而(潛在地)優化了表達式的求值(是以展示了 AST 的有用性):

  • 任何加上 “0” 的運算數都可以被簡化成非零運算數。
  • 任何乘以 “1” 的運算數都可以被簡化成非零運算數。
  • 任何乘以 “0” 的運算數都可以被簡化成零。

不止這些。是以我們引入了一個在求值前執行的步驟,叫做 

simplify()

,使用它執行這些具體的簡化工作:

清單 11. 電腦(src/calc.scala)

def simplify(e : Expr) : Expr =
    {
      e match {
        // Double negation returns the original value
        case UnaryOp("-", UnaryOp("-", x)) => x
        // Positive returns the original value
        case UnaryOp("+", x) => x
        // Multiplying x by 1 returns the original value
        case BinaryOp("*", x, Number(1)) => x
        // Multiplying 1 by x returns the original value
        case BinaryOp("*", Number(1), x) => x
        // Multiplying x by 0 returns zero
        case BinaryOp("*", x, Number(0)) => Number(0)
        // Multiplying 0 by x returns zero
        case BinaryOp("*", Number(0), x) => Number(0)
        // Dividing x by 1 returns the original value
        case BinaryOp("/", x, Number(1)) => x
        // Adding x to 0 returns the original value
        case BinaryOp("+", x, Number(0)) => x
        // Adding 0 to x returns the original value
        case BinaryOp("+", Number(0), x) => x
        // Anything else cannot (yet) be simplified
        case _ => e
      }
    }
      

還是要注意如何使用模式比對的常量比對和變量綁定特性,進而使得編寫這些表達式可以易如反掌。 對 

evaluate()

 惟一一個更改的地方就是包含了在求值前先簡化的調用:

清單 12. 電腦(src/calc.scala)

def evaluate(e : Expr) : Double =
    {
      simplify(e) match {
        case Number(x) => x
        case UnaryOp("-", x) => -(evaluate(x))
        case BinaryOp("+", x1, x2) => (evaluate(x1) + evaluate(x2))
        case BinaryOp("-", x1, x2) => (evaluate(x1) - evaluate(x2))
        case BinaryOp("*", x1, x2) => (evaluate(x1) * evaluate(x2))
        case BinaryOp("/", x1, x2) => (evaluate(x1) / evaluate(x2))
      }
    }
      

還可以再進一步簡化;注意一下:它是如何實作隻簡化樹的最底層的?如果我們有一個包含 

BinaryOp("*", Number(0), Number(5))

 和 

Number(5)

 的 

BinaryOp

 的話,那麼内部的 

BinaryOp

 就可以被簡化成 

Number(0)

,但外部的 

BinaryOp

 也會如此,這是因為此時 外部 

BinaryOp

 的其中一個運算數是零。

我突然犯了作家的職業病了,是以我想将它留予讀者來定義。其實是想增加點趣味性罷了。 如果讀者願意将他們的實作發給我的話,我将會把它放在下一篇文章的代碼分析中。将會有兩個測試單元來測試這種情況,并會立刻失敗。您的任務(如果您選擇接受它的話)是使這些測試 — 以及其他任何測試,隻要該測試采取了任意程度的 

BinaryOp

 和 

UnaryOp

 嵌套 — 通過。

結束語

顯然我還沒有說完;還有分析的工作要做,但是電腦 AST 已經成形。我們無需作出大的變動就可以添加其他的運算,運作 AST 也無需大量的代碼(按照 Gang of Four 的 Visitor 模式),而且我們已經有了一些執行計算本身的工作代碼(如果客戶機願意為我們建構用于求值的代碼的話)。

更重要的是,您已經看到了 case 類是如何與模式比對合作,使得建立 AST 并對其求值變得輕而易舉。這是 Scala 代碼(以及大多數函數性語言)很常用的設計,而且如果您準備認真地研究這個環境的話,這是您應當掌握的内容之一。

參考資料

學習

  • 您可以參閱本文在 developerWorks 全球網站上的 英文原文。
  • “面向 Java 開發人員的 Scala 指南:集合類型”(Ted Neward,developerWorks,2008 年 6 月)論述了模式比對。 
  • “面向 Java 開發人員的 Scala 指南:類操作”(Ted Neward, developerWorks, February 2008) 論述了 Scala 中的各種控制構造。 
  • “ 面向 Java 開發人員的 Scala 指南 (Ted Neward,developerWorks):閱讀整個系列。 
  • “Java 語言中的函數程式設計”(Abhijit Belapurkar,developerWorks,2004 年 7 月):從 Java 開發人員的角度了解函數程式設計的優點和用法。
  • “Scala by Example”(Martin Odersky,2007 年 12 月):這是一篇簡短的、代碼驅動的 Scala 介紹性文章(PDF 格式)。
  • Programming in Scala (Martin Odersky、Lex Spoon 和 Bill Venners;Artima,2007 年 12 月):第一份 Scala 介紹,篇幅和一本書差不多。
  • developerWorks Java 技術專區:這裡有數百篇關于 Java 程式設計各方面的文章。

獲得産品和技術

  • 下載下傳 Scala:開始學習本系列。
  • SUnit:scala.testing 包中的标準 Scala 發行版的一部分。

讨論

  • developerWorks blogs:加入 developerWorks 社群。 

繼續閱讀