Object-Oriented Programming in Python
Object-Oriented Programming in Python organizes code through classes and objects, enabling structured and reusable software development.
Object-oriented programming in Python is the design and implementation of software through objects that combine state and behavior, classes that define reusable object models, and relationships such as inheritance, polymorphism, abstraction, composition, and delegation.
Foundations of Object-Oriented Programming in Python
Objects are entities with identity, state, and behavior. Each object has a unique identity distinguishing it from other objects, maintains state through attributes, and exhibits behavior through methods. Classes are reusable definitions that serve as blueprints for creating and organizing related instances and their associated methods.
Object-oriented design emphasizes coherent responsibilities assigned to objects, collaboration among objects through well-defined interfaces, ownership of state by individual objects, and relationships defined among abstractions rather than simplistic usage of class syntax alone. It involves thoughtful structuring of code where objects encapsulate meaningful state and behavior, and interact without exposing unnecessary implementation details.
Key concepts include:
- Classes: Templates defining structure and behavior for objects.
- Instances: Individual objects created from classes.
- Instance state: Data stored uniquely per instance.
- Class state: Data shared among all instances of a class.
- Methods: Functions bound to classes or instances to implement behavior.
- Inheritance: Mechanism for classes to derive from others, reusing and specializing behavior.
- Polymorphism: Ability to use objects of different classes interchangeably based on shared behavior.
- Composition: Building objects by combining other objects, expressing "has-a" relationships.
- Delegation: Forwarding responsibility for behavior from one object to another.
| Concept | Principal Responsibility |
|---|---|
| Class | Define object blueprint, structure, and shared behavior |
| Instance | Represent a concrete object with unique identity and state |
| Instance Attribute | Store per-object state |
| Class Attribute | Store shared state or constants associated with the class |
| Instance Method | Provide behavior bound to an instance, accessing/modifying instance state |
| Class Method | Provide behavior bound to the class, often factory or alternative constructors |
| Static Method | Provide class-associated behavior without access to instance or class state |
| Inheritance | Enable specialization and reuse by deriving classes from bases |
| Polymorphism | Allow common operations on different object types via shared interface or behavior |
| Abstract Base Class | Define explicit behavioral contracts preventing instantiation without required method implementations |
| Composition | Assemble objects from collaborating components with distinct responsibilities |
| Delegation | Forward operations from one object to another, optionally adapting behavior |
A conceptual representation of these relationships:
Python Classes
A Python class is an object created by executing a class definition. It defines attributes, methods, inheritance relationships, and behavior shared by its instances.
The basic structure of a class definition uses the class statement, followed by the class name, optional base classes in parentheses, and an indented body containing attributes and methods:
class ClassName(BaseClass1, BaseClass2):
class_attribute = value
def __init__(self, param):
self.instance_attribute = param
def instance_method(self):
# behavior using self
pass
When Python executes the class definition, the body code runs in a new namespace. The resulting namespace dictionary is used to create the class object bound to the class name. This class object can then be used to create instances.
Example:
class BankAccount:
interest_rate = 0.02 # Class attribute shared by all accounts
def __init__(self, owner, balance=0):
self.owner = owner # Instance attribute
self.balance = balance # Instance attribute
def deposit(self, amount):
self.balance += amount
def withdraw(self, amount):
if amount <= self.balance:
self.balance -= amount
return amount
else:
raise ValueError("Insufficient funds")
# Creating instances
alice_account = BankAccount("Alice", 1000)
bob_account = BankAccount("Bob", 500)
Class names should be descriptive and represent a defensible abstraction with cohesive behavior, avoiding becoming a container for unrelated functions.
Class objects in Python are first-class: they can be referenced, assigned, passed as arguments, stored in collections, inspected, and called to create instances.
Examples of class objects as first-class entities:
AnotherName = BankAccount # Assign class to another name
all_classes = [BankAccount, AnotherName]
def create_account(account_class, owner):
return account_class(owner)
new_account = create_account(BankAccount, "Charlie")
Python Instances
An instance is an object associated with a class that carries object-specific state while participating in behavior defined by its class and applicable base classes.
Instantiating a class conceptually involves calling the class, which creates a new instance, initializes its state via __init__, and returns the object. This process excludes the lower-level internal mechanics of object creation.
The __init__ method initializes an already created instance, distinct from the complete object creation process.
Example:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p1 = Point(1, 2)
p2 = Point(3, 4)
print(p1.x, p1.y) # 1 2
print(p2.x, p2.y) # 3 4
print(p1 is p2) # False, different identities
The isinstance function checks if an object is an instance of a class or its subclasses, while type returns the exact runtime type.
Example contrasting type and isinstance:
class Animal:
pass
class Dog(Animal):
pass
dog = Dog()
print(type(dog) is Dog) # True
print(type(dog) is Animal) # False
print(isinstance(dog, Dog)) # True
print(isinstance(dog, Animal)) # True
Instance State in Python
Instance state is data associated with an individual object through instance attributes and other supported storage mechanisms.
Instance attributes are typically initialized and accessed through self, which is the conventional parameter name referring to the current instance passed to an instance method. self is not a reserved keyword but a strong convention.
Example:
class Counter:
def __init__(self):
self.count = 0
def increment(self):
self.count += 1
def reset(self):
self.count = 0
Mutation of instance state changes the data within the object. This is distinct from rebinding a local variable that refers to an instance. Multiple names (aliases) referring to the same instance observe the same state changes.
Example:
c1 = Counter()
c2 = c1 # Both names refer to the same instance
c3 = Counter() # Different instance
c1.increment()
print(c1.count) # 1
print(c2.count) # 1, same instance as c1
print(c3.count) # 0, independent instance
Instance attributes can also hold nested mutable values like lists or dictionaries. Ownership of the attribute reference differs from exclusivity of the referenced mutable object.
Alternative instance-storage mechanisms include __slots__, which restrict the attributes an instance can have but do not conceptually define instance state only by the presence of an instance __dict__.
Shared Class State in Python
Class attributes are attributes associated with the class object and commonly visible through instances when no instance-specific attribute shadows the same name.
Shared class state differs from per-instance state. Shared mutable class attributes can intentionally or unintentionally connect all instances, leading to subtle bugs if mutated unintentionally.
Example contrasting immutable and mutable class attributes:
class Config:
default_timeout = 30 # Immutable shared configuration
class Logger:
logs = [] # Mutable shared attribute (dangerous)
log1 = Logger()
log2 = Logger()
log1.logs.append("Error 1") # Affects all instances
print(log2.logs) # ['Error 1']
# Corrected design: per-instance logs
class LoggerFixed:
def __init__(self):
self.logs = []
log3 = LoggerFixed()
log4 = LoggerFixed()
log3.logs.append("Warning")
print(log4.logs) # []
Instance attribute shadowing occurs when an instance attribute shares the same name as a class attribute. Assigning through an instance creates or updates the instance attribute without affecting the class attribute.
Example:
class Device:
status = "off" # Class attribute
d1 = Device()
d2 = Device()
print(d1.status) # off
print(d2.status) # off
d1.status = "on" # Instance attribute shadows class attribute
print(d1.status) # on
print(d2.status) # off
Device.status = "standby"
print(d1.status) # on (instance attribute)
print(d2.status) # standby (class attribute updated)
Shared class state is appropriate for constants, shared configuration, counters, registries, or metadata, but requires explicit ownership and mutation rules.
| Attribute Type | Ownership | Lookup Priority | Mutation Effect | Sharing | Shadowing Behavior | Common Failure Modes |
|---|---|---|---|---|---|---|
| Instance Attribute | Individual object | Instance first, then class | Affects only that instance | None | Shadows class attribute | Unintended sharing if assigned at class level |
| Class Attribute | Class object | Class only if not on instance | Affects all instances if mutable | Shared by all instances | Can be shadowed by instance attribute | Mutating shared mutable attribute unintentionally |
Python Methods
Python methods are callable behaviors associated with classes. The main types differ in binding behavior and intended responsibility: instance methods, class methods, and static methods.
Instance Methods in Python
Instance methods are ordinary functions defined in a class that become bound to instances when accessed through them. They receive the instance as the first parameter, conventionally named self.
Example:
class Greeter:
def greet(self, name):
print(f"Hello, {name}! From {self}")
g = Greeter()
g.greet("Alice") # Called through instance
# Calling instance method through class with explicit instance
Greeter.greet(g, "Bob")
Instance methods can read and modify instance state, use class-level information where appropriate, and collaborate with other methods while focusing on behavior of one instance.
Class Methods in Python
Class methods are decorated with @classmethod. They receive the class as the first parameter, conventionally named cls, providing behavior bound to the class.
Class methods are often used for alternative constructors and enable subclass-aware construction by referring to cls rather than a hard-coded class name.
Example:
class Person:
def __init__(self, name):
self.name = name
@classmethod
def from_full_name(cls, full_name):
first_name = full_name.split()[0]
return cls(first_name)
p = Person.from_full_name("John Smith")
print(p.name) # John
class Employee(Person):
pass
e = Employee.from_full_name("Jane Doe")
print(e.name) # Jane
Static Methods in Python
Static methods are decorated with @staticmethod. They are class-associated callables that receive no automatic instance or class argument.
Static methods are coherent when the operation conceptually belongs near the class but does not require access to instance or class state. Otherwise, a module-level function may be clearer.
Example:
class MathUtils:
@staticmethod
def add(a, b):
return a + b
print(MathUtils.add(3, 4)) # 7
Example class with all three method types:
class Example:
class_value = 42
def instance_method(self):
print(f"Instance method, class_value: {self.class_value}")
@classmethod
def class_method(cls):
print(f"Class method, class_value: {cls.class_value}")
@staticmethod
def static_method():
print("Static method, no access to instance or class state")
e = Example()
e.instance_method()
Example.class_method()
Example.static_method()
| Method Type | Automatic Binding | Conventional First Parameter | Access to Instance State | Access to Class State | Subclass Awareness | Typical Use Case |
|---|---|---|---|---|---|---|
| Instance Method | Bound to instance | self | Yes | Via instance or class | Yes | Behavior of a particular object |
| Class Method | Bound to class | cls | No | Yes | Yes | Alternative constructors, class-level behavior |
| Static Method | No automatic binding | None | No | No | No | Utility functions related to class |
Decorators like @classmethod and @staticmethod modify descriptor access behavior, not just serving as documentation.
Example of method selection and implementation:
- Use instance method when behavior depends on instance state.
- Use class method for alternative constructors or behaviors involving class state.
- Use static method for utility functions logically grouped with class but independent of instance or class state.
- Use module-level function if behavior is unrelated to class abstraction.
Encapsulation in Python
Encapsulation involves designing objects so that state and behavior are exposed through coherent interfaces while implementation details remain replaceable or intentionally non-public.
Leading-underscore naming conventions signal implementation-oriented attributes or methods but do not enforce access restrictions.
Double-leading-underscore names are transformed by name mangling to reduce accidental name collisions in subclasses, not to provide security or true privacy.
Example:
class Example:
public_attr = "public"
_impl_attr = "internal use"
__mangled_attr = "name mangled"
e = Example()
print(e.public_attr) # public
print(e._impl_attr) # internal use
# print(e.__mangled_attr) # AttributeError
print(e._Example__mangled_attr) # name mangled, accessible but discouraged
Behavioral encapsulation is achieved by methods that validate state transitions and preserve invariants, rather than exposing unrestricted direct mutation.
Example:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
def get_celsius(self):
return self._celsius
def set_celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
t = Temperature(20)
t.set_celsius(-300) # Raises ValueError
Managed Attributes with Python Properties
Properties provide managed attributes that preserve attribute-access syntax while delegating retrieval, assignment, or deletion to methods.
Read-only computed properties use @property to compute a value derived from instance state without storing redundant data.
Example of read-only property:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
return self.width * self.height
r = Rectangle(3, 4)
print(r.area) # 12
Property setters enable validation, normalization, or coordinated state updates while preserving object invariants.
Example with setter:
class Person:
def __init__(self, age):
self._age = age
@property
def age(self):
return self._age
@age.setter
def age(self, value):
if value < 0:
raise ValueError("Age cannot be negative")
self._age = value
p = Person(30)
p.age = 35 # Valid
# p.age = -5 # Raises ValueError
Property deleters are used only when deletion is meaningful. Properties do not require defining all getter, setter, and deleter methods—only those needed.
Python encourages starting with simple public attributes and introducing properties when behavior or invariants require controlled access.
Inheritance in Python
Inheritance is a class relationship where a subclass participates in behavior and attribute lookup defined by one or more base classes while being able to specialize or extend that behavior.
Subclass construction specifies base classes in parentheses after the class name. Inherited methods and attributes become available via ordinary attribute resolution.
Example:
class Animal:
def speak(self):
print("Animal sound")
class Dog(Animal):
def speak(self):
print("Woof!")
a = Animal()
d = Dog()
a.speak() # Animal sound
d.speak() # Woof!
issubclass and isinstance check subtype relationships, distinguishing subclass compatibility from exact type identity.
Method Overriding in Python
Subclasses can provide methods with the same name as base classes. These override base methods and take precedence in method resolution.
Example:
class Vehicle:
def move(self):
print("Moving")
class Car(Vehicle):
def move(self):
print("Driving")
v = Vehicle()
c = Car()
v.move() # Moving
c.move() # Driving
Subclasses may extend inherited behavior by invoking base methods rather than completely replacing them.
Multiple Inheritance in Python
Python supports multiple inheritance where a class can have more than one base class.
Example:
class Flyer:
def fly(self):
print("Flying")
class Swimmer:
def swim(self):
print("Swimming")
class Duck(Flyer, Swimmer):
pass
d = Duck()
d.fly() # Flying
d.swim() # Swimming
The diamond problem occurs when multiple inheritance forms a diamond shape. Naive repeated base-class calls may duplicate shared ancestor initialization.
Python Method Resolution Order
The Method Resolution Order (MRO) is a deterministic linearization used to search classes for attributes and cooperative methods.
Example:
class A:
def greet(self):
print("A")
class B(A):
def greet(self):
print("B")
class C(A):
def greet(self):
print("C")
class D(B, C):
pass
print(D.__mro__)
d = D()
d.greet() # B, because B precedes C in MRO
MRO preserves local precedence and consistent ordering.
| Concept | Purpose | Principal Mechanism | Common Risk |
|---|---|---|---|
| Single Inheritance | Simple specialization | One base class | Deep hierarchies become complex |
| Multiple Inheritance | Combine independent behaviors | Linearized MRO | Ambiguous attribute resolution |
| Overriding | Customize inherited behavior | Method replacement | Violating behavioral contract |
| MRO | Attribute lookup order | C3 linearization | Unexpected method called |
| Cooperative Invocation | Ensuring all classes participate | Use of super() | Omitting base calls causes bugs |
Cooperative Inheritance with super() in Python
super() allows continuing attribute or method lookup according to MRO, not hard-coding a particular parent class.
Example:
class A:
def process(self):
print("A")
super().process()
class B(A):
def process(self):
print("B")
super().process()
class C(B):
def process(self):
print("C")
super().process()
class D(C):
def process(self):
print("D")
# End of chain; no super call
d = D()
d.process()
Output:
D
C
B
A
Cooperative constructors forward arguments and honor calling conventions to ensure all initialization occurs once and in order.
Example contrasting direct base calls with cooperative super():
class Base:
def __init__(self):
print("Base init")
class Left(Base):
def __init__(self):
print("Left init")
super().__init__()
class Right(Base):
def __init__(self):
print("Right init")
super().__init__()
class Child(Left, Right):
def __init__(self):
print("Child init")
super().__init__()
c = Child()
Output:
Child init
Left init
Right init
Base init
Direct base-class calls (e.g., Base.__init__(self)) can cause duplicate initializations or skip classes, breaking cooperative behavior.
Polymorphism in Python
Polymorphism is the ability to apply a common operation to different objects whose compatible behavior allows each object to provide its appropriate result.
Subclass Polymorphism in Python
Instances of different subclasses can be used through behavior defined or expected by a common base abstraction.
Example:
class Shape:
def area(self):
raise NotImplementedError
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side * self.side
shapes = [Circle(2), Square(3)]
for shape in shapes:
print(shape.area())
Behavioral substitutability requires that subclasses preserve the expectations of callers regarding operations, result meaning, accepted operations, and invariants.
Duck Typing in Python
Duck typing bases compatibility on supported behavior rather than explicit inheritance.
Example:
class Duck:
def quack(self):
print("Quack")
class Person:
def quack(self):
print("I'm quacking like a duck")
def make_it_quack(obj):
obj.quack()
make_it_quack(Duck())
make_it_quack(Person())
Python often attempts the required operation and handles meaningful failure rather than performing explicit concrete-type checks.
Example contrasting rigid type check and duck typing:
def speak(obj):
if isinstance(obj, Dog):
obj.bark()
elif isinstance(obj, Cat):
obj.meow()
else:
print("Unknown animal")
# Versus duck typing:
def speak_duck(obj):
try:
obj.speak()
except AttributeError:
print("Object cannot speak")
Inheritance-based polymorphism and duck typing can coexist; Python code may deliberately use explicit nominal relationships or behavioral compatibility depending on abstraction.
| Feature | Subclass Polymorphism | Duck Typing |
|---|---|---|
| Required Relationship | Explicit inheritance hierarchy | Supported behavior, no inheritance required |
| Compatibility Basis | Class-based interface | Behavior-based interface |
| Interface Expression | Base class and overridden methods | Presence of required methods |
| Flexibility | Less flexible, rigid hierarchy | More flexible, dynamic |
| Representative Risks | Inappropriate inheritance, brittle | Runtime errors if behavior missing |
Abstract Base Classes in Python
Abstract base classes (ABCs) define explicit behavioral abstractions and prevent ordinary instantiation while required abstract operations remain unimplemented.
Using abc.ABC as a base and @abstractmethod decorator declares abstract methods while allowing concrete shared behavior.
Example:
from abc import ABC, abstractmethod
class Vehicle(ABC):
@abstractmethod
def drive(self):
pass
def stop(self):
print("Stopping")
class Car(Vehicle):
def drive(self):
print("Driving car")
class Bike(Vehicle):
def drive(self):
print("Riding bike")
vehicles = [Car(), Bike()]
for v in vehicles:
v.drive()
v.stop()
Attempting to instantiate an abstract base class or subclass lacking implementations of abstract methods raises TypeError.
Example:
try:
v = Vehicle()
except TypeError as e:
print(e) # Can't instantiate abstract class Vehicle with abstract methods drive
ABCs are useful for explicit shared contracts, while duck typing or composition may avoid unnecessary nominal hierarchy.
Composition in Python
Composition builds an object from collaborating component objects whose responsibilities remain distinct, commonly expressing a has-a relationship rather than an is-a specialization.
Dependency ownership in composition varies: components may be created internally, supplied externally, shared among objects, or replaceable through configuration.
Example:
class Engine:
def start(self):
print("Engine started")
class Car:
def __init__(self, engine):
self.engine = engine # Composition
def start(self):
self.engine.start()
print("Car is running")
engine = Engine()
car = Car(engine)
car.start()
Composition reduces inheritance coupling by allowing behavior to vary through replacement of collaborating objects rather than creating additional subclasses.
Contrasting design:
# Inheritance-based design
class CarWithEngine(Engine):
def start(self):
super().start()
print("Car is running")
# Composition-based design
class Car:
def __init__(self, engine):
self.engine = engine
def start(self):
self.engine.start()
print("Car is running")
The composed version better represents independent responsibilities.
Lifecycle considerations include whether the composed object owns, shares, or merely references collaborators.
Delegation in Python
Delegation involves an object receiving an operation and forwarding all or part of the responsibility to another object, optionally adapting inputs, outputs, policy, or surrounding behavior.
Explicit delegation is implemented by methods calling corresponding behavior on a collaborator, distinct from inheritance or merely storing another object.
Example of explicit delegation:
class Logger:
def log(self, message):
print(f"Log: {message}")
class Service:
def __init__(self, logger):
self.logger = logger
def process(self, data):
self.logger.log(f"Processing {data}")
# perform processing
return data.upper()
logger = Logger()
service = Service(logger)
result = service.process("example")
print(result)
Dynamic delegation uses __getattr__ to forward unresolved attribute access, but can hide interfaces and forward unintended behavior.
Example of dynamic delegation:
class Wrapper:
def __init__(self, delegate):
self._delegate = delegate
def __getattr__(self, name):
return getattr(self._delegate, name)
class Target:
def action(self):
print("Action performed")
t = Target()
w = Wrapper(t)
w.action() # Delegated to Target.action()
Explicit delegation is usually clearer when only a small stable interface should be exposed.
Delegation coexists with composition and polymorphism without implying that every composed object should expose its collaborator's full interface.
Solved Object-Oriented Programming Exercises in Python
Exercise 1: BankAccount Class
class BankAccount:
interest_rate = 0.03 # shared class attribute
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance # instance attribute with controlled access
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._balance += amount
def withdraw(self, amount):
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
@classmethod
def from_string(cls, account_str):
owner, balance = account_str.split(',')
return cls(owner.strip(), float(balance))
@staticmethod
def validate_amount(amount):
return amount > 0
@property
def balance(self):
return self._balance
@balance.setter
def balance(self, value):
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
# Usage
account1 = BankAccount("Alice", 1000)
account2 = BankAccount.from_string("Bob, 500")
account1.deposit(200)
account2.withdraw(100)
print(account1.balance) # 1200
print(account2.balance) # 400
Explanation:
- Defined class with shared interest rate.
- Instance attributes for owner and balance.
- Methods enforce validation and state changes.
- Alternative constructor
from_stringuses class method. - Static method validates amounts without needing instance or class state.
- Managed property
balanceenforces invariant on assignment.
Exercise 2: Abstract Base Class and Duck Typing
from abc import ABC, abstractmethod
class PaymentProcessor(ABC):
@abstractmethod
def pay(self, amount):
pass
class CreditCardProcessor(PaymentProcessor):
def pay(self, amount):
print(f"Charging credit card: ${amount}")
class PaypalProcessor(PaymentProcessor):
def pay(self, amount):
print(f"Processing PayPal payment: ${amount}")
class CashPayment:
def pay(self, amount):
print(f"Paying cash: ${amount}")
def process_payment(processor, amount):
processor.pay(amount)
cc = CreditCardProcessor()
pp = PaypalProcessor()
cash = CashPayment()
for p in [cc, pp, cash]:
process_payment(p, 100)
Explanation:
PaymentProcessoris an abstract base class defining a payment interface.CreditCardProcessorandPaypalProcessorimplement the abstract method.CashPaymentdoes not inherit but supports the same method (pay) — demonstrating duck typing.- Client code treats all processors uniformly without branching on concrete types.
Exercise 3: Composition, Delegation, and Cooperative Multiple Inheritance
class LoggerMixin:
def log(self, message):
print(f"[LOG]: {message}")
class Storage:
def __init__(self):
self._data = {}
def save(self, key, value):
self._data[key] = value
def load(self, key):
return self._data.get(key, None)
class Service(LoggerMixin, Storage):
def __init__(self):
super().__init__() # cooperative call
def process(self, key, value):
self.log(f"Processing key={key}, value={value}")
self.save(key, value)
s = Service()
s.process("x", 42)
print(s.load("x"))
# Inspect MRO
print(Service.__mro__)
Output:
[LOG]: Processing key=x, value=42
42
(<class '__main__.Service'>, <class '__main__.LoggerMixin'>, <class '__main__.Storage'>, <class 'object'>)
Explanation:
LoggerMixinandStoragedefine independent behaviors.Servicecomposes behavior via multiple inheritance and usessuper()cooperatively.processdelegates logging and storage responsibilities to respective base classes.- MRO shows deterministic search order, ensuring each base is initialized once.
Reviewing an object-oriented Python design involves checking for:
- Classes with unrelated responsibilities that should be split.
- Unnecessary inheritance that can be replaced by composition.
- Duplicated state or accidental shared mutable class data.
- Weak or missing invariants leading to inconsistent object states.
- Excessive getters and setters that break encapsulation.
- Fragile multiple inheritance without cooperative
super()use. - Inappropriate concrete-type branching instead of polymorphism.
- Abstract classes without meaningful contracts or incomplete implementations.
- Delegation exposing too much of collaborator internals instead of a minimal interface.
Adhering to clear subclassing, encapsulation, and collaboration principles leads to maintainable and correct object-oriented Python code.