Filter concepts by levelShowing all levels.

Python · Object-Oriented Python

OOP fundamentals

Concepts
15

How to define a class, build objects from it, and give them behaviour — then the three ways one object can relate to another, and when to reach for inheritance versus composition.

This section

Defining a class

The blueprint, the objects it builds, and the setup step that runs automatically.

Classes and objects

corebeginner

A class defines the shape something has — its attributes and behaviour. An object is one concrete thing built from that shape. class Dog: defines the blueprint; Dog() builds an object from it.

Think of it as

A class is a cookie cutter; an object is one cookie. The cutter defines the shape every cookie will have, but cutting it does not use up the cutter — you can stamp out as many independent cookies as you like, each one its own dough.

python
class ClassName:
    """A blueprint — this body defines shape, not one instance."""
    ...

obj = ClassName()   # calling the class builds one object

What we're doing: Define a bare class, build two objects from it, and show they are independent.

dogs.pypython
class Dog:
    pass

rex = Dog()
fido = Dog()

rex.name = "Rex"
print(rex.name)
print(hasattr(fido, "name"))
print(rex is fido)
print(type(rex) is Dog)
1
class Dog: pass defines the blueprint. It builds nothing by itself.
4
Dog() calls the class, which constructs and returns one new object — bound to rex.
5
A second call to Dog() builds a completely separate object, bound to fido.
7
Setting rex.name only touches rex — fido was never given a name attribute.
Output
Rex
False
False
True

Why this works: Dog() is a call to the class itself, and every call constructs a fresh, independent object — nothing about one object's attributes is shared with another unless the class deliberately arranges that (see class attributes). rex and fido are both built by Dog, so type(rex) is Dog is True and isinstance(rex, Dog) would be too, but rex is fido is False because identity checks the specific object built, not which class built it.

Expecting Dog() to reuse a previously built object

Wrong

python
class Dog:
    pass

rex = Dog()
rex.name = "Rex"

another_rex = Dog()
print(another_rex.name)  # AttributeError

Better

python
class Dog:
    pass

rex = Dog()
rex.name = "Rex"

another_rex = rex
print(another_rex.name)

What you see: AttributeError: 'Dog' object has no attribute 'name' — the new object never saw the name set on the first one.

Why: Every call to Dog() builds a brand-new, empty object — it does not look up or reuse any object built by an earlier call. To refer to the same object again, bind a second name to it directly (another_rex = rex), rather than calling the class a second time.

One blueprint, many independent objects

class Dog

the blueprint — no dog yet

Dog()

called twice, independently

rex, fido

two distinct objects, same shape

  1. class Dog — the blueprint — no dog yet
  2. Dog() — called twice, independently
  3. rex, fido — two distinct objects, same shape

Class vs. the objects it builds

Class vs. the objects it builds
ExpressionWhat it is
class Dog: ...the blueprint — defines what every Dog will have
Dog()one new object, built from the blueprint
type(rex)<class '__main__.Dog'> — the class that built rex
isinstance(rex, Dog)True — rex was built by the Dog class
rex is fidoFalse — two calls to Dog() build two distinct objects

Together

python
class Dog:
    pass

rex = Dog()
fido = Dog()
print(type(rex))
print(isinstance(rex, Dog))
print(rex is fido)

Remember: A class is the blueprint; Name() builds one independent object from it. type(obj) and isinstance(obj, Name) both ask which blueprint built it.

See also: constructors · instance attributes · class attributes

Constructors

corebeginner

def __init__(self, ...): is the method Python calls automatically right after building a new object, to set up its starting attributes. Dog("Rex") builds the object, then calls __init__(new_dog, "Rex") for you.

Think of it as

__init__ is the setup checklist run the moment a new object comes off the line, before it's handed to whoever asked for it — not the machine that builds the object itself. The object already exists when __init__ starts; its job is only to fill in the starting attributes.

python
class ClassName:
    def __init__(self, arg1, arg2):
        self.attr1 = arg1
        self.attr2 = arg2

obj = ClassName(value1, value2)  # __init__ runs automatically

What we're doing: Give Dog a constructor that requires a name and defaults an age, and show __init__ runs once per object.

dog_init.pypython
class Dog:
    def __init__(self, name, age=0):
        self.name = name
        self.age = age
        print(f"built {name}")

rex = Dog("Rex", 3)
fido = Dog("Fido")
print(rex.name, rex.age)
print(fido.name, fido.age)
2
__init__ takes self plus whatever arguments the caller supplies; age has a default.
5
The print inside __init__ proves it runs once per Dog(...) call, right after the object is built.
7
Dog("Rex", 3) supplies both arguments; __init__(rex, "Rex", 3) runs.
8
Dog("Fido") omits age — __init__ falls back to its default, exactly like any other function.
Output
built Rex
built Fido
Rex 3
Fido 0

Why this works: Name(...) is a call to the class, and Python responds to it in two steps: __new__ allocates a bare object, then __init__ is called on that object with self bound to it and the rest of the call's arguments passed through unchanged — exactly the parameter-passing rules any function follows, defaults included. __init__ never returns the object itself; it only mutates the self it was handed, which is why leaving off a return statement is correct, not an oversight.

Returning a value from __init__

Wrong

python
class Dog:
    def __init__(self, name):
        self.name = name
        return self  # TypeError

Better

python
class Dog:
    def __init__(self, name):
        self.name = name  # no return needed

What you see: __init__() should return None, not 'Dog' — TypeError, raised immediately when Dog(...) is called.

Why: __init__ is defined to always return None because its job is to mutate the object Python already built, never to produce a new one — returning anything else is treated as a programmer error and raises immediately rather than being silently ignored.

Build, then initialize

Dog("Rex", 3)

the call

__new__

allocates an empty object

__init__(self, ...)

sets self.name, self.age

  1. Dog("Rex", 3) — the call
  2. __new__ — allocates an empty object
  3. __init__(self, ...) — sets self.name, self.age

What happens when Dog("Rex", 3) runs

What happens when Dog("Rex", 3) runs
StepWhat happens
1. __new__a new, empty Dog object is allocated (default: object.__new__)
2. __init__Python calls __init__(new_obj, "Rex", 3) on that object
3. inside __init__self.name = "Rex" and self.age = 3 set its attributes
4. resultDog("Rex", 3) evaluates to the now-initialized object

Together

python
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

rex = Dog("Rex", 3)
print(rex.name, rex.age)

Remember: __init__(self, ...) runs automatically right after Name(...) builds the object — it sets up attributes on self and must return None.

See also: classes and objects · instance attributes · instance methods

Advertisement

Attributes

Data that belongs to one instance, versus data shared by every instance.

Instance attributes

corebeginner

