Skip to content

Latest commit

 

History

History
764 lines (592 loc) · 22.7 KB

File metadata and controls

764 lines (592 loc) · 22.7 KB

Python Classes and Objects: Complete Guide

Classes and objects are the foundation of object-oriented programming (OOP) in Python. They allow you to organize code by grouping related data (attributes) and behavior (methods) together, creating reusable blueprints for modeling real-world entities and abstract concepts.

Understanding Classes and Objects

A class is a blueprint or template that defines what data and methods objects will have. An object (also called an instance) is a specific realization of a class with actual values for its attributes.

# Class definition - the blueprint
class Dog:
    def __init__(self, name, age):
        self.name = name  # Instance attribute
        self.age = age    # Instance attribute
    
    def bark(self):  # Instance method
        return f"{self.name} says woof!"

# Object creation - actual instances
dog1 = Dog("Buddy", 3)  # Creates an object
dog2 = Dog("Max", 5)    # Creates another object

# Each object has its own data
print(dog1.name)  # "Buddy"
print(dog2.name)  # "Max"
print(dog1.bark())  # "Buddy says woof!"

Class Definition Syntax

Basic Structure

class ClassName:
    """Optional class docstring explaining what this class represents."""
    
    # Class attributes (shared by all instances)
    species = "Canis lupus"
    
    def __init__(self, parameter1, parameter2):
        """Constructor method - runs when object is created."""
        self.attribute1 = parameter1  # Instance attribute
        self.attribute2 = parameter2  # Instance attribute
    
    def method_name(self, parameter):
        """Instance method - operates on individual objects."""
        # Method implementation
        return some_value

Naming Conventions

# Class names use PascalCase (CapWords)
class BankAccount:      # Good
class bankAccount:      # Avoid - not Pythonic
class bank_account:     # Avoid - use for functions/variables

# Method and attribute names use snake_case  
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name      # Good
        self.lastName = last_name         # Avoid - not Pythonic
        
    def get_full_name(self):              # Good
        return f"{self.first_name} {self.last_name}"
    
    def getFull_Name(self):               # Avoid - not Pythonic
        pass

The init Method (Constructor)

The __init__ method is Python's constructor - it's automatically called when you create a new object:

class Student:
    def __init__(self, name, student_id, major="Undeclared"):
        """Initialize a new student object."""
        self.name = name
        self.student_id = student_id
        self.major = major
        self.grades = []  # Start with empty grade list
        self.enrollment_date = datetime.now()
        
        # You can perform validation in __init__
        if not isinstance(student_id, str) or len(student_id) != 8:
            raise ValueError("Student ID must be 8-character string")
    
    def __str__(self):
        """String representation for human reading."""
        return f"Student({self.name}, ID: {self.student_id})"

# Creating objects calls __init__ automatically
student1 = Student("Alice", "12345678", "Computer Science")
student2 = Student("Bob", "87654321")  # Uses default major

print(student1)  # Student(Alice, ID: 12345678)
print(student1.major)  # Computer Science
print(student2.major)  # Undeclared

Constructor Parameters and Defaults

class Rectangle:
    def __init__(self, width, height=None):
        """Create rectangle. If height not given, creates a square."""
        self.width = width
        self.height = height if height is not None else width
        
        # Validation in constructor
        if width <= 0 or self.height <= 0:
            raise ValueError("Dimensions must be positive")
    
    def get_area(self):
        return self.width * self.height

# Different ways to create rectangles
rect1 = Rectangle(5, 3)    # 5x3 rectangle
rect2 = Rectangle(4)       # 4x4 square (height defaults to width)

Instance Attributes vs Class Attributes

Instance Attributes

Instance attributes belong to individual objects and can have different values for each instance:

class Employee:
    def __init__(self, name, salary):
        self.name = name        # Instance attribute
        self.salary = salary    # Instance attribute
        self.performance_reviews = []  # Each employee has own list

emp1 = Employee("Alice", 75000)
emp2 = Employee("Bob", 80000)

