1. Introduction
Python is a multi-paradigm language, but at its heart it is built around the idea that almost everything is an object. Lists, strings, integers, functions — they are all objects. Object-Oriented Programming (OOP) is the design philosophy that structures code around objects: bundles of data (attributes) and behaviour (methods) that model real-world or conceptual entities.
A class is the blueprint for creating objects. Just as a house blueprint defines what every house built from it will look like and how it will behave, a Python class defines the attributes and methods that every instance of that class will possess.
This blog takes you from defining your first class all the way through advanced topics like inheritance, dunder methods, class methods, static methods, properties, dataclasses, and abstract base classes — with working code examples at every step.
2. What Is a Class?
A class is a user-defined data type. It packages related data and the functions that operate on that data into one cohesive unit. When you create an object from a class, you get an instance — a concrete realisation of the blueprint with its own independent state.
Key vocabulary: class = blueprint, instance/object = a thing built from that blueprint, attribute = data stored on an object, method = a function defined inside a class.
Defining a Minimal Class
class Dog: """A simple class representing a dog.""" pass # 'pass' means: nothing here yet# Create two independent instancesfido = Dog()buddy = Dog()print(type(fido)) # <class '__main__.Dog'>print(fido is buddy) # False — different objects in memory
3. The __init__ Method & Instance Attributes
The __init__ method is the initialiser (commonly called the constructor). Python calls it automatically when a new instance is created. It is where you set up every attribute the object needs from birth.
The first parameter of every method in a class is self — a reference to the instance being created or operated on. Python passes it automatically; you never supply it yourself.
class Dog: """Represents a dog with a name, breed, and age.""" def __init__(self, name: str, breed: str, age: int): # Instance attributes — unique to each object self.name = name self.breed = breed self.age = age def bark(self): return f"{self.name} says: Woof!" def describe(self): return f"{self.name} is a {self.age}-year-old {self.breed}."# Instantiatefido = Dog(name="Fido", breed="Labrador", age=3)rex = Dog(name="Rex", breed="German Shepherd", age=5)print(fido.bark()) # Fido says: Woof!print(rex.describe()) # Rex is a 5-year-old German Shepherd.
Best practice: always annotate parameter types in __init__ for clarity, even though Python does not enforce them at runtime.
4. Class Attributes vs Instance Attributes
Instance attributes are defined on self inside __init__ and are unique to each object. Class attributes are defined directly in the class body and are shared by all instances. Modifying a class attribute through the class affects every instance; setting it on a specific instance creates a new instance-level shadow copy.
class BankAccount: # Class attribute — shared by all accounts interest_rate = 0.035 # 3.5% total_accounts = 0 def __init__(self, owner: str, balance: float = 0.0): # Instance attributes self.owner = owner self.balance = balance BankAccount.total_accounts += 1 # modify via class, not self def apply_interest(self): self.balance += self.balance * BankAccount.interest_rateacc1 = BankAccount("Alice", 1000)acc2 = BankAccount("Bob", 2000)print(BankAccount.total_accounts) # 2acc1.apply_interest()print(acc1.balance) # 1035.0# Change the class attribute — affects all instancesBankAccount.interest_rate = 0.04acc2.apply_interest()print(acc2.balance) # 2080.0
5. Types of Methods
Python classes support three kinds of methods, each serving a distinct role:
| Method Type | First Parameter | Decorator | Typical Use |
| Instance method | self | (none) | Read/modify instance state |
| Class method | cls | @classmethod | Factories; access/modify class state |
| Static method | (none) | @staticmethod | Utility logic; no access to class or instance |
All Three in One Class
class Temperature: scale = "Celsius" # class attribute def __init__(self, value: float): self.value = value # ── Instance method ────────────────────────────────────── def to_fahrenheit(self) -> float: return self.value * 9/5 + 32 # ── Class method — alternative constructor (factory) ────── classmethod def from_fahrenheit(cls, f: float) -> "Temperature": return cls((f - 32) * 5/9) # ── Static method — pure utility, no self or cls ────────── staticmethod def is_freezing(celsius: float) -> bool: return celsius <= 0 def __repr__(self): return f"Temperature({self.value:.2f} {Temperature.scale})"t1 = Temperature(100)print(t1.to_fahrenheit()) # 212.0t2 = Temperature.from_fahrenheit(32) # factory classmethodprint(t2) # Temperature(0.00 Celsius)print(Temperature.is_freezing(-5)) # True
6. Inheritance
Inheritance lets a child class (subclass) reuse and extend the attributes and methods of a parent class (superclass). This promotes the DRY principle — Don’t Repeat Yourself — and models natural is-a relationships: a Dog is an Animal, a Car is a Vehicle.
Single Inheritance
class Animal: """Base class for all animals.""" def __init__(self, name: str, species: str): self.name = name self.species = species def speak(self) -> str: return f"{self.name} makes a sound." def __str__(self): return f"{self.name} ({self.species})"class Dog(Animal): # Dog inherits from Animal def __init__(self, name: str, breed: str): super().__init__(name, species="Canis lupus familiaris") self.breed = breed # Override speak() from the parent def speak(self) -> str: return f"{self.name} says: Woof!" def fetch(self, item: str) -> str: return f"{self.name} fetches the {item}!"class Cat(Animal): def __init__(self, name: str, indoor: bool = True): super().__init__(name, species="Felis catus") self.indoor = indoor def speak(self) -> str: return f"{self.name} says: Meow!"dog = Dog("Fido", "Labrador")cat = Cat("Whiskers")print(dog.speak()) # Fido says: Woof!print(cat.speak()) # Whiskers says: Meow!print(dog.fetch("ball")) # Fido fetches the ball!print(str(dog)) # Fido (Canis lupus familiaris)# isinstance() checks inheritance chainprint(isinstance(dog, Animal)) # Trueprint(isinstance(dog, Cat)) # False
Multiple Inheritance & MRO
Python supports inheriting from more than one parent. The Method Resolution Order (MRO) determines which parent’s method is called first when there is ambiguity. Python uses the C3 linearisation algorithm.
class Flyable: def move(self): return "Flying through the air"class Swimmable: def move(self): return "Swimming through water"class Duck(Flyable, Swimmable): # Flyable listed first def quack(self): return "Quack!"donald = Duck()print(donald.move()) # Flying through the air (Flyable wins — listed first)print(Duck.__mro__) # MRO tuple shows resolution order
7. Encapsulation & Access Control
Encapsulation means bundling data and the methods that operate on it, and restricting direct access to internal state. Python uses naming conventions rather than strict access modifiers:
- public_attr — accessible anywhere (default).
- _protected_attr — convention: internal to class and subclasses, do not access from outside.
- __private_attr — name-mangled to _ClassName__attr; harder (not impossible) to access externally.
class Person: def __init__(self, name: str, age: int): self.name = name # public self._id = id(self) # protected (convention) self.__age = age # private (name-mangled) def get_age(self) -> int: return self.__age def have_birthday(self): self.__age += 1p = Person("Alice", 30)print(p.name) # Aliceprint(p.get_age()) # 30# print(p.__age) # AttributeError
8. Properties — Controlled Attribute Access
The @property decorator lets you expose a method as if it were a plain attribute. This gives you clean dot-notation access while still running validation or computed logic behind the scenes. You can also define a setter and deleter.
class Circle: def __init__(self, radius: float): self._radius = radius # store privately property def radius(self) -> float: """Getter — read as circle.radius""" return self._radius radius.setter def radius(self, value: float): """Setter — enforces positive radius""" if value < 0: raise ValueError("Radius cannot be negative.") self._radius = value property def area(self) -> float: """Computed property — no setter needed""" import math return math.pi * self._radius ** 2 property def circumference(self) -> float: import math return 2 * math.pi * self._radiusc = Circle(5)print(c.radius) # 5print(f"{c.area:.2f}") # 78.54c.radius = 10 # calls setterprint(f"{c.area:.2f}") # 314.16# c.radius = -1 # raises ValueError
9. Dunder (Magic) Methods
Dunder methods (double underscore on both sides) are how Python hooks your class into built-in operations: printing, comparison, arithmetic, iteration, and more. Implementing them makes your objects feel like first-class Python citizens.
| Method | Triggered By | Purpose |
| __init__ | ClassName(…) | Initialise new instance |
| __repr__ | repr(obj) | Unambiguous developer string |
| __str__ | str(obj) / print(obj) | Human-readable string |
| __len__ | len(obj) | Return length |
| __eq__ | obj1 == obj2 | Equality comparison |
| __lt__ | obj1 < obj2 | Less-than comparison |
| __add__ | obj1 + obj2 | Addition operator |
| __getitem__ | obj[key] | Index / key access |
| __iter__ | for x in obj | Make object iterable |
| __enter__/__exit__ | with obj as x | Context manager protocol |
A Rich Example — Vector2D
import mathclass Vector2D: """2D vector with full operator support.""" def __init__(self, x: float, y: float): self.x = x self.y = y def __repr__(self) -> str: return f"Vector2D({self.x}, {self.y})" def __str__(self) -> str: return f"({self.x}, {self.y})" def __add__(self, other: "Vector2D") -> "Vector2D": return Vector2D(self.x + other.x, self.y + other.y) def __sub__(self, other: "Vector2D") -> "Vector2D": return Vector2D(self.x - other.x, self.y - other.y) def __mul__(self, scalar: float) -> "Vector2D": return Vector2D(self.x * scalar, self.y * scalar) def __eq__(self, other: object) -> bool: if not isinstance(other, Vector2D): return NotImplemented return self.x == other.x and self.y == other.y def __abs__(self) -> float: # magnitude return math.sqrt(self.x**2 + self.y**2) def __len__(self) -> int: # dimension return 2v1 = Vector2D(3, 4)v2 = Vector2D(1, 2)print(v1 + v2) # (4, 6)print(v1 - v2) # (2, 2)print(v1 * 3) # (9, 12)print(abs(v1)) # 5.0 — magnitudeprint(v1 == Vector2D(3, 4)) # True
10. Abstract Base Classes (ABCs)
An Abstract Base Class defines an interface — a set of methods that every subclass must implement. If a subclass fails to implement an abstract method, Python raises a TypeError when you try to instantiate it. Use abc.ABC and the @abstractmethod decorator.
from abc import ABC, abstractmethodclass Shape(ABC): """Abstract base class — cannot be instantiated directly.""" abstractmethod def area(self) -> float: ... abstractmethod def perimeter(self) -> float: ... def describe(self) -> str: # concrete method — inherited as-is return (f"{self.__class__.__name__}: " f"area={self.area():.2f}, perimeter={self.perimeter():.2f}")class Rectangle(Shape): def __init__(self, w: float, h: float): self.w, self.h = w, h def area(self) -> float: return self.w * self.h def perimeter(self) -> float: return 2 * (self.w + self.h)class Circle(Shape): import math as _math def __init__(self, r: float): self.r = r def area(self) -> float: import math; return math.pi * self.r**2 def perimeter(self) -> float: import math; return 2 * math.pi * self.rshapes = [Rectangle(4, 6), Circle(5)]for s in shapes: print(s.describe())# Shape() # TypeError: Can't instantiate abstract class
11. Dataclasses — Less Boilerplate
The @dataclass decorator (Python 3.7+) auto-generates __init__, __repr__, and __eq__ from your field annotations. For data-holding classes, this eliminates significant boilerplate while keeping the code readable.
from dataclasses import dataclass, fieldfrom typing import Listdataclassclass Student: name: str age: int grades: List[float] = field(default_factory=list) def average(self) -> float: return sum(self.grades) / len(self.grades) if self.grades else 0.0 def add_grade(self, g: float) -> None: self.grades.append(g)s = Student(name="Priya", age=20)s.add_grade(88.5)s.add_grade(92.0)s.add_grade(79.5)print(s) # Student(name='Priya', age=20, grades=[88.5, 92.0, 79.5])print(s.average()) # 86.67# frozen=True makes the dataclass immutable (hashable)dataclass(frozen=True)class Point: x: float y: floatp = Point(1.0, 2.0)# p.x = 5 # FrozenInstanceError
Use @dataclass when the class primarily holds data. Use a regular class when behaviour dominates or you need fine-grained control over __init__.
12. The Four Pillars of OOP in Python
| Pillar | What It Means | Python Feature |
| Encapsulation | Bundle data + behaviour; hide internal details | _protected, __private, @property |
| Abstraction | Expose only what is necessary; hide complexity | Abstract Base Classes (ABC), interfaces |
| Inheritance | Reuse and extend parent class behaviour | class Child(Parent), super() |
| Polymorphism | Different classes, same interface, different impl | Method overriding, duck typing |
13. Best Practices
- Name classes with CapWords (PascalCase): BankAccount, not bank_account.
- Keep classes focused — one class, one responsibility (Single Responsibility Principle).
- Use @property instead of explicit getter/setter methods for attribute access control.
- Always call super().__init__() in child class initialisers to ensure proper initialisation.
- Implement __repr__ on every non-trivial class — it makes debugging vastly easier.
- Prefer composition over inheritance when the relationship is has-a rather than is-a.
- Use @dataclass for simple data containers instead of writing boilerplate __init__ and __repr__.
- Define abstract interfaces with ABC so subclasses cannot forget to implement required methods.
- Avoid deep inheritance hierarchies — more than 2–3 levels becomes hard to reason about.
- Write docstrings for every class and public method; use type hints for all parameters.
14. Quick Reference Cheat Sheet
| Concept | Syntax / Example |
| Define a class | class Dog: |
| Constructor | def __init__(self, name): self.name = name |
| Instance method | def bark(self): return ‘Woof!’ |
| Class method | @classmethod def create(cls, …): return cls(…) |
| Static method | @staticmethod def utility(x): return x * 2 |
| Inheritance | class Puppy(Dog): … |
| Call parent method | super().__init__(name) |
| Property (getter) | @property def age(self): return self._age |
| Property (setter) | @age.setter def age(self, v): self._age = v |
| Dunder string | def __repr__(self): return f’Dog({self.name})’ |
| Abstract method | @abstractmethod def speak(self): … |
| Dataclass | @dataclass class Point: x: float; y: float |
| Check type | isinstance(obj, Dog) |
| Check subclass | issubclass(Puppy, Dog) |
15. Conclusion
Python classes are the cornerstone of scalable, maintainable code. Starting from a simple class with __init__, you can progressively layer on inheritance, polymorphism, encapsulation via properties, operator overloading through dunder methods, enforced interfaces with ABCs, and reduced boilerplate with dataclasses.
The real power of OOP comes not from any single feature but from how these concepts work together to model complex domains in code that reads naturally and grows gracefully. Keep your classes small and focused, lean on composition where appropriate, and write tests — and your Python OOP code will stand the test of time.
Happy Coding!
Discover more from DataSangyan
Subscribe to get the latest posts sent to your email.