An instance attribute is data that belongs to one specific object — self.name = value inside __init__ (or any method) creates or updates it on that object alone. Two instances of the same class hold two separate copies.

Think of it as

Each object carries its own private notebook (__dict__). self.name = value writes a page in the notebook belonging to THAT object — it never touches any other object's notebook, even one built from the identical class.

python
class ClassName:
    def __init__(self, value):
        self.attr = value   # instance attribute, set at construction time

obj = ClassName(1)
obj.other = 2                # can also be set after construction

What we're doing: Show that two instances of the same class hold completely independent attributes.

instance_attrs.pypython
class Dog:
    def __init__(self, name):
        self.name = name

rex = Dog("Rex")
fido = Dog("Fido")

rex.age = 3

print(rex.name, getattr(rex, "age", None))
print(fido.name, getattr(fido, "age", None))
print(rex.__dict__)
print(fido.__dict__)
3
self.name = name runs once per __init__ call, writing to the __dict__ of whichever instance self is.
7
rex.age = 3 is added after construction and touches ONLY rex — fido was never involved.
12
rex.__dict__ shows name and age; fido.__dict__ shows only name — proof the two are separate notebooks.
Output
Rex 3
Fido None
{'name': 'Rex', 'age': 3}
{'name': 'Fido'}

Why this works: Every instance built by a class gets its own __dict__ the moment it's constructed, and self.attr = value is really shorthand for writing into that specific dict — self is just a reference to one particular object, so the write can only ever land in that object's own storage. rex.age = 3 never touches fido because the two instances, despite sharing a class, do not share any storage at all unless the class itself arranges it (see class attributes).

Assuming setting one instance's attribute changes another

Wrong

python
class Dog:
    def __init__(self, name):
        self.name = name

rex = Dog("Rex")
fido = Dog("Fido")
rex.age = 3
print(fido.age)  # AttributeError — fido never got one

Better

python
class Dog:
    def __init__(self, name, age=0):
        self.name = name
        self.age = age

rex = Dog("Rex", 3)
fido = Dog("Fido")   # age defaults, set explicitly on THIS instance
print(fido.age)

What you see: AttributeError: 'Dog' object has no attribute 'age' — setting an attribute on one instance never creates it on another.

Why: Instance attributes are per-object by definition — each instance owns its own __dict__, so nothing set on rex is visible when looking up an attribute on fido. If every instance should start with the same attribute, give it a default in __init__ (or use a class attribute for one truly shared value) rather than setting it on instances one at a time.

Two instances, two separate __dict__ notebooks

class Dog

shared code — __init__, methods

rex.__dict__

{'name': 'Rex', 'age': 3}

fido.__dict__

{'name': 'Fido'} — age was never set here

  1. class Dog — shared code — __init__, methods
  2. rex.__dict__ — {'name': 'Rex', 'age': 3}
  3. fido.__dict__ — {'name': 'Fido'} — age was never set here

Working with instance attributes

Working with instance attributes
CodeEffect
self.name = "Rex"creates/overwrites name on this instance only
rex.age = 3attributes can be set from outside __init__ too
del rex.ageremoves age from rex — AttributeError on the next read
vars(rex){'name': 'Rex'} — this instance's own attribute dict
getattr(rex, "age", 0)0 — safe read with a fallback if age isn't set

Together

python
class Dog:
    def __init__(self, name):
        self.name = name

rex = Dog("Rex")
rex.age = 3
print(vars(rex))
del rex.age
print(getattr(rex, "age", 0))

Remember: self.attr = value writes to ONE instance's own __dict__ — instances of the same class never share attributes unless the class arranges it.

See also: constructors · class attributes · instance methods

Class attributes

standardbeginner

A class attribute is defined directly in the class body (not inside __init__) and lives on the class object itself, shared by every instance that does not set its own attribute of the same name.

Think of it as

A class attribute is a notice pinned to the class's own bulletin board, not written in any one instance's private notebook. Every instance reads it by walking up to the board when its own notebook has no page for that name — but writing through an instance always starts a new page in ITS notebook, never edits the board.

python
class ClassName:
    shared_value = 0        # class attribute — one copy, on the class

    def __init__(self, value):
        self.own_value = value   # instance attribute — one copy per instance

What we're doing: Use a class attribute as a shared counter of how many instances have been built.

dog_count.pypython
class Dog:
    count = 0

    def __init__(self, name):
        self.name = name
        Dog.count += 1

rex = Dog("Rex")
fido = Dog("Fido")
print(Dog.count)
print(rex.count, fido.count)
2
count = 0 in the class body creates one class attribute, shared before any instance exists.
6
Dog.count += 1 updates the shared class attribute — written through the class, not through self.
10
rex.count and fido.count both read the same shared value by falling back to the class.
Output
2
2 2

Why this works: count lives on the Dog class itself, not on any instance, so Dog.count += 1 inside __init__ mutates the one shared value every time a new Dog is built — both rex.count and fido.count read that same class-level value because neither instance has ever been given a count attribute of its own. Had __init__ instead written self.count += 1, that line would create a separate, instance-level count on whichever object was under construction, and the shared counter would never move.

Mutating a mutable class attribute through an instance

Wrong

python
class Dog:
    tricks = []   # ONE shared list, not per-instance

    def add_trick(self, trick):
        self.tricks.append(trick)   # mutates the SHARED list

rex = Dog()
fido = Dog()
rex.add_trick("sit")
print(fido.tricks)  # ["sit"] — fido got rex's trick too

Better

python
class Dog:
    def __init__(self):
        self.tricks = []   # a fresh list PER instance

    def add_trick(self, trick):
        self.tricks.append(trick)

rex = Dog()
fido = Dog()
rex.add_trick("sit")
print(fido.tricks)

What you see: fido.tricks unexpectedly contains rex's trick — every instance was silently sharing the same list object the whole time.

Why: self.tricks.append(...) does not assign to self.tricks — it mutates whatever object tricks currently points to, and since no instance ever set its own tricks attribute, every instance is reading and mutating the SAME class-level list. Defining any mutable default (list, dict, set) in __init__ instead of the class body gives each instance its own object, the same fix mutable default arguments need.

Reading and writing a class attribute

Reading and writing a class attribute
CodeEffect
class Dog: species = "Canis familiaris"defines a class attribute, shared by every Dog
rex.speciesreads the class attribute — rex has no instance override
rex.species = "Wolf"creates an INSTANCE attribute on rex only — the class value is untouched
Dog.species = "Wolf"changes the class attribute itself — visible to every instance without one of its own
Dog.speciesreads the class attribute directly, no instance involved

Together

python
class Dog:
    species = "Canis familiaris"

rex = Dog()
fido = Dog()
print(rex.species, fido.species)
rex.species = "Wolf"
print(rex.species, fido.species)