print(emp1.name)    # Alice
print(emp2.name)    # Bob
print(emp1.salary)  # 75000
print(emp2.salary)  # 80000

Class Attributes

Class attributes are shared by all instances of the class:

class Employee:
    company_name = "TechCorp"           # Class attribute
    total_employees = 0                  # Class attribute
    benefits = ["Health", "Dental"]      # Class attribute
    
    def __init__(self, name, salary):
        self.name = name                # Instance attribute
        self.salary = salary            # Instance attribute
        Employee.total_employees += 1   # Modify class attribute
    
    @classmethod
    def get_company_info(cls):
        return f"{cls.company_name} has {cls.total_employees} employees"

# All instances share class attributes
emp1 = Employee("Alice", 75000)
emp2 = Employee("Bob", 80000)

print(emp1.company_name)        # TechCorp
print(emp2.company_name)        # TechCorp
print(Employee.total_employees) # 2
print(Employee.get_company_info())  # TechCorp has 2 employees

# Modifying class attribute affects all instances
Employee.company_name = "NewTechCorp"
print(emp1.company_name)        # NewTechCorp
print(emp2.company_name)        # NewTechCorp

Careful with Mutable Class Attributes

# Dangerous - all instances share the same list
class StudentWrong:
    grades = []  # This is shared by ALL students!
    
    def __init__(self, name):
        self.name = name
    
    def add_grade(self, grade):
        self.grades.append(grade)  # Modifies shared list

student1 = StudentWrong("Alice")
student2 = StudentWrong("Bob")

student1.add_grade(85)
student2.add_grade(92)

print(student1.grades)  # [85, 92] - Alice sees Bob's grade!
print(student2.grades)  # [85, 92] - Bob sees Alice's grade!

# Correct - each instance gets its own list
class StudentCorrect:
    def __init__(self, name):
        self.name = name
        self.grades = []  # Each student gets own list
    
    def add_grade(self, grade):
        self.grades.append(grade)

Instance Methods

Instance methods are functions that belong to objects and operate on their data through the self parameter:

class BankAccount:
    def __init__(self, account_holder, initial_balance=0):
        self.account_holder = account_holder
        self.balance = initial_balance
        self.transaction_history = []
    
    def deposit(self, amount):
        """Add money to the account."""
        if amount <= 0:
            raise ValueError("Deposit amount must be positive")
        
        self.balance += amount
        self.transaction_history.append(f"Deposited ${amount}")
        return self.balance
    
    def withdraw(self, amount):
        """Remove money from the account."""
        if amount <= 0:
            raise ValueError("Withdrawal amount must be positive")
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        
        self.balance -= amount
        self.transaction_history.append(f"Withdrew ${amount}")
        return self.balance
    
    def get_balance(self):
        """Return current balance."""
        return self.balance
    
    def get_transaction_history(self):
        """Return copy of transaction history."""
        return self.transaction_history.copy()
    
    def transfer_to(self, other_account, amount):
        """Transfer money to another account."""
        self.withdraw(amount)
        other_account.deposit(amount)
        self.transaction_history.append(f"Transferred ${amount} to {other_account.account_holder}")
        other_account.transaction_history.append(f"Received ${amount} from {self.account_holder}")

# Using instance methods
alice_account = BankAccount("Alice", 1000)
bob_account = BankAccount("Bob", 500)

alice_account.deposit(200)      # Alice has $1200
alice_account.withdraw(100)     # Alice has $1100
alice_account.transfer_to(bob_account, 300)  # Alice: $800, Bob: $800

print(alice_account.get_balance())  # 800
print(bob_account.get_balance())    # 800
print(alice_account.get_transaction_history())

Special Methods (Dunder Methods)

Special methods (also called "magic methods" or "dunder methods") define how objects behave with Python's built-in functions and operators:

String Representation

