Skip to content
Hello Python
4/7

OOP Basics — Classes & Inheritance

Topic 4 of 7, with 3 concept checks. self, attributes, and the method resolution order

Define object behavior through clear class contracts

Object contracts

Separate instance state from class behavior, follow method lookup through inheritance, and prefer a small explicit public contract over a hierarchy that hides changing state.

Core lesson 01

self is the instance the method was called on; Python passes it automatically as the first argument when you call obj.method(), which is really sugar for Class.method(obj).

Python doesn't have hidden magic for accessing 'the current object' — when you call obj.method(arg), Python translates it to Class.method(obj, arg) behind the scenes. self is just a parameter name (convention, not a keyword) that receives that instance. This is also why calling a method directly on the class requires passing the instance explicitly.

Python example
class Dog:
    def __init__(self, name):
        self.name = name
    def bark(self):
        return f"{self.name} says woof"

rex = Dog("Rex")
print(rex.bark())          # Rex says woof
print(Dog.bark(rex))       # identical -- same call, spelled explicitly

What to remember

What is self, and why must it be the first parameter of instance methods?

Common footguns

  • Forgetting self as the first parameter in a method definition — Python accepts it and throws a confusing 'missing argument' TypeError on the first real call.

Core lesson 02

Instance attributes (self.x = ...) belong to one object. Class attributes (defined in the class body) are shared until an instance attribute shadows them. Lookup follows the Method Resolution Order (MRO) up through parent classes.

When you access obj.attr, Python first checks the instance's own __dict__. If not found there, it walks the class hierarchy (the MRO) looking on the class, then its parents, in a well-defined order. A class attribute acts like a shared default that any instance can override just by assigning to self.attr — which creates a new instance attribute rather than modifying the shared one.

Python example
class Counter:
    total_created = 0   # class attribute, shared
    def __init__(self):
        Counter.total_created += 1
        self.count = 0      # instance attribute, per-object

a, b = Counter(), Counter()
a.count += 5
print(a.count, b.count)          # 5 0 -- independent
print(Counter.total_created)     # 2  -- shared, tracked once

What to remember

What's the difference between instance attributes, class attributes, and how does inheritance affect lookup?

Common footguns

  • Using a mutable class attribute (like a list) as a 'default' — since it's shared, mutating it through one instance affects every instance (same root cause as the mutable-default-argument trap).
class Counter:
    total_created = 0     <- lives on the CLASS
    def __init__(self):
        self.count = 0    <- lives on EACH INSTANCE

a.count         -> instance dict first, found there
a.total_created -> not on instance, found on class (shared)

Core lesson 03

Overriding means redefining a method with the same name in a subclass, replacing the parent's version. super() lets the subclass call the parent's implementation too, usually to extend rather than fully replace it.

If a subclass defines a method with the same name as its parent, calling it on a subclass instance uses the subclass's version — this is overriding. Often you still want the parent's behavior to run too (e.g. its __init__ setting up base attributes); super().method(...) calls the next class in the MRO's version of that method, letting you layer behavior instead of duplicating it.

Python example
class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return f"{self.name} makes a sound"

class Dog(Animal):
    def speak(self):
        base = super().speak()
        return f"{base}, specifically: Woof!"

print(Dog("Rex").speak())   # Rex makes a sound, specifically: Woof!

What to remember

What's the difference between method overriding and using super()?

Common footguns

  • Forgetting to call super().__init__() in a subclass's __init__ — the parent's setup code never runs, silently leaving expected attributes missing.

Python lab

Browser Python lab

Runtime · idle

Python loads on your first run. Your code stays in this browser.

Best practices

  • Always call super().__init__() in a subclass's __init__ unless deliberately skipping the parent's setup.
  • Avoid mutable class attributes as shared defaults — initialize them per-instance in __init__ instead.
  • Favor composition over deep inheritance hierarchies — easier to reason about and test.
  • Use @property instead of plain getter/setter methods for computed or validated attributes.

Apply the concept in Interview practice

Design Parking SystemeasyLeetCode #1603 · O(1) per operation

Model the lot as a class with a count array indexed by spot size, decrementing on a successful addCar after checking bounds.

Open problem
Design HashMapeasyLeetCode #706 · O(1) average per operation

Build a simple hash table from scratch using an array of buckets (each a list of key-value pairs), practicing class design and encapsulation without the built-in dict.

Open problem
Design Underground SystemmediumLeetCode #1396 · O(1) per operation

Use two dicts: one tracking each rider's check-in station/time, another accumulating (total time, count) per station pair to compute averages.

Open problem

Concept checks

Q01

What is self, and why must it be the first parameter of instance methods?

Q02

What's the difference between instance attributes, class attributes, and how does inheritance affect lookup?

Q03

What's the difference between method overriding and using super()?