Problem overview
A shop takes payments by card, by PayPal and in cash. Every payment has an amount in cents and a total(), which is the amount plus a processing fee. The total is worked out the same way for every payment. Only the fee depends on the kind of payment:
Card:amount * 2 / 100, rounded down.PayPal:amount * 3 / 100 + 30, with the division rounded down.Cash: 0.
For Card(1999) the fee is 39 (the exact value 39.98 is rounded down), so the total is 2038. PayPal(500) has a fee of 45 and a total of 545, and Cash(700) has a total of 700. The three fees add up to 84.
Approach: share what is common, leave the fee open
Writing total() three times would repeat the same line. An abstract class avoids that. Payment keeps the amount and the one total() method, and declares that every payment has a fee() without saying what it is, because only a card, a PayPal payment or a cash payment knows its own fee.
In Python, an abstract class inherits from ABC (from the abc module), and an abstract method is marked with @abstractmethod. While a class has an abstract method, Python refuses to create an object from it. A subclass becomes concrete by writing that method.
The subclasses inherit __init__ and total and write only fee. When total() runs for a card, self.fee() finds Card.fee, so the shared method uses the right fee for each object.
Step by step
- Store the amount. In
Payment.__init__, setself.amount. It lives in the parent once and all three subclasses inherit it. - Declare
feeas abstract. Put@abstractmethodabove it and usepassas its body. It says that every payment has a fee without writing one. - Write
totalasself.amount + self.fee(), once, for all kinds of payment. - Card's fee:
self.amount * 2 // 100. - PayPal's fee:
self.amount * 3 // 100 + 30. The 30 cents come on top even for an amount of 0. - Cash's fee: return 0. Cash must still define
fee: a subclass that leaves it out stays abstract and can't be created.
The solution in Python
from abc import ABC, abstractmethod
class Payment(ABC):
def __init__(self, amount):
self.amount = amount
@abstractmethod
def fee(self): pass
def total(self):
return self.amount + self.fee()
class Card(Payment):
def fee(self):
return self.amount * 2 // 100
class PayPal(Payment):
def fee(self):
return self.amount * 3 // 100 + 30
class Cash(Payment):
def fee(self):
return 0
Using the three payments from the first example:
payments = [Card(1999), PayPal(500), Cash(700)]
print(*[p.total() for p in payments])
print(sum(p.fee() for p in payments))
2038 545 700
84
The list comprehension calls the same total() on every item. Each object supplies its own fee through the shared method.
Rounding: multiply first, then divide
The operator // divides and rounds down, so 1999 * 2 // 100 is 39, while 1999 * 2 / 100 is the float 39.98. Order matters, too. Python evaluates amount * 2 // 100 left to right, multiplying first. If you divide first, the rounding happens too early: 1999 // 100 * 2 gives 38, not 39. And for an amount of 150, 150 // 100 * 2 is 2 while 150 * 2 // 100 is 3.
What happens without the abstract method
Trying to create the abstract class itself, or a subclass that has no fee, fails:
class Free(Payment): pass
Free(5)
TypeError: Can't instantiate abstract class Free without an implementation for abstract method 'fee'
The same error appears for Payment(5). That is the point of an abstract method: forgetting to write a fee is caught when the object is created, not in the middle of a calculation.
Why the shared method calls self.fee()
The line return self.amount + self.fee() is written in Payment, a class that has no idea what a card is. It still gives the right answer for a card, because self is the actual object, a Card, and Python looks for fee starting from the object's own class. This pattern, a fixed recipe in the parent with one step left to the subclasses, is the main reason to use an abstract class. If the rule for a total ever changes, say a minimum charge, you edit one method instead of three.
The second example shows the rounding at small amounts. Card(49) has a fee of 49 * 2 // 100, which is 0, so its total is 49. PayPal(33) has 33 * 3 // 100, which is 0, plus 30, so its total is 63. Card(50) has a fee of exactly 1 and a total of 51. The fees add up to 31.
Edge cases and common mistakes
- Amount 0.
Card(0)has total 0,Cash(0)has total 0, andPayPal(0)has total 30 because of the fixed 30 cents. - Small amounts.
Card(49)has a fee of 0 (0.98 rounded down), so its total is 49.Card(50)has a fee of exactly 1. - Using
/instead of//. The fee becomes a float such as39.98instead of whole cents. - Forgetting
ABC.@abstractmethodis only enforced for classes that inherit fromABC. Without it,Paymentcould be created andfee()would quietly returnNone. - Writing
totalin each subclass. It works, but it repeats the formula. The whole idea is to write it once in the parent.
Complexity
Reading the amount and doing one multiplication, one division and one addition costs the same for a payment of 5 cents and one of a million.
Every fee and every total is a few arithmetic operations, so each takes O(1) time.