Remember: self.attr = value always creates an instance attribute — to change a shared class attribute, assign through the CLASS (Dog.attr = value), not an instance.

See also: classes and objects · instance attributes · class methods

Advertisement

Methods

Three kinds of method, distinguished by what gets bound as the first parameter.

Instance methods

corebeginner

An instance method is a function defined inside a class body that takes self as its first parameter. rex.bark() automatically passes rex as self, so the method can read and change that specific instance's attributes.

Think of it as

An instance method is a form letter addressed 'Dear self,' — the same letter (function body) is reused for every instance, but rex.bark() fills in self with rex specifically, so the letter only ever talks about the dog that called it.

python
class ClassName:
    def method_name(self, arg):
        return self.attr + arg   # self gives access to this instance

obj.method_name(value)   # self is bound to obj automatically

What we're doing: Define two instance methods, one reading state and one changing it, and call both through an instance.

dog_methods.pypython
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def describe(self):
        return f"{self.name} is {self.age} years old"

    def have_birthday(self):
        self.age += 1

rex = Dog("Rex", 3)
print(rex.describe())
rex.have_birthday()
print(rex.describe())
5
describe(self) reads self.name and self.age — the specific instance the call was made through.
8
have_birthday(self) mutates self.age — a change visible on rex specifically, not on any other Dog.
13
rex.describe() binds self to rex automatically; no argument is passed explicitly for it.
14
rex.have_birthday() also binds self to rex, then increments rex.age by one.
Output
Rex is 3 years old
Rex is 4 years old

Why this works: rex.describe() is Python's syntax for looking up describe on rex's class and then calling it with rex automatically supplied as the first argument, self — this is exactly what Dog.describe(rex) would do written out by hand. Because self is the real object rex, self.age += 1 inside have_birthday mutates rex's own age attribute, which is why the second describe() call reflects the change.

Forgetting self when defining or calling an instance method

Wrong

python
class Dog:
    def bark():   # missing self
        return "woof"

rex = Dog()
print(rex.bark())  # TypeError

Better

python
class Dog:
    def bark(self):
        return "woof"

rex = Dog()
print(rex.bark())

What you see: TypeError: Dog.bark() takes 0 positional arguments but 1 was given — rex.bark() always supplies rex as an argument, whether or not the method has a parameter to receive it.

Why: obj.method() unconditionally passes obj as the first argument to whatever method it finds — there is no way to call an instance method through an instance without that binding happening, so the method must declare a parameter (self, by convention) to receive it.

obj.method() binds self automatically

rex.bark()

called through an instance

self = rex

bound automatically

Dog.bark(rex)

what actually runs

  1. rex.bark() — called through an instance
  2. self = rex — bound automatically
  3. Dog.bark(rex) — what actually runs

How an instance method call is really dispatched

How an instance method call is really dispatched
CallWhat actually runs
rex.bark()Dog.bark(rex) — rex is bound to self automatically
Dog.bark(rex)the same call, spelled out explicitly
rex.rename("Max")Dog.rename(rex, "Max") — extra arguments pass through after self
Dog.bark<function Dog.bark at 0x...> — an ordinary function, before binding
rex.bark<bound method Dog.bark of <Dog object>> — self already attached

Together

python
class Dog:
    def __init__(self, name):
        self.name = name

    def bark(self):
        return f"{self.name} says woof"

rex = Dog("Rex")
print(rex.bark())
print(Dog.bark(rex))

Remember: obj.method(args) is Class.method(obj, args) — self is the instance the call went through, bound automatically, never passed explicitly.

See also: classes and objects · class methods · static methods

Class methods

standardbeginner

@classmethod marks a method whose first parameter is the class itself (cls), not an instance. Called as Dog.from_string(...) or rex.from_string(...), Python binds cls automatically — most often used for an alternative constructor.

Think of it as

A class method's first parameter is the blueprint itself, not a cookie stamped from it — it can build a new instance (cls(...)) or read/change class-level state, but it never has one specific instance's data to work with unless it makes one.

python
class ClassName:
    @classmethod
    def alt_constructor(cls, raw_value):
        return cls(parse(raw_value))   # builds an instance via cls, not the class name

What we're doing: Build an alternative constructor with @classmethod, and confirm cls resolves to the calling class, including a subclass.

dog_classmethod.pypython
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @classmethod
    def from_string(cls, text):
        name, age = text.split("-")
        return cls(name, int(age))


class Puppy(Dog):
    pass


rex = Dog.from_string("Rex-3")
pup = Puppy.from_string("Pup-1")
print(type(rex).__name__, rex.name, rex.age)
print(type(pup).__name__, pup.name, pup.age)
6
@classmethod marks from_string as receiving cls, the class, instead of self, an instance.
9
cls(name, int(age)) calls whichever class cls is bound to — not hardcoded as Dog.
17
Puppy.from_string(...) binds cls to Puppy, so cls(...) builds a Puppy, not a Dog.
Output
Dog Rex 3
Puppy Pup 1

Why this works: @classmethod changes how Python binds the first parameter: instead of the instance a call went through, it binds the class the call was made on — Dog.from_string binds cls to Dog, and Puppy.from_string binds cls to Puppy, because Puppy inherits from_string unchanged but the class it is accessed through is different. Writing cls(...) rather than Dog(...) is what lets the same method correctly build a Puppy when called on Puppy, without from_string needing to know Puppy exists.

Hardcoding the class name instead of using cls

Wrong

python
class Dog:
    def __init__(self, name):
        self.name = name

    @classmethod
    def from_string(cls, text):
        return Dog(text)  # hardcoded — breaks for subclasses

class Puppy(Dog):
    pass

pup = Puppy.from_string("Pup")
print(type(pup).__name__)  # "Dog" — wrong

Better

python
class Dog:
    def __init__(self, name):
        self.name = name

    @classmethod
    def from_string(cls, text):
        return cls(text)  # uses whichever class was called on

class Puppy(Dog):
    pass

pup = Puppy.from_string("Pup")
print(type(pup).__name__)

What you see: Puppy.from_string(...) silently returns a Dog instead of a Puppy — no error, just the wrong type further down the line.

Why: Writing Dog(text) throws away the whole reason @classmethod exists: cls is already bound to whichever class the method was actually called on, so using it instead of the literal class name is what makes an alternative constructor work correctly for every subclass, not just the one it was originally written in.

Instance method vs. class method vs. static method (preview)

Instance method vs. class method vs. static method (preview)
KindFirst parameterCalled as
instance methodself (the instance)rex.bark()
class methodcls (the class)Dog.from_string(...) or rex.from_string(...)
static methodneitherDog.helper() or rex.helper()

Together

python
class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    @classmethod
    def from_string(cls, text):
        name, age = text.split("-")
        return cls(name, int(age))

