Detailed walkthrough of Python's class system including inheritance, multiple inheritance, and special methods. Bookmark this one.
Python classes feel very similar to ES6 classes, but with a key difference: every instance method explicitly receives self as its first parameter — there's no implicit this.
class Dog {
constructor(name, breed) {
this.name = name;
this.breed = breed;
this.tricks = [];
}
bark() {
return `${this.name} says Woof!`;
}
learn(trick) {
this.tricks.push(trick);
}
toString() {
return `Dog(${this.name})`;
}
}
const d = new Dog("Rex", "Lab");
console.log(d.bark());
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
self.tricks = []
def bark(self):
return f"{self.name} says Woof!"
def learn(self, trick):
self.tricks.append(trick)
def __repr__(self):
return f"Dog({self.name!r})"
# No 'new' keyword needed
d = Dog("Rex", "Lab")
print(d.bark())
self is just a convention — you could name it anything. But always use self. Python passes the instance automatically as the first argument whenever you call a method on an object. That's all self is.
class Counter:
count = 0 # ← class variable, shared across all instances
def __init__(self, name):
self.name = name # ← instance variable, unique to each instance
Counter.count += 1
c1 = Counter("first")
c2 = Counter("second")
print(Counter.count) # 2
print(c1.name) # "first"
print(c2.name) # "second"
If a class variable is a mutable type (list, dict), all instances share the same object. This is the same mutable-default trap from Lesson 2 — always initialize mutable state in __init__ with self.
Dunder = "double underscore". These are Python's hooks into built-in behavior. They're how Python implements operator overloading and protocol interfaces.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
# Called by str() and print()
def __str__(self):
return f"Vector({self.x}, {self.y})"
# Called by repr() and in the REPL
def __repr__(self):
return f"Vector({self.x!r}, {self.y!r})"
# + operator
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
# == operator
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# len()
def __len__(self):
return int((self.x**2 + self.y**2) ** 0.5)
# [] access
def __getitem__(self, index):
return (self.x, self.y)[index]
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6)
print(v1 == v2) # False
print(len(v2)) # 5
| Dunder | Triggered by | JS equivalent |
|---|---|---|
__init__ | ClassName() | constructor() |
__str__ | str(obj), print(obj) | toString() |
__repr__ | REPL, repr(obj) | — |
__len__ | len(obj) | .length |
__add__ | obj + other | — |
__eq__ | obj == other | — |
__getitem__ | obj[key] | Proxy get |
__iter__ | for x in obj | [Symbol.iterator] |
class Animal {
constructor(name) {
this.name = name;
}
speak() {
return `${this.name} makes a sound.`;
}
}
class Dog extends Animal {
speak() {
// super call
const base = super.speak();
return `${base} Woof!`;
}
}
const d = new Dog("Rex");
d instanceof Dog; // true
d instanceof Animal; // true
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() # super() call
return f"{base} Woof!"
d = Dog("Rex")
isinstance(d, Dog) # True
isinstance(d, Animal) # True
type(d) # <class '__main__.Dog'>
class User:
_registry = []
def __init__(self, name):
self.name = name
User._registry.append(self)
@classmethod
def from_dict(cls, data):
"""Alternative constructor — cls is the class itself (like 'this' for the class)"""
return cls(data["name"])
@staticmethod
def validate_name(name):
"""No access to class or instance — pure utility function"""
return isinstance(name, str) and len(name) > 0
u = User.from_dict({"name": "Alice"})
User.validate_name("Bob") # True
Python's @property is cleaner than JS getters/setters — it lets you start with a plain attribute and add logic later without changing the API.
class Temperature:
def __init__(self, celsius):
self._celsius = celsius # _ prefix = "private by convention"
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero!")
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9/5 + 32
t = Temperature(100)
print(t.fahrenheit) # 212.0
t.celsius = 0
print(t.fahrenheit) # 32.0
t.celsius = -999 # ValueError!
For data-holding classes, @dataclass removes boilerplate — it auto-generates __init__, __repr__, and __eq__.
from dataclasses import dataclass, field
@dataclass
class Point:
x: float
y: float
label: str = "unnamed"
tags: list = field(default_factory=list) # mutable default — safe!
p1 = Point(1.0, 2.0)
p2 = Point(1.0, 2.0)
print(p1) # Point(x=1.0, y=2.0, label='unnamed', tags=[])
print(p1 == p2) # True — __eq__ auto-generated!
1. What is self in a Python method?
2. Which dunder method does print(obj) call?
3. What does @classmethod receive as its first argument?
4. Why use field(default_factory=list) in a dataclass?
Questions answered correctly.