class Book:
    def __init__(self, title, author, pages):
        self.title = title
        self.author = author
        self.pages = pages
    
    def __str__(self):
        """Human-readable representation (for print(), str())."""
        return f"'{self.title}' by {self.author}"
    
    def __repr__(self):
        """Developer representation (for debugging, repr())."""
        return f"Book(title='{self.title}', author='{self.author}', pages={self.pages})"

book = Book("1984", "George Orwell", 328)

print(book)        # Uses __str__: '1984' by George Orwell
print(repr(book))  # Uses __repr__: Book(title='1984', author='George Orwell', pages=328)

# If __str__ is not defined, __repr__ is used as fallback
print(str(book))   # '1984' by George Orwell

Comparison Methods

class Student:
    def __init__(self, name, gpa):
        self.name = name
        self.gpa = gpa
    
    def __eq__(self, other):
        """Define equality comparison."""
        if not isinstance(other, Student):
            return NotImplemented
        return self.gpa == other.gpa
    
    def __lt__(self, other):
        """Define less-than comparison."""
        if not isinstance(other, Student):
            return NotImplemented
        return self.gpa < other.gpa
    
    def __le__(self, other):
        """Define less-than-or-equal comparison."""
        return self < other or self == other
    
    def __str__(self):
        return f"{self.name} (GPA: {self.gpa})"

alice = Student("Alice", 3.8)
bob = Student("Bob", 3.5)
charlie = Student("Charlie", 3.8)

print(alice == charlie)  # True (same GPA)
print(alice == bob)      # False
print(bob < alice)       # True
print(alice <= charlie) # True

# Can now sort lists of students
students = [alice, bob, charlie]
students.sort()  # Sorts by GPA using __lt__
print([str(s) for s in students])  # Bob comes first (lowest GPA)

Container-like Behavior

class Playlist:
    def __init__(self, name):
        self.name = name
        self.songs = []
    
    def add_song(self, song):
        self.songs.append(song)
    
    def __len__(self):
        """Support len() function."""
        return len(self.songs)
    
    def __getitem__(self, index):
        """Support indexing and iteration."""
        return self.songs[index]
    
    def __contains__(self, song):
        """Support 'in' operator."""
        return song in self.songs
    
    def __str__(self):
        return f"Playlist '{self.name}' with {len(self)} songs"

playlist = Playlist("Rock Classics")
playlist.add_song("Bohemian Rhapsody")
playlist.add_song("Stairway to Heaven")
playlist.add_song("Hotel California")

print(len(playlist))              # 3
print(playlist[0])                # Bohemian Rhapsody
print("Hotel California" in playlist)  # True

# Can iterate because of __getitem__
for song in playlist:
    print(f"♪ {song}")

Method Types

Instance Methods

Regular methods that operate on individual objects:

class Calculator:
    def __init__(self):
        self.result = 0
    
    def add(self, value):  # Instance method
        """Add value to current result."""
        self.result += value
        return self
    
    def multiply(self, value):  # Instance method
        """Multiply current result by value."""
        self.result *= value
        return self
    
    def get_result(self):  # Instance method
        """Get current result."""
        return self.result

calc = Calculator()
calc.add(5).multiply(3)  # Method chaining works because methods return self
print(calc.get_result())  # 15

Class Methods

Methods that operate on the class itself, not instances:

class Person:
    population = 0
    
    def __init__(self, name, birth_year):
        self.name = name
        self.birth_year = birth_year
        Person.population += 1
    
    @classmethod
    def get_population(cls):
        """Class method to get total population."""
        return cls.population
    
    @classmethod
    def from_age(cls, name, age):
        """Alternative constructor that takes age instead of birth year."""
        from datetime import datetime
        birth_year = datetime.now().year - age
        return cls(name, birth_year)  # Create instance using regular constructor
    
    def get_age(self):
        """Instance method to get person's age."""
        from datetime import datetime
        return datetime.now().year - self.birth_year

# Regular constructor
person1 = Person("Alice", 1990)

# Alternative constructor (class method)
person2 = Person.from_age("Bob", 25)

print(Person.get_population())  # 2
print(person2.get_age())        # 25 (approximately)

