Build A Calculator Using Python
Building a calculator seems like a beginner's exercise until you try to make one that handles real-world input gracefully. I've seen production parsers fail...
Listen to Article
PlayingClick play to listen to audio narration
Table of Contents
Introduction
Building a calculator seems like a beginner’s exercise until you try to make one that handles real-world input gracefully. I’ve seen production parsers fail on expressions like --5 + 3 or silently corrupt financial calculations due to floating-point quirks. This isn’t just about parsing numbers—it’s about building a reliable expression evaluator that won’t embarrass you when someone pastes in a malformed formula from a spreadsheet.
We’ll construct a recursive descent parser from scratch, no eval() shortcuts, no external libraries. Just pure Python with proper tokenization, AST construction, and evaluation.
Why This Matters
Every engineer encounters expression parsing somewhere in their career. Configuration files with formulas, DSL interpreters, query engines, financial calculators—all rely on the same core principles. Understanding how to build a robust calculator teaches you:
- Lexical analysis and grammar design
- Error handling in parsers
- Operator precedence and associativity
- Memory-efficient evaluation strategies
More importantly, it reveals how seemingly simple features can hide complex edge cases that cause production outages.
How It Works
The architecture follows a classic compiler pipeline: input string → tokens → abstract syntax tree (AST) → evaluated result.
flowchart TD
A[Input String] --> B[Tokenizer]
B --> C[Token Stream]
C --> D[Recursive Descent Parser]
D --> E[Abstract Syntax Tree]
E --> F[Evaluator]
F --> G[Result]
H[Error Handler] --> B
H --> D
H --> F
Here’s the breakdown:
- Tokenizer: Scans the input character by character, grouping them into meaningful tokens (
NUMBER,PLUS,MINUS, etc.) - Parser: Consumes the token stream according to grammar rules, building a tree structure that represents the mathematical expression
- AST: Hierarchical representation where operators become internal nodes and operands become leaves
- Evaluator: Traverses the AST recursively, computing values bottom-up
This separation of concerns makes each component testable and replaceable. You could swap the evaluator for a symbolic math engine without touching the parser.
Core Concepts
Tokens: The smallest units of meaning. Our tokenizer recognizes numbers, operators (+, -, *, /, ^), parentheses, and whitespace.
Grammar Rules: Define valid expression structures. We use Extended Backus-Naur Form (EBNF):
expression = term (("+" | "-") term)*
term = factor (("*" | "/") factor)*
factor = unary ("^" factor)?
unary = ("+" | "-") unary | primary
primary = NUMBER | "(" expression ")"
Recursive Descent: Each grammar rule becomes a function that consumes matching tokens. Functions call each other recursively to handle nested expressions.
Operator Precedence: Enforced by grammar hierarchy. Terms bind tighter than expressions, factors tighter than terms, ensuring 2 + 3 * 4 evaluates correctly.
Examples & Code Walkthrough
Let’s implement this step by step:
import re
from enum import Enum
from dataclasses import dataclass
from typing import List, Union, Optional
class TokenType(Enum):
NUMBER = "NUMBER"
PLUS = "PLUS"
MINUS = "MINUS"
MULTIPLY = "MULTIPLY"
DIVIDE = "DIVIDE"
POWER = "POWER"
LPAREN = "LPAREN"
RPAREN = "RPAREN"
EOF = "EOF"
@dataclass
class Token:
type: TokenType
value: Union[float, str]
position: int
class TokenizerError(Exception):
pass
class ParserError(Exception):
pass
class EvaluationError(Exception):
pass
class Tokenizer:
"""Converts raw string input into a stream of tokens."""
TOKEN_PATTERNS = [
(TokenType.NUMBER, r'\d+\.?\d*([eE][+-]?\d+)?'),
(TokenType.PLUS, r'\+'),
(TokenType.MINUS, r'-'),
(TokenType.MULTIPLY, r'\*'),
(TokenType.DIVIDE, r'/'),
(TokenType.POWER, r'\^'),
(TokenType.LPAREN, r'\('),
(TokenType.RPAREN, r'\)'),
(TokenType.EOF, r'$'),
]
def __init__(self, text: str):
self.text = text
self.pos = 0
self.tokens: List[Token] = []
def tokenize(self) -> List[Token]:
while self.pos < len(self.text):
# Skip whitespace
if self.text[self.pos].isspace():
self.pos += 1
continue
matched = False
for token_type, pattern in self.TOKEN_PATTERNS[:-1]: # Exclude EOF
regex = re.compile(pattern)
match = regex.match(self.text, self.pos)
if match:
value = match.group()
if token_type == TokenType.NUMBER:
try:
value = float(value)
except ValueError:
raise TokenizerError(f"Invalid number at position {self.pos}")
self.tokens.append(Token(token_type, value, self.pos))
self.pos = match.end()
matched = True
break
if not matched:
raise TokenizerError(f"Unexpected character '{self.text[self.pos]}' at position {self.pos}")
# Add EOF token
self.tokens.append(Token(TokenType.EOF, '', self.pos))
return self.tokens
# AST Node definitions
class ASTNode:
pass
@dataclass
class NumberNode(ASTNode):
value: float
@dataclass
class BinaryOpNode(ASTNode):
left: ASTNode
op: TokenType
right: ASTNode
@dataclass
class UnaryOpNode(ASTNode):
op: TokenType
operand: ASTNode
class Parser:
"""Builds an AST from a token stream using recursive descent."""
def __init__(self, tokens: List[Token]):
self.tokens = tokens
self.pos = 0
def current_token(self) -> Token:
return self.tokens[self.pos]
def consume(self, expected_type: TokenType) -> Token:
token = self.current_token()
if token.type != expected_type:
raise ParserError(f"Expected {expected_type.value}, got {token.type.value} at position {token.position}")
self.pos += 1
return token
def parse(self) -> ASTNode:
node = self.parse_expression()
if self.current_token().type != TokenType.EOF:
raise ParserError(f"Unexpected token {self.current_token().type.value}")
return node
def parse_expression(self) -> ASTNode:
"""expression = term (("+" | "-") term)*"""
node = self.parse_term()
while self.current_token().type in (TokenType.PLUS, TokenType.MINUS):
op_token = self.consume(self.current_token().type)
right = self.parse_term()
node = BinaryOpNode(left=node, op=op_token.type, right=right)
return node
def parse_term(self) -> ASTNode:
"""term = factor (("*" | "/") factor)*"""
node = self.parse_factor()
while self.current_token().type in (TokenType.MULTIPLY, TokenType.DIVIDE):
op_token = self.consume(self.current_token().type)
right = self.parse_factor()
node = BinaryOpNode(left=node, op=op_token.type, right=right)
return node
def parse_factor(self) -> ASTNode:
"""factor = unary ("^" factor)?"""
node = self.parse_unary()
if self.current_token().type == TokenType.POWER:
op_token = self.consume(TokenType.POWER)
right = self.parse_factor() # Right associative
node = BinaryOpNode(left=node, op=op_token.type, right=right)
return node
def parse_unary(self) -> ASTNode:
"""unary = ("+" | "-") unary | primary"""
if self.current_token().type in (TokenType.PLUS, TokenType.MINUS):
op_token = self.consume(self.current_token().type)
operand = self.parse_unary()
return UnaryOpNode(op=op_token.type, operand=operand)
return self.parse_primary()
def parse_primary(self) -> ASTNode:
"""primary = NUMBER | "(" expression ")" """
token = self.current_token()
if token.type == TokenType.NUMBER:
self.consume(TokenType.NUMBER)
return NumberNode(value=token.value)
if token.type == TokenType.LPAREN:
self.consume(TokenType.LPAREN)
node = self.parse_expression()
self.consume(TokenType.RPAREN)
return node
raise ParserError(f"Expected number or '(', got {token.type.value} at position {token.position}")
class Evaluator:
"""Evaluates an AST to produce a numerical result."""
@staticmethod
def evaluate(node: ASTNode) -> float:
if isinstance(node, NumberNode):
return node.value
if isinstance(node, BinaryOpNode):
left_val = Evaluator.evaluate(node.left)
right_val = Evaluator.evaluate(node.right)
if node.op == TokenType.PLUS:
return left_val + right_val
elif node.op == TokenType.MINUS:
return left_val - right_val
elif node.op == TokenType.MULTIPLY:
return left_val * right_val
elif node.op == TokenType.DIVIDE:
if right_val == 0:
raise EvaluationError("Division by zero")
return left_val / right_val
elif node.op == TokenType.POWER:
return left_val ** right_val
if isinstance(node, UnaryOpNode):
operand_val = Evaluator.evaluate(node.operand)
if node.op == TokenType.PLUS:
return +operand_val
elif node.op == TokenType.MINUS:
return -operand_val
raise EvaluationError(f"Unknown node type: {type(node)}")
class Calculator:
"""Main calculator interface combining all components."""
def __init__(self):
self.last_result: Optional[float] = None
def calculate(self, expression: str) -> float:
try:
# Handle variable substitution
if 'ans' in expression.lower():
if self.last_result is None:
raise EvaluationError("No previous result available")
expression = expression.replace('ans', str(self.last_result))
tokenizer = Tokenizer(expression)
tokens = tokenizer.tokenize()
parser = Parser(tokens)
ast = parser.parse()
result = Evaluator.evaluate(ast)
self.last_result = result
return result
except (TokenizerError, ParserError, EvaluationError) as e:
raise type(e)(f"Calculation failed: {str(e)}")
# Usage example
if __name__ == "__main__":
calc = Calculator()
test_cases = [
"2 + 3 * 4", # Should be 14
"(2 + 3) * 4", # Should be 20
"2 ^ 3 ^ 2", # Should be 512 (right associative)
"-5 + 3", # Should be -2
"10 / 0", # Should raise error
"2 * ans", # Uses previous result
]
for expr in test_cases:
try:
result = calc.calculate(expr)
print(f"{expr} = {result}")
except Exception as e:
print(f"{expr} -> ERROR: {e}")
This implementation handles operator precedence correctly, supports unary operations, and includes basic error handling. The modular design allows for easy extension—for instance, adding trigonometric functions would only require extending the tokenizer and evaluator.
Best Practices
-
Separate concerns rigorously: Keep tokenization, parsing, and evaluation in distinct classes. This makes debugging much easier when something goes wrong.
-
Fail fast with descriptive errors: Include position information in error messages. “Unexpected token at position 15” is infinitely more helpful than “Parse error.”
-
Test edge cases systematically: Write tests for empty strings, unmatched parentheses, division by zero, very large numbers, and floating-point precision issues.
-
Consider memory usage for long expressions: For production use, implement iterative evaluation instead of recursion to avoid stack overflow on deeply nested expressions.
-
Validate input early: Reject obviously malformed expressions before expensive parsing begins.
Common Mistakes & Anti-Patterns
-
Using
eval()for arithmetic: While tempting,eval()executes arbitrary code and opens massive security vulnerabilities. Never trust user input witheval(). -
Ignoring operator associativity: Power operations are right-associative (
2^3^2 = 2^(3^2) = 512, not(2^3)^2 = 64). Getting this wrong produces silently incorrect results. -
Poor error recovery: When a parse error occurs mid-expression, don’t just crash. Provide context about what went wrong and where.
-
Mixing responsibilities: Don’t combine tokenization and parsing logic. This leads to unmaintainable spaghetti code when you need to add new features.
-
Not handling floating-point precision: Operations like
0.1 + 0.2don’t equal exactly0.3in IEEE 754. Consider using thedecimalmodule for financial calculations.
Performance Considerations
Time complexity is O(n) for tokenization and O(n) for parsing, where n is the length of the input string. Evaluation is O(d) where d is the depth of the AST, typically proportional to the number of operators.
Space complexity is O(n) for storing tokens and O(d) for the call stack during parsing and evaluation. For very long expressions (>10,000 operators), consider switching to an iterative parser to avoid Python’s recursion limit.
For high-throughput scenarios, pre-compile frequently used expressions into ASTs and cache them. This eliminates repeated parsing overhead.
Memory allocation patterns are generally predictable, but Python’s object model creates significant overhead compared to compiled languages. If performance is critical, consider implementing the core logic in Cython or using NumPy for batch calculations.
Real-World Usage
Financial platforms like Bloomberg Terminal use similar parsing engines for formula evaluation in spreadsheets. Trading algorithms parse complex pricing models expressed as strings. Scientific computing frameworks like SymPy build on these same principles for symbolic mathematics.
Configuration management tools like Terraform evaluate expressions in HCL (HashiCorp Configuration Language) using parsers nearly identical to what we’ve built. Kubernetes uses expression parsing for resource constraints and scheduling rules.
Even database systems implement SQL expression evaluators following similar patterns, though they add type checking and optimization passes on top.
Frequently Asked Questions (FAQ)
Q: How do I handle variables in expressions? A: Extend your tokenizer to recognize identifiers, add variable assignment to your grammar, and maintain a symbol table during evaluation. Be careful about scope resolution.
Q: Can I add functions like sin(), cos(), log()? A: Yes, extend the tokenizer for function names, add function call rules to your grammar, and implement function dispatch in the evaluator. Consider using a dictionary mapping function names to implementations.
Q: What about complex numbers?
A: Modify your number token to accept imaginary parts (e.g., 3+4j) and update the evaluator to use Python’s built-in complex type. The grammar stays largely the same.
Q: How do I prevent stack overflow on deeply nested expressions? A: Replace recursive descent with an iterative shunting-yard algorithm, or increase Python’s recursion limit cautiously. For production systems, iterative parsing is preferred.
Q: Should I use a parser generator like ANTLR instead? A: For simple calculators, hand-written parsers are fine and give you full control. Parser generators shine when you have complex grammars or need to generate parsers for multiple languages.
Conclusion
Building a calculator teaches you more about software engineering than writing a hundred CRUD applications. It forces you to think about correctness, error handling, and the gap between specification and implementation.
The modular approach we’ve taken makes it easy to extend—add functions, variables, or even compile to bytecode. But remember: simplicity often wins. Don’t over-engineer unless you need the flexibility.
Next time you’re tempted to reach for eval(), remember there’s a better way. Your users—and your security team—will thank you.
Written by Compiler & Language Architect
Editorial staff persona focusing on programming language design, compiler backend optimization, parser implementation, and type systems theory.