The Four Principles of OOP in Python

Encapsulation, abstraction, inheritance and polymorphism - what each one means in Python specifically, where Python differs from Java or C++, and when each is worth using.

The four, in one table

PrincipleMeansIn Python
EncapsulationKeep data and the code that changes it together, and control accessBy convention, using underscores and @property
AbstractionExpose what something does, hide howMethods, and abc for enforced interfaces
InheritanceA class reuses and extends anotherFull support, including multiple inheritance
PolymorphismOne interface, many implementationsDuck typing - no shared base class required

Encapsulation

class Thermostat:
    def __init__(self, target=20):
        self._target = target                # internal by convention

    @property
    def target(self):
        return self._target

    @target.setter
    def target(self, value):
        if not 5 <= value <= 30:
            raise ValueError("target must be between 5 and 30")
        self._target = value


t = Thermostat()
t.target = 22               # goes through the setter and is validated
print(t.target)             # reads through the getter

try:
    t.target = 90
except ValueError as error:
    print(error)

The object protects its own rules. A caller cannot put it into an impossible state through the public interface.

Python does not enforce privacy. t._target = 90 works. The convention is understood and respected: an underscore means "this is not part of the interface, and it may change without warning". Python trusts the programmer rather than the compiler.

Do not write Java in Python

# Unnecessary in Python
class Point:
    def __init__(self, x):
        self._x = x

    def get_x(self):
        return self._x

    def set_x(self, value):
        self._x = value


# Idiomatic: start with a plain attribute
class Point:
    def __init__(self, x):
        self.x = x

Getters and setters that do nothing add noise. Start with a public attribute; if validation becomes necessary later, convert it to a property without changing a single line of calling code. That ability is exactly why Python does not need defensive accessors up front.

Abstraction

class EmailSender:
    def send(self, to, subject, body):
        self._connect()
        self._authenticate()
        self._transmit(to, subject, body)
        self._disconnect()
        return True

    def _connect(self): ...
    def _authenticate(self): ...
    def _transmit(self, to, subject, body): ...
    def _disconnect(self): ...


sender = EmailSender()
sender.send("a@example.com", "Hello", "Text")     # four steps, one call

The caller sees send. Everything else is an implementation detail that can be rewritten without breaking anyone.

from abc import ABC, abstractmethod


class Storage(ABC):
    """Anything that can save and load notes."""

    @abstractmethod
    def save(self, key, value):
        ...

    @abstractmethod
    def load(self, key):
        ...


class MemoryStorage(Storage):
    def __init__(self):
        self._data = {}

    def save(self, key, value):
        self._data[key] = value

    def load(self, key):
        return self._data.get(key)


# Storage()          # TypeError: cannot instantiate an abstract class
store = MemoryStorage()
store.save("a", "first note")
print(store.load("a"))

An abstract base class states the contract. Any subclass that forgets a method cannot be instantiated at all, so the failure happens at creation rather than at the first call.

Inheritance

class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def describe(self):
        return f"{self.name} earns {self.salary:,}"

    def annual_pay(self):
        return self.salary * 12


class Manager(Employee):
    def __init__(self, name, salary, reports):
        super().__init__(name, salary)
        self.reports = reports

    def describe(self):                        # overrides the parent
        base = super().describe()
        return f"{base}, managing {len(self.reports)} people"

    def annual_pay(self):
        return super().annual_pay() + 100_000  # a bonus


m = Manager("Meera", 90_000, ["arun", "sara"])
print(m.describe())
print(f"{m.annual_pay():,}")
print(isinstance(m, Employee))       # True - a Manager IS an Employee

Use inheritance only when the "is a" relationship is genuinely true, and when the subclass can be used anywhere the parent is expected.

# A classic misuse
class Stack(list):                # a Stack is NOT a list
    def push(self, item):
        self.append(item)


s = Stack()
s.push(1)
s.insert(0, 99)          # inherited, and it breaks the stack rules
print(s)


