Swift函數式編程十二(表格應用)

代碼地址

這個示例爲希望被解析的表達式編寫解析器,併爲這些表達式編寫一個求值器,然後將其嵌入界面中。

解析

基於解析器組合算子中的算術表達式解析器,引入額外的抽象層級。

之前,編寫的解析器會直接返回計算結果。比如在解析 "2*3" 這樣的乘法表達式時:

let multiplication = curry { return $0*($1 ?? 1) }<^>integer<*>(character{ $0 == "*" }*>integer).optional

multiplication 的類型是 Parser<Int>,也就是說,在這個傳入字符串 "2*3" 並執行解析器之後, 會返回整數值 6。只有在表達式中不含有對任何外部數據的依賴的時候,才能夠直接將結果計算出來。然而,在電子表格中,希望可以表達類似於 A3 這樣對其它單元格值的引用,或者是像 SUM(A1:A3) 這樣的函數調用。

要支持這些特性,解析器需要將輸入的字符串轉換爲一棵抽象語法樹 (abstract syntax tree, AST),這是一種用於描述表達式內容的中間表現形式。在轉換爲抽象語法樹後,可以取得這些結構化的數據,再對其做實際的計算。將這種中間表現形式定義爲一個枚舉:

indirect enum Expression {
    case int(Int)
    case reference(String, Int)
    case infix(Expression, String, Expression)
    case funcation(String, Expression)
}

枚舉 Expression 包含四個枚舉值:

  • int表示簡單的數值。
  • reference表示對其它單元格內值的引用,比如"A3"。其中,列引用被指定爲從"A"開始的大寫字母。而行引用則被定義爲從 0 開始的數字。枚舉值 reference 有兩個關聯值,一個字符串和一個整數,用於儲存該引用的行與列。
  • infix表示類似"A2+3"這樣位於運算符左右側兩個參數之間的一次運算。枚舉值infix中的關聯值儲存了運算符左側的表達式,運算符本身,以及右側的表達式。
  • function表示一個類似於"SUM(A1:A3)"這樣的函數調用。第一個關聯值是函數名,第二個則是函數的參數(在這個示例中,函數只能夠接收單個參數)。

在有了的 Expression 枚舉之後,就可以爲每種表達式編寫一個解析器了。

枚舉值 int 的解析器,可以利用解析器組合算子中的整數解析器,然後將整數結果封裝爲一個 Expression 類型:

extension Expression {
    static var intParser: Parser<Expression> {
        return { int($0) } <^> integer
    }
}
if let v = Expression.intParser.run("123") { print(v) }
/*輸出:
(Spreadsheet.Expression.int(123), "")
*/

行列座標的解析器,先定義一個大寫字母的解析器,然後將大寫字母的解析器與一個整數解析器合併起來,再將解析結果封裝爲枚舉值 .reference:

let capitalLetter = character { CharacterSet.uppercaseLetters.contains($0) }

extension Expression {
    static var referenceParser: Parser<Expression> {
        return curry { reference(String($0), $1) } <^> capitalLetter <*> integer
    }
}
if let v = Expression.referenceParser.run("A3") { print(v) }
/*輸出:
(Spreadsheet.Expression.reference("A", 3), "")
*/

枚舉值 .function的解析器,需要解析出一個函數名 (一個或更多個大寫字 母),以及接下來的圓括號中的表達式。在此,圓括號中的表達式將只支持形如 "A1:A2" 的 參數類型。需要先定義三個額外的輔助函數。首先添加函數 string創建一個用於匹配特定字符串的解析器,使用已有的函數 character 就來實現。接着爲 Parser 定義一個便利方法,用來將當前解析器封裝在一個用於解析左右圓括號的 解析器中。然後定義一個將接受三個參數的非柯里化函數轉化爲柯里化函數的函數。這樣就可以繼續爲函數表達式編寫解析器了:

func string(_ string: String) -> Parser<String> {
    return Parser<String> { input in
        var remainder = input
        for c in string {
            let paser = character { $0 == c }
            guard let (_, newRemainder) =  paser.run(remainder) else { return nil }
            remainder = newRemainder
        }
        
        return (string, remainder)
    }
}
if let v = string("SUM").run("SUM") { print(v) }
/*輸出:
("SUM", "")
*/

extension Parser {
    var parenthesized: Parser<Result> {
        return string("(") *> self <* string(")")
    }
}
if let v = string("SUM").parenthesized.run("(SUM)") { print(v) }
/*輸出:
("SUM", "")
*/

