Classes and Objects

Creating your first class in Python feels deceptively simple, but there’s a lot happening under the hood. I remember when I first wrote a class, I thought the __init__ method was just Python’s weird way of naming constructors. It took me months to realize that __init__ isn’t actually the constructor—it’s the initializer that sets up an already-created object.

Understanding this distinction changed how I approach class design. The actual object creation happens before __init__ is called, which explains why you can access self immediately and why certain advanced techniques like singleton patterns work the way they do.

The Anatomy of a Python Class

Let’s start with a practical example that demonstrates the essential components of a well-designed class. I’ll use a Task class because task management is something most developers can relate to:

class Task:
    total_tasks = 0  # Class variable - shared by all instances
    
    def __init__(self, title, priority="medium"):
        self.title = title          # Instance variable
        self.priority = priority    # Instance variable
        self.completed = False      # Instance variable
        Task.total_tasks += 1       # Update class variable
    
    def mark_complete(self):
        self.completed = True
        return f"Task '{self.title}' marked as complete"

This class demonstrates several important concepts that form the foundation of object-oriented design. The total_tasks class variable tracks how many tasks have been created across all instances—it’s shared data that belongs to the class itself, not to any individual task. Instance variables like title and completed are unique to each task object, representing the specific state of that particular task.

Methods like mark_complete() define what actions you can perform on a task. They encapsulate behavior with the data, creating a cohesive unit that models a real-world concept. This is the essence of object-oriented programming—bundling related data and functionality together in a way that mirrors how we think about the problem domain.

Understanding Self and Method Calls

The self parameter confused me for weeks when I started with Python. Coming from other languages, I expected the object reference to be implicit. Python’s explicit self actually makes the code more readable once you get used to it—you always know when you’re accessing instance data versus local variables.

When you call a method on an object, Python automatically passes the object as the first argument:

task = Task("Learn Python OOP")
result = task.mark_complete()  # Python passes 'task' as 'self'

This explicit passing of self enables some powerful metaprogramming techniques that we’ll explore in later parts. For now, just remember that self refers to the specific instance the method was called on, giving each object access to its own data and the ability to modify its own state.

Instance Variables vs Class Variables

The distinction between instance and class variables trips up many developers. Instance variables are unique to each object, while class variables are shared across all instances of the class. This sharing can lead to unexpected behavior if you’re not careful:

class Counter:
    total_count = 0  # Class variable - shared by all instances
    
    def __init__(self, name):
        self.name = name           # Instance variable
        self.count = 0            # Instance variable
        Counter.total_count += 1  # Modify class variable
    
    def increment(self):
        self.count += 1
        Counter.total_count += 1

Understanding this distinction is crucial for designing classes that behave predictably. Each counter maintains its own individual count, but they all contribute to and share the total count across all instances. This pattern is useful for tracking global statistics while maintaining individual object state.

Method Types and Their Uses

Python supports several types of methods, each serving different purposes. Instance methods (the ones we’ve been using) operate on specific objects. But you can also define methods that work at the class level:

class MathUtils:
    def __init__(self, precision=2):
        self.precision = precision
    
    def round_number(self, number):
        """Instance method - uses instance data"""
        return round(number, self.precision)
    
    @classmethod
    def create_high_precision(cls):
        """Class method - alternative constructor"""
        return cls(precision=6)
    
    @staticmethod
    def is_even(number):
        """Static method - utility function"""
        return number % 2 == 0

Class methods receive the class itself as the first argument (conventionally named cls) instead of an instance. They’re often used as alternative constructors, providing different ways to create objects based on different input parameters or configurations. Static methods don’t receive any automatic arguments—they’re essentially regular functions that happen to be defined inside a class for organizational purposes, useful for utility functions that are related to the class but don’t need access to instance or class data.

Property Decorators for Controlled Access

One of Python’s most elegant features is the property decorator, which lets you create methods that behave like attributes. This is incredibly useful for validation, computed properties, or maintaining backward compatibility:

class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius
    
    @property
    def celsius(self):
        return self._celsius
    
    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Temperature cannot be below absolute zero")
        self._celsius = value
    
    @property
    def fahrenheit(self):
        return (self._celsius * 9/5) + 32

The property decorator transforms method calls into attribute access, making your classes more intuitive to use. Users can interact with temperature objects using simple assignment and access patterns, while the class handles validation and conversion behind the scenes. The underscore prefix on _celsius is a Python convention indicating that the attribute is intended for internal use, helping other developers understand the intended interface.

Building Robust Constructors

A well-designed constructor does more than just assign values to instance variables. It should validate input, set reasonable defaults, and ensure the object starts in a valid state. I’ve learned to think of constructors as the contract between the class and its users:

class EmailAccount:
    def __init__(self, email, password):
        if not self._is_valid_email(email):
            raise ValueError(f"Invalid email address: {email}")
        
        if len(password) < 8:
            raise ValueError("Password must be at least 8 characters")
        
        self.email = email.lower()  # Normalize email
        self._password = password   # Private attribute
        self.connected = False
    
    def _is_valid_email(self, email):
        import re
        pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
        return re.match(pattern, email) is not None

Notice how the constructor validates inputs, normalizes data, and sets up the object in a consistent state. This approach prevents invalid objects from being created and makes debugging much easier. The _is_valid_email method uses the single underscore convention to indicate it’s intended for internal use within the class, helping maintain clean public interfaces while organizing internal functionality.

In the next part, we’ll explore inheritance and polymorphism—two concepts that really showcase the power of object-oriented programming. You’ll learn how to create class hierarchies that model real-world relationships and how Python’s dynamic typing makes polymorphism incredibly flexible and powerful.