Skip to content

← Modern Syntax and Modernisation step 12 of 18

Medium End-to-End

Class patterns, __match_args__ and an expression simplifier

A class pattern is rewritten by the compiler into an isinstance check plus a sequence of attribute reads. Which attributes, and in which order, comes from __match_args__ — a class-level tuple of strings.

@dataclass
class Add:
    left: Expr
    right: Expr

Add.__match_args__       # ('left', 'right')

case Add(a, b): is therefore exactly case Add(left=a, right=b):. Three consequences worth internalising:

  • __match_args__ is public API. Reordering the fields of a dataclass — a change that looks like formatting, that no type checker objects to, and that every keyword call site survives — silently reverses the meaning of every positional pattern in every consumer. In a Subtract(left, right) that is a wrong answer, not a crash.
  • A class with no __match_args__ accepts zero positional subpatterns. case SomeClass(x): raises TypeError: SomeClass() accepts 0 positional sub-patterns. Keyword subpatterns still work.
  • kw_only=True makes __match_args__ empty, because there are no positional __init__ parameters to name. KW_ONLY truncates it at the sentinel. So a dataclass refactor toward keyword-only arguments can break pattern matching in a different module.

A non-tuple __match_args__ raises TypeError at match time, not at class creation — worth knowing when you hand-write one.

What you are building

A tiny expression compiler over five frozen dataclasses:

type Expr = Lit | Var | Neg | Add | Mul

The classes are given. You write four functions:

  • build(tokens: list[str]) -> Expr — parse reverse-Polish tokens. "+", "*" and "~" (unary negate) are operators; a token that parses as an int is a Lit; anything else is a Var. Pop the right operand before the left.
  • simplify(expr: Expr) -> Expr — bottom-up algebraic simplification: x * 0 → 0 (either side), x * 1 → x (either side), x + 0 → x (either side), Lit(a) + Lit(b) → Lit(a+b), Lit(a) * Lit(b) → Lit(a*b), -Lit(n) → Lit(-n), -(-x) → x.
  • render(expr: Expr) -> strLit as its digits, Var as its name, Neg as -x, Add as (l + r), Mul as (l * r).
  • evaluate(expr: Expr, env: dict[str, int]) -> int.

solve(tokens, env) returns {"original": render(built), "simplified": render(simplified), "value": evaluate(simplified, env)}.

Use positional class patterns (case Add(left, right):) and nest them (case (Lit(0), _) | (_, Lit(0)):). That is the point of the exercise; a chain of isinstance calls produces the same answers and none of the reading practice.

The typing payoff

Because Expr is a closed union of five dataclasses and every match covers all five, mypy --strict accepts these functions with no final return and no else — it can prove the match is exhaustive. Delete one case and you get Missing return statement for free. That is the property the next item turns into a hard guarantee with assert_never.