[Swift] 数式を評価する lexer/parser を作る (+1.0 + +1.0 を計算する) (3)

Swift

ここまでにつくってきた lexer/parser を拡張して、+1.0 を理解できるようにしつつ + 以外の演算子も使えるようにしてみます

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterery beta 5
  • Xcode 13 beta5

前回までにやったこと

"1 + 1" を評価するところから始まり、小数点と groupSeparator(,) を理解できるようになり、"1.0 + 1.0" が評価できるようになりました。

Swift[Swift] 数式を評価する lexer/parser を作る (1.0 + 1.0 を計算する) (2)

+1.0 + +1.0

前回対応した 1.0 + 1.0 と何が違うかというと "+" がついていることです。

人間の目には、大したことない違いですが、コンピュータからすると、この + は、演算子なのか? 符号なのか? 判断が必要となります。

符号としては、+ 以外にも - も考えられます。

演算子としては、さらに多くの要素が考えられますが、ここでは、+ - * / の4つに制限して考えてみます。いわゆる四則演算です。

符号への対応

どうやって Token を判別するかは難しいところなのですが、ここでは、出現位置による仮定で処理してしまうことにします。

つまり、式 = <第1項><演算子><第2項> という構造になっていると想定していきます。

このような想定に立てば、項に登場する文字として、前回は使った文字列である 数値+",." に "+-" を入れれば良いことになります。

そして、Double がその文字列を 数値に変換できなければ、項として不正であったとして扱いにします。

+1.0 を理解できるかのテスト

最初に "+1.0" が理解できるかを確認するテストを作成しておきます。


    func test_PlusOneZeroPlusPlusOneZero() throws {
        let sut = BruteForceLexer("+1.0 + +1.0")
        let tokens = sut.lex()
        XCTAssertEqual(tokens.count, 3)
        let sutParser = MathExpressionParser(tokens)
        let expression = try XCTUnwrap(sutParser.parse())
        XCTAssertEqual(expression.calc(), 2)
    }

正しく Token に分解されれば、3つの Token になり、Token が正しく生成されれば + の計算は既にできるはずなのでうまくいくはずです。

前回までのコードを使って実行してみると、5つの Token が見つかり、その後計算できずにエラーとなります。

まだ対応するコードを実装していないので、予想通りの結果です。

項を表現する要素としての + と -

前回、CharacterSet の extension として、numericCharacters を定義しましたが、その定義に 追加することにします。


extension CharacterSet {
    static var numericCharacters: CharacterSet {
        var numeric = CharacterSet.decimalDigits
        numeric.insert(charactersIn: "+-.,")  // +- を追加
        return numeric
    }
}

上記を追加してから、テストをあらためて実行すると、テストをパスすることがわかります。

+2.0 + -1.0 も確認

なんとなく気になったので、"+2.0 + -1.0" の計算ができるかもテストを追加してみました。


    func test_PlusTwoZeroPlusMinusOneZero() throws {
        let sut = BruteForceLexer("+2.0 + -1.0")
        let tokens = sut.lex()
        XCTAssertEqual(tokens.count, 3)
        let sutParser = MathExpressionParser(tokens)
        let expression = try XCTUnwrap(sutParser.parse())
        XCTAssertEqual(expression.calc(), 1)
    }

2 - 1 = 1 なので、結果は1になるはずです。動作させてみると パスします。(1以外の数値も対応していますので、想定通りです)

四則演算への対応

四則演算のテスト

