HSHimanshu Sharma
Python Notes

Python OOP part 4: abstract classes, interfaces, private members and __str__

Python OOP part 4: abstract classes, interfaces, private members and __str__ Advertisement

The first three parts covered classes and objects, inheritance and polymorphism. This last part deals with four design questions that come up in every real project. How do I force subclasses to provide a method? How do I describe a set of behaviours without any implementation? How do I keep some data from being changed by accident? And how do I control what print(obj) shows? At the end, a small banking application ties everything together. All code was run on Python 3.9.

Abstract methods and abstract classes

Sometimes a parent class knows that every child must do something, but not how. A Shape must have an area, but the formula depends on the shape. A method that is declared but has no implementation is an abstract method. A class that contains abstract methods is an abstract class, and it is meant to be inherited from, never instantiated.

Python provides this in the abc module (abstract base classes):

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        """Every shape must say how to compute its area."""

    def describe(self):                    # a normal, shared method
        return f"{type(self).__name__} with area {self.area():.1f}"

try:
    Shape()
except TypeError as e:
    print(e)

On Python 3.9 this printed Can't instantiate abstract class Shape with abstract method area (the wording differs a little between versions). The abstract class refuses to create objects. A child must implement every abstract method, or it is abstract too:

class Square(Shape):
    def __init__(self, s): self.s = s
    def area(self): return self.s ** 2

class Incomplete(Shape):
    pass

print(Square(3).describe())               # Square with area 9.0
try:
    Incomplete()
except TypeError as e:
    print("cannot create Incomplete")     # cannot create Incomplete

An abstract class can mix abstract methods with ordinary ones, so it provides shared code (describe) and a contract (area) at once. Without ABC, writing raise NotImplementedError in the parent works too, but the mistake is found only when the method is called, not when the object is created.

Abstract properties and class methods

Decorators can be stacked, with @abstractmethod innermost:

class Account(ABC):
    @property
    @abstractmethod
    def kind(self): ...

class Savings(Account):
    kind = "savings"

print(Savings().kind)        # savings

Interfaces

An interface is a pure contract: a list of method names with no code at all. Java has an interface keyword; Python does not need one. The usual way is an abstract class where every method is abstract:

class Payable(ABC):
    @abstractmethod
    def pay(self, amount): ...
    @abstractmethod
    def refund(self, amount): ...

class UPI(Payable):
    def pay(self, amount): return f"UPI paid {amount}"
    def refund(self, amount): return f"UPI refunded {amount}"

class Cash(Payable):
    def pay(self, amount): return f"cash paid {amount}"
    def refund(self, amount): return f"cash refunded {amount}"

def checkout(method, amount):
    return method.pay(amount)

print(checkout(UPI(), 100), "|", checkout(Cash(), 40))
# UPI paid 100 | cash paid 40

Any code that works with a Payable can use any implementation, which is polymorphism with a guarantee that the methods exist. Since Python also supports duck typing, interfaces are optional; use them for larger projects or libraries where a clear, enforced contract helps. The typing.Protocol class goes further, checking by shape rather than by inheritance.

Abstract class Interface (in the Python sense)
Abstract methods some all
Concrete methods and data allowed normally none
Typical purpose shared code plus a contract a contract only

Public, protected and private members

Many languages have keywords for access control. Python relies on naming conventions, with one small piece of enforcement.

Name Called Meaning
balance public free for anyone to use
_balance protected (by convention) internal; for the class and its subclasses
__balance private (name-mangled) internal to the class itself
class Account:
    def __init__(self):
        self.owner = "Asha"        # public
        self._branch = "Pune"      # protected by convention
        self.__pin = 4321          # private

a = Account()
print(a.owner, a._branch)          # Asha Pune
try:
    a.__pin
except AttributeError as e:
    print(e)                       # 'Account' object has no attribute '__pin'

The single underscore is only a request: Python does nothing to stop a._branch. The double underscore triggers name mangling: inside the class, __pin is stored under the name _Account__pin.

print(sorted(vars(a)))             # ['_Account__pin', '_branch', 'owner']
print(a._Account__pin)             # 4321

So private members are hidden from accidents, not from determined programmers. The mangling has a real purpose: a subclass that defines its own __pin does not overwrite the parent's, because each gets a different mangled name. Respect the convention: use _name for internals and __name only when you need that protection from subclasses.

To expose private data safely, offer methods or properties (see part 1) that validate:

