Skip to content
ES EN
Theme: automatic

Solved exercise: shopping receipt with classes and dunders in Python

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

Exam Code: Solved exercise: shopping receipt with classes and dunders in Python
Introduction to Object-Oriented Programming · Midterm examQuestion 3 · 2.5 points · 30 min

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 155

Expected 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.

Code and tests on GitLab

Test cases

CaseInputExpected outputActual outputResult
Statement exampleU coffee 120 2 W cheese 1890 250 U croissant 95 3 W cheese 1890 130 U coffee 120 1 W olives 640 155cheese 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.62cheese 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.62OK
Empty input(empty)Lines: 0 TOTAL: 0.00Lines: 0 TOTAL: 0.00OK
Single productU water 100 1water 1 x 1.00 = 1.00 Lines: 1 TOTAL: 1.00water 1 x 1.00 = 1.00 Lines: 1 TOTAL: 1.00OK
Same name, different products, tiesU bread 150 2 U bread 140 2 W bread 3000 100 U juice 300 1 U bread 150 1bread 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.30bread 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.30OK
Rounding and merging loose itemsW grapes 250 2 W walnuts 1500 1 W grapes 250 2walnuts 1 g at 15.00/kg = 0.02 grapes 4 g at 2.50/kg = 0.01 Lines: 2 TOTAL: 0.03walnuts 1 g at 15.00/kg = 0.02 grapes 4 g at 2.50/kg = 0.01 Lines: 2 TOTAL: 0.03OK
Blank lines and large amounts U laptop 99999 2 U mouse 1500 10laptop 2 x 999.99 = 1999.98 mouse 10 x 15.00 = 150.00 Lines: 2 TOTAL: 2149.98laptop 2 x 999.99 = 1999.98 mouse 10 x 15.00 = 150.00 Lines: 2 TOTAL: 2149.98OK

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 a set or using them as dict keys raises TypeError: unhashable type. A related error is a hash that includes the quantity, which gives two equal objects different hashes.
  • Writing UnitLine(...) or Line(...) inside __add__. The first silently turns merged weight lines into a unit line. The second raises TypeError because the class is abstract. The correct form is type(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 float and round() 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 isinstance in __eq__. Both subclasses are instances of Line, 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__ modify self.quantity and return self. It happens to work here, but it breaks the meaning of +: after c = a + b, the object a would 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

Byline

· Chief editor · English edition · London

“This exercise is decided by a single cent, and the student who rounds once instead of twice is the one who earns it.”