rex = Dog.from_string("Rex-3")
print(rex.name, rex.age)

Remember: @classmethod binds cls (the class) as the first parameter — use it to build alternative constructors with cls(...) so subclasses build correctly.

See also: instance methods · static methods · class attributes

Static methods

standardbeginner

@staticmethod marks a method with neither self nor cls — it behaves like a plain function, just namespaced inside the class because it is logically related to it. Nothing is bound automatically at the call site.

Think of it as

A static method is a utility tool kept in the class's toolbox because it is related to what the class does, not because it needs the class or any instance to work. Taking it out of the box and using it never requires knowing which dog, or even that any dog exists.

python
class ClassName:
    @staticmethod
    def helper(x, y):
        return x + y   # no self, no cls — just the arguments given

What we're doing: Use a static method as a validation helper called both through the class and through an instance.

dog_static.pypython
class Dog:
    @staticmethod
    def is_valid_name(name):
        return bool(name) and name[0].isupper()

    def __init__(self, name):
        if not Dog.is_valid_name(name):
            raise ValueError(f"invalid name: {name!r}")
        self.name = name

rex = Dog("Rex")
print(rex.is_valid_name("Fido"))
print(Dog.is_valid_name("fido"))
2
@staticmethod marks is_valid_name as taking no implicit first argument at all.
7
__init__ calls Dog.is_valid_name(name) to validate the argument before any instance exists to be self.
11
rex.is_valid_name("Fido") works identically to calling it on the class — rex is not passed as an argument.
Output
True
False

Why this works: is_valid_name needs only the name string it is given — it never reads or writes any Dog instance's state, so marking it @staticmethod correctly signals that no self or cls binding is needed. Calling it as Dog.is_valid_name(name) inside __init__ works even though no instance exists yet, which is exactly why a validation helper like this is a natural static method rather than an instance method.

Adding self to a static method by habit

Wrong

python
class Dog:
    @staticmethod
    def is_valid_name(self, name):   # self doesn't belong here
        return bool(name)

Dog.is_valid_name("Rex")  # TypeError — one argument short

Better

python
class Dog:
    @staticmethod
    def is_valid_name(name):
        return bool(name)

Dog.is_valid_name("Rex")

What you see: TypeError: Dog.is_valid_name() missing 1 required positional argument: 'name' — @staticmethod passes nothing implicitly, so the single real argument fills self and name is left short.

Why: @staticmethod deliberately removes the automatic self/cls binding that instance and class methods get — writing self as if it were still there just becomes an ordinary required parameter, silently misaligned with every real argument the caller passes.

The three method kinds, side by side

The three method kinds, side by side
DecoratorFirst parameterCan access
(none)selfthis instance's attributes, and the class via type(self)
@classmethodclsthe class and its attributes — no specific instance
@staticmethod(none)only what is passed in as arguments

Together

python
class Dog:
    @staticmethod
    def is_valid_name(name):
        return bool(name) and name[0].isupper()

print(Dog.is_valid_name("Rex"))
print(Dog.is_valid_name("rex"))

Remember: @staticmethod methods take no self or cls — they behave like a plain function, just namespaced inside the class for organization.

See also: instance methods · class methods · classes and objects

Advertisement

Encapsulation, abstraction, inheritance, polymorphism

Hiding detail, exposing behaviour, extending a class, and treating different types alike.

Encapsulation

standardbeginner

Encapsulation bundles data with the methods that operate on it, and marks internal details with a leading underscore (_balance) or two (__balance) as a signal, not a lock — Python has no true private attribute.

Think of it as

A single underscore is a door labelled 'staff only' — nothing stops you opening it, but the label says you shouldn't. A double underscore is the same door, renamed by the building itself so an outsider's key (the plain attribute name) no longer fits by accident.

python
class ClassName:
    def __init__(self, value):
        self._internal = value    # convention: internal use only
        self.__hidden = value     # name-mangled to _ClassName__hidden

What we're doing: Use double-underscore mangling to make an attribute name harder to collide with in a subclass, and read the mangled name directly to show it is not truly private.

account.pypython
class Account:
    def __init__(self, balance):
        self.__balance = balance

    def deposit(self, amount):
        self.__balance += amount

    def get_balance(self):
        return self.__balance

acct = Account(100)
acct.deposit(50)
print(acct.get_balance())
print(acct._Account__balance)
print(vars(acct))
3
self.__balance is mangled at compile time to self._Account__balance, inside the Account class body.
6
Every reference to __balance inside the class body is mangled identically, so deposit still reaches the same attribute.
14
acct._Account__balance reads the mangled name directly — proof it is hidden by convention, not truly inaccessible.
Output
150
150
{'_Account__balance': 150}

Why this works: A name spelled with two leading underscores (and at most one trailing) is rewritten by the interpreter, at the point the class body is compiled, from __balance to _ClassName__balance — every use inside that class body is rewritten consistently, so the class's own methods keep working normally. The rewriting exists to prevent a subclass from accidentally overwriting a base class's internal attribute with the same short name, not to make the attribute inaccessible — acct._Account__balance reaches it just as directly as any other attribute.

Believing __name makes an attribute truly private

Wrong

python
class Account:
    def __init__(self, balance):
        self.__balance = balance

acct = Account(100)
print(acct.__balance)  # AttributeError — but NOT because it's private

Better

python
class Account:
    def __init__(self, balance):
        self.__balance = balance

    def get_balance(self):
        return self.__balance   # controlled access from inside the class

acct = Account(100)
print(acct.get_balance())

What you see: AttributeError: 'Account' object has no attribute '__balance' — because the actual attribute is named _Account__balance, not because access is blocked.

Why: acct.__balance fails only because that literal name was never set — name mangling means __balance from OUTSIDE the class body is not rewritten, so it looks for an attribute that genuinely doesn't exist under that name. Reading acct._Account__balance directly still works fine, which is why encapsulation in Python is a documented convention for authors and readers to respect, not an access-control mechanism the interpreter enforces.

Underscore conventions and what they actually do

Underscore conventions and what they actually do
SpellingMeaningEnforced?
namepublic — part of the intended interfacen/a
_nameinternal — please treat as implementation detailconvention only, still accessible
__namename-mangled to _ClassName__nameharder to reach by accident, not impossible
__name__a dunder — reserved for Python itself, not for authorsn/a

Together

python
class Account:
    def __init__(self, balance):
        self.__balance = balance

acct = Account(100)
print(acct._Account__balance)   # mangled name still works
print(vars(acct))

Remember: Single underscore (_name) means "internal, please" by convention. Double underscore (__name) is mangled to _ClassName__name — never truly private.

See also: instance attributes · abstraction · classes and objects

Abstraction

standardbeginner

