Here1 (credits to Ben Lynn2) is a simple calculator in Haskell.
I decided to try porting it over3 to Scala. The original example also makes this work in Javascript using Haste
; I’ve left this out for now (though I might come back and get this to work under Scala.js
later).
The underlying basis is the same, using the awesome fastparse
library to stand-in for Parsec
/Megaparsec
from Haskell, and the code feels quite concise and readable to me4.
This is the entirety of the parsing code we need:
def numParser[_: P] = P( ("-".? ~ CharIn("0-9").rep ~ ".".? ~ CharIn("0-9").rep).!.map(_.toDouble) )
def parenParser[_: P]: P[Double] = P( "(" ~/ exprParser ~ ")" ) // needs explicit type because it's recursive.
def factorParser[_: P] = P( parenParser | numParser )
def termParser[_: P] = P( factorParser ~ (CharIn("*/").! ~/ factorParser).rep ).map(eval)
def exprParser[_: P] = P( termParser ~ (CharIn("+\\-").! ~/ termParser).rep ).map(eval)
… along with a tiny bit of evaluation:
def eval(ast: (Double, Seq[(String, Double)])): Double = {
val (base, ops) = ast
ops.foldLeft(base) {
case (n1, (op, n2)) =>
op match {
case "*" => n1 * n2
case "/" => n1 / n2
case "+" => n1 + n2
case "-" => n1 - n2
}
}
}
That’s it.
That’s all you need to take in a String
and convert it to a Double
.
What Ben says about the evolution of parsing-as-it-used is true: it was completely normal to resort to lex/yacc and then flex/bison for these earlier, but Parser Combinators and PEGs take it to a whole other level, and once you’ve become comfortable with them, there’s no going back to the verbose old days.
Just to break down the parsing code a bit:
This parses a number: def numParser[_: P] = P( ("-".? ~ CharIn("0-9").rep ~ ".".? ~ CharIn("0-9").rep).!.map(_.toDouble) )
. Or rather, it returns a function that can parse a number.
It’s possible to play5 with this within a repl; I use sbt console
6 when something depends on a class/object within a project, but this is an independent enough chunk that you could use ammonite
7 instead too.
agam-agam@ import fastparse._
import fastparse._
agam-agam@ import NoWhitespace._
import NoWhitespace._
agam-agam@ def numParser[_: P] = P( ("-".? ~ CharIn("0-9").rep ~ ".".? ~ CharIn("0-9").rep).!.map(_.toDouble) )
defined function numParser
agam-agam@ parse("45", numParser(_))
res263: Parsed[Double] = Success(45.0, 2)
agam-agam@ parse("4.5", numParser(_))
res264: Parsed[Double] = Success(4.5, 3)
Where it gets interesting is composing these parsing functions.
Given a function to parse a factor
(a number, or sub-expression within parentheses), we can define a function to parse combinations of multiplying/dividing these as:
def termParser[_: P] = P( factorParser ~ (CharIn("*/").! ~/ factorParser).rep ).map(eval)
… which reads very “regex-like”, imo!
Anyway, I added a basic I/o driver around this, so it can be “run” as:
➜ sbt run
[info] Loading settings for project simplecalc-build from metals.sbt ...
[info] Loading project definition from /home/agam/code/simplecalc/project
[info] Loading settings for project simplecalc from build.sbt ...
[info] Set current project to hello-world (in build file:/home/agam/code/simplecalc/)
[info] Compiling 1 Scala source to /home/agam/code/simplecalc/target/scala-2.13/classes ...
[warn] there was one deprecation warning (since 2.11.0); re-run with -deprecation for details
[warn] one warning found
[info] running Main
Simple calculator
Enter an expression, or 'END' to quit: 5 6
Result: 11.0
Enter an expression, or 'END' to quit: 2 - 5.2 * 1.5
Result: -5.800000000000001
Enter an expression, or 'END' to quit: 1.8 2.3 / 4.5
Result: 2.311111111111111
Enter an expression, or 'END' to quit: 42 1 - 5
Result: 38.0
Enter an expression, or 'END' to quit: END
[success] Total time: 55 s, completed Aug 6, 2020, 11:36:47 PM
(Yeah, the weird decimal point behavior is just Double
being a double; that is beyond the scope of this example 😐)
- ”Parser Combinators” ↩︎
- Indeed, Ben has a bunch of other awesome stuff like this, and I might just go Scala-ize more of them 🙂 ↩︎
- Repo (with this core code, as well as a
Main
driver and tests) is here ↩︎ - My subjective opinion is that Scala is a sweet spot between ↩︎
- I’ve taken a “shortcut” with my
map(_.toDouble)
bit there, so it doesn’t fail properly right now and you getjava.lang.NumberFormatException: empty String
, but normally you’d get aParsed.Failure
back ↩︎ - Console ↩︎
- Ammonite ↩︎