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.
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 explicitlyWhat 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.
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 onceWhat 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.
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 problemDesign 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 problemDesign 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 problemConcept checks
What is self, and why must it be the first parameter of instance methods?
Hint
It's not magic — it's just how the instance gets passed in.
obj.method(x) is really Class.method(obj, x).
Answer
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.
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 explicitlyWatch out
- 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.
What's the difference between instance attributes, class attributes, and how does inheritance affect lookup?
Hint
One belongs to each object, the other is shared by the whole class.
Attribute lookup walks up a chain called the MRO if not found on the instance.
Answer
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.
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 onceclass 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)Watch out
- 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).
What's the difference between method overriding and using super()?
Hint
One replaces the parent's behavior entirely, the other extends it.
super() lets a subclass call the parent's version explicitly, often alongside its own additions.
Answer
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.
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!Watch out
- Forgetting to call super().__init__() in a subclass's __init__ — the parent's setup code never runs, silently leaving expected attributes missing.