Skip to content

← Pattern Matching, f-Strings and Newer Syntax step 11 of 23

Medium End-to-End

Simplify Arithmetic Expressions with Class Patterns

A calculator often stores an expression as a tree of small objects, so (2 + x) * 3 becomes Mul(Add(Lit(2), Var("x")), Lit(3)). To simplify the tree, you check each node's class and look at its children. A class pattern does both. You have used int() and str(), and your own classes work the same way.

The starter's type Expr = Lit | Var | Neg | Add | Mul uses the alias syntax added in Python 3.12, which can name classes defined below it.

A class pattern checks the type and reads attributes

match expr:
    case Add(left=Lit(0), right=other):
        return other

This case matches when expr is an Add, as isinstance would check, and its left attribute matches Lit(0). The capture pattern other stores its right attribute. Python reads the attributes and never calls Add(...).

case Add(a, b): means the same as case Add(left=a, right=b):. Python takes the names, in order, from Add.__match_args__, which @dataclass sets to ("left", "right"). If you reorder a dataclass's fields, its positional patterns change meaning.

Match two values at once

A comma after match makes the subject a tuple. Then one case covers a zero on either side:

match simplify(left), simplify(right):
    case (Lit(0), keep) | (keep, Lit(0)):
        return keep

Each side of | must store values in the same names. Python calls this binding a name. Here both sides bind keep, so the body can use it whichever side matched. If the names differ, Python reports SyntaxError: alternative patterns bind different names.

What to write

build(tokens) reads reverse Polish notation, where each operator follows its operands, so ["2", "x", "+", "3", "*"] means (2 + x) * 3. Use a stack. For "+" or "*", pop the right operand, then the left, and push an Add or a Mul. For "~", pop one operand and push a Neg. For any other token, push Lit(int(token)), or Var(token) if int raises ValueError. The tokens are always valid. Do not change the list.

simplify(expr) returns Lit and Var unchanged. For other nodes, simplify the children first, then apply the first rule that fits. If none fits, rebuild the same kind of node from the simplified children. Add no other rules.

Node, after simplifying its children Result
Neg(Lit(n)) Lit(-n)
Neg(Neg(x)) x
Add(Lit(0), x) or Add(x, Lit(0)) x
Add(Lit(a), Lit(b)) Lit(a + b)
Mul with Lit(0) on either side Lit(0)
Mul(Lit(1), x) or Mul(x, Lit(1)) x
Mul(Lit(a), Lit(b)) Lit(a * b)

render(expr) writes a Lit as str(value), a Var as its name, and a Neg as "-" before its operand, so double negation renders as "--x". It writes Add and Mul as f"({left} + {right})" and f"({left} * {right})".

evaluate(expr, env) returns the int value, reading each Var from env.

solve(tokens, env) builds the tree, simplifies it, and returns both renderings and the value of the simplified tree:

solve(["2", "3", "+", "4", "*"], {})
# {"original": "((2 + 3) * 4)", "simplified": "20", "value": 20}

A match that covers all five classes needs no fallback case, because mypy --strict, which the grader runs, can see that every path returns.