Python OOP exam exercise: a shopping receipt built with inheritance, an abstract base class and the special methods __eq__, __hash__, __add__ and __iter__.
Updated on
Exam Code Python Object-oriented programming Exam level Tested code

A small grocery shop wants to print a receipt for every purchase. The till records items sold by the unit (one coffee, three croissants) and items sold loose by weight (cheese, olives), whose price is given per kilogram. Every amount of money is handled in whole cents so that no floating-point error creeps in.
Write an object-oriented program with at least this structure. An abstract base class Line holds the attributes name, price and quantity. Two subclasses, UnitLine and WeightLine, override how the amount is computed. A class Receipt groups the lines. For a UnitLine the amount is price * quantity. For a WeightLine the quantity is in grams, and the amount is the price per kilogram times the grams divided by 1000. It is rounded to the nearest cent with halves rounded up, using integer arithmetic only.
Two lines represent the same product if they are of the same type and have the same name and the same price. Implement this equality with __eq__, together with a consistent __hash__. Two lines of the same product are merged with the + operator (__add__). The result is a new line of the same type whose quantity is the sum of both. Adding two different products raises ValueError. When a line for a product that is already on the receipt is added, it is merged with the existing line rather than appended separately.
Receipt must implement __len__, which gives the number of lines after merging, and __iter__, which walks the lines in printing order. It must also implement __str__, which returns the full receipt. Lines are printed by amount, largest first. Ties are broken by name in alphabetical order, then by type (weight lines before unit lines), and finally by ascending price.
Input (standard input): one till record per line. A unit item is written as U name price quantity, with the price in cents per unit. A loose item is written as W name price grams, with the price in cents per kilogram. Names contain no spaces. Price and quantity are integers greater than or equal to 1. Blank lines are ignored, and the input may be empty.
Output: one line per product, in the order above. A unit product is printed as name quantity x price = amount. A loose product is printed as name grams g at price/kg = amount. Two final lines follow, Lines: n and TOTAL: amount, where the total is the sum of the line amounts. Every amount is printed in currency units with a decimal point and exactly two decimals; for instance, 2048 cents is printed as 20.48.
Sample input
U coffee 120 2
W cheese 1890 250
U croissant 95 3
W cheese 1890 130
U coffee 120 1
W olives 640 155Expected output
cheese 380 g at 18.90/kg = 7.18
coffee 3 x 1.20 = 3.60
croissant 3 x 0.95 = 2.85
olives 155 g at 6.40/kg = 0.99
Lines: 4
TOTAL: 14.62- Correct hierarchy: abstract base class Line holding the common attributes, and subclasses UnitLine and WeightLine overriding amount() and the detail text
- 0.75
- Special methods of Line: __eq__ consistent with __hash__, and __add__ merging two lines into a new object of the same type
- 0.75
- Receipt class with __len__, __iter__ and __str__, with lines in the required order
- 0.5
- Input parsing, integer rounding to the cent and exact formatting of amounts and totals
- 0.5
Hints
Hint 1 · What is shared between the two kinds of line, and what changes?
Both kinds of line share the attributes, the equality, the merging and the overall printed shape: name, detail, equals sign, amount. Only two things differ: how the amount is computed and the text of the detail. Put everything shared in Line and declare those two operations with @abstractmethod. Each subclass then writes only what makes it different, and nobody can instantiate a generic Line.
Hint 2 · How do I round to the cent without float or round()?
To round x / 1000 with halves going up, add half the divisor before the integer division: (x + 500) // 1000. Check by hand that 1500 gives 2 and 1499 gives 1. Careful: when two weight lines are merged, add the grams and round once. Do not add two amounts that have already been rounded.
Hint 3 · How does the base class build an object of the right subclass?
Inside __add__ you do not know whether self is a unit line or a weight line. Do not hard-code any concrete class name. type(self) returns the object's actual class, and you can call it like a constructor. For sorting, give Line a method that returns a tuple with the criteria in order, using the negated amount, and pass it as key to sorted.
Solution
Explained solution
The solution rests on three ideas. First, an abstract base class gathers all the shared behaviour. Second, two minimal subclasses contribute only the amount calculation and the detail text. Third, a container, Receipt, behaves like a Python collection thanks to __len__ and __iter__. Money stays in integer cents throughout and is turned into text only at the last moment, in the function money.
If you want to revisit classes, constructors and inheritance first, the blog covers the same ground in Java in Solved exercise: inheritance, constructors and super in Java. The ideas carry over to Python almost unchanged. The differences are super() and the special methods.
import sys
from abc import ABC, abstractmethod
def money(cents):
"""Format non-negative integer cents as 20.48."""
return f"{cents // 100}.{cents % 100:02d}"
class Line(ABC):
RANK = 9 # tie-break position by type; subclasses override it
def __init__(self, name, price, quantity):
if price <= 0 or quantity <= 0:
raise ValueError("price and quantity must be positive")
self.name = name
self.price = price
self.quantity = quantity
@abstractmethod
def amount(self):
"""Amount of the line in cents."""
@abstractmethod
def detail(self):
"""Text between the name and the equals sign."""
def __eq__(self, other):
if not isinstance(other, Line):
return NotImplemented
return (type(self) is type(other)
and self.name == other.name
and self.price == other.price)
def __hash__(self):
return hash((type(self), self.name, self.price))
def __add__(self, other):
if not isinstance(other, Line):
return NotImplemented
if self != other:
raise ValueError("cannot add different products")
return type(self)(self.name, self.price,
self.quantity + other.quantity)
def sort_key(self):
return (-self.amount(), self.name, self.RANK, self.price)
def __str__(self):
return f"{self.name} {self.detail()} = {money(self.amount())}"
class UnitLine(Line):
RANK = 1
def amount(self):
return self.price * self.quantity
def detail(self):
return f"{self.quantity} x {money(self.price)}"
class WeightLine(Line):
RANK = 0
def amount(self):
# price in cents per kg, quantity in grams; halves round up
return (self.price * self.quantity + 500) // 1000
def detail(self):
return f"{self.quantity} g at {money(self.price)}/kg"
class Receipt:
def __init__(self):
self._lines = []
def add(self, line):
for i, existing in enumerate(self._lines):
if existing == line:
self._lines[i] = existing + line
return
self._lines.append(line)
def __len__(self):
return len(self._lines)
def __iter__(self):
return iter(sorted(self._lines, key=Line.sort_key))
def total(self):
return sum(line.amount() for line in self._lines)
def __str__(self):
rows = [str(line) for line in self]
rows.append(f"Lines: {len(self)}")
rows.append(f"TOTAL: {money(self.total())}")
return "\n".join(rows)
KINDS = {"U": UnitLine, "W": WeightLine}
def main():
receipt = Receipt()
for text in sys.stdin:
parts = text.split()
if not parts:
continue
cls = KINDS[parts[0]]
receipt.add(cls(parts[1], int(parts[2]), int(parts[3])))
print(receipt)
main()The base class. Line inherits from ABC and declares amount and detail as abstract methods. As a result, Line("x", 1, 1) raises TypeError, and so does any subclass that forgets one of the two methods. The constructor checks that price and quantity are positive. The statement guarantees valid input, but an object should never be born in an impossible state. The class attribute RANK drives the tie-break by type. WeightLine sets it to 0 and UnitLine to 1, so weight lines sort first.
Equality and hashing. __eq__ returns NotImplemented when the other operand is not a line. Python then tries the reflected comparison, and if that also fails the result is False. The comparison uses type(self) is type(other) rather than isinstance, because a weight line and a unit line with the same name and price are not the same product. Overriding __eq__ makes Python set __hash__ to None, so we redefine it from the same tuple of fields. Equal objects then share a hash, as set and dict require.
Merging with +. __add__ reuses equality (self != other) to reject different products. It builds the result with type(self)(...), which lets the merge be written once in the base class. If self is a WeightLine, the result is a WeightLine. Neither operand is modified; a new object is returned, as + does for numbers and strings. Nobody holding a reference to the original line gets a surprise.
The subclasses. UnitLine.amount just multiplies. WeightLine.amount computes (self.price * self.quantity + 500) // 1000, which rounds halves up in pure integer arithmetic. The test "rounding and merging loose items" checks exactly this. One gram of walnuts at 15.00/kg is worth 1.5 cents and is printed as 0.02. The two 2 g lines of grapes merge into 4 g worth 1 cent, not 2. The base __str__ builds the line by calling detail() and amount(), which dynamic dispatch resolves in the subclass.
The container. Receipt.add scans the list. If it finds an equal line (existing == line), it replaces it with existing + line; otherwise it appends the new line. __len__ makes len(receipt) work. __iter__ returns an iterator over the list sorted with key=Line.sort_key. That key is the tuple (-amount, name, RANK, price), which encodes the required order. The amount is negated so that larger amounts come first. Finally, __str__ loops over self, which calls __iter__, and appends the count and the total.
Checking against the tests. In the example, cheese appears twice, at 250 g and at 130 g. The two records merge into one 380 g line worth 7.18, and the coffees merge into 3 units. The test "same name, different products, ties" has three bread lines that never merge with each other: two unit lines with different prices and one weight line. It also has a tie at 3.00 between loose bread and juice, settled by name. Empty input prints Lines: 0 and TOTAL: 0.00, because sum of an empty sequence is 0. The large-amounts test shows that money needs no thousands separator.
Test cases
| Case | Input | Expected output | Actual output | Result |
|---|---|---|---|---|
| Statement example | U coffee 120 2
W cheese 1890 250
U croissant 95 3
W cheese 1890 130
U coffee 120 1
W olives 640 155 | cheese 380 g at 18.90/kg = 7.18
coffee 3 x 1.20 = 3.60
croissant 3 x 0.95 = 2.85
olives 155 g at 6.40/kg = 0.99
Lines: 4
TOTAL: 14.62 | cheese 380 g at 18.90/kg = 7.18
coffee 3 x 1.20 = 3.60
croissant 3 x 0.95 = 2.85
olives 155 g at 6.40/kg = 0.99
Lines: 4
TOTAL: 14.62 | OK |
| Empty input | (empty) | Lines: 0
TOTAL: 0.00 | Lines: 0
TOTAL: 0.00 | OK |
| Single product | U water 100 1 | water 1 x 1.00 = 1.00
Lines: 1
TOTAL: 1.00 | water 1 x 1.00 = 1.00
Lines: 1
TOTAL: 1.00 | OK |
| Same name, different products, ties | U bread 150 2
U bread 140 2
W bread 3000 100
U juice 300 1
U bread 150 1 | bread 3 x 1.50 = 4.50
bread 100 g at 30.00/kg = 3.00
juice 1 x 3.00 = 3.00
bread 2 x 1.40 = 2.80
Lines: 4
TOTAL: 13.30 | bread 3 x 1.50 = 4.50
bread 100 g at 30.00/kg = 3.00
juice 1 x 3.00 = 3.00
bread 2 x 1.40 = 2.80
Lines: 4
TOTAL: 13.30 | OK |
| Rounding and merging loose items | W grapes 250 2
W walnuts 1500 1
W grapes 250 2 | walnuts 1 g at 15.00/kg = 0.02
grapes 4 g at 2.50/kg = 0.01
Lines: 2
TOTAL: 0.03 | walnuts 1 g at 15.00/kg = 0.02
grapes 4 g at 2.50/kg = 0.01
Lines: 2
TOTAL: 0.03 | OK |
| Blank lines and large amounts |
U laptop 99999 2
U mouse 1500 10 | laptop 2 x 999.99 = 1999.98
mouse 10 x 15.00 = 150.00
Lines: 2
TOTAL: 2149.98 | laptop 2 x 999.99 = 1999.98
mouse 10 x 15.00 = 150.00
Lines: 2
TOTAL: 2149.98 | OK |
Actual outputs: code compiled with Python 3.13.16 and run in an isolated container on 6 November 2026.
Complexity
Let r be the number of input records and n the number of distinct lines after merging (n ≤ r). In the worst case each call to add scans the whole list, so loading the receipt costs O(r·n). Printing sorts once in O(n log n) and computes the total in O(n). For a till receipt with a few dozen lines this is ample.
If the number of distinct products were large, the natural improvement is to use the __hash__ we already wrote. A dictionary keyed by the line itself makes each insertion amortised O(1) and the whole load O(r). Memory is O(n) in both versions. On top of that comes the sorted copy that sorted creates on each iteration.
Common mistakes
- Overriding
__eq__and forgetting__hash__. The class becomes unhashable, so putting lines in asetor using them asdictkeys raisesTypeError: unhashable type. A related error is a hash that includes the quantity, which gives two equal objects different hashes. - Writing
UnitLine(...)orLine(...)inside__add__. The first silently turns merged weight lines into a unit line. The second raisesTypeErrorbecause the class is abstract. The correct form istype(self)(...). - Merging weight lines by adding their already rounded amounts instead of their grams. In the example, cheese would come out as 4.73 + 2.46 = 7.19 instead of 7.18. In the grapes test it would be 0.02 instead of 0.01.
- Using
floatandround()for cents. Python rounds halves to even (round(2.5)is 2), and 0.1 has no exact binary representation. Work in integers and round with(x + 500) // 1000. - Comparing types with
isinstancein__eq__. Both subclasses are instances ofLine, so a weight line and a unit line with the same name and price would count as the same product and be merged. - Making
__add__modifyself.quantityand returnself. It happens to work here, but it breaks the meaning of+: afterc = a + b, the objectawould have changed. In-place modification belongs in__iadd__.
Variants
Add a third type: OfferLine with buy 3, pay for 2
The examiner asks for a subclass OfferLine (code O in the input) for unit items on a "buy 3, pay for 2" deal. Inherit from UnitLine, give it its own RANK, and override amount as self.price * (self.quantity - self.quantity // 3). You can also override detail to append (3 for 2), then register the class in KINDS. Neither Receipt nor __add__ changes. Merging two offers still yields an OfferLine thanks to type(self), which shows the hierarchy is well designed.
Allow sum() over lines of the same product
sum(lines) starts by computing 0 + lines[0]. The integer's __add__ returns NotImplemented, so Python then tries lines[0].__radd__(0). Define a __radd__ in Line that returns self when the other operand is 0 and delegates to __add__ otherwise. With that, sum([a, b, c]) merges three equal lines without a start value.
Constant-time insertion with a dictionary
Replace the list in Receipt with a dictionary, self._lines = {}. In add, write self._lines[line] = self._lines[line] + line if the key exists, and self._lines[line] = line otherwise. In __iter__ and total, iterate over self._lines.values(). This works because __eq__ and __hash__ use the same fields, and none of them changes when lines are merged.
On the syllabus
Objects and classes are listed among the course objectives of Carnegie Mellon University's 15-112 Fundamentals of Programming and Computer Science. The same page lists code tracing to deduce the output of small programs. This exercise practises both skills: you design a small class hierarchy in Python, then trace by hand how merging and sorting produce each line of the receipt.
Class creation and inheritance are also syllabus topics in UNAM's Programación (1236). Object-oriented programming is a unit of IPN's Paradigmas de Programación. Class reuse through inheritance and contracts through interfaces appear in Universidad de los Andes' ISIS1209.
Article generated with AI.larebelion


