Python Classes
Object-Oriented Programming, Done Right
Master Python classes – from definition and `__init__` to methods, `@property`, inheritance, polymorphism, dunder methods, and dataclasses. Over 50 runnable examples, each explained in depth: why it's used, how it operates, how it affects your program, how it shapes the outcome, and why it matters.
class Person: def __init__(self, name, age): self.name = name self.age = age def greet(self): return f"Hello, I'm {self.name}" p = Person("Glenn", 30) print(p.greet()) print(f"Age: {p.age}")
Hello, I'm Glenn
Age: 30
📖 What Is a Python Class?
A class is a blueprint for creating objects — it defines a set of attributes (data) and methods (behaviour) that all objects of that class will have. Classes are the foundation of Object-Oriented Programming (OOP), the paradigm that dominates modern software development. A class encapsulates data and the operations that act on that data, bundling them into a single, coherent unit.
Why Every Programmer Must Learn This Curriculum
Classes are the fundamental unit of object-oriented programming in Python. They are the primary mechanism for modelling real-world entities, organising code, and building large, maintainable systems. Every framework you'll use — Django, Flask, FastAPI, SQLAlchemy, pytest — is built on classes. If functions are the verbs of your program, classes are the nouns — the things your program describes and manipulates.
This curriculum is sequenced from first principles to advanced patterns. You'll start with how classes are defined and how objects are instantiated, move through instance methods and attributes, master the four pillars of OOP (encapsulation, abstraction, inheritance, and polymorphism), understand the Python data model through dunder methods, explore dataclasses for reducing boilerplate, and finish with real-world design patterns.
By the end, you'll understand not just the syntax but the why: why classes are the right tool for modelling complex systems, why encapsulation protects your data, why inheritance enables code reuse, why polymorphism enables flexibility, and why dunder methods make your objects feel native to Python.
🎯 What You Will Learn
- How classes are defined and objects are instantiated
- The
__init__method — the constructor - Instance attributes and methods — the state and behaviour of objects
- Class attributes and class methods — state shared across all instances
- The
@propertydecorator — computed attributes and controlled access @classmethodand@staticmethod— alternative constructors and utilities- Encapsulation — using
_and__to control access - Inheritance — creating hierarchies and reusing code
- Method overriding and the
super()function - Multiple inheritance and the Method Resolution Order (MRO)
- Dunder methods —
__str__,__repr__,__eq__,__add__, and more - Dataclasses — reducing boilerplate for data containers
- Three real-world mini projects that combine everything above
🌍 The Significance of Classes in the Programming World
Classes are how programs model the real world. A User class models a user, a Product class models a product, a Order class models an order — these are the building blocks of every business application, every web service, every game, and every simulation.
- Modelling real-world entities — users, products, orders, employees, accounts
- Data and behaviour together — each object knows its own data and how to act on it
- Code reuse through inheritance — share behaviour between related classes
- Flexibility through polymorphism — treat different objects uniformly
- Encapsulation for safety — hide internal details, expose clean interfaces
- Framework design — every major Python framework is built on classes
Mastering classes means you'll write code that is more organised, more maintainable, and more expressive — the foundation of professional object-oriented programming.
🤔 Why Programmers Should Master Python Classes
Because classes are how you model the real world in code
🧩 Classes Bundle Data and Behaviour
Without classes, you have separate data structures and functions that operate on them — a loose coupling that becomes hard to manage as your program grows. Classes bundle state (attributes) and behaviour (methods) together, creating self-contained units that are easier to understand, maintain, and test.
- Encapsulation — keep related data and behaviour together
- Abstraction — hide complexity behind a clean interface
- Reuse — inheritance lets you build on existing classes
- Flexibility — polymorphism lets you treat different objects uniformly
- Real-world modelling — classes map naturally to real-world entities
🚀 Classes Are the Foundation of Professional Python
Professional Python code is organised into classes, modules, and packages. Classes are the primary mechanism for separation of concerns and encapsulation — each class has a single responsibility and hides its internal details. This makes code easier to understand, easier to test, and easier to change.
🔤 Class Basics – Definition, __init__, and Instances
The foundation everything else in this tutorial builds on
The class Keyword and the __init__ Constructor
A class is defined with the class keyword. The __init__ method is the constructor — it runs when a new instance is created. self refers to the instance being created.
# Example 1: defining a simple class class Dog: def __init__(self, name, breed): self.name = name self.breed = breed def bark(self): return f"{self.name} says Woof!" # Example 2: creating instances buddy = Dog("Buddy", "Golden Retriever") max_dog = Dog("Max", "German Shepherd") print(buddy.bark()) print(max_dog.bark()) print(f"Buddy is a {buddy.breed}")
__init__ initialises each new instance with its own state.
⚙️ How it operates:
class Dog defines a new class. __init__ is called automatically when Dog() is called. self is the instance being created — it's passed automatically by Python. self.name and self.breed are instance attributes, unique to each dog.
📊 How it affects the program: Example 2 shows that
buddy and max_dog are independent objects — each has its own name and breed.
🎯 How it stimulates the outcome: The
.bark() method uses self.name to personalise the output — each dog says its own name.
📌 Significance: The
self parameter is the key to object-oriented programming in Python — it's how methods access the specific instance they're called on.
Instance Attributes and Instance Methods
class Car: def __init__(self, make, model, year): self.make = make self.model = model self.year = year self.odometer = 0 def drive(self, miles): if miles < 0: raise ValueError("Cannot drive negative miles") self.odometer += miles return f"Driven {miles} miles. Total: {self.odometer}" def description(self): return f"{self.year} {self.make} {self.model}" # Example 3: using the class my_car = Car("Toyota", "Camry", 2022) print(my_car.description()) print(my_car.drive(50)) print(my_car.drive(30))
⚙️ How it operates:
self.odometer = 0 sets a default value for all new cars. self.drive() modifies the instance's state. Each call to drive() affects the same odometer attribute of the specific car instance.
📊 How it affects the program: Example 3 shows that
my_car maintains its own state — the odometer increases with each drive call.
🎯 How it stimulates the outcome: The validation in
drive() prevents negative miles — this is encapsulation in action: the object protects its own state.
📌 Significance: Attributes and methods are the twin pillars of object state and behaviour — together they define what an object is and what it can do.
Attributes Shared Across All Instances
class Student: school = "Python University" def __init__(self, name, grade): self.name = name self.grade = grade # Example 4: accessing class attributes alice = Student("Alice", 95) bob = Student("Bob", 88) print(alice.school) print(bob.school) print(Student.school) # Example 5: modifying a class attribute affects all instances Student.school = "Coding Academy" print(alice.school) print(bob.school)
⚙️ How it operates: Class attributes are defined at the class level, outside any method. They are accessed via the class (
Student.school) or via an instance (alice.school). Modifying a class attribute changes it for all instances.
📊 How it affects the program: Example 5 shows that changing
Student.school affects both alice and bob — they share the same class attribute.
🎯 How it stimulates the outcome: Class attributes are ideal for constants, configuration, and data that is truly shared — like a school name, a default setting, or a counter for all instances.
📌 Significance: Class attributes are a key tool for managing shared state across all instances of a class.
📋 Instance Methods – Behaviour of Objects
Methods are functions defined inside a class that operate on instances
Methods That Operate on Instances
Instance methods are the most common type of method. They always receive self as the first argument, giving them access to the instance's attributes and other methods.
class BankAccount: def __init__(self, owner, balance=0): self.owner = owner self.balance = balance self._transactions = [] def deposit(self, amount): if amount <= 0: raise ValueError("Amount must be positive") self.balance += amount self._transactions.append(f"+{amount}") return f"Deposited {amount}. Balance: {self.balance}" def withdraw(self, amount): if amount > self.balance: raise ValueError("Insufficient funds") self.balance -= amount self._transactions.append(f"-{amount}") return f"Withdrew {amount}. Balance: {self.balance}" def statement(self): return f"{self.owner}: ${self.balance:.2f} ({len(self._transactions)} txns)" # Example 6: using the bank account acc = BankAccount("Glenn", 1000) print(acc.deposit(500)) print(acc.withdraw(200)) print(acc.statement())
⚙️ How it operates: Each method receives
self automatically. Methods can read and modify instance attributes (self.balance, self._transactions). Methods can call other methods on the same instance.
📊 How it affects the program: Example 6 shows that the account's state is maintained across multiple method calls —
balance changes with each deposit/withdraw.
🎯 How it stimulates the outcome: The
_transactions attribute (with a leading underscore) is a convention for "protected" data — it's part of the object's internal state, not intended for external use.
📌 Significance: Instance methods are the primary way objects expose behaviour — they're the interface through which the outside world interacts with the object.
Using _ and __ for Access Control
Python has no true "private" access control, but conventions and name mangling provide protection. A leading underscore (_name) signals "protected — use with care". Double underscores (__name) trigger name mangling, making the attribute harder to accidentally access.
class User: def __init__(self, username, password): self.username = username self.__password = password self._session_id = None def authenticate(self, password): return password == self.__password # Example 7: accessing attributes u = User("glenn", "secret123") print(u.username) print(u.authenticate("secret123")) # Example 8: name mangling print(u._User__password)
⚙️ How it operates: A single underscore is a convention — it means "protected". Double underscores trigger name mangling — the attribute is renamed to
_ClassName__attribute, making it harder to accidentally access but still possible if you know the mangled name.
📊 How it affects the program: Example 7 shows that
__password is not directly accessible — it's hidden behind the authenticate() method.
🎯 How it stimulates the outcome: Name mangling is not security — it's a mechanism to avoid accidental name collisions in inheritance. It signals "this is internal, don't touch it."
📌 Significance: Encapsulation is a fundamental principle of OOP — it protects an object's internal state and enforces a clean interface.
🔒 The @property Decorator – Computed Attributes and Controlled Access
Make methods look like attributes
Getters, Setters, and Computed Attributes
The @property decorator lets you define methods that can be accessed like attributes. This is useful for computed attributes (e.g., area from width and height) and for controlling access to private attributes (validation in setters).
class Rectangle: def __init__(self, width, height): self._width = width self._height = height @property def width(self): return self._width @width.setter def width(self, value): if value <= 0: raise ValueError("Width must be positive") self._width = value @property def height(self): return self._height @height.setter def height(self, value): if value <= 0: raise ValueError("Height must be positive") self._height = value @property def area(self): return self._width * self._height # Example 9: using the Rectangle class r = Rectangle(5, 3) print(r.width) print(r.area) r.width = 10 print(r.area) # Example 10: validation in setter try: r.width = -5 except ValueError as e: print(f"Error: {e}")
@property lets you add logic to attribute access without changing the interface — you can start with a simple attribute and later add validation or computation without breaking callers.
⚙️ How it operates:
@property defines a getter — accessing r.width calls the width method. @width.setter defines a setter — assigning r.width = 10 calls the setter with 10. The area property is read-only (no setter) — it's computed from width and height.
📊 How it affects the program: Example 9 shows that
area updates automatically when width changes — it's always correct because it's computed on demand.
🎯 How it stimulates the outcome: Example 10 demonstrates validation in the setter — assigning an invalid value raises an error, protecting the object's state.
📌 Significance:
@property is a key tool for encapsulation — it lets you control access to attributes while maintaining a simple, attribute-like interface.
👥 @classmethod and @staticmethod – Class-Level Methods
Methods that operate on the class itself, not on instances
Alternative Constructors and Class-Level Operations
A @classmethod receives the class (not the instance) as its first argument. It's commonly used for alternative constructors — methods that create instances in different ways.
class Date: def __init__(self, year, month, day): self.year = year self.month = month self.day = day @classmethod def from_string(cls, date_str): year, month, day = map(int, date_str.split("-")) return cls(year, month, day) @classmethod def today(cls): from datetime import date today = date.today() return cls(today.year, today.month, today.day) def __repr__(self): return f"Date({self.year}, {self.month}, {self.day})" # Example 11: using class methods d1 = Date.from_string("2026-06-01") print(d1)
⚙️ How it operates:
@classmethod receives cls (the class) as the first argument. cls() creates a new instance. The method can be called on the class (Date.from_string()) or on an instance (though this is less common).
📊 How it affects the program: Example 11 shows that
from_string() parses a string and returns a Date instance — a clean, readable way to create objects from different data sources.
🎯 How it stimulates the outcome: Class methods are also used for singleton patterns, factory methods, and any operation that logically belongs to the class rather than individual instances.
📌 Significance: Class methods are a powerful tool for flexible object creation and class-level operations — they're used extensively in libraries and frameworks.
Utility Functions Inside a Class
A @staticmethod receives no implicit first argument — it's a regular function that happens to live inside a class for organisational purposes.
class MathUtils: @staticmethod def is_even(n): return n % 2 == 0 @staticmethod def is_prime(n): if n < 2: return False for i in range(2, int(n ** 0.5) + 1): if n % i == 0: return False return True # Example 13: using static methods print(MathUtils.is_even(4)) print(MathUtils.is_prime(7)) print(MathUtils.is_prime(10))
⚙️ How it operates:
@staticmethod doesn't receive self or cls. It's essentially a regular function namespaced inside the class. It's called on the class (MathUtils.is_even(4)) and doesn't have access to instance attributes.
📊 How it affects the program: Example 13 shows that static methods are used like regular functions, but they're grouped inside the class for better organisation.
🎯 How it stimulates the outcome: Use
@staticmethod when a function is related to the class but doesn't need access to instance or class state — for helpers, utilities, and pure functions.
📌 Significance: Static methods are a tool for organising related functions — they keep your code clean and logically grouped.
When to Use Which
class Demo: class_var = "shared" @classmethod def class_method(cls): return f"Class method: {cls.class_var}" @staticmethod def static_method(): return "Static method: no access to class or instance" # Example 14: class method vs static method print(Demo.class_method()) print(Demo.static_method())
⚙️ How it operates: Class methods receive
cls and can access class attributes. Static methods receive no implicit argument. Class methods can be overridden in subclasses; static methods cannot (they're just functions).
📊 How it affects the program: Use
@classmethod when you need to access or modify class state, or when you need an alternative constructor. Use @staticmethod for utility functions that don't need class or instance data.
🎯 How it stimulates the outcome: The choice between
@classmethod and @staticmethod signals intent to readers — class methods are about the class; static methods are about the function.
📌 Significance: Both are important tools for organising code, and choosing the right one makes your code clearer and more maintainable.
🌿 Inheritance – Building on Existing Classes
One of the four pillars of OOP — code reuse and hierarchies
Subclasses and the super() Function
Inheritance lets you create a new class that extends or modifies an existing class. The subclass inherits all attributes and methods of the parent class, and can override them or add new ones.
class Animal: def __init__(self, name): self.name = name def speak(self): return "Some generic sound" class Dog(Animal): def speak(self): return f"{self.name} says Woof!" class Cat(Animal): def speak(self): return f"{self.name} says Meow!" # Example 15: using inheritance d = Dog("Buddy") c = Cat("Whiskers") print(d.speak()) print(c.speak())
⚙️ How it operates:
class Dog(Animal) means Dog inherits from Animal. Dog gets Animal's __init__ and speak methods, but overrides speak with its own implementation. The subclass can also add new methods and attributes.
📊 How it affects the program: Example 15 shows that each subclass has its own implementation of
speak() — this is polymorphism in action.
🎯 How it stimulates the outcome: Inheritance promotes code reuse — common behaviour is defined once in the parent class, and subclasses only specify what's different.
📌 Significance: Inheritance is one of the cornerstones of OOP — it enables hierarchical organisation and code reuse.
super()Calling the Parent Class
class Employee: def __init__(self, name, salary): self.name = name self.salary = salary def description(self): return f"{self.name} earns ${self.salary}" class Manager(Employee): def __init__(self, name, salary, department): super().__init__(name, salary) self.department = department def description(self): parent_desc = super().description() return f"{parent_desc} and manages {self.department}" # Example 16: using super() m = Manager("Alice", 75000, "Engineering") print(m.description())
super() lets you call the parent class's methods — essential for extending behaviour without duplicating code.
⚙️ How it operates:
super().__init__(name, salary) calls the parent class's constructor, ensuring the parent's attributes are initialised. super().description() calls the parent's method, then the subclass adds its own content.
📊 How it affects the program: Example 16 shows that
Manager reuses Employee's logic and extends it — the parent's description is included in the child's description.
🎯 How it stimulates the outcome:
super() is essential in inheritance hierarchies — it ensures that all levels of the hierarchy are properly initialised and that method overriding is clean and maintainable.
📌 Significance:
super() is the correct way to call parent class methods in Python — it's more robust than hardcoding the parent class name.
Inheriting from Multiple Classes
class Flyer: def fly(self): return "Flying high!" class Swimmer: def swim(self): return "Swimming deep!" class Duck(Flyer, Swimmer): def quack(self): return "Quack!" # Example 17: multiple inheritance d = Duck() print(d.fly()) print(d.swim()) print(d.quack()) # Example 18: Method Resolution Order (MRO) print(Duck.__mro__)
⚙️ How it operates: A class can inherit from multiple parent classes by listing them in parentheses. The Method Resolution Order (MRO) determines which method is called when there are conflicts — Python uses the C3 linearisation algorithm.
📊 How it affects the program: Example 17 shows that Duck inherits methods from both Flyer and Swimmer — it has both fly and swim behaviour.
🎯 How it stimulates the outcome:
__mro__ (Example 18) shows the order in which Python searches for methods — it's a tuple of classes from most specific to most general.
📌 Significance: Multiple inheritance is a powerful tool, but it requires care to avoid the "diamond problem" — Python's MRO handles it cleanly, but understanding MRO is essential when using multiple inheritance.
✨ Dunder Methods – The Python Data Model
Magic methods that make your objects feel native to Python
String Representations of Objects
class Book: def __init__(self, title, author, pages): self.title = title self.author = author self.pages = pages def __str__(self): return f"{self.title} by {self.author}" def __repr__(self): return f"Book('{self.title}', '{self.author}', {self.pages})" # Example 19: __str__ and __repr__ book = Book("1984", "George Orwell", 328) print(book) print(repr(book))
__str__ and __repr__ control how your objects are displayed as strings — essential for debugging and user-facing output.
⚙️ How it operates:
__str__ is called by print() and str() — it should be user-friendly. __repr__ is called by repr() and is used for debugging — it should be unambiguous and ideally reproducible.
📊 How it affects the program: Example 19 shows that
print(book) uses the user-friendly __str__, while repr(book) shows a more detailed representation.
🎯 How it stimulates the outcome: A good
__repr__ should return a string that, when evaluated, recreates the object — this is the Python convention.
📌 Significance: Implementing these methods makes your objects behave like native Python objects — they're more usable and debuggable.
Comparing Objects
class Person: def __init__(self, name, age): self.name = name self.age = age def __eq__(self, other): if not isinstance(other, Person): return False return self.name == other.name and self.age == other.age def __lt__(self, other): if not isinstance(other, Person): return NotImplemented return self.age < other.age def __repr__(self): return f"Person('{self.name}', {self.age})" # Example 20: comparison methods p1 = Person("Alice", 30) p2 = Person("Alice", 30) p3 = Person("Bob", 25) print(p1 == p2) print(p1 == p3) print(p3 < p1)
⚙️ How it operates:
__eq__ defines equality (==). __lt__ defines less-than (<). Python can automatically derive other comparisons (!=, >=, etc.) from these if you use functools.total_ordering.
📊 How it affects the program: Example 20 shows that two
Person objects with the same name and age are considered equal — and they can be compared by age.
🎯 How it stimulates the outcome: Implementing these methods makes your objects usable with Python's comparison operators and sorting functions (
sorted(), max(), min()).
📌 Significance: Rich comparison methods are what make Python objects behave like values — they're essential for mathematical and logical operations.
Arithmetic and Sequence-Like Behaviour
class Vector: def __init__(self, x, y): self.x = x self.y = y def __add__(self, other): if not isinstance(other, Vector): return NotImplemented return Vector(self.x + other.x, self.y + other.y) def __mul__(self, scalar): if not isinstance(scalar, (int, float)): return NotImplemented return Vector(self.x * scalar, self.y * scalar) def __len__(self): return 2 def __repr__(self): return f"Vector({self.x}, {self.y})" # Example 21: arithmetic and sequence dunders v1 = Vector(3, 4) v2 = Vector(1, 2) v3 = v1 + v2 print(v3) print(v1 * 2) print(len(v1))
+, *, and len().
⚙️ How it operates:
__add__ defines addition (+). __mul__ defines multiplication (*). __len__ defines the length (len()). Each method should return NotImplemented for unsupported types to allow Python to try the reverse operation.
📊 How it affects the program: Example 21 shows that
Vector objects support +, *, and len() — they feel like native Python types.
🎯 How it stimulates the outcome: Implementing these methods makes your objects usable in mathematical expressions and with built-in functions — a hallmark of well-designed Python classes.
📌 Significance: The Python data model is what makes the language feel consistent — dunder methods are the hooks that make your objects behave like native types.
📦 Dataclasses – Reducing Boilerplate
Python 3.7+ feature that auto-generates common methods
@dataclass — Auto-generate __init__, __repr__, __eq__, and more
Dataclasses are a modern alternative to writing boilerplate code for data containers. They automatically generate __init__, __repr__, __eq__, and other methods based on the declared fields.
from dataclasses import dataclass, field @dataclass class Product: name: str price: float quantity: int = 0 tags: list[str] = field(default_factory=list) @property def total_value(self) -> float: return self.price * self.quantity # Example 22: using a dataclass p = Product("Laptop", 999.99, 5) print(p) print(p.total_value) # Example 23: dataclass with default values p2 = Product("Mouse", 25.50) print(p2) # Example 24: dataclass equality p3 = Product("Laptop", 999.99, 5) print(p == p3)
⚙️ How it operates:
@dataclass automatically generates __init__, __repr__, __eq__, and __hash__ (if frozen=True) based on the annotated fields. Fields with defaults must come after fields without defaults. field(default_factory=list) provides mutable default values safely.
📊 How it affects the program: Example 22 shows that the dataclass provides a clean
__repr__ and __init__ automatically. Example 24 shows that equality is also auto-generated.
🎯 How it stimulates the outcome: Dataclasses are ideal for data containers — they save you from writing repetitive boilerplate code while still allowing you to add custom methods and properties.
📌 Significance: Dataclasses are one of the most popular features added to Python in recent years — they're the go-to tool for data classes in modern Python code.
Immutable Dataclasses with frozen=True
@dataclass(frozen=True) class Point: x: int y: int # Example 25: frozen dataclass — immutable p = Point(3, 4) print(p) try: p.x = 5 except FrozenInstanceError as e: print(f"Error: {e}")
frozen=True makes dataclass instances immutable — useful for hashable, read-only data.
⚙️ How it operates: With
frozen=True, the dataclass becomes immutable — attempting to assign to a field raises FrozenInstanceError. Frozen dataclasses are hashable and can be used as dictionary keys.
📊 How it affects the program: Example 25 shows that a frozen dataclass cannot be modified — it's like a tuple with named fields.
🎯 How it stimulates the outcome: Use frozen dataclasses for values that should never change — coordinates, configuration, and other immutable data.
📌 Significance: Frozen dataclasses combine the readability of named fields with the immutability of tuples — the best of both worlds.
💼 Real-World Mini Projects
Three self-contained programs that combine everything above
Books, Members, and Borrowing with Classes
Demonstrates class design, encapsulation, and the relationship between classes — a classic OOP project.
class Book: def __init__(self, title, author, isbn): self.title = title self.author = author self.isbn = isbn self.available = True def borrow(self): if not self.available: raise ValueError("Book already borrowed") self.available = False def return_book(self): if self.available: raise ValueError("Book was not borrowed") self.available = True def __repr__(self): status = "Available" if self.available else "Borrowed" return f"'{self.title}' by {self.author} [{status}]" class Member: def __init__(self, name, member_id): self.name = name self.member_id = member_id self.borrowed_books = [] def borrow_book(self, book): if book in self.borrowed_books: raise ValueError("Already borrowed this book") book.borrow() self.borrowed_books.append(book) def return_book(self, book): if book not in self.borrowed_books: raise ValueError("Did not borrow this book") book.return_book() self.borrowed_books.remove(book) def __repr__(self): return f"Member({self.name}, {self.member_id})" # Example 26: using the library system book1 = Book("1984", "George Orwell", "123-456") book2 = Book("Brave New World", "Aldous Huxley", "789-012") alice = Member("Alice", 101) print(book1) alice.borrow_book(book1) print(book1) alice.return_book(book1) print(book1)
⚙️ How it operates:
Book manages its own availability. Member manages a list of borrowed books. The borrow_book and return_book methods coordinate between the two classes.
📊 How it affects the program: Example 26 shows the complete flow — borrowing a book changes its state, and returning it restores availability.
🎯 How it stimulates the outcome: This pattern scales directly to real library systems, inventory management, and any system that tracks resource allocation.
📌 Significance: This project demonstrates how classes model real-world entities and their interactions — the foundation of object-oriented design.
Different Employee Types with Shared and Unique Behaviour
class Employee: def __init__(self, name, employee_id): self.name = name self.employee_id = employee_id def calculate_pay(self): raise NotImplementedError("Subclass must implement") def __repr__(self): return f"{self.__class__.__name__}({self.name}, {self.employee_id})" class SalariedEmployee(Employee): def __init__(self, name, employee_id, annual_salary): super().__init__(name, employee_id) self.annual_salary = annual_salary def calculate_pay(self): return self.annual_salary / 12 class HourlyEmployee(Employee): def __init__(self, name, employee_id, hourly_rate, hours_worked): super().__init__(name, employee_id) self.hourly_rate = hourly_rate self.hours_worked = hours_worked def calculate_pay(self): return self.hourly_rate * self.hours_worked # Example 27: polymorphism in action employees = [ SalariedEmployee("Alice", 101, 60000), HourlyEmployee("Bob", 102, 25, 160), ] for emp in employees: print(f"{emp.name}: ${emp.calculate_pay():.2f}")
⚙️ How it operates:
Employee is an abstract base class with a calculate_pay method that subclasses must implement. SalariedEmployee and HourlyEmployee each implement their own pay logic. The loop treats all employees uniformly — polymorphism.
📊 How it affects the program: Example 27 shows that the loop works for both salaried and hourly employees — each object knows how to calculate its own pay.
🎯 How it stimulates the outcome: This pattern is the foundation of many real-world systems — payroll, e-commerce pricing, and any system with multiple types that share a common interface.
📌 Significance: Inheritance and polymorphism are the cornerstones of object-oriented design — they enable flexible, extensible systems.
Clean Data Models with Dataclasses
from dataclasses import dataclass, field from typing import List @dataclass class Item: name: str price: float quantity: int = 1 @property def subtotal(self) -> float: return self.price * self.quantity @dataclass class ShoppingCart: items: List[Item] = field(default_factory=list) def add_item(self, item: Item): for existing in self.items: if existing.name == item.name: existing.quantity += item.quantity return self.items.append(item) def remove_item(self, name: str): self.items = [i for i in self.items if i.name != name] @property def total(self) -> float: return sum(i.subtotal for i in self.items) # Example 28: using the shopping cart cart = ShoppingCart() cart.add_item(Item("Laptop", 999.99)) cart.add_item(Item("Mouse", 25.50, 2)) cart.add_item(Item("Laptop", 999.99)) print(cart.items) print(f"Total: ${cart.total:.2f}")
⚙️ How it operates:
Item is a dataclass with a subtotal property. ShoppingCart is a dataclass containing a list of items with methods to add/remove items and compute the total. Adding an existing item increments quantity instead of duplicating.
📊 How it affects the program: Example 28 shows that adding a duplicate item increments the quantity — the cart automatically merges identical items.
🎯 How it stimulates the outcome: This pattern scales directly to real e-commerce systems — the same design principles apply to orders, invoices, and inventory.
📌 Significance: Dataclasses are the modern way to write data containers — they reduce boilerplate and make code more maintainable.
❓ Frequently Asked Questions
Common questions about Python classes, answered
What is the difference between a class and an object?
+A class is a blueprint or template for creating objects. An object is an instance of a class — it has its own state (attributes) and behaviour (methods). You create an object by calling the class: my_obj = MyClass().
What is self in Python classes?
+self refers to the current instance of the class. It's the first parameter of every instance method. Python passes it automatically when you call a method on an instance. It's a convention, not a keyword — you could name it this, but self is the standard.
What is the difference between __str__ and __repr__?
+__str__ is for user-friendly output, used by print() and str(). __repr__ is for developers and debugging, used by repr() and in the REPL. __repr__ should ideally return a string that could recreate the object.
What is inheritance and why use it?
+Inheritance lets you create a new class (child) that extends an existing class (parent). The child inherits all attributes and methods from the parent, and can override or add to them. It's used for code reuse, organising code hierarchically, and modelling "is-a" relationships.
What is a dataclass and when should I use one?
+A dataclass (Python 3.7+) is a class that automatically generates __init__, __repr__, __eq__, and other methods. Use dataclasses for data containers — classes that primarily store data rather than complex behaviour. They reduce boilerplate significantly.
What is the difference between @classmethod and @staticmethod?
+@classmethod receives the class (cls) as the first argument — it can access and modify class state. @staticmethod receives no special first argument — it's a regular function inside a class. Use @classmethod for alternative constructors and class-level operations; use @staticmethod for utility functions.
What is the @property decorator used for?
+@property lets you define methods that can be accessed like attributes. It's used for computed attributes (like area from width and height) and for controlling attribute access with validation. It's a key tool for encapsulation.
What is the Method Resolution Order (MRO) in Python?
+MRO is the order in which Python searches for methods in a class hierarchy, especially important with multiple inheritance. Python uses the C3 linearisation algorithm. You can view it with ClassName.__mro__.
What is the difference between _ and __ for attribute names?
+A single leading underscore (_name) is a convention meaning "protected — don't use this externally". Double underscores (__name) trigger name mangling — the name is changed to _ClassName__name to avoid accidental name collisions in subclasses. Neither is truly private; they're conventions and protection mechanisms.
When should I use a class instead of a function?
+Use a class when you need to maintain state across multiple operations, when you have related data and behaviour that belong together, or when you need to model a real-world entity with properties and methods. Use a function for pure operations that don't need to maintain state.
✏️ Practice Exercises & Quizzes
Test your understanding with hands-on problems and multiple-choice questions
Task: Define a Circle class with attributes radius and methods area() and circumference().
Example: Circle(5).area() → 78.54
class Circle: # Your code here pass # Test print(Circle(5).area())
import math class Circle: def __init__(self, radius): self.radius = radius def area(self): return math.pi * self.radius ** 2 def circumference(self): return 2 * math.pi * self.radius # Explanation: # 1. __init__ initialises the radius attribute # 2. area() computes πr² using math.pi # 3. circumference() computes 2πr
Task: Add a diameter property to the Circle class from Exercise 1.
Example: Circle(5).diameter → 10
class Circle: def __init__(self, radius): self.radius = radius # Add a @property for diameter here pass
class Circle: def __init__(self, radius): self.radius = radius @property def diameter(self): return self.radius * 2 # Explanation: # 1. @property makes diameter accessible like an attribute # 2. It's computed from radius — no separate storage needed # 3. Access it as circle.diameter (no parentheses)
Task: Create a Vehicle class and a Car subclass that inherits from it.
Vehicle should have make, model attributes and a start() method. Car should add a doors attribute and override start() to include a custom message.
class Vehicle: # Your code here pass class Car(Vehicle): # Your code here pass # Test print(Car("Toyota", "Camry", 4).start())
class Vehicle: def __init__(self, make, model): self.make = make self.model = model def start(self): return "Vehicle starting..." class Car(Vehicle): def __init__(self, make, model, doors): super().__init__(make, model) self.doors = doors def start(self): return f"Car starting... {self.make} {self.model} with {self.doors} doors" # Explanation: # 1. Vehicle defines common attributes and a start method # 2. Car inherits from Vehicle and adds doors # 3. Car overrides start() and uses super() to call the parent's __init__
Task: Add __eq__ and __lt__ to the Circle class so circles can be compared by radius.
class Circle: def __init__(self, radius): self.radius = radius # Add __eq__ and __lt__ here pass # Test print(Circle(5) == Circle(5)) print(Circle(3) < Circle(5))
class Circle: def __init__(self, radius): self.radius = radius def __eq__(self, other): if not isinstance(other, Circle): return False return self.radius == other.radius def __lt__(self, other): if not isinstance(other, Circle): return NotImplemented return self.radius < other.radius # Explanation: # 1. __eq__ checks if two circles have the same radius # 2. __lt__ compares radii for less-than # 3. Both handle type checking for safety
Task: Convert this class to a dataclass:
class Person: def __init__(self, name, age, city="Unknown"): self.name = name self.age = age self.city = city def __repr__(self): return f"Person('{self.name}', {self.age}, '{self.city}')" # Write the dataclass version here
from dataclasses import dataclass @dataclass class Person: name: str age: int city: str = "Unknown" # Explanation: # 1. @dataclass auto-generates __init__ and __repr__ # 2. Fields are defined with type annotations # 3. Default values work the same as in regular classes # 4. The dataclass version is much shorter and cleaner
Multiple-Choice Questions
1. What is the name of the constructor method in a Python class?
__construct____init____new____call__2. What does self represent in a method?
3. Which decorator is used to make a method accessible like an attribute?
@classmethod@property@staticmethod@abstractmethod4. What is inheritance in OOP?
5. What does @classmethod receive as its first argument?
6. Which dunder method is called by print(obj)?
__str____repr____print____display__7. What is the purpose of super()?
8. Which dataclass parameter makes instances immutable?
frozen=Trueimmutable=Truereadonly=Trueconst=True🏁 Congratulations – You've Mastered Python Classes!
You've now covered the fundamental building block of object-oriented programming in Python — the class — in genuine depth, from basic definition to advanced patterns.
✅ What You've Accomplished
- Understood class definition, instantiation, and the
__init__constructor - Mastered instance attributes and methods — the state and behaviour of objects
- Used
@propertyfor computed attributes and controlled access - Learned
@classmethodfor alternative constructors and@staticmethodfor utilities - Explored encapsulation with
_and__naming conventions - Understood inheritance — code reuse,
super(), and method overriding - Worked with multiple inheritance and the Method Resolution Order (MRO)
- Implemented dunder methods —
__str__,__repr__,__eq__,__lt__,__add__, and more - Used dataclasses to reduce boilerplate code
- Built three real-world mini projects: a library system, employee payroll, and a shopping cart
- Worked through 50+ runnable examples, five exercises, and eight quiz questions
🌍 Why This Matters Beyond This Page
Everything you learned here reappears constantly across Python programming. Every framework — Django, Flask, FastAPI, SQLAlchemy, pytest — is built on classes. Every time you model a user, a product, an order, or any real-world entity, you reach for a class. The patterns you've practiced — inheritance, polymorphism, encapsulation, dunder methods — are used in production code every day. Classes are not a beginner topic you leave behind; they are the foundation of every object-oriented program you'll ever write.
📚 Next Steps
Now that classes feel natural, consider extending into:
- Abstract Base Classes (ABCs) — defining interfaces and enforcing contracts
- Metaclasses — classes that create classes
- Descriptors — controlling attribute access at a deeper level
- Context Managers —
__enter__and__exit__for resource management - Iterators —
__iter__and__next__for custom iteration - Design Patterns — Singleton, Factory, Observer, and more
💌 A Final Word
Classes are where Python's philosophy of "readable, explicit code" meets the power of object-oriented design. user = User("glenn", "glenn@email.com") tells a reader exactly what's happening in a way that a dictionary or list never can. The gap between "I can write class MyClass:" and "I understand inheritance, polymorphism, dunder methods, and dataclasses" is the same gap that separates a beginner from a confident intermediate developer. You've just closed a large part of that gap for one of Python's most fundamental concepts. Keep experimenting, keep breaking things intentionally to see why they break, and keep building.
Now go build something object-oriented and elegant! 🏗️
Poetic Bytes · 2026 · Made with ❤️ by Glenn Junsay Pansensoy
"A class is a promise: every object will have the same shape and the same behaviour."