Abstraction means a caller depends on WHAT an object does (its method names and behaviour) rather than HOW it does it internally. connection.query(sql) hides whatever socket, buffer, and protocol logic actually run underneath.

Think of it as

A car's pedal is an abstraction: pressing it means 'go faster,' and every driver relies on exactly that, whether the engine underneath is a four-cylinder or an electric motor. Swapping the engine never requires the driver to relearn what the pedal means.

python
class Stack:
    def __init__(self):
        self._items = []          # hidden implementation detail

    def push(self, item):         # the public interface
        self._items.append(item)

    def pop(self):
        return self._items.pop()

What we're doing: Swap the internal storage of a Stack from a list to a deque without changing any code that calls it.

stack.pypython
from collections import deque

class Stack:
    def __init__(self):
        self._items = []       # version 1: a list

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

    def pop(self):
        return self._items.pop()


class FastStack:
    def __init__(self):
        self._items = deque()  # version 2: a deque — same interface

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

    def pop(self):
        return self._items.pop()


def drain(stack):
    stack.push(1)
    stack.push(2)
    return stack.pop(), stack.pop()

print(drain(Stack()))
print(drain(FastStack()))
5
_items stores a plain list — an implementation detail, not part of the interface.
16
FastStack stores a deque instead, for faster pops from a large stack, but exposes the identical push/pop interface.
25
drain(stack) only calls push and pop — it works on either implementation without any change, because it depends on the interface, not the storage.
Output
(2, 1)
(2, 1)

Why this works: drain(stack) never reaches into stack._items directly — it only calls push(...) and pop(), the abstraction both classes expose identically. That is what makes it possible to hand drain() either a Stack or a FastStack and get correct, identical behaviour despite one storing items in a list and the other in a deque: the caller was written against WHAT the object does, never HOW.

Reaching past the interface into an object's implementation detail

Wrong

python
class Stack:
    def __init__(self):
        self._items = []

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

s = Stack()
s.push(1)
print(s._items[-1])  # bypasses the interface — breaks if storage changes

Better

python
class Stack:
    def __init__(self):
        self._items = []

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

    def peek(self):
        return self._items[-1]   # the interface grows to cover the need

s = Stack()
s.push(1)
print(s.peek())

What you see: No error today — but the code silently breaks the moment _items changes shape (a list to a deque, which does not support negative indexing the same way).

Why: Reaching past _internal into another object's implementation detail defeats the entire point of abstraction: the caller is now depending on HOW the object works, not WHAT it does, so any change to the internal representation — even one that preserves all documented behaviour — can break the caller. If a caller needs a capability the interface does not expose, the fix is to grow the interface (add a method), not to reach past it.

Concrete detail vs. the abstraction hiding it

Concrete detail vs. the abstraction hiding it
What the caller writesWhat it hides
stack.push(x)whichever list operation actually stores x underneath
file.read()buffering, the OS system call, encoding
shape.area()the specific formula — circle, rectangle, or triangle
conn.query(sql)the socket, the wire protocol, retry logic

Together

python
class Stack:
    def __init__(self):
        self._items = []   # implementation detail

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

    def pop(self):
        return self._items.pop()

s = Stack()
s.push(1)
s.push(2)
print(s.pop())

Remember: Callers should depend on WHAT an object's methods do, never HOW they do it internally — a method name is a promise; its body is free to change.

See also: encapsulation · instance methods · polymorphism

Inheritance

corebeginner

class Puppy(Dog): makes Puppy inherit every attribute and method Dog defines. Puppy can add new ones, or override an inherited one by redefining it — and super() reaches up to call the parent version from inside the override.

Think of it as

Inheritance is starting a new document from a template rather than a blank page — Puppy gets everything Dog already wrote, for free, the moment it says class Puppy(Dog):. It can leave any of it unchanged, add new sections, or cross out and rewrite one — and super() is the way to reference what the template originally said, even after rewriting it.

python
class Base:
    def method(self):
        return "base"

class Sub(Base):
    def method(self):              # overrides Base.method
        return super().method() + " + sub"

What we're doing: Extend a base class with a new attribute in __init__, override a method, and call the parent version from inside the override with super().

dog_inherit.pypython
class Dog:
    def __init__(self, name):
        self.name = name

    def speak(self):
        return f"{self.name} says woof"


class Puppy(Dog):
    def __init__(self, name, weeks_old):
        super().__init__(name)      # reuse Dog's setup
        self.weeks_old = weeks_old

    def speak(self):
        return super().speak() + " (in a tiny voice)"


pup = Puppy("Fido", 6)
print(pup.speak())
print(pup.weeks_old)
print(isinstance(pup, Dog))
9
class Puppy(Dog): makes Puppy a subclass — it inherits speak() and could inherit __init__ too, but overrides it below.
11
super().__init__(name) calls Dog.__init__ to set self.name, instead of duplicating that logic in Puppy.
14
speak is overridden — Puppy instances use this version, not Dog's.
15
super().speak() inside the override still reaches Dog's original speak(), and extends its result.
Output
Fido says woof (in a tiny voice)
6
True

Why this works: Puppy(Dog) makes Python look up any attribute or method missing on Puppy by following its Method Resolution Order up to Dog next — but speak is defined directly on Puppy, so that override is found first and used for every Puppy instance. Inside the override, super().speak() explicitly asks Python to continue the lookup past Puppy, to Dog, retrieving the original implementation rather than reimplementing it — the same super().__init__(name) does for setup in the constructor.

Overriding __init__ without calling super().__init__()

Wrong

python
class Dog:
    def __init__(self, name):
        self.name = name

class Puppy(Dog):
    def __init__(self, name, weeks_old):
        self.weeks_old = weeks_old   # self.name never set!

pup = Puppy("Fido", 6)
print(pup.name)  # AttributeError

Better

python
class Dog:
    def __init__(self, name):
        self.name = name

class Puppy(Dog):
    def __init__(self, name, weeks_old):
        super().__init__(name)   # runs Dog's setup too
        self.weeks_old = weeks_old

pup = Puppy("Fido", 6)
print(pup.name)

What you see: AttributeError: 'Puppy' object has no attribute 'name' — overriding __init__ replaces Dog's entirely; it does not run automatically alongside it.

Why: Defining __init__ in Puppy completely replaces Dog's __init__ for Puppy instances — Python does not run both automatically. Anything Dog's constructor used to set up (here, self.name) is skipped unless the override explicitly calls super().__init__(...) to run it too.

A subclass extends, and can override, its base

class Dog

defines bark(), __init__

class Puppy(Dog)

inherits both, unchanged

override bark()

super().bark() reaches the original

  1. class Dog — defines bark(), __init__
  2. class Puppy(Dog) — inherits both, unchanged
  3. override bark() — super().bark() reaches the original