# Composition instead
class Stack:
    def __init__(self):
        self._items = []          # a Stack HAS a list

    def push(self, item):
        self._items.append(item)

    def pop(self):
        if not self._items:
            raise IndexError("pop from an empty stack")
        return self._items.pop()

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

Polymorphism

class Dog:
    def speak(self):
        return "Woof"


class Cat:
    def speak(self):
        return "Meow"


class Robot:
    def speak(self):
        return "Beep"


for thing in [Dog(), Cat(), Robot()]:
    print(thing.speak())

These three classes share no base class and no interface declaration. The loop works because each one has a speak method. That is duck typing: if it walks like a duck and quacks like a duck, Python treats it as a duck.

def total_length(items):
    return sum(len(item) for item in items)


print(total_length(["ab", "cde"]))                    # strings
print(total_length([[1, 2], [3]]))                    # lists
print(total_length([{"a": 1}, {"b": 2, "c": 3}]))     # dictionaries
print(total_length([(1,), range(5)]))                 # tuples and ranges

This function was never written for any of those types. It requires only that each item supports len. In a statically typed language it would need an interface and every type would have to declare it.

Polymorphism through dunder methods

class Money:
    def __init__(self, amount):
        self.amount = amount

    def __add__(self, other):
        return Money(self.amount + other.amount)

    def __len__(self):
        return len(str(self.amount))

    def __str__(self):
        return f"Rs {self.amount:,.2f}"


print(Money(100) + Money(250))       # Rs 350.00
print(len(Money(1000)))              # 4

Built in operations such as +, len, str, in and iteration are all polymorphic. Implementing the right dunder method makes your class work with syntax it never knew about.

The four together

from abc import ABC, abstractmethod


class Shape(ABC):                                  # abstraction
    @abstractmethod
    def area(self):
        ...

    def describe(self):                            # shared behaviour
        return f"{type(self).__name__} with area {self.area():.2f}"


class Rectangle(Shape):                            # inheritance
    def __init__(self, width, height):
        self._width = width                        # encapsulation
        self._height = height

    @property
    def width(self):
        return self._width

    def area(self):
        return self._width * self._height


class Circle(Shape):
    def __init__(self, radius):
        self._radius = radius

    def area(self):
        return 3.14159 * self._radius ** 2


shapes = [Rectangle(3, 4), Circle(5), Rectangle(2, 2)]

for shape in shapes:                               # polymorphism
    print(shape.describe())

print(f"total area {sum(s.area() for s in shapes):.2f}")
print(max(shapes, key=lambda s: s.area()).describe())

Common mistakes

  • Writing getters and setters for every attribute out of habit.
  • Using inheritance for code reuse when the "is a" relationship is false.
  • Building deep hierarchies; three levels is usually already too many.
  • Checking types with isinstance where duck typing would have worked.
  • Assuming a leading underscore prevents access.
  • Making everything a class when functions would be clearer.

Best practices

  • Start with public attributes; add property only when validation is needed.
  • Prefer composition to inheritance, and inherit only for a true "is a".
  • Rely on duck typing; ask what an object can do, not what it is.
  • Use abc when several classes must implement the same contract.
  • Keep hierarchies shallow and each class focused.

Practice

  1. Convert a class with get_ and set_ methods into one using properties, without changing the callers.
  2. Write three unrelated classes with a render method and process them in one loop.
  3. Define an abstract base class and show the error when a subclass omits a method.
  4. Take a class that inherits from list and rewrite it using composition, explaining what improved.
  5. Implement __add__ and __eq__ on a class so it works with sum and in a set.

Conclusion

Encapsulation keeps rules with data, abstraction hides how, inheritance shares behaviour where "is a" holds, and polymorphism lets one piece of code work with many types. In Python the last one needs no declarations at all - which is why duck typing, not inheritance, is the usual answer.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

Classes and Objects

A class describes a kind of thing; an object is one of them. Python builds every object the same way, and understanding that removes most of the myste...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.