Skip to main content
Python

Deep Dive into Python Classes: Beyond the Basics

5 mins

Iceberg to illustrating bacic classes features above water, but underwater there are more features to learn.

Deep Dive into Python Classes: Beyond the Basics #

As intermediate Python developers, we’re familiar with the basic syntax of classes. But there’s much more to Python’s object-oriented implementation than meets the eye. Let’s explore some of the more nuanced aspects of Python classes that can make your code more pythonic and powerful.

The Truth About self #

You’ve probably written self countless times in your Python classes, but have you ever wondered why we use it? Let’s start with a surprising fact: self is just a convention. You could use any valid variable name:

class Person:
    def __init__(this, name):  # Using 'this' instead of 'self'
        this.name = name
    
    def greet(me):  # Using 'me' instead of 'self'
        return f"Hello, I'm {me.name}"

While this code works perfectly fine, and maybe familiar to Java developers, please don’t do this in practice! Using self is a strong Python convention that enhances code readability and maintainability for fellow pythoners. The name self clearly indicates that we’re referring to the instance itself.

Under the hood, when you call an instance method, Python automatically passes the instance as the first argument. This code:

person = Person("Alice")
person.greet()

Is actually transformed by Python into:

Person.greet(person)

This explains why instance methods must have that first parameter, regardless of what we name it.

Special Methods: The Magic Behind Python Objects #

Python’s special methods (also called magic methods or dunder methods) allow us to define how our objects behave in different contexts. Let’s explore the most important ones:

__str__ vs __repr__ #

These methods control how your object is converted to a string, but they serve different purposes:

class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius
    
    def __str__(self):
        # For end-users: readable and concise
        return f"{self.celsius}°C"
    
    def __repr__(self):
        # For developers: complete information for debugging
        return f"Temperature(celsius={self.celsius})"

temp = Temperature(25)
print(str(temp))      # Output: 25°C
print(repr(temp))     # Output: Temperature(celsius=25)

Best practices:

  • __str__ should be readable and concise
  • __repr__ should contain enough information to recreate the object
  • Always implement __repr__; if you don’t provide __str__, Python will use __repr__ as a fallback

__eq__ and Friends: Comparison Magic #

The @total_ordering decorator is a great example of Python’s “batteries included” philosophy. By implementing just __eq__ and __lt__, we get all other comparison methods (<=, >, >=) for free!

from functools import total_ordering

@total_ordering
class Score:
    def __init__(self, value):
        self.value = value
    
    def __eq__(self, other):
        if not isinstance(other, Score):
            return NotImplemented
        return self.value == other.value
    
    def __lt__(self, other):
        if not isinstance(other, Score):
            return NotImplemented
        return self.value < other.value

# Now we get all comparison operations for free!
score1 = Score(85)
score2 = Score(90)
print(score1 < score2)   # True
print(score1 <= score2)  # True
print(score1 > score2)   # False
print(score1 >= score2)  # False

Class Instantiation: More Than Meets the Eye #

When you create a new instance of a class, Python follows a specific sequence:

  1. __new__ is called to create the instance
  2. __init__ is called to initialize it

While you rarely need to override __new__, understanding this process can be valuable:

class Singleton:
    _instance = None
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance
    
    def __init__(self):
        # This will be called every time, even though __new__ returns the same instance
        pass

# Creating instances
a = Singleton()
b = Singleton()
print(a is b)  # True - they're the same instance!

This example implements the Singleton pattern, ensuring only one instance of the class ever exists. It’s a practical demonstration of when you might want to override __new__.

Leverage Python’s @property Decorator instead of Getters and Setters #

Rather than writing separate getter and setter methods, use Python’s @property decorator to define properties. This makes your code cleaner and more Pythonic.

class Circle:
    def __init__(self, radius):
        self._radius = radius
    
    @property
    def radius(self):
        return self._radius
    
    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("Radius must be positive")
        self._radius = value
    
    @property
    def area(self):
        return 3.14159 * self._radius ** 2

Example usage:

# Creating an instance of Circle
circle = Circle(5)

# Accessing the radius property
print(circle.radius)  # Output: 5

# Setting the radius property
circle.radius = 10
print(circle.radius)  # Output: 10

# Trying to set an invalid radius
try:
    circle.radius = -3
except ValueError as e:
    print(e)  # Output: Radius must be positive

# Accessing the area property
print(circle.area)  # Output: 314.159 (area of the circle with radius 10)

Use @classmethod for Alternative Constructors #

Sometimes you want to create instances of a class in ways other than the standard constructor. Python’s @classmethod decorator allows you to define alternative constructors.

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day
    
    @classmethod
    def from_string(cls, date_string):
        year, month, day = map(int, date_string.split('-'))
        return cls(year, month, day)

# Regular instantiation
date1 = Date(2024, 3, 15)
# Alternative constructor
date2 = Date.from_string('2024-03-15')

Common Pitfalls to Avoid #

  1. Mutable Default Arguments:

When you use mutable objects as default arguments, they’re shared across all instances of the class. This can lead to unexpected behavior.

# Bad
class Container:
    def __init__(self, items=[]):  # Don't do this!
        self.items = items

# Good
class Container:
    def __init__(self, items=None):
        self.items = items if items is not None else []
  1. Not Using super() in Inheritance:

Use the super() function to call the parent class’s methods. This ensures that all classes in the inheritance chain are properly initialized.

# Bad
class Child(Parent):
    def __init__(self):
        Parent.__init__(self)  # Don't do this!

# Good
class Child(Parent):
    def __init__(self):
        super().__init__()  # Do this instead!

Conclusion #

Python’s class system is rich with features that allow you to write more elegant and powerful code. While the basics will get you far, understanding these intermediate concepts will help you write more pythonic and maintainable code.

Remember:

  • Use self consistently (it’s a convention for a reason)
  • Implement __repr__ at minimum, and __str__ when needed
  • Use special methods to make your objects behave naturally in Python
  • Take advantage of properties and class methods for cleaner APIs
  • Be aware of common pitfalls, especially with inheritance and mutable defaults