class Account:
    def __init__(self, pin):
        self.__pin = pin
    def check_pin(self, attempt):
        return attempt == self.__pin

print(Account(1234).check_pin(1234), Account(1234).check_pin(1))     # True False

__str__ and __repr__

When you print an object, Python converts it to text. Two special methods control that:

  • __str__ returns a friendly description, used by print() and str().
  • __repr__ returns an unambiguous, developer-focused description, used in the console, in lists and by repr(). Ideally it looks like the code that would recreate the object.
class Book:
    def __init__(self, title, price):
        self.title, self.price = title, price
    def __str__(self):
        return f"{self.title} (Rs {self.price})"
    def __repr__(self):
        return f"Book({self.title!r}, {self.price})"

b = Book("Python", 499)
print(b)                     # Python (Rs 499)
print(repr(b), [b])          # Book('Python', 499) [Book('Python', 499)]
print(str(b), f"{b}", f"{b!r}")
# Python (Rs 499) Python (Rs 499) Book('Python', 499)

Without either method, you would see an unhelpful <__main__.Book object at 0x...>. If a class defines only __repr__, str() falls back to it, so if you write one, write __repr__.

The same distinction exists for built-in types, where the difference is easy to see:

from datetime import date
d = date(2026, 10, 24)
print(str(d), repr(d))         # 2026-10-24 datetime.date(2026, 10, 24)
print(str("hi"), repr("hi"))   # hi 'hi'
str() repr()
Audience users developers
Goal readable unambiguous
Used by print, format, f-strings console, debugging, containers
If missing falls back to repr default <object at 0x...>

A small banking application

This example uses an abstract class, inheritance, polymorphism, private data, a custom exception and __str__/__repr__.

from abc import ABC, abstractmethod

class InsufficientFunds(Exception):
    pass

class BankAccount(ABC):
    _count = 0

    def __init__(self, owner, balance=0):
        self.owner = owner
        self.__balance = balance
        self.history = []
        BankAccount._count += 1

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

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self.__balance += amount
        self.history.append(("deposit", amount))

    def withdraw(self, amount):
        if amount > self.__balance - self.minimum_balance():
            raise InsufficientFunds(f"cannot withdraw {amount}; balance is {self.__balance}")
        self.__balance -= amount
        self.history.append(("withdraw", amount))

    @abstractmethod
    def minimum_balance(self):
        ...

    def __str__(self):
        return f"{self.owner}: Rs {self.__balance}"

    def __repr__(self):
        return f"{type(self).__name__}({self.owner!r}, {self.__balance})"

class Savings(BankAccount):
    def minimum_balance(self): return 500

class Current(BankAccount):
    def minimum_balance(self): return -2000      # overdraft allowed

accounts = [Savings("Asha", 1000), Current("Ravi", 100)]
for acc in accounts:
    acc.deposit(200)
    try:
        acc.withdraw(1000)
    except InsufficientFunds as e:
        print("failed:", e)
    print(acc, "|", repr(acc), "|", acc.history)
failed: cannot withdraw 1000; balance is 1200
Asha: Rs 1200 | Savings('Asha', 1200) | [('deposit', 200)]
Ravi: Rs -700 | Current('Ravi', -700) | [('deposit', 200), ('withdraw', 1000)]

Notice what each part did. The abstract method minimum_balance forces every account type to state its own rule, while withdraw is written once. The balance is private, and the outside world reads it only through a read-only property. The loop treats both account types the same way, and the output differs because of polymorphism. Trying BankAccount("x") would raise TypeError, and acc.balance = 5 would raise AttributeError because the property has no setter. A real application would also store money as integers of paise or as Decimal, never as floats.

Common mistakes

  • Forgetting to inherit from ABC, so @abstractmethod is not enforced.
  • Implementing only some abstract methods and wondering why the class can't be created.
  • Treating _name or __name as real security.
  • Accessing obj.__x from outside and getting AttributeError because of name mangling.
  • Defining __str__ only and then seeing unhelpful output in lists and the console.
  • Using floats for money.

Practice

Create an abstract Employee class with an abstract salary() method and children FullTime and Contractor. Add a private __id, a __repr__ and a __str__, and write a function that prints a payroll for a mixed list. Then add a Payable interface and make both classes implement it.

Continue

You now know the main object-oriented tools in Python. To check your understanding, try the Python interview questions and review exception handling, which the banking example used. The video lessons Episode 61, Episode 62 and Episode 63 cover the same ground, and the Python video course continues with the rest of the language.

Advertisement