What a subclass gains, keeps, and can change

What a subclass gains, keeps, and can change
ActionEffect
class Puppy(Dog): passPuppy has every Dog attribute and method, unchanged
def bark(self): ... in Puppyoverrides Dog.bark for Puppy instances only
super().bark()calls Dog's original bark from inside Puppy's override
isinstance(pup, Dog)True — a Puppy is-a Dog
Puppy.__mro__(Puppy, Dog, object) — the lookup order for attributes

Together

python
class Dog:
    def bark(self):
        return "woof"

class Puppy(Dog):
    def bark(self):
        return super().bark() + " (but higher-pitched)"

pup = Puppy()
print(pup.bark())
print(isinstance(pup, Dog))

Remember: class Sub(Base): inherits everything; overriding __init__ replaces the parent one entirely unless the override calls super().__init__(...) explicitly.

See also: classes and objects · polymorphism · composition vs inheritance

Polymorphism

standardbeginner

Polymorphism means calling the same method name — shape.area() — on objects of different types and getting each type's own correct behaviour, without the caller ever checking which type it actually has.

Think of it as

Every device with a power button behaves correctly when it's pressed — a lamp lights up, a laptop wakes, a car starts — without the person pressing it needing to know which device it is first. shape.area() is the same idea: the caller presses the same button; each shape answers for itself.

python
def total_area(shapes):
    return sum(shape.area() for shape in shapes)   # no isinstance() check needed

What we're doing: Write one function that works on any object with an area() method, whether or not they share a common base class.

shapes.pypython
class Circle:
    def __init__(self, r):
        self.r = r

    def area(self):
        return round(3.14159 * self.r ** 2, 2)


class Rectangle:
    def __init__(self, w, h):
        self.w, self.h = w, h

    def area(self):
        return self.w * self.h


def total_area(shapes):
    return sum(shape.area() for shape in shapes)


shapes = [Circle(2), Rectangle(3, 4), Circle(1)]
for shape in shapes:
    print(type(shape).__name__, shape.area())
print(total_area(shapes))
1
Circle and Rectangle share no common base class — nothing links them except both defining area().
17
total_area calls shape.area() for every shape without checking type(shape) at all.
22
Each shape answers area() with its own formula — the loop body never branches on type.
Output
Circle 12.57
Rectangle 12
Circle 3.14
27.71

Why this works: total_area never inspects what kind of object shape is — it only assumes shape.area() exists and returns a number, and lets each object's own class decide how that number is computed. This is Python's duck typing: Circle and Rectangle need no shared base class or declared interface for this to work, only the same method name with compatible behaviour, which is what lets total_area handle both, and any future shape that also defines area(), without a single change.

Branching on type() instead of trusting polymorphism

Wrong

python
def total_area(shapes):
    total = 0
    for shape in shapes:
        if isinstance(shape, Circle):
            total += 3.14159 * shape.r ** 2
        elif isinstance(shape, Rectangle):
            total += shape.w * shape.h
        # every new shape type needs another elif here
    return total

Better

python
def total_area(shapes):
    return sum(shape.area() for shape in shapes)
    # every new shape type just needs its own area() method

What you see: Not an error — but total_area has to be edited every time a new shape type is added, and duplicates the area formula that already lives on each class.

Why: Branching on isinstance()/type() to decide how to treat each object throws away the entire benefit of polymorphism: the point of giving every shape its own area() method is that callers never need to know the full list of shape types that exist. Trusting the method call instead keeps total_area correct for any shape, including ones written after it.

The same call, different types, different results

The same call, different types, different results
CallResult
Circle(2).area()12.566... — πr²
Rectangle(3, 4).area()12 — width × height
for s in shapes: s.area()each shape computes its own area — no type check needed
len("abc"), len([1,2]), len({1:2})one function, three unrelated types, each defines __len__

Together

python
class Circle:
    def __init__(self, r): self.r = r
    def area(self): return 3.14159 * self.r ** 2

class Rectangle:
    def __init__(self, w, h): self.w, self.h = w, h
    def area(self): return self.w * self.h

for shape in [Circle(2), Rectangle(3, 4)]:
    print(shape.area())

Remember: Polymorphism: calling obj.method() and letting each type answer for itself — no isinstance()/type() branching, and no shared base class required (duck typing).

See also: inheritance · abstraction · instance methods

Advertisement

Object relationships

Three ways one object can relate to another, from loosest to tightest — and when to compose instead of inherit.

Composition

standardbeginner

Composition builds a class out of other objects it creates and owns — a Car creates its own Engine inside __init__. The Engine's lifetime is entirely tied to the Car's: destroy the car, and its engine goes with it.

Think of it as

A car's engine is built as part of the car and scrapped with it — nobody removes the engine from a car and installs the exact same one, still running, into a different car. Composition is that tight a bond: the part exists because the whole created it, and stops existing when the whole does.

python
class Whole:
    def __init__(self):
        self.part = Part()        # created here, owned by Whole

    def do_thing(self):
        return self.part.do_thing()   # delegates to the owned part

What we're doing: Build a Car out of an Engine and a GPS it owns, delegating behaviour to each rather than reimplementing it.

car.pypython
class Engine:
    def start(self):
        return "engine running"


class GPS:
    def route_to(self, destination):
        return f"routing to {destination}"


class Car:
    def __init__(self):
        self.engine = Engine()   # Car creates and owns both parts
        self.gps = GPS()

    def start(self):
        return self.engine.start()

    def navigate_to(self, destination):
        return self.gps.route_to(destination)


car = Car()
print(car.start())
print(car.navigate_to("the lake"))
print(type(car.engine).__name__)
12
self.engine = Engine() creates the Engine inside Car's own constructor — Car owns it from this line onward.
13
self.gps = GPS() does the same for a second, independently owned part.
17
start() delegates to self.engine.start() instead of reimplementing engine-starting logic inside Car.
Output
engine running
routing to the lake
Engine

Why this works: Car never inherits from Engine or GPS — it creates one instance of each inside its own __init__ and stores them as attributes, so a Car "has-a" Engine and "has-a" GPS rather than being either. car.start() then delegates to self.engine.start() instead of duplicating what an engine does, which is the whole benefit of composition: Engine and GPS can each be tested, changed, or replaced independently, as long as Car keeps calling the same methods on whatever object it holds.

Reaching for inheritance where composition actually fits

Wrong

python
class Engine:
    def start(self):
        return "vroom"

class Car(Engine):     # a Car is NOT a kind of Engine
    pass

print(Car().start())   # works, but the relationship is wrong

Better

python
class Engine:
    def start(self):
        return "vroom"

class Car:
    def __init__(self):
        self.engine = Engine()   # a Car HAS an Engine

    def start(self):
        return self.engine.start()

print(Car().start())

