bigonotes
CS ConceptsOOP

OOP Practice Exercises

Three Python exercises (library, animals, bank) that combine dunder methods, abstraction, encapsulation and Singleton.

4 min readLast updated OOP

These exercises combine everything from the OOP lessons. Try each one before reading the hints, then compare with the reference solution. Each comes with test code you can paste in to check your work.

# Exercise Concepts practiced
1 Library system __eq__, __str__, __contains__, __len__, read-only property
2 Animal kingdom Abstract classes, overriding, __lt__, __add__
3 Bank Encapsulation, class variables, computed values, Singleton

Exercise 1: Library system#

Build a library where:

  • A book has a title, an author and a unique ID.
  • Two books are equal if they have the same ID.
  • Printing a book shows meaningful information.
  • A library holds a collection of books. You can add a book, check whether a book is in the library, and get the number of books.
  • The library's name can't be changed from outside.

Hints

  • __eq__ on Book compares IDs; __str__ formats the output.
  • in calls __contains__, len() calls __len__.
  • Store the name as self.__name and expose a getter-only @property.

Reference solution

class Book:
    def __init__(self, title, author, book_id):
        self.title = title
        self.author = author
        self.book_id = book_id

    def __eq__(self, other):
        return isinstance(other, Book) and self.book_id == other.book_id

    def __str__(self):
        return f"Title: {self.title} | Author: {self.author}"


class Library:
    def __init__(self, name="City Library"):
        self.__name = name
        self.books = []

    @property
    def name(self):  # no setter, so it's read-only
        return self.__name

    def add_book(self, book):
        self.books.append(book)

    def __contains__(self, book):
        return book in self.books  # list membership uses Book.__eq__

    def __len__(self):
        return len(self.books)

Tests

book1 = Book("ABC", "XYZ", "123")
book2 = Book("ABC", "XYZ", "1234")
book3 = Book("ABC", "XYZ", "999")
print(book1 == book2)  # False: different IDs
print(book1)           # Title: ABC | Author: XYZ

lib = Library()
lib.add_book(book1)
lib.add_book(book2)
print(book1 in lib)    # True
print(book3 in lib)    # False
print(len(lib))        # 2
# lib.name = "Other"   # AttributeError

Exercise 2: Animal kingdom#

Build an animal system where:

  • Every animal has a name and can speak and move, but each kind does it differently.
  • Nobody can create a generic animal directly.
  • A dog barks and runs; a bird chirps and flies.
  • A list of animals can be sorted alphabetically by name.
  • Two animals of the same type can be added together to make a new animal whose name combines both.

Hints

  • Make Animal an ABC with abstract speak() and move().
  • list.sort() uses __lt__; define it once on Animal.
  • __add__ on each subclass returns a new instance of that subclass. type(self)(...) works generically.

Reference solution

from abc import ABC, abstractmethod


class Animal(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def speak(self):
        pass

    @abstractmethod
    def move(self):
        pass

    def __lt__(self, other):
        return self.name < other.name

    def __add__(self, other):
        if type(self) is not type(other):
            return NotImplemented  # only merge animals of the same kind
        return type(self)(self.name + other.name)


class Dog(Animal):
    def speak(self):
        print("Woof!")

    def move(self):
        print("Running")


class Bird(Animal):
    def speak(self):
        print("Chirp!")

    def move(self):
        print("Flying")

Tests

try:
    Animal("Generic")
    print("FAIL: Animal should be abstract")
except TypeError:
    print("PASS: can't instantiate Animal")

Dog("Bruno").speak()    # Woof!
Bird("Tweety").move()   # Flying

animals = [Dog("Zeus"), Bird("Aarav"), Dog("Bruno"), Bird("Mango")]
animals.sort()
print([a.name for a in animals])  # ['Aarav', 'Bruno', 'Mango', 'Zeus']

print((Dog("Bruno") + Dog("Max")).name)  # BrunoMax

Exercise 3: Bank#

Build a banking system that meets these requirements.

Accounts

  • An account has an owner, a balance and an account number.
  • The balance can't be set directly; it changes only through deposit and withdraw.
  • Withdrawing more than the balance is not allowed.
  • Two accounts are equal if they have the same account number.
  • Printing an account shows meaningful information.

Money

  • The total money across all accounts is available without looping over accounts.
  • Interest can be calculated from the current balance and a rate, without storing it.

The bank

  • Only one bank can ever exist. The bank holds accounts; you can add one and look one up by number.

Hints

  • Private __balance + getter-only @property blocks direct assignment.
  • A class variable (BankAccount.total_money) updated in __init__, deposit and withdraw gives the running total.
  • Interest is a method (or property) computed on demand.
  • Override __new__ for the Singleton, and guard __init__ so a second Bank() doesn't wipe the accounts.

Reference solution

class BankAccount:
    total_money = 0  # shared across all accounts

    def __init__(self, owner, balance, acc_num):
        self.owner = owner
        self.acc_num = acc_num
        self.__balance = balance
        BankAccount.total_money += balance

    def __eq__(self, other):
        return (isinstance(other, BankAccount)
                and self.acc_num == other.acc_num)

    def __str__(self):
        return (f"Owner: {self.owner} | Balance: {self.__balance} "
                f"| Account: {self.acc_num}")

    @property
    def balance(self):
        return self.__balance

    def deposit(self, amount):
        if amount <= 0:
            print("Invalid deposit")
            return
        self.__balance += amount
        BankAccount.total_money += amount

    def withdraw(self, amount):
        if amount <= 0 or amount > self.__balance:
            print("Invalid withdrawal")
            return
        self.__balance -= amount
        BankAccount.total_money -= amount

    def interest(self, rate_percent):
        return self.__balance * rate_percent / 100


class Bank:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def __init__(self):
        # __init__ runs on every Bank() call,
        # so only set up state the first time
        if not hasattr(self, "accounts"):
            self.accounts = {}

    def add_account(self, account):
        self.accounts[account.acc_num] = account

    def get_account(self, acc_num):
        return self.accounts.get(acc_num)

Tests

acc1 = BankAccount("Bruno", 1000, "ACC001")
try:
    acc1.balance = 9999
    print("FAIL: balance should be read-only")
except AttributeError:
    print("PASS: balance is protected")

acc1.deposit(500)
acc1.withdraw(200)
acc1.withdraw(99999)          # Invalid withdrawal
print(acc1.balance)           # 1300

acc2 = BankAccount("Max", 2000, "ACC002")
acc3 = BankAccount("Charlie", 1000, "ACC001")
print(acc1 == acc2, acc1 == acc3)  # False True
print(acc1)
print(BankAccount.total_money)     # 1300 + 2000 + 1000 = 4300
print(acc1.interest(10))           # 130.0

bank1, bank2 = Bank(), Bank()
print(bank1 is bank2)              # True
bank1.add_account(acc1)
bank1.add_account(acc2)
print(bank2.get_account("ACC002")) # same bank, so acc2 is found

Key takeaways#

  • Dunder methods make your classes work with ==, in, len(), sort() and +.
  • Abstract base classes stop callers creating incomplete objects.
  • Private attributes + getter-only properties give read-only state.
  • Class variables track totals across all instances.
  • A __new__-based Singleton needs an __init__ guard, because __init__ runs on every call.

Found a mistake? Tell me on LinkedIn ↗

Further reading#