This is the first of a series of blog posts targeted toward people who may be new to Haskell. Some knowledge of programming in Haskell is assumed.
Writing a simple calculator app can be a great way to introduce yourself to a new programming language. In the case of Haskell, well, it's basically made to do stuff like that. After all, a calculator is essentially just a pure function: input a string, output a number. Along the way, we recursively build up an abstract syntax tree, perform transformations on it, and finally, we fold the abstract syntax tree into a single result.
Additionally, writing a calculator will be a good introduction to a few essential Haskell libraries such as Megaparsec, optparse-applicative, and more.
Make sure to have the following in your project dependencies:
- text
- megaparsec
- parser-combinators
- optparse-applicative
- mtl
Before we can begin parsing our input, we must have some easy-to-use internal representation for it. Our representation is going to be in the form of a tree.
-- src/Expr.hs
module Expr (Expr, Oper) where
import Data.Text (Text)
data Oper
= Add | Sub
| Mul | Div
| Pow | Fac
deriving (Show)
data Expr
= ELit Double
| EVar Text
| EBinOp Oper Expr Expr
| EUnaOp Oper Expr
deriving (Show)
(Note: it's usually recommended to use Data.Text
instead of String
in Haskell, as it's much more efficient.)
Our basic calculator will support 6 operations (+
, -
, *
, /
, ^
, and !
).
As an example, (5 * 4)! + pi * 3^2
will be represented in our AST like this:
EBinOp Add
EUnaOp Fac
EBinOp Mul
ELit 5.0
ELit 4.0
EBinOp Mul
EVar "pi"
EBinOp Pow
ELit 3.0
ELit 2.0
We can now begin parsing the actual user input as we have our abstract syntax tree ready. For this, we will be using the Megaparsec library. Megaparsec is a monadic parser combinator library - check out this blog post to understand what parsing is, what parser combinators are, and how to write your own parser combinators.
To get started, we should first define our Parser
type. Parsec
is a type defined in Text.Megaparsec
. The Void
indicates we aren't supplying a custom error component, and Text
is our input stream.
-- src/Parser.hs
module Parser where
import Data.Char
import Data.Text (Text, singleton)
import Data.Void (Void)
import qualified Text.Megaparsec as P
import qualified Text.Megaparsec.Char as P
import qualified Text.Megaparsec.Char.Lexer as L
import Control.Monad.Combinators.Expr
import Expr
type Parser = P.Parsec Void Text
Now we should define some helper functions. Since we are ignoring whitespace, we'll need something to take care of whitespace. Megaparsec also conveniently provides a lexeme
combinator that takes in a whitespace parser, and that allows us to parse any future tokens without considering whitespace.
We will also need to be able to parse specific arbitrary text/symbols with symbol
, variables with identifier
, and of course float literals with float
.
Another useful combinator we will add is parens
, which will parse something enclosed in parentheses.
-- src/Parser.hs ... -- The first argument to L.space is a single whitespace parser. -- The last two are for comments, which our syntax doesn't support. spaces :: Parser () spaces = L.space P.space1 P.void P.void lexeme :: Parser a -> Parser a lexeme = L.lexeme spaces symbol :: Text -> Parser Text symbol = L.symbol spaces float :: Parser Double float = lexeme L.float -- To prevent ambiguity, identifiers must start with a letter - but can -- contain any alphanumeric characters afterward. -- Also, using combinators such as `takeWhileP` is much more efficient -- than using something like `many` or `some`. identifier :: Parser Text identifier = lexeme $ mappend <$> (singleton <$> P.letterChar) <*> P.takeWhileP (Just "alphanumeric character") isAlphaNum parens :: Parser a -> Parser a parens = P.between (lexeme (P.char '(')) (lexeme (P.char ')'))
Finally, with these helper functions, we can begin to write the main parser. Essentially it's just going to be a few mutually recursive functions. Megaparsec (well, actually the parser-combinators
library) will make all of it extremely easy with makeExprParser
, which handles parsing operators of all precedences and fixities. All we need to do is pass in an operator table.
-- src/Parser.hs ... parseExpression :: Parser Expr parseExpression = makeExprParser parseTerm operTable <?> "expression" where operTable = [ [ postfix Fac ] , [ prefix Sub , prefix Add ] , [ binary InfixR Pow ] , [ binary InfixL Mul , binary InfixL Div ] , [ binary InfixL Add , binary InfixL Sub ] ] binary assoc oper = assoc (EBinOp oper <$ operParser oper) prefix oper = Prefix (EUnaOp oper <$ operParser oper) postfix oper = Postfix (EUnaOp oper <$ operParser oper) operParser = symbol . operSymbol operSymbol oper = case oper of Add -> "+" Sub -> "-" Mul -> "*" Div -> "/" Pow -> "^" Fac -> "!" -- Using the LambdaCase language extension -- can make that case expression look nicer
Whew. That was quite a bit of code for a single function. Let's dissect it.
makeExprParser
requires two arguments. The first is going to be a parser for terms (essentially the operands). The second argument is a table of operators. Specifically, a 2-dimensional list. Each inner list specifies a level of operator precedence. For example, the Fac
operator has the highest precedence. Each element of the inner list must be an operator definition: InfixL
, InfixR
, Prefix
, and Postfix
. We defined some helper functions to prevent lots of repeated code. As for what each of those operator definitions are, I highly recommend reading the docs.
Moving on, we need to define parseTerm
now. Terms are just going to be either a nested expression in parentheses, a variable, or a float literal. We can use the <|>
operator from Megaparsec to allow choice.
-- src/Parser.hs ... parseTerm :: Parser Expr parseTerm = parens parseExpression <|> (EVar <$> identifier) <|> (ELit <$> float)
Should be mostly straightforward.
To finish up the Parser
module, we should add a function to allow us to run our parser for some input. Since the parser may fail, it should evaluate into an Either String Expr
. Errors are just going to be simple String
messages.
-- src/Parser.hs module Parser (parse) where import Data.Bifunctor (first) ... parse :: Text -> Either String Expr parse = first P.errorBundlePretty . P.parse parseExpression "input"
The P.parse
function from Megaparsec takes in a string for the name of the input file for error reporting purposes. Since we don't care about input files, we can just put in some arbitrary string.
first
from Data.Bifunctor
maps over Left
(P.parse
returns a ParseErrorBundle
in Left
, but we want a String
, so we use errorBundlePretty
which is also provided by Megaparsec).
Our parser is completed! We've done quite a lot already in such few lines of code.
All that's left for us to do is implement the actual evaluator. It's going to recursively traverse through the parsed abstract syntax tree, and end up returning a single Double
- the answer.
Since our evaluator may need to report errors, we should use something like the Except
monad (from the mtl
library). For convenience, we should also define a type synonym.
-- src/Eval.hs
module Eval where
import Control.Monad.Except
import Expr
type Eval = Except String
Now we need to write our actual recursive expression evaluator function. It will take in an Expr
and return an Eval Double
.
-- src/Eval.hs ... evaluateExpr :: Expr -> Eval Double evaluateExpr (ELit num) = pure num evaluateExpr (EVar var) = case var of "pi" -> pure pi "e" -> pure (exp 1) _ -> throwError ("Unknown variable " ++ show var) evaluateExpr (EBinOp op a b) = do a' <- evaluateExpr a b' <- evaluateExpr b case op of Add -> pure (a' + b') Sub -> pure (a' - b') Mul -> pure (a' * b') Div -> pure (a' / b') Pow -> pure (a' ** b') _ -> throwError ("Invalid binary operator " ++ show op) evaluateExpr (EUnaOp op a) = do a' <- evaluateExpr a case op of Sub -> pure (negate a') Add -> pure a' Fac -> return (fromInteger (product [1 .. (floor a')])) _ -> throwError ("Invalid unary operator " ++ show op)
Now of course we need to define a function evaluate
to allow us to run the evaluator.
-- src/Eval.hs module Eval (evaluate) where ... evaluate :: Expr -> Either String Double evaluate = runExcept . evaluateExpr
We're so close! We still have to add one last - but essential - thing: the command line interface. We will use optparse-applicative
to do this.
-- app/Main.hs module Main where import Data.Text (Text, pack) import Options.Applicative import Parser import Eval -- Note: `Parser` is from optparse-applicative, not our `Parser` module parseInput :: Parser String parseInput = argument (pack <$> str) (metavar "INPUT") main :: IO () main = runCalculator =<< execParser (parseInput `withInfo` "Calculator") where withInfo opts desc = info (helper <*> opts) (progDesc desc) runCalculator :: Text -> IO () runCalculator inp = case parse inp >>= evaluate of Left err -> putStrLn err Right res -> print res
To understand the code above, I can only really recommend reading the optparse-applicative
docs. There's not much pure Haskell code to get into there.
We're finished! In only around 115 lines of Haskell code, we have a working calculator. Let's test it.
stack run -- "1+2"
3.0
stack run -- "-(16^0.5)"
-4.0
stack run -- "(3 * 2)! + pi * 3^2"
748.27433388230813914616379044952
Looks like it's working as expected!
Great question. There are tons of ways you could improve this simple calculator app. Here are some ideas:
sqrt
, exp
, log
, ...)let x = <expr> in <expr>
)
If you have any questions, please contact me or ask in the comments.