Card 04/ 08

ExampleDifficulty: Intermediate2 min

Interpreter Example: Evaluating Discount Rules

"total over 100 and member" becomes a tree of And, TotalOver and IsMember.

A shop's discount rules form a natural Interpreter, and this example maps the template onto them. A rule is a condition on a basket, or two rules joined by and or or. Each becomes a class, and two rules are evaluated against three baskets.

The two rules as trees
Each class in the rule language and its kind
ClassKindEvaluates to true when
TotalOverTerminalThe basket total is above the amount
IsMemberTerminalThe customer is a member
AndNon-terminalBoth of its parts are true
OrNon-terminalEither of its parts is true
BasketContextNot an expression; it is what the rules are evaluated against
py
from abc import ABC, abstractmethod
from dataclasses import dataclass


@dataclass(frozen=True)
class Basket:
    total: float
    member: bool


class Rule(ABC):
    @abstractmethod
    def interpret(self, basket: Basket) -> bool: ...


class TotalOver(Rule):
    def __init__(self, amount: float) -> None:
        self.amount = amount

    def interpret(self, basket: Basket) -> bool:
        return basket.total > self.amount


class IsMember(Rule):
    def interpret(self, basket: Basket) -> bool:
        return basket.member


class And(Rule):
    def __init__(self, left: Rule, right: Rule) -> None:
        self.left = left
        self.right = right

    def interpret(self, basket: Basket) -> bool:
        return self.left.interpret(basket) and self.right.interpret(basket)


class Or(Rule):
    def __init__(self, left: Rule, right: Rule) -> None:
        self.left = left
        self.right = right

    def interpret(self, basket: Basket) -> bool:
        return self.left.interpret(basket) or self.right.interpret(basket)


member_discount = And(TotalOver(100), IsMember())
free_delivery = Or(TotalOver(50), IsMember())

for basket in [Basket(120, True), Basket(120, False), Basket(30, True)]:
    print(
        f"total {basket.total:>5}, member {basket.member!s:<5}:",
        f"member discount {member_discount.interpret(basket)!s:<5}",
        f"free delivery {free_delivery.interpret(basket)}",
    )
OutputRan with python:3.12-slim (Python 3.12.13)
total   120, member True : member discount True  free delivery True
total   120, member False: member discount False free delivery True
total    30, member True : member discount False free delivery True
Two rules, built once, evaluated against three baskets.

A member spending 120 gets both the discount and free delivery. A non-member spending 120 gets free delivery only, and a member spending 30 gets free delivery because Or needs only one part to be true.

The terminal classes are the only ones that read the basket. And and Or know nothing about totals or membership; they combine whatever their parts return, so a new condition, such as a discount code, is one new terminal class that both can hold at once.