func curry<A, B, C, D>(_ f: @escaping (A, B, C) -> D) -> (A) -> (B) -> (C) -> D {
    return { a in { b in { c in f(a, b, c) } } }
}

extension Expression {
    static var funcationParser: Parser<Expression> {
        let name = ({ String($0) } <^> capitalLetter.many1)
        let argument = curry { infix($0, $1, $2) } <^> referenceParser <*> string(":") <*> referenceParser
        return curry { funcation($0, $1) } <^> name <*> argument.parenthesized
    }
}

if let v = Expression.funcationParser.run("SUM(A1:A2)") { print(v) }
/*輸出:
(Spreadsheet.Expression.funcation("SUM", Spreadsheet.Expression.infix(Spreadsheet.Expression.reference("A", 1), ":", Spreadsheet.Expression.reference("A", 2))), "")
*/

在 functionParser 中,首先定義了一個用於解析函數名的解析器。接下來則爲函數的參數 定義瞭解析器,該參數是一個使用 ":" 作爲運算符的 .infix 表達式,並以另外兩個單元格的引用作爲運算參數。最後按照先解析函數名,再解析圓括號裏函數參數的順序,將上述解析器進行了合併。

定義一個對整數進行運算表達式解析器,不過這個 解析器可以處理多個運算符。先定義一個解析器 calculation,用於解析跟隨在整數之後的 "+" 或 "-" 或 "*" 或 "/" 符號:

let calculation = curry { ($0, $1) } <^> (string("+") <|> string("-") <|> string("*") <|> string("/")) <*> intParser

該解析器的結果是一個包含運算符與整數的多元組。接着可以定義一個能夠零次或多次解析輸入字符串的解析器,像... <^> intParser <*> calculation.many1這樣。需要編寫一個用來合併右側兩個解析器結果的函數。這個函數要接收的參數類型是已知的,它們分別是一個 (從 intParser 返回的) Expresssion,以及一個由解析器 calculation.many1 返回的元素類型爲多元組 (String, Expression) 的數組:

func combineOperands(first: Expression, rest: [(String, Expression)]) -> Expression {
    return rest.reduce(first) { Expression.infix($0, $1.0, $1.1) }
}

上述函數使用了 reduce 將第一個表達式與之後由 .infix 表達式返回的 (operator, expression) 合併。先可以像這樣編寫第一版 infixParser 了:

extension Expression {
    static var infixParser: Parser<Expression> {
        let calculation = curry { ($0, $1) } <^> (string("+") <|> string("-") <|> string("*") <|> string("/")) <*> intParser
        return curry(combineOperands(first:rest:)) <^> intParser <*> calculation.many1
    }
}

if let v = Expression.infixParser.run("10+5-3") { print(v) }
/*輸出:
(Spreadsheet.Expression.infix(Spreadsheet.Expression.infix(Spreadsheet.Expression.int(10), "+", Spreadsheet.Expression.int(5)), "-", Spreadsheet.Expression.int(3)), "")
*/

if let v = Expression.infixParser.run("2*3*4") { print(v) }
/*輸出:
(Spreadsheet.Expression.infix(Spreadsheet.Expression.infix(Spreadsheet.Expression.int(2), "*", Spreadsheet.Expression.int(3)), "*", Spreadsheet.Expression.int(4)), "")
*/

需要再解決運算只能在整數間進行的限制,爲此引入了 primitiveParser,它能夠解析出任意可以被算術運算符進行運算的元素:

func lazy<A>(parser: @autoclosure @escaping () -> Parser<A>) -> Parser<A> {
    return Parser<A> { parser().run($0) }
}

extension Expression {
    static var primitiveParser: Parser<Expression> {
        return return intParser <|> referenceParser <|> funcationParser
    }

    static var parser = lazy(parser: infixParser) <|> lazy(parser: infixParser).parenthesized <|> primitiveParser
}

primitiveParser 使用了選擇解析運算符 <|>,定義中可運算的元素可以是一個整數,一個單元 格引用,或者一個函數調用。

parser 使用了選擇解析運算符 <|>,定義中可運算的元素可以是一個子表達式,是一個被圓括號括起的子表達式,或者一個primitiveParser。這裏最重要的部分是對輔助函數 lazy 的使用。必須確保任意表達式的解析器 parser 僅在必要時才被求值。否則將會陷入無盡的循環之中。爲了實現這個功能,lazy 使用 @autoclosure 將參數封裝在了一個函數中。

