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 byprint()andstr().__repr__returns an unambiguous, developer-focused description, used in the console, in lists and byrepr(). 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@abstractmethodis not enforced. - Implementing only some abstract methods and wondering why the class can't be created.
- Treating
_nameor__nameas real security. - Accessing
obj.__xfrom outside and gettingAttributeErrorbecause 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.