OOP & Classes
class, write __init__, use self, create instance and class attributes, define instance methods and class methods, and use dunder methods (__str__, __repr__, __len__) to make objects feel like built-in Python types.
What is Object-Oriented Programming?
OOP is a programming paradigm that organises code around objects — bundles of related data (attributes) and behaviour (methods). A class is the blueprint; an instance is the actual object created from that blueprint.
Think of a class as the cookie cutter and an instance as an actual cookie. You can bake hundreds of different cookies from the same cutter — each with its own flavour and decoration — but they all share the same shape.
Defining a Class
Use the class keyword. The __init__ method (the constructor) runs automatically every time you create an instance. self refers to the instance being created — it must be the first parameter of every instance method.
class Student: """Represents a student with name, age, and grades.""" # Class attribute — shared by ALL instances school = "BitWithBite Academy" def __init__(self, name, age, gpa=0.0): # Instance attributes — unique per object self.name = name self.age = age self.gpa = gpa self.grades = [] # mutable — create fresh per instance! def add_grade(self, score): """Add a grade and recalculate GPA.""" self.grades.append(score) self.gpa = sum(self.grades) / len(self.grades) def is_honours(self): """Return True if GPA is 3.7 or above.""" return self.gpa >= 3.7 def profile(self): return f"{self.name}, {self.age}, GPA {self.gpa:.2f}" # Creating instances ali = Student("Ali", 20) sara = Student("Sara", 22, 3.9) # Access instance attributes print(ali.name) # Ali print(sara.gpa) # 3.9 # Access class attribute (shared) print(ali.school) # BitWithBite Academy print(Student.school) # BitWithBite Academy — same # Call instance methods ali.add_grade(88) ali.add_grade(92) ali.add_grade(78) print(ali.profile()) # Ali, 20, GPA 86.00 print(ali.is_honours()) # False (86 < 87 threshold)
grades = [] as a class attribute (outside __init__), every instance shares the same list — adding a grade for Ali would appear in Sara's grades too! Always create mutable defaults inside __init__ with self.grades = [].Dunder Methods — Making Objects Pythonic
Dunder methods (double-underscore methods, also called magic methods) let your objects behave like built-in Python types. Define __str__ to control how an object looks when printed; define __len__ to make len(obj) work; define __eq__ to control == comparisons.
class ShoppingCart: def __init__(self, owner): self.owner = owner self.items = [] def add(self, item, price): self.items.append((item, price)) def total(self): return sum(p for _, p in self.items) # __str__ — called by print() and str() def __str__(self): lines = [f" {i:20} PKR {p:,.0f}" for i, p in self.items] return f"🛒 {self.owner}'s Cart\n" + "\n".join(lines) + f"\n Total: PKR {self.total():,.0f}" # __repr__ — called in the REPL / for debugging def __repr__(self): return f"ShoppingCart(owner={self.owner!r}, items={len(self.items)})" # __len__ — called by len(cart) def __len__(self): return len(self.items) # __contains__ — called by 'item' in cart def __contains__(self, item_name): return item_name in [i for i, _ in self.items] # __add__ — called by cart1 + cart2 def __add__(self, other): merged = ShoppingCart(f"{self.owner}&{other.owner}") merged.items = self.items + other.items return merged cart = ShoppingCart("Fatima") cart.add("Python Book", 1500) cart.add("Keyboard", 3200) cart.add("Mouse", 1800) print(cart) # calls __str__ print(len(cart)) # 3 print("Mouse" in cart) # True
a + b calls a.__add__(b). a == b calls a.__eq__(b). len(a) calls a.__len__(). str(a) calls a.__str__(). a[0] calls a.__getitem__(0). This is how Python makes everything consistent — even integers and lists are just objects with dunder methods.Class Methods & Static Methods
Not all methods need access to a specific instance. Python gives you two decorators: @classmethod for methods that work with the class itself (not an instance), and @staticmethod for utility functions that happen to live inside a class but don't need self or cls.
class BankAccount: _total_accounts = 0 # class attribute — tracked globally INTEREST_RATE = 0.05 # constant — class attribute def __init__(self, owner, balance=0): self.owner = owner self.balance = balance BankAccount._total_accounts += 1 # Instance method — needs self def deposit(self, amount): self.balance += amount def apply_interest(self): self.balance *= (1 + BankAccount.INTEREST_RATE) # Class method — receives cls, not self @classmethod def total_accounts(cls): """How many accounts have been created?""" return cls._total_accounts # Alternative constructor — common pattern @classmethod def from_dict(cls, data): """Create an account from a dict.""" return cls(data["owner"], data["balance"]) # Static method — utility, no cls/self needed @staticmethod def validate_amount(amount): """Return True if amount is a positive number.""" return isinstance(amount, (int, float)) and amount > 0 def __str__(self): return f"Account({self.owner}, PKR {self.balance:,.2f})" acc1 = BankAccount("Ali", 10000) acc2 = BankAccount.from_dict({"owner": "Sara", "balance": 25000}) print(BankAccount.total_accounts()) # 2 print(BankAccount.validate_amount(-50)) # False acc1.deposit(5000) acc1.apply_interest() print(acc1) # Account(Ali, PKR 15,750.00)
Properties — Controlled Attribute Access
The @property decorator lets you access a method like an attribute. This is Python's way of adding validation or computation to attribute access without breaking the interface. Pair it with @name.setter to validate values on assignment.
class Temperature: """Store temperature in Celsius with validation.""" def __init__(self, celsius): self.celsius = celsius # triggers the setter below @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): """Read-only computed property.""" return self._celsius * 9/5 + 32 @property def kelvin(self): return self._celsius + 273.15 def __str__(self): return f"{self.celsius:.1f}°C = {self.fahrenheit:.1f}°F = {self.kelvin:.2f}K" t = Temperature(100) print(t) # 100.0°C = 212.0°F = 373.15K print(t.fahrenheit) # accessed like attribute, not method call! t.celsius = 0 # triggers setter — validated print(t) # 0.0°C = 32.0°F = 273.15K # t.celsius = -300 ← ValueError: Temperature below absolute zero!
@property for computed and validated attributesobj.fahrenheit not obj.get_fahrenheit(). And if you later need to add validation to an attribute that was previously public, you can add a property without breaking any code that already uses it — callers never see the difference.self refer to inside a class method?print(obj) displays?cls instead of self?@property allow you to do?Book: attributes
title, author, pages, available=True. Methods: checkout() sets available=False, return_book() sets True. Properties: status returns "Available"/"Checked Out". Dunders: __str__, __repr__.Library: attribute
books (list). Methods: add_book(book), find_by_author(author) → list, available_books() → list. Dunders: __len__ returns total books, __contains__ checks if title is in library. Class method from_list(cls, book_list) creates library from list of dicts.
💡 Show hints
@propertyfor status:return "Available" if self.available else "Checked Out"- Library
__contains__:return any(b.title == item for b in self.books) - from_list classmethod: loop the list, create Book objects, add them
- find_by_author: use list comprehension with .lower() for case-insensitive match
class Book: def __init__(self, title, author, pages): self.title = title self.author = author self.pages = pages self.available= True @property def status(self): return "Available" if self.available else "Checked Out" def checkout(self): if not self.available: raise ValueError("Already checked out!") self.available = False def return_book(self): self.available = True def __str__(self): return f"'{self.title}' by {self.author} [{self.status}]" def __repr__(self): return f"Book({self.title!r}, {self.author!r})" class Library: def __init__(self, name): self.name = name self.books = [] def add_book(self, book): self.books.append(book) def find_by_author(self, author): return [b for b in self.books if author.lower() in b.author.lower()] def available_books(self): return [b for b in self.books if b.available] def __len__(self): return len(self.books) def __contains__(self, title): return any(b.title == title for b in self.books) @classmethod def from_list(cls, name, book_dicts): lib = cls(name) for d in book_dicts: lib.add_book(Book(**d)) return lib def __str__(self): return f"{self.name} Library — {len(self)} books" lib = Library.from_list("City", [ {"title":"Python Crash Course", "author":"Eric Matthes", "pages":544}, {"title":"Clean Code", "author":"Robert Martin", "pages":431}, ]) print(lib) print("Clean Code" in lib) # True lib.books[0].checkout() print(len(lib.available_books())) # 1