Static Methods

Methods that belong to the class but don't access class or instance data:

class MathUtils:
    @staticmethod
    def is_prime(n):
        """Check if a number is prime."""
        if n < 2:
            return False
        for i in range(2, int(n ** 0.5) + 1):
            if n % i == 0:
                return False
        return True
    
    @staticmethod
    def factorial(n):
        """Calculate factorial of n."""
        if n <= 1:
            return 1
        return n * MathUtils.factorial(n - 1)

# Can call on class or instance
print(MathUtils.is_prime(17))    # True
print(MathUtils.factorial(5))    # 120

# Also works with instance, but not common
utils = MathUtils()
print(utils.is_prime(15))        # False

Properties

Properties provide a way to use method syntax while maintaining attribute-like access:

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius  # Private attribute
    
    @property
    def celsius(self):
        """Get temperature in Celsius."""
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        """Set temperature in Celsius with validation."""
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        """Get temperature in Fahrenheit."""
        return (self._celsius * 9/5) + 32
    
    @fahrenheit.setter
    def fahrenheit(self, value):
        """Set temperature using Fahrenheit."""
        self.celsius = (value - 32) * 5/9  # Uses celsius setter for validation
    
    @property
    def kelvin(self):
        """Get temperature in Kelvin."""
        return self._celsius + 273.15
    
    @kelvin.setter
    def kelvin(self, value):
        """Set temperature using Kelvin."""
        self.celsius = value - 273.15  # Uses celsius setter for validation

temp = Temperature(25)  # 25°C

print(temp.celsius)     # 25
print(temp.fahrenheit)  # 77.0
print(temp.kelvin)      # 298.15

# Setting temperature using different scales
temp.fahrenheit = 100
print(temp.celsius)     # 37.77777777777778

temp.kelvin = 300
print(temp.celsius)     # 26.85

Encapsulation and Privacy

Python doesn't have true private attributes, but uses naming conventions to indicate intended usage:

class BankAccount:
    def __init__(self, account_number, initial_balance):
        self.account_number = account_number      # Public
        self._balance = initial_balance           # Protected (internal use)
        self.__pin = self._generate_pin()        # Private (name mangled)
    
    def _generate_pin(self):
        """Protected method - intended for internal use."""
        import random
        return random.randint(1000, 9999)
    
    def __encrypt_data(self, data):
        """Private method - name mangled."""
        return f"encrypted_{data}"
    
    def deposit(self, amount):
        """Public method."""
        if amount > 0:
            self._balance += amount
            return self._balance
        raise ValueError("Amount must be positive")
    
    def get_balance(self):
        """Public method to access protected attribute."""
        return self._balance
    
    def verify_pin(self, pin):
        """Public method to verify PIN."""
        return pin == self.__pin

account = BankAccount("12345", 1000)

print(account.account_number)   # OK - public
print(account._balance)         # Discouraged but works - protected
# print(account.__pin)          # AttributeError - private

print(account.get_balance())    # 1000 - use public interface
print(account.verify_pin(account._BankAccount__pin))  # True - name mangling

Class Design Patterns

Simple Data Container

class Point:
    """Represents a point in 2D space."""
    
    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y
    
    def distance_from_origin(self):
        return (self.x ** 2 + self.y ** 2) ** 0.5
    
    def distance_from(self, other):
        return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
    
    def __str__(self):
        return f"Point({self.x}, {self.y})"

p1 = Point(3, 4)
p2 = Point(0, 0)
print(p1.distance_from_origin())  # 5.0
print(p1.distance_from(p2))       # 5.0

State Management

class TrafficLight:
    """Models a traffic light with state changes."""
    
    def __init__(self):
        self._state = "red"
        self._states = ["red", "green", "yellow"]
    
    @property
    def state(self):
        return self._state
    
    def next_state(self):
        """Advance to next state in cycle."""
        current_index = self._states.index(self._state)
        next_index = (current_index + 1) % len(self._states)
        self._state = self._states[next_index]
    
    def can_go(self):
        """Check if vehicles can proceed."""
        return self._state == "green"
    
    def should_slow(self):
        """Check if vehicles should slow down."""
        return self._state == "yellow"