例によって テストコードを作ってから実装していきます。以下は、減算、乗算、除算のテストです。


    func test_PlusTwoZeroMinusMinusOneZero() throws {
        let sut = BruteForceLexer("+2.0 - -1.0")
        let tokens = sut.lex()
        XCTAssertEqual(tokens.count, 3)
        let sutParser = MathExpressionParser(tokens)
        let expression = try XCTUnwrap(sutParser.parse())
        XCTAssertEqual(expression.calc(), 3)
    }

    func test_PlusTwoZeroTimesMinusThreeZero() throws {
        let sut = BruteForceLexer("+2.0 * -3.0")
        let tokens = sut.lex()
        XCTAssertEqual(tokens.count, 3)
        let sutParser = MathExpressionParser(tokens)
        let expression = try XCTUnwrap(sutParser.parse())
        XCTAssertEqual(expression.calc(), -6)
    }

    func test_PlusSixZeroDivideMinusThreeZero() throws {
        let sut = BruteForceLexer("+6.0 / -3.0")
        let tokens = sut.lex()
        XCTAssertEqual(tokens.count, 3)
        let sutParser = MathExpressionParser(tokens)
        let expression = try XCTUnwrap(sutParser.parse())
        XCTAssertEqual(expression.calc(), -2)
    }

動作させてみると、+ 以外の演算子を知らないので、エラーとなります。(想定通りです)

四則演算対応 @ lexer

lexer での四則演算への対応は簡単です。

CharacterSet の extension で定義していた operatorCharacters に、四則演算の文字を追加すれば完了です。


extension CharacterSet {
    static var operatorCharacters: CharacterSet {
        var ope = CharacterSet()
        ope.insert(charactersIn: "+-*/")
        return ope
    }
}

lex 関数も以下のように変更して、演算子として読み込んだ文字を Token として記録するようにします。


class BruteForceLexer {
    let str: String
    let numericCharacterSet = CharacterSet.numericCharacters
    let groupingSeparator = Locale.current.groupingSeparator ?? ""
    
    init(_ str: String) {
        self.str = str
    }
    
    func lex() -> [Token]{
        var foundTokens: [Token] = []
        let scanner = Scanner(string: self.str)
        scanner.charactersToBeSkipped = CharacterSet.whitespaces // without newline
        
        while !scanner.isAtEnd {
            // read 1st token
            if let numericString = scanner.scanCharacters(from: CharacterSet.numericCharacters),
               let value = Double(numericString.filter({ !(groupingSeparator.contains($0)) })) {
                foundTokens.append(Token.Numeric(value))
            }
            // read 2nd token
            if let ope = scanner.scanCharacters(from: CharacterSet.operatorCharacters) {
                guard ope.count == 1 else { fatalError("too much operation characters") }
                if let opeString = ope.first {
                    foundTokens.append(Token.Operator(String(opeString))) 
                }
            }
            // read 3rd token
            if let numericString = scanner.scanCharacters(from: CharacterSet.numericCharacters),
               let value = Double(numericString.filter({ !(groupingSeparator.contains($0)) })) {
                foundTokens.append(Token.Numeric(value))
            }
            break
        }
        return foundTokens
    }
}

四則演算対応 @ parser

parser 側は、演算子の種類を確認して、適切な計算をしないといけません。


struct Expression {
    let lhs: Double //Term.Value
    let ope: String
    let rhs: Double //Term.Value
    
    init(_ lhs: Double,_ symbol: String, _ rhs: Double) {
        self.lhs = lhs
        self.ope = symbol
        self.rhs = rhs
    }
    
    func calc() -> Double {
        if ope == "+" {
            return lhs + rhs
        } else if ope == "-" {
            return lhs - rhs
        } else if ope == "*" {
            return lhs * rhs
        } else if ope == "/" {
            return lhs / rhs
        }
        fatalError("Unknown operator")
    }
}

上記のコードで、テストをパスすることが確認できるようになります。

ここまでの実装では、<第1項><演算子><第2項>という表現しか理解できません。

次回以降で、より多くの項を処理できるようにしつつ、演算子の優先順位の実装を考えていきます。

できたこと:数式を評価する lexer/parser を作る(3)

数式を評価する lexer/parser を作る(3)
  • 項に符号がついているケースへの対応を実装した
  • 四則演算は、演算子を記録しておき、計算時に処理を分岐させた
  • +1.0 * -2.3 というような数式も計算できるようになった

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です