Skip to content

Rishpraveen/Python-OOP-Headstart

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

7 Commits
 
 
 
 

Repository files navigation

Object-Oriented Programming in Python - Complete Study Guide

A comprehensive guide to mastering OOP concepts in Python with practical examples and clear explanations.

Table of Contents


Classes & Objects Basics

Key Concepts

  • Class: A blueprint/template for creating objects
  • Object: An instance of a class
  • Everything in Python is an object (strings, integers, etc.)

Basic Class Structure

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

__init__() Method

  • Special method that runs when an object is created
  • Used to initialize object attributes
  • Runs only once per object creation

The self Parameter

What is self?

  • 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

How it Works

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

Think of self as "I"

  • When Bruno the dog says "I am Bruno" → that's self.name
  • Each object has its own self reference

Attribute Access Levels

Python uses naming conventions to indicate access levels:

1. Public Attributes (no underscore)

class User:
    def __init__(self, username):
        self.username = username  # Public - access freely

user = User("john")
print(user.username)  # ✅ Encouraged

2. Protected Attributes (single underscore _)

class User:
    def __init__(self, email):
        self._email = email  # Protected - internal use

user = User("test@email.com")
print(user._email)  # ⚠️ Works but not recommended

3. Private Attributes (double underscore __)

class 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

When to Use Each

  • Public: Safe to expose (usernames, IDs)
  • Protected: Internal use, subclasses might need it
  • Private: Absolutely internal, security-sensitive data

Getter & Setter Methods

Why Use Them?

  • Control access to attributes
  • Add validation rules
  • Keep data consistent
  • Hide implementation details

Traditional Approach

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 validation

Properties (@property)

Modern Pythonic Approach

class 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

Benefits of @property

  • Clean syntax - looks like normal attribute access
  • Hidden validation - rules enforced behind the scenes
  • Future-proof - can change implementation without breaking code

Instance vs Class Attributes

Instance Attributes

  • Unique to each object
  • Defined in __init__ with self
  • 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)

Class (Static) Attributes

  • 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)  # Wolf

Practical Example - User Counter

class 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

Method Types

Instance Methods

  • Have self parameter
  • Work with specific object's data
  • Most common type
def deposit(self, amount):  # Instance method
    self.balance += amount  # Works with this object's balance

Static Methods

  • Use @staticmethod decorator
  • No self parameter
  • 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

Private Methods

  • 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}")

The Four Pillars of OOP

1. Encapsulation

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 access

2. Abstraction

Show 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 user

3. Inheritance

Create 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)

4. Polymorphism

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!

Quick Reference

Class Definition Template

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

Access Level Cheat Sheet

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

Method Types Summary

Type Decorator Parameters Usage
Instance None self, ... Object-specific operations
Static @staticmethod No self Utility functions
Property @property self Attribute-like access

OOP Principles Memory Aid

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

Practice Tips

  1. Start simple - Begin with basic classes and gradually add complexity
  2. Follow conventions - Use _ for protected, __ for private
  3. Favor composition over inheritance when relationships aren't clear
  4. Use @property instead of getter/setter methods for Pythonic code
  5. Keep methods focused - Each method should do one thing well
  6. 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!

About

my roadmap to python

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published