← Back to Course Index
Lesson 3 of 10

Classes & OOP in Python

~25 min · class, __init__, self, inheritance, dunder methods

Ref
Primary Source
Official Docs — Classes (section 9)

Detailed walkthrough of Python's class system including inheritance, multiple inheritance, and special methods. Bookmark this one.

1 — Defining a Class

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.

JavaScript
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());
Python
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 explicit, not magic

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.

2 — Class vs Instance Variables

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"
⚠ Class variable mutation trap

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.

3 — Dunder (Magic) Methods

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
DunderTriggered byJS 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]

4 — Inheritance

JavaScript
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
Python
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'>

@classmethod and @staticmethod

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

5 — Properties (Getters & Setters)

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!

6 — dataclasses (Python 3.7+)

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!

🧠 Quiz

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?

0/4

Questions answered correctly.