A comprehensive guide to mastering OOP concepts in Python with practical examples and clear explanations.
- Classes & Objects Basics
- The
selfParameter - Attribute Access Levels
- Getter & Setter Methods
- Properties (@property)
- Instance vs Class Attributes
- Method Types
- The Four Pillars of OOP
- Quick Reference
- Class: A blueprint/template for creating objects
- Object: An instance of a class
- Everything in Python is an object (strings, integers, etc.)
class Dog:
def __init__(self, name, breed):
self.name = name
self.breed = breed
def bark(self):
print("Whoof whoof")
# Creating objects (instances)
dog1 = Dog("Bruce", "Scottish Terrier")
dog2 = Dog("Freya", "Greyhound")
# Accessing attributes and methods
print(dog1.name) # Bruce
dog1.bark() # Whoof whoof- Special method that runs when an object is created
- Used to initialize object attributes
- Runs only once per object creation
- Represents the specific object calling the method
- Automatically passed by Python (you don't pass it manually)
- Gives access to the object's attributes and methods
class Person:
def __init__(self, name, age):
self.name = name # Store name in THIS person
self.age = age # Store age in THIS person
def greet(self):
print(f"Hello, I'm {self.name} and I'm {self.age} years old")
person1 = Person("Rish", 21)
person1.greet() # Python automatically passes person1 as self- When Bruno the dog says "I am Bruno" → that's
self.name - Each object has its own
selfreference
Python uses naming conventions to indicate access levels:
class User:
def __init__(self, username):
self.username = username # Public - access freely
user = User("john")
print(user.username) # ✅ Encouragedclass User:
def __init__(self, email):
self._email = email # Protected - internal use
user = User("test@email.com")
print(user._email) # ⚠️ Works but not recommendedclass User:
def __init__(self, password):
self.__password = password # Private - name mangled
user = User("secret123")
# print(user.__password) # ❌ AttributeError
# print(user._User__password) # ✅ Works but strongly discouraged- Public: Safe to expose (usernames, IDs)
- Protected: Internal use, subclasses might need it
- Private: Absolutely internal, security-sensitive data
- Control access to attributes
- Add validation rules
- Keep data consistent
- Hide implementation details
class User:
def __init__(self, email):
self._email = email
def get_email(self):
return self._email
def set_email(self, new_email):
if '@' not in new_email:
raise ValueError("Invalid email")
self._email = new_email
user = User("test@email.com")
print(user.get_email()) # Get email safely
user.set_email("new@email.com") # Set with validationclass User:
def __init__(self, email):
self._email = email
@property
def email(self): # Getter
print("Email accessed")
return self._email
@email.setter
def email(self, new_email): # Setter
if '@' not in new_email:
raise ValueError("Invalid email")
self._email = new_email
user = User("test@email.com")
print(user.email) # Looks like attribute access
user.email = "new@email.com" # Looks like assignment- Clean syntax - looks like normal attribute access
- Hidden validation - rules enforced behind the scenes
- Future-proof - can change implementation without breaking code
- Unique to each object
- Defined in
__init__withself - Each object gets its own copy
class Dog:
def __init__(self, name):
self.name = name # Instance attribute
dog1 = Dog("Tommy")
dog2 = Dog("Rocky")
print(dog1.name) # Tommy
print(dog2.name) # Rocky (separate from dog1)- Shared by all objects of the class
- Defined directly in the class (outside methods)
- One copy for the entire class
class Dog:
species = "Canis familiaris" # Class attribute
def __init__(self, name):
self.name = name
dog1 = Dog("Tommy")
dog2 = Dog("Rocky")
print(dog1.species) # Canis familiaris
print(dog2.species) # Canis familiaris (same for both)
# Change for all instances
Dog.species = "Wolf"
print(dog1.species) # Wolf
print(dog2.species) # Wolfclass User:
user_count = 0 # Class attribute - shared counter
def __init__(self, username):
self.username = username # Instance attribute
User.user_count += 1 # Increment for each new user
user1 = User("alice")
user2 = User("bob")
print(f"Total users: {User.user_count}") # Total users: 2- Have
selfparameter - Work with specific object's data
- Most common type
def deposit(self, amount): # Instance method
self.balance += amount # Works with this object's balance- Use
@staticmethoddecorator - No
selfparameter - Don't access object data
- Utility functions related to the class
class BankAccount:
@staticmethod
def is_valid_interest_rate(rate):
return 0 <= rate <= 5 # Just checks a rule, no object data needed
# Can call on class directly
print(BankAccount.is_valid_interest_rate(3)) # True- Use double underscore
__method - Name-mangled by Python
- For internal use only
class BankAccount:
def deposit(self, amount):
self.__log_transaction("deposit", amount) # Call private method
def __log_transaction(self, type, amount): # Private method
print(f"Logging {type} of {amount}")Bundle data and methods together, hide internal details
# Bad - No encapsulation
class BadBankAccount:
def __init__(self, balance):
self.balance = balance
account = BadBankAccount(100)
account.balance = -50 # ❌ Can break rules!
# Good - With encapsulation
class BankAccount:
def __init__(self):
self._balance = 0.0 # Protected
@property
def balance(self):
return self._balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Amount must be positive")
self._balance += amount # Controlled accessShow only essential features, hide complex implementation
# Without abstraction - user must know all steps
email = EmailService()
email.connect()
email.authenticate()
email.send_email()
email.disconnect()
# With abstraction - user only needs to know what, not how
class EmailService:
def _connect(self): # Hidden
print("Connecting...")
def _authenticate(self): # Hidden
print("Authenticating...")
def send_email(self, recipient, message): # Public interface
self._connect()
self._authenticate()
print(f"Sending to {recipient}: {message}")
self._disconnect()
email = EmailService()
email.send_email("bob@email.com", "Hello!") # Simple for userCreate new classes based on existing ones
class Vehicle: # Parent class
def __init__(self, brand, model):
self.brand = brand
self.model = model
def start(self):
print(f"{self.__class__.__name__} is starting")
class Car(Vehicle): # Child class
def __init__(self, brand, model, doors):
super().__init__(brand, model) # Call parent constructor
self.doors = doors # Add new attribute
class Bike(Vehicle): # Another child class
def __init__(self, brand, model, wheels):
super().__init__(brand, model)
self.wheels = wheels
car = Car("Toyota", "Camry", 4)
bike = Bike("Honda", "CBR", 2)
car.start() # Car is starting (inherited method)
bike.start() # Bike is starting (inherited method)Same interface, different behavior
# Without polymorphism - messy conditionals
vehicles = [Car("Toyota", "Camry"), Motorcycle("Honda", "CBR")]
for vehicle in vehicles:
if isinstance(vehicle, Car):
vehicle.start() # Different method names
vehicle.stop()
elif isinstance(vehicle, Motorcycle):
vehicle.start_bike() # Different method names
vehicle.stop_bike()
# With polymorphism - clean and simple
class Vehicle:
def start(self): pass
def stop(self): pass
class Car(Vehicle):
def start(self): print("Car starting")
def stop(self): print("Car stopping")
class Motorcycle(Vehicle):
def start(self): print("Motorcycle starting")
def stop(self): print("Motorcycle stopping")
vehicles = [Car("Toyota", "Camry"), Motorcycle("Honda", "CBR")]
for vehicle in vehicles:
vehicle.start() # Same method call, different behavior
vehicle.stop() # Polymorphism in action!class ClassName:
class_attribute = "shared value" # Class attribute
def __init__(self, param1, param2):
self.instance_attr = param1 # Public instance attribute
self._protected_attr = param2 # Protected instance attribute
self.__private_attr = "secret" # Private instance attribute
def public_method(self): # Public method
return self.instance_attr
def _protected_method(self): # Protected method
return self._protected_attr
def __private_method(self): # Private method
return self.__private_attr
@property
def getter_property(self): # Getter property
return self._protected_attr
@getter_property.setter
def getter_property(self, value): # Setter property
self._protected_attr = value
@staticmethod
def utility_method(param): # Static method
return param * 2| Type | Syntax | Access | Usage |
|---|---|---|---|
| Public | self.attr |
Everywhere | Safe to expose |
| Protected | self._attr |
Internal/subclasses | Convention only |
| Private | self.__attr |
Class only | Name mangled |
| Type | Decorator | Parameters | Usage |
|---|---|---|---|
| Instance | None | self, ... |
Object-specific operations |
| Static | @staticmethod |
No self |
Utility functions |
| Property | @property |
self |
Attribute-like access |
E.A.I.P.
- Encapsulation: Bundle and protect data
- Abstraction: Hide complexity, show essentials
- Inheritance: Reuse code through parent-child relationships
- Polymorphism: Same interface, different behaviors
- Start simple - Begin with basic classes and gradually add complexity
- Follow conventions - Use
_for protected,__for private - Favor composition over inheritance when relationships aren't clear
- Use
@propertyinstead of getter/setter methods for Pythonic code - Keep methods focused - Each method should do one thing well
- Write docstrings - Document your classes and methods
Remember: OOP is about organizing code in a way that models real-world relationships and makes code more maintainable, reusable, and understandable!