light = TrafficLight()
print(light.state)      # red
print(light.can_go())   # False

light.next_state()
print(light.state)      # green
print(light.can_go())   # True

Builder Pattern

class Pizza:
    """Pizza with customizable toppings."""
    
    def __init__(self, size):
        self.size = size
        self.toppings = []
        self.crust = "regular"
        self.sauce = "tomato"
    
    def add_topping(self, topping):
        """Add a topping and return self for method chaining."""
        self.toppings.append(topping)
        return self
    
    def set_crust(self, crust):
        """Set crust type and return self for method chaining."""
        self.crust = crust
        return self
    
    def set_sauce(self, sauce):
        """Set sauce type and return self for method chaining."""
        self.sauce = sauce
        return self
    
    def __str__(self):
        return f"{self.size} pizza with {self.crust} crust, {self.sauce} sauce, and {', '.join(self.toppings)}"

# Method chaining for fluent interface
pizza = (Pizza("large")
         .set_crust("thin")
         .set_sauce("pesto")
         .add_topping("mozzarella")
         .add_topping("mushrooms")
         .add_topping("pepperoni"))

print(pizza)  # large pizza with thin crust, pesto sauce, and mozzarella, mushrooms, pepperoni

Common Patterns and Best Practices

Validation in Constructor

class Rectangle:
    def __init__(self, width, height):
        # Validate inputs early
        if not isinstance(width, (int, float)) or not isinstance(height, (int, float)):
            raise TypeError("Width and height must be numbers")
        if width <= 0 or height <= 0:
            raise ValueError("Width and height must be positive")
        
        self.width = width
        self.height = height
    
    def get_area(self):
        return self.width * self.height

Defensive Programming

class StudentGrades:
    def __init__(self, student_name):
        self.student_name = student_name
        self._grades = []
    
    def add_grade(self, grade):
        """Add a grade with validation."""
        if not isinstance(grade, (int, float)):
            raise TypeError("Grade must be a number")
        if not 0 <= grade <= 100:
            raise ValueError("Grade must be between 0 and 100")
        
        self._grades.append(grade)
    
    def get_grades(self):
        """Return a copy of grades to prevent external modification."""
        return self._grades.copy()
    
    def get_average(self):
        """Calculate average grade."""
        if not self._grades:
            return 0
        return sum(self._grades) / len(self._grades)

Resource Management

class FileManager:
    """Context manager for file operations."""
    
    def __init__(self, filename, mode):
        self.filename = filename
        self.mode = mode
        self.file = None
    
    def __enter__(self):
        """Called when entering 'with' block."""
        self.file = open(self.filename, self.mode)
        return self.file
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        """Called when exiting 'with' block."""
        if self.file:
            self.file.close()

# Usage with 'with' statement
with FileManager("example.txt", "w") as f:
    f.write("Hello, world!")
# File is automatically closed

Why Classes Matter in Python Functions

As emphasized in the python-org README, classes and objects are fundamental to organizing Python code because:

  • Data Organization: Group related data and behavior together in logical units
  • Code Reusability: Create templates that can be used to make multiple objects
  • Abstraction: Hide implementation details behind clean interfaces
  • Modularity: Break complex problems into manageable, self-contained units
  • Real-world Modeling: Represent entities and concepts from your problem domain

Classes enable you to:

  • Model complex systems: Users, accounts, products, games, etc.
  • Manage state: Track changing data over time
  • Encapsulate behavior: Keep related functions with their data
  • Create APIs: Provide clean interfaces for other code to use
  • Build frameworks: Create extensible systems others can build upon

Mastering classes and objects - including constructors, methods, attributes, and special methods - is essential for writing maintainable, scalable Python applications. They provide the foundation for organizing code in a way that matches how we think about problems in the real world.