現在可以用 primitiveParser 替換 intPaser 來編寫之前的 infixParser :

extension Expression {
    static var infixParser: Parser<Expression> {
        let calculation = curry { ($0, $1) } <^> (string("+") <|> string("-") <|> string("*") <|> string("/")) <*> primitiveParser
        return curry(combineOperands(first:rest:)) <^> primitiveParser <*> calculation.many1
    }
}
/*輸出:
(Spreadsheet.Expression.infix(Spreadsheet.Expression.infix(Spreadsheet.Expression.int(2), "+", Spreadsheet.Expression.int(4)), "*", Spreadsheet.Expression.funcation("SUM", Spreadsheet.Expression.infix(Spreadsheet.Expression.reference("A", 1), ":", Spreadsheet.Expression.reference("A", 2)))), "")
*/

求值

求值階段的目標是將一棵 Expression 樹轉換爲一個實際的結果。首先定義一 個 Result 類型:

enum Result {
    case int(Int)
    case list([Result])
    case error(String)
}

Result 類型共有三個枚舉值:

  • int用於結果爲整數的表達式的求值,比如"2*3"或是"SUM(A1:A3)"(假設在單元格A1 - 至 A3 中的值都是合法的)。
  • list用於返回多個結果的表達式的求值。在示例中,只有像"A1:A3"這樣的表達式會發生這種情況,這些表達式通常爲 "SUM" 或是 "MIN" 這類函數的參數。
  • error表示在求值過程中出現的錯誤,在其關聯值中會儲存錯誤的詳細說明。

對 Expression 進行求值,會在其拓展中添加一個 evaluate 方法,參數 context 是一個數組,其中包含了該電子表格中其他單元格的所有表達式,這是爲了能夠處理像是 A2 這樣的單元格引用。簡單起⻅將該電子表格限制爲只有一列單元格,這樣就可以使用一個簡單的數組來表示所有的表達式了。
接下來只需要對 Expression 中不同的枚舉值依次進行匹配,然後返回對應的 Result 值 就可以了。下面是 evaluate 方法:

extension Expression {
    func evaluate(context: [Expression?]) -> Result {
        switch self {
        case let .int(x):
            return .int(x)
        case let .reference(_, row):
            return context[row]?.evaluate(context: context) ?? Result.error("Invalid reference \(self)")
        case .funcation:
            return evaluateFunction(context: context) ?? .error("Invalid function call \(self)")
        case let .infix(l, op, r):
            return self.evaluateArithmetic(context: context) ?? self.evaluateList(context: context) ?? .error("Invalid operator \(op) for operands \(l), \(r)")
        }
    }
}

第一個匹配條件 .int 很容易處理。.int 的關聯值已經是一個整數值,只需要提取該值然後返回一個 .int 結果值就可以了。

第二個匹配條件 .reference 則稍微複雜一些。考慮到電子表格被限制爲僅有一列,只需要匹配對 A 列單元格的引用,並且將第二個關聯值與變量 row 進行 綁定。在獲得被引用單元格的行座標後,利用 context 參數查詢該單元格的表達式,並且 爲其表達式遞歸地調用 evaluate。在該引用不存在的情況下,將一個 .error 作爲結果值返回。

第三個匹配條件 .function,只是將求值任務轉移給了 Expression 另一個被命名爲 evaluateFunction 的方法。雖然可以將這部分代碼也寫入 evaluate 方法中,但爲了避免單個方法太過冗⻓決定將這個步驟提了出來。evaluateFuction 的代碼如下:

extension Expression {
    func evaluateFunction(context: [Expression?]) -> Result? {
        guard case let Expression.funcation(name, parameter) = self, case let .list(list) = parameter.evaluate(context: context) else {
            return nil
        }
        
        switch name {
        case "SUM":
            return list.reduce(.int(0), lift(+))
        case "MIN":
            return list.reduce(.int(Int.max), lift { min($0, $1) })
        default:
            return .error("Unknown function \(name)")
        }
    }
}

guard 語句會先檢查該方法是否確實被 .function 枚舉值調用,然後確定表達式中函數的參數求值後的結果是否爲一個 .list。如果不滿足以上某個條件,會直接返回 nil。
剩下的 switch 語句並不複雜:爲每個函數名實現一個匹配條件,然後利用 reduce 對從 parameter 中求得的列表進行相應的計算就可以了。
這裏要提到一個重要的細節,list 是一個元素類型爲 Result 的數組。因此不能直接使用標準的算術運算符 (比如 "+") 或是函數 (比如 min)。爲此定義一個函數 lift,用於將任 意類型爲 (Int, Int) -> Int 的函數轉換爲一個類型爲 (Result, Result) -> Result 的函數:

