Card 08/ 09

ExerciseDifficulty: Advanced2 min

Practice: Discount Rules at a Checkout

No discount, a percentage off, and buy two get one free, chosen per customer.

Discounts at a checkout are a natural Strategy, and this exercise asks you to write them both ways. A basket holds items at £5, £3, £2 and £4, in pence: [500, 300, 200, 400]. Each customer gets one of three rules: no discount; a percentage off the whole basket, set per promotion; or buy two get one free, where in every three items, taken from most to least expensive, the third is free.

  1. Write the rules as classes behind a Discount interface with one method, apply(prices).
  2. Write the same rules as functions.
  3. What does each rule charge for the basket, with 10 per cent off for the second?
  4. Which form handles the percentage more cleanly, and why?
Show a solution
py
from abc import ABC, abstractmethod
from collections.abc import Callable


class Discount(ABC):
    @abstractmethod
    def apply(self, prices: list[int]) -> int: ...


class NoDiscount(Discount):
    def apply(self, prices: list[int]) -> int:
        return sum(prices)


class PercentageOff(Discount):
    def __init__(self, percent: int) -> None:
        self._percent = percent

    def apply(self, prices: list[int]) -> int:
        return sum(prices) * (100 - self._percent) // 100


class BuyTwoGetOneFree(Discount):
    def apply(self, prices: list[int]) -> int:
        ordered = sorted(prices, reverse=True)
        return sum(price for i, price in enumerate(ordered) if i % 3 != 2)


basket = [500, 300, 200, 400]
for discount in (NoDiscount(), PercentageOff(10), BuyTwoGetOneFree()):
    print(f"{type(discount).__name__}: {discount.apply(basket)}")


def percentage_off(percent: int) -> Callable[[list[int]], int]:
    return lambda prices: sum(prices) * (100 - percent) // 100


def buy_two_get_one_free(prices: list[int]) -> int:
    ordered = sorted(prices, reverse=True)
    return sum(price for i, price in enumerate(ordered) if i % 3 != 2)


for name, rule in (("none", sum), ("10% off", percentage_off(10)), ("3 for 2", buy_two_get_one_free)):
    print(f"{name}: {rule(basket)}")
OutputRan with python:3.12-slim (Python 3.12.13)
NoDiscount: 1400
PercentageOff: 1260
BuyTwoGetOneFree: 1100
none: 1400
10% off: 1260
3 for 2: 1100
Three classes, then the same three rules as functions, with the same totals.

The basket costs 1400 with no discount, 1260 with 10 per cent off, and 1100 with buy two get one free: sorted, it is 500, 400, 300 and 200, and the third item, 300, is free.

The percentage is the one rule with configuration. As a class it is a constructor argument held in a field; as a function it is percentage_off(10), a function that returns the rule with the percentage captured in a closure. Both are clean here, and the function needs less code. The class would win if each rule also had to describe itself on the receipt, because that is a second method. No discount needs no code of its own as a function: the built-in sum already fits the slot.