What you see: Not an error — Car(Engine) runs fine, which is exactly the trap: it works today, but isinstance(car, Engine) is now True, and Car inherits every Engine method whether or not it makes sense for a car to expose it directly.

Why: Inheritance should model "is a specialized kind of," and a car is not a specialized engine — it merely uses one. Modelling the relationship as composition instead keeps Car's public interface limited to what a Car should expose, and lets the Engine be swapped for a different implementation without touching Car's own class hierarchy.

Composition: the whole owns and creates the part

Composition: the whole owns and creates the part
CodeRelationship
self.engine = Engine()Car creates its own Engine in __init__ — ownership starts here
self.engine.start()Car delegates to the part it owns, rather than reimplementing start logic
del carcar's engine has no other owner — nothing else was holding a reference to it
Car "has-a" Enginecontrast with Puppy "is-a" Dog (inheritance)

Together

python
class Engine:
    def start(self):
        return "vroom"

class Car:
    def __init__(self):
        self.engine = Engine()   # Car creates and owns its Engine

    def start(self):
        return self.engine.start()

print(Car().start())

Remember: "Has-a", built and owned by the whole: self.part = Part() inside __init__. The part's lifetime is tied to the owner — nothing else holds a reference to it.

See also: aggregation · composition vs inheritance · instance attributes

Aggregation

standardbeginner

Aggregation is a "has-a" relationship where the whole holds a reference to a part it did NOT create — Team(players) stores players built and passed in from outside. The players exist independently and outlive the team if it is discarded.

Think of it as

A sports team roster lists players, but the team did not create any of them, and a player who leaves the team keeps existing — they can join another team, or none at all. That's the difference from composition's engine: the part's life was never tied to the whole's in the first place.

python
class Team:
    def __init__(self, players):
        self.players = players   # accepted from outside, not built here

What we're doing: Share one Player across two different Team objects, and show the player survives after a team is discarded.

team.pypython
class Player:
    def __init__(self, name):
        self.name = name


class Team:
    def __init__(self, name, players):
        self.name = name
        self.players = players   # referenced, built elsewhere


alice = Player("Alice")
bob = Player("Bob")

falcons = Team("Falcons", [alice, bob])
allstars = Team("All-Stars", [alice])   # alice is on BOTH teams

print([p.name for p in falcons.players])
print([p.name for p in allstars.players])

del falcons
print(alice.name)   # alice still exists — Team never owned her
9
self.players = players stores a reference to Player objects built OUTSIDE Team — Team never constructs a Player.
16
alice is passed into both falcons and allstars — one object, shared between two wholes, which composition forbids.
21
Deleting falcons does not affect alice — her lifetime was never tied to any one team.
Output
['Alice', 'Bob']
['Alice']
Alice

Why this works: Team's __init__ takes players as an argument and simply stores the reference — it never calls Player(...) itself, so a Player object's lifetime and identity are entirely independent of any Team that references it. That independence is what makes sharing alice across falcons and allstars possible, and what makes del falcons harmless to her: nothing about a Team being discarded implies anything about the players it merely pointed at.

Confusing aggregation with composition and assuming the whole owns the part

Wrong

python
class Team:
    def __init__(self, name, players):
        self.name = name
        self.players = players

falcons = Team("Falcons", [Player("Alice")])
del falcons
# assuming Alice is "gone" too — she isn't; nothing ever deleted her

Better

python
alice = Player("Alice")   # created independently, kept referenced elsewhere
falcons = Team("Falcons", [alice])
del falcons
print(alice.name)   # still here — aggregation never implied ownership