func lift(_ op: @escaping (Int, Int) -> Int) -> (Result, Result) -> Result {
    return { lhs, rhs in
        guard case let (Result.int(x), Result.int(y)) = (lhs, rhs) else {
            return .error("Invalid operands \(lhs), \(rhs) for integer operator")
        }
        
        return .int(op(x, y))
    }
}

在這裏必須先確保處理的確實是兩個 Result.int 值,否則會返回一個 .error 作爲結果。一旦獲取了兩個整數,就可以由參數傳入的運算函數 op 來計算結果,並將其再封裝回一個 Result.int 值。

evaluate 方法要處理的最後一個條件是枚舉值 .infix。將對 .infix 表達式的求值代碼拆分到了 Expression 的兩個拓展中。第一個是 evaluateArithmetic。這個方法嘗試在當 .infix 表達式是一個算術表達式時對其進行求值,如果求值失敗則會返回 nil:

extension Expression {
    func evaluateArithmetic(context: [Expression?]) -> Result? {
        guard case let .infix(l, op, r) = self else {
            return nil
        }
        
        let x = l.evaluate(context: context)
        let y = r.evaluate(context: context)
        switch op {
        case "+":
            return lift(+)(x, y)
        case "-":
            return lift(-)(x, y)
        case "*":
            return lift(*)(x, y)
        case "/":
            return lift(/)(x, y)
        default:
            return nil
        }
    }
}

由於這個方法可以被任意的 Expression 調用,需要先檢查是否真的在處理一個 .infix 表達式。同樣使用 guard 語句來直接獲取關聯值:左側的表達式,運算符,以及右側的表達式。
接着會爲左右兩側的表達式調用 evaluate,然後通過 switch 語句匹配運算符來計算結果。這裏又一次使用了 lift 函數,使像是兩個 Result 值相加這樣的運算變得可行。這裏要注意不必再考慮 x 或 y 可能不是 .int 的情況,lift 函數會搞定的。

Expression 中第二個用於計算 .infix 表達式的方法用於處理列表運算符,比如 "A1:A3":

extension Expression {
    func evaluateList(context: [Expression?]) -> Result? {
        guard case let .infix(l, op, r) = self, op == ":", case let .reference(_, row1) = l, case let .reference(_, row2) = r else {
            return nil
        }
        
        return .list((row1...row2).map{ Expression.reference("A", $0).evaluate(context: context) })
    }
}

再一次先檢查 evvaluateList 是否被適當的 Expression 調用。在 guard 語句中,檢查了是否在處理一個 .infix 表達式,其中的運算符是否是一個 ":",以及左右兩個表達式是不是引用 "A" 列中某個單元格的 .reference。如果不匹配其中任何一個條件,會直接返回 nil。
如果所有的檢查都通過,會對從 row1 開始,到包括 row2 在內的所有序列值進行映射,對這些單元格中的表達式依次進行求值,再將映射得到的結果封裝在 .list 中作爲結果返回。
以上就是需要在 Expression 的 evaluate 方法中實現的全部內容了。考慮到總是希望在對某個單元格求值時先求得 context 中另一個單元格的值 (需要能解決對單元格的引用), 添加一個額外的便利方法來對一個表達式數組進行求值:

func evaluate(expressions: [Expression?]) -> [Result] {
    return expressions.map{ $0?.evaluate(context: expressions) ?? .error("Invalid expression \($0)") }
}

現在可以定義一個示例表達式的數組並嘗試對它們進行求值:

let expressions: [Expression] = [
    .infix(.infix(.int(1), "+", .int(2)), "*", .int(3)),
    .infix(.reference("A", 0), "*", .int(3)),
    .funcation("SUM", .infix(.reference("A", 0), ":", .reference("A", 1)))
]
print(evaluate(expressions: expressions))
/*輸出:
[Spreadsheet.Result.int(9), Spreadsheet.Result.int(27), Spreadsheet.Result.int(36)]
*/

用戶界面

構建了上圖中用於嵌入解析與求值代碼的用戶界面。然而這個應用還有諸多限制,代碼也還有很大的優化空間。比如一個單元格不能使用混合運算;不必因爲用戶修改了單個單元格的內容就對所有的單元格進行解析和求值;如果 10 個單元格同時引用了一 個單元格,那麼這個單元格會被求值 10 次……

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章