What you see: Code written assuming a Team "owning" its players (composition's guarantee) breaks when a Player needs to be looked up after its team is deleted or reassigned — aggregation never made that promise.

Why: Aggregation and composition look identical at the syntax level (self.part = part) — the difference is entirely about WHO created the part and WHETHER its lifetime is tied to the whole, which nothing in the code enforces. Assuming ownership where only a reference exists is a design mistake, not a technical error Python will catch.

Aggregation vs. composition — who creates and owns the part

Aggregation vs. composition — who creates and owns the part
QuestionComposition (Car/Engine)Aggregation (Team/Player)
Who creates the part?the whole, internallycreated externally, passed in
Can the part be shared?no — one owner onlyyes — one Player, many Teams
Does the part outlive the whole?no — tied to its lifetimeyes — independent lifetime

Together

python
class Player:
    def __init__(self, name):
        self.name = name

class Team:
    def __init__(self, name, players):
        self.name = name
        self.players = players   # referenced, not created here

alice = Player("Alice")
team = Team("Falcons", [alice])
print(alice in team.players)

Remember: Aggregation: self.part = part, where part was built OUTSIDE — the whole references it but never owns its lifetime, and it can be shared or outlive the whole.

See also: composition · association · composition vs inheritance

Association

standardbeginner

Association is the most general relationship: two independent objects that know about and use each other, without one being "part of" or "owning" the other — a Student and a Course they enroll in, or a Doctor and their Patient.

Think of it as

A doctor and a patient are associated, not composed: neither is built from the other, neither owns the other's lifetime, and each can be associated with many others at once — a doctor with many patients, a patient with many doctors. Composition and aggregation are both special, tighter cases of this same general idea.

python
class Student:
    def __init__(self, name):
        self.name = name
        self.courses = []   # associated Course objects, neither owned nor exclusive

    def enroll(self, course):
        self.courses.append(course)
        course.students.append(self)   # the association can go both ways

What we're doing: Model a many-to-many association between Students and Courses, where either side can be reached from the other.

enrollment.pypython
class Student:
    def __init__(self, name):
        self.name = name
        self.courses = []


class Course:
    def __init__(self, title):
        self.title = title
        self.students = []


def enroll(student, course):
    student.courses.append(course)
    course.students.append(student)


algorithms = Course("Algorithms")
databases = Course("Databases")
alice = Student("Alice")

enroll(alice, algorithms)
enroll(alice, databases)

print([c.title for c in alice.courses])
print([s.name for s in algorithms.students])
3
Student.courses starts empty — a Student knows about Courses without owning or containing them.
14
enroll() links the two objects from BOTH sides — each just holds a reference to the other.
21
alice is enrolled in two courses at once; a course can likewise have many students — a many-to-many association.
Output
['Algorithms', 'Databases']
['Alice']

Why this works: Neither Student nor Course creates the other, and neither controls when the other is destroyed — enroll() simply has each object append a reference to the other into a list, which is all association requires. That plain, mutual referencing is what lets the relationship be many-to-many (alice.courses holds two Courses; algorithms.students could hold many Students) without either side needing special ownership logic.

Only linking one direction and assuming the relationship is symmetric

Wrong

python
def enroll(student, course):
    student.courses.append(course)
    # forgot: course.students.append(student)

algorithms = Course("Algorithms")
alice = Student("Alice")
enroll(alice, algorithms)
print([s.name for s in algorithms.students])  # empty — never linked back

Better

python
def enroll(student, course):
    student.courses.append(course)
    course.students.append(student)   # both sides updated together

algorithms = Course("Algorithms")
alice = Student("Alice")
enroll(alice, algorithms)
print([s.name for s in algorithms.students])

What you see: algorithms.students comes back empty even though alice.courses correctly lists algorithms — the two references were never actually kept in sync.

Why: A bidirectional association is really two separate one-way references that happen to point at each other — nothing links them automatically, so code that updates one side has to explicitly update the other too, or the two views of the "same" relationship silently disagree.

Association compared with the two relationships that specialize it

Association compared with the two relationships that specialize it
RelationshipExtra constraint beyond association
Association(none) — just "knows about and uses"
Aggregationplus "has-a" — one holds a reference to the other as a part
Compositionplus "has-a" AND owns the lifetime — creates and destroys together

Together

python
class Doctor:
    def __init__(self, name):
        self.name = name
        self.patients = []   # associated with, not owning

class Patient:
    def __init__(self, name):
        self.name = name

dr_lee = Doctor("Dr. Lee")
patient = Patient("Sam")
dr_lee.patients.append(patient)
print([p.name for p in dr_lee.patients])

Remember: Association is the general "knows-about/uses" relationship — no ownership either way. Aggregation and composition are stricter special cases of it.

See also: aggregation · composition · composition vs inheritance

Composition vs inheritance

standardintermediate

Inheritance models "is-a" through a fixed class hierarchy; composition models "has-a" by holding and delegating to other objects. "Favour composition over inheritance" means default to composition unless "is-a" is genuinely true and stable.

Think of it as

Inheritance is renting one apartment with a single, fixed layout inherited from the building's blueprint — comfortable until you need a wall the blueprint never had. Composition is furnishing a room with separate, swappable pieces — a different couch does not require a different building.

python
class Duck:
    def __init__(self, fly_behavior, swim_behavior):
        self.fly_behavior = fly_behavior     # composed in, swappable
        self.swim_behavior = swim_behavior

    def perform_fly(self):
        return self.fly_behavior.fly()       # delegates, doesn't inherit

What we're doing: Show inheritance producing a combinatorial explosion of subclasses, then fix it by composing independent behaviours instead.

duck_compose.pypython
# The inheritance version needs one subclass per COMBINATION of abilities:
class Bird:
    pass

class FlyingSwimmingBird(Bird):
    def fly(self): return "flying"
    def swim(self): return "swimming"

class SwimmingOnlyBird(Bird):
    def swim(self): return "swimming"
    # a penguin can't fly — but what about a bird that swims AND runs?
    # every new combination needs another subclass


# The composed version: behaviours are independent, swappable objects.
class FlyBehavior:
    def fly(self):
        return "flying"

class SwimBehavior:
    def swim(self):
        return "swimming"

class CannotFly:
    def fly(self):
        return "cannot fly"


class Duck:
    def __init__(self, fly_behavior, swim_behavior):
        self.fly_behavior = fly_behavior
        self.swim_behavior = swim_behavior

    def perform_fly(self):
        return self.fly_behavior.fly()

    def perform_swim(self):
        return self.swim_behavior.swim()


mallard = Duck(FlyBehavior(), SwimBehavior())
penguin = Duck(CannotFly(), SwimBehavior())
print(mallard.perform_fly(), mallard.perform_swim())
print(penguin.perform_fly(), penguin.perform_swim())
5
FlyingSwimmingBird needs its own subclass just for this one combination of abilities.
17
FlyBehavior and SwimBehavior are independent objects, not part of any Bird class hierarchy.
30
Duck composes whichever behaviours it is given — no new Duck subclass needed for a new combination.
41
penguin swaps in CannotFly() instead of FlyBehavior() — a runtime choice, not a class-hierarchy one.
Output
flying swimming
cannot fly swimming

Why this works: The inheritance sketch needs a new subclass for every combination of abilities a bird might have — a bird that swims and runs but cannot fly would need yet another one, and the count grows multiplicatively with the number of independent behaviours. The composed Duck instead holds a reference to whichever fly_behavior and swim_behavior objects it was given, so mallard and penguin are both plain Duck instances that simply delegate to different behaviour objects — no subclass required for either.

Inheriting to reuse code from a class that is not genuinely a supertype

Wrong

python
class Stack(list):        # reusing list's methods via inheritance
    def push(self, item):
        self.append(item)

s = Stack()
s.push(1)
s.insert(0, 99)   # list's full interface leaks through — not a real stack!

Better

python
class Stack:
    def __init__(self):
        self._items = []   # composition: a Stack HAS a list, isn't ONE

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

s = Stack()
s.push(1)
# s.insert(0, 99) does not exist — the interface is exactly what Stack defines

What you see: A Stack(list) exposes every list method (insert, sort, __getitem__...) whether or not any of them make sense for something that is supposed to only push and pop.

Why: Inheriting from list here is really "inheriting for code reuse," not because a Stack genuinely is-a list — the giveaway is that list's full interface (arbitrary insertion, indexing, sorting) leaks straight through, breaking the actual contract a stack is supposed to have. Composition (holding a list internally) keeps the public interface limited to exactly what the class intends to expose.

The combinatorial explosion inheritance can cause

Bird

base class

FlyingBird, SwimmingBird...

one subclass per COMBINATION

composition instead

mix independent behaviours per instance

  1. Bird — base class
  2. FlyingBird, SwimmingBird... — one subclass per COMBINATION
  3. composition instead — mix independent behaviours per instance

Which one fits — the "is-a" vs "has-a" test

Which one fits — the "is-a" vs "has-a" test
Question to askIf yes
Is a Duck really a kind of Bird, permanently?inheritance may fit — genuine "is-a"
Does a Duck need to fly, swim, AND quack, but a Penguin only swim and quack?composition — mix in only the behaviours each needs
Would a new combination require yet another subclass?composition — swap parts instead of multiplying classes
Is the relationship "uses" or "is built from", not "is a specialized kind of"?composition (or association) — see those concepts

Together

python
class FlyBehavior:
    def fly(self): return "flying"

class SwimBehavior:
    def swim(self): return "swimming"

class Duck:
    def __init__(self):
        self.fly_behavior = FlyBehavior()
        self.swim_behavior = SwimBehavior()

d = Duck()
print(d.fly_behavior.fly(), d.swim_behavior.swim())

Remember: "Is-a" and stable → inheritance. "Has-a", or several independent things that vary → composition, to avoid a subclass explosion.

See also: inheritance · composition · polymorphism

Advertisement