Programming with Python | Chapter 14: OOP – More on Methods and Dunder Methods

Chapter Objectives

  • Distinguish between instance methods, class methods, and static methods.
  • Learn how to define and use class methods using the @classmethod decorator.
  • Understand the role of the cls parameter in class methods.
  • Learn how to define and use static methods using the @staticmethod decorator.
  • Understand dunder methods (special methods with double underscores, e.g., __init__).
  • Implement common dunder methods to customize object behavior:
    • __str__: For user-friendly string representation (used by print()).
    • __repr__: For unambiguous, developer-focused string representation.
    • __len__: To allow len() to work on objects.
    • __eq__: To define equality comparison (==) between objects.

Introduction

In previous chapters, we focused on instance methods – functions bound to specific objects, operating on their unique state via the self parameter. This chapter expands our understanding of methods by introducing class methods, which operate on the class itself, and static methods, which are related to the class but don’t depend on the instance or class state. We will also explore dunder methods (short for “double underscore” methods), like the __init__ method we’ve already seen. These special methods allow us to integrate our custom objects more seamlessly with Python’s built-in functions and operators, defining how they should be represented as strings, compared for equality, or measured for length.

Theory & Explanation

Method Types Recap

Instance Methods:

  • Most common type.
  • Operate on an instance (object) of the class.
  • First parameter is always self, representing the instance.
  • Can access and modify both instance attributes (self.attribute) and class attributes (self.class_attribute or ClassName.class_attribute).
  • Defined without any special decorator.
Python
class MyClass:
    def instance_method(self, arg1):
        print(f"Instance method called on {self} with {arg1}")
        # Access self.some_attribute, etc.

Class Methods:

  • Operate on the class itself, rather than on a specific instance.
  • Defined using the @classmethod decorator above the method definition.
  • First parameter is always cls (by convention), representing the class itself.
  • Cannot directly access instance attributes (as they belong to specific objects), but can access and modify class attributes (cls.class_attribute).
  • Often used as alternative constructors or for operations related to the class as a whole.
Python
class MyClass:
    class_var = 10

    @classmethod
    def class_method(cls, arg1):
        print(f"Class method called on {cls} with {arg1}")
        print(f"Accessing class var: {cls.class_var}")
        # Cannot access self.instance_attribute here

graph TD
    A["Call: object.method()"] --> B{Is method decorated?};
    B -- "@classmethod" --> C["Pass Class (cls) to method"];
    B -- "@staticmethod" --> D[Pass only explicit args];
    B -- "No Decorator" --> E["Pass Instance (self) to method"];

    F["Call: Class.method()"] --> G{Is method decorated?};
    G -- @classmethod --> C;
    G -- @staticmethod --> D;
    G -- No Decorator --> H[TypeError: missing 'self'];

    C --> Z[Execute Method];
    D --> Z;
    E --> Z;

    subgraph Instance Method
        E
    end
    subgraph Class Method
        C
    end
    subgraph Static Method
        D
    end

Static Methods:

  • Don’t operate on the instance (self) or the class (cls). They are essentially regular functions namespaced within the class.
  • Defined using the @staticmethod decorator.
  • Do not receive self or cls automatically as the first argument.
  • Cannot directly access instance or class attributes (unless explicitly passed the instance/class or accessing globals).
  • Used for utility functions that are logically related to the class but don’t depend on its state.
Python
class MyClass:
    @staticmethod
    def static_method(arg1, arg2):
        print(f"Static method called with {arg1}, {arg2}")
        # Cannot access self or cls here
        # Often performs some calculation based only on arguments
        return arg1 + arg2

Calling Methods:

  • Instance methods: my_object.instance_method(arg)
  • Class methods: MyClass.class_method(arg) or my_object.class_method(arg) (calling on an instance still passes the class as cls)
  • Static methods: MyClass.static_method(arg1, arg2) or my_object.static_method(arg1, arg2)

Dunder Methods (Special Methods)

Python classes can implement special methods with double underscores at the beginning and end of their names (e.g., __init__, __str__). These are often called “dunder” methods. They are not typically called directly by your code (e.g., you don’t usually write my_object.__str__()). Instead, Python calls them implicitly in response to certain operations or built-in functions. Implementing these methods allows your custom objects to behave more like built-in types.

1. __str__(self):

  • Purpose: Returns an informal, user-friendly string representation of the object.
  • Called By: print(my_object), str(my_object).
  • Return Value: Must return a string (str).
  • If not defined: Python falls back to using __repr__.

2. __repr__(self):

  • Purpose: Returns an unambiguous, official string representation of the object, often useful for debugging. Ideally, eval(repr(my_object)) should recreate the object (though not always feasible).
  • Called By: Directly typing the object name in the REPL, repr(my_object), and as a fallback for __str__.
  • Return Value: Must return a string (str).
  • If not defined: Python provides a default representation like <__main__.ClassName object at 0x...>.

Convention: Always try to implement __repr__. If __str__ is not implemented, __repr__ will be used for print() as well. If __str__ is implemented, print() will use it, while the REPL and repr() will still use __repr__.

3. __len__(self):

  • Purpose: Returns the “length” of the object, often interpreted as the number of items it contains.
  • Called By: len(my_object).
  • Return Value: Must return a non-negative integer (int).
  • If not defined: Calling len() on the object raises a TypeError.

4. __eq__(self, other):

  • Purpose: Defines behavior for the equality operator (==). Compares self with other.
  • Called By: self == other.
  • Return Value: Should return True if objects are considered equal, False otherwise. Can also return NotImplemented if comparison isn’t supported for the given other type (allowing Python to try other.__eq__(self)).
  • If not defined: Default behavior compares object identities (memory addresses), meaning two distinct objects are never equal even if their attributes are identical.

There are many other dunder methods for arithmetic operators (__add__, __sub__), comparison (__lt__, __gt__), attribute access (__getattr__, __setattr__), container emulation (__getitem__, __setitem__), etc., allowing extensive customization.

graph TD
    %% Nodes (declared first, no links yet)
    print_obj["print(obj)"]
    repr_obj["repr(obj) or REPL output"]

    has_str{{Has obj.__str__?}}
    use_str["Use obj.__str__()"]
    has_repr{{Has obj.__repr__?}}
    use_repr["Use obj.__repr__()"]
    use_default["Use default object representation"]

    output_str["User-friendly string"]
    output_repr["Developer-focused string"]
    output_default["ClassName object at hex address"]

    %% Subgraphs (only for grouping, no arrows inside)
    subgraph User_Action
        print_obj
        repr_obj
    end

    subgraph Python_Internals
        has_str
        use_str
        has_repr
        use_repr
        use_default
    end

    subgraph Output
        output_str
        output_repr
        output_default
    end

    %% Links (must be outside subgraphs in v11.5.0)
    print_obj --> has_str
    has_str -- Yes --> use_str
    has_str -- No --> has_repr
    has_repr -- Yes --> use_repr
    has_repr -- No --> use_default

    repr_obj --> has_repr

    use_str --> output_str
    use_repr --> output_repr
    use_default --> output_default

Code Examples

Example 1: Class Methods and Static Methods

Python
# employee_methods.py
import datetime

class Employee:
    """Represents an employee with different method types."""

    # Class attribute
    num_employees = 0
    raise_amount = 1.04 # 4% raise

    def __init__(self, first, last, pay):
        self.first = first
        self.last = last
        self.pay = float(pay)
        self.email = f"{first.lower()}.{last.lower()}@company.com"

        Employee.num_employees += 1 # Increment class attribute

    # Instance method
    def fullname(self):
        return f"{self.first} {self.last}"

    # Instance method
    def apply_raise(self):
        self.pay = self.pay * self.raise_amount # Access instance specific raise? No, see class method

    # Class method - often used as alternative constructors
    @classmethod
    def set_raise_amount(cls, amount):
        """Sets the raise amount for ALL employees."""
        print(f"Setting raise amount to {amount} for class {cls.__name__}")
        cls.raise_amount = amount

    @classmethod
    def from_string(cls, emp_str):
        """Parses employee data from 'first-last-pay' string."""
        first, last, pay = emp_str.split('-')
        # Creates and returns a new Employee instance using the class (cls)
        return cls(first, last, pay)

    # Static method - logically related but doesn't depend on instance/class state
    @staticmethod
    def is_workday(day):
        """Checks if a given date is a workday (Mon-Fri)."""
        # datetime.weekday(): Monday is 0 and Sunday is 6
        if day.weekday() == 5 or day.weekday() == 6: # Saturday or Sunday
            return False
        return True

# --- Using the methods ---
print(f"Initial number of employees: {Employee.num_employees}") # 0

emp1 = Employee("Corey", "Schafer", 50000)
emp2 = Employee("Test", "User", 60000)

print(f"Number of employees: {Employee.num_employees}") # 2

# Using instance methods
print(emp1.fullname())
print(emp2.email)

# Using a class method (called on the class)
Employee.set_raise_amount(1.05) # Set raise to 5% for all

# Accessing the class attribute via instance or class shows the change
print(f"Emp1 raise amount: {emp1.raise_amount}") # 1.05
print(f"Emp2 raise amount: {emp2.raise_amount}") # 1.05
print(f"Employee raise amount: {Employee.raise_amount}") # 1.05

# Using class method as alternative constructor
emp_str_3 = "John-Doe-70000"
emp3 = Employee.from_string(emp_str_3)
print(f"\nCreated employee from string: {emp3.fullname()}, Pay: {emp3.pay}")
print(f"Number of employees: {Employee.num_employees}") # 3

# Using static method
my_date = datetime.date(2025, 4, 12) # A Saturday
print(f"\nIs {my_date} a workday? {Employee.is_workday(my_date)}") # False
my_date = datetime.date(2025, 4, 14) # A Monday
print(f"Is {my_date} a workday? {Employee.is_workday(my_date)}") # True

Explanation:

  • apply_raise (instance method) uses self.raise_amount. If we wanted it to always use the current class raise amount, it should use Employee.raise_amount or self.__class__.raise_amount.
  • set_raise_amount (@classmethod) takes cls and modifies the class attribute cls.raise_amount, affecting all future calculations that use it.
  • from_string (@classmethod) acts as an alternative way to create Employee instances, parsing a string and calling cls(first, last, pay) which is equivalent to Employee(first, last, pay).
  • is_workday (@staticmethod) performs a calculation based only on its input (day) and doesn’t need self or cls. It’s logically grouped with Employee but doesn’t depend on employee data or the employee count.

Example 2: Implementing Dunder Methods

Python
# vector2d.py
import math

class Vector2D:
    """Represents a 2D vector with x and y components."""

    def __init__(self, x=0, y=0):
        if not isinstance(x, (int, float)) or not isinstance(y, (int, float)):
            raise TypeError("Vector components must be numeric")
        self.x = x
        self.y = y

    def magnitude(self):
        """Calculates the magnitude (length) of the vector."""
        return math.sqrt(self.x**2 + self.y**2)

    # --- Dunder Methods ---

    def __str__(self):
        """User-friendly string representation."""
        # Used by print()
        return f"Vector({self.x}, {self.y})"

    def __repr__(self):
        """Unambiguous developer representation."""
        # Used by REPL, repr()
        # Aims to show how to recreate the object
        return f"Vector2D(x={self.x}, y={self.y})"

    def __len__(self):
        """Returns the 'length' - we'll define it as integer magnitude."""
        # Used by len()
        # Must return an integer
        return int(self.magnitude())

    def __eq__(self, other):
        """Checks for equality (self == other)."""
        # Used by == operator
        if isinstance(other, Vector2D):
            # Compare components for equality
            return self.x == other.x and self.y == other.y
        return NotImplemented # Indicate comparison not supported for other types

    def __add__(self, other):
        """Defines vector addition (self + other)."""
        # Used by + operator
        if isinstance(other, Vector2D):
            # Return a *new* Vector2D object
            return Vector2D(self.x + other.x, self.y + other.y)
        raise TypeError("Can only add Vector2D to another Vector2D")


# --- Using the Vector2D class ---
v1 = Vector2D(3, 4)
v2 = Vector2D(3, 4)
v3 = Vector2D(1, 2)
v4 = Vector2D() # Uses default x=0, y=0

# __str__ usage
print(v1) # Output: Vector(3, 4)
print(str(v3)) # Output: Vector(1, 2)

# __repr__ usage (e.g., in REPL or using repr())
print(repr(v1)) # Output: Vector2D(x=3, y=4)
print([v1, v3]) # Output: [Vector2D(x=3, y=4), Vector2D(x=1, y=2)] (uses repr in containers)

# __len__ usage
print(f"Magnitude of v1: {v1.magnitude():.2f}") # 5.00
print(f"Length (int magnitude) of v1: {len(v1)}") # Output: 5

# __eq__ usage
print(f"v1 == v2: {v1 == v2}") # Output: True (attribute values match)
print(f"v1 == v3: {v1 == v3}") # Output: False
print(f"v1 == (3, 4): {v1 == (3, 4)}") # Output: False (types don't match, __eq__ returns NotImplemented)

# __add__ usage
v_sum = v1 + v3
print(f"v1 + v3 = {v_sum}") # Output: v1 + v3 = Vector(4, 6) (uses __str__)
print(repr(v_sum))         # Output: Vector2D(x=4, y=6) (uses __repr__)

# try:
#     v_bad_sum = v1 + 5 # Raises TypeError defined in __add__
# except TypeError as e:
#     print(e)

Explanation:

  • The Vector2D class defines __init__.
  • __str__ provides a simple Vector(x, y) format for printing.
  • __repr__ provides a more detailed Vector2D(x=..., y=...) format, useful for debugging and showing how the object was likely created.
  • __len__ allows len(vector_object) to work, returning the integer part of the vector’s magnitude.
  • __eq__ defines that two Vector2D objects are equal if their corresponding x and y components are equal. It returns NotImplemented if compared with a non-Vector2D type.
  • __add__ defines vector addition using the + operator, returning a new Vector2D object representing the sum.

Common Mistakes or Pitfalls

  • @classmethod / @staticmethod Decorators: Forgetting to add the @classmethod or @staticmethod decorator when defining class or static methods, leading to errors related to the implicit self parameter being passed (or expected but not received).
  • Confusing cls and self: Using self in a class method or cls in an instance method incorrectly.
  • Incorrect Dunder Method Signatures: Defining dunder methods with the wrong parameters (e.g., __eq__(self) instead of __eq__(self, other)), causing them not to work as expected with built-in operations.
  • __str__ vs __repr__: Not implementing __repr__, leading to unhelpful default object representations. Or implementing only __str__ and getting confusing output when debugging or viewing objects in containers (which often use __repr__).
  • __eq__ without Type Checking: Implementing __eq__ without checking the type of other (isinstance(other, ClassName)) can lead to AttributeError if you try to access attributes on other that don’t exist (e.g., comparing your object to an integer). Returning NotImplemented is crucial for interoperability.
  • Modifying Objects in Dunder Methods (Unexpectedly): Methods like __add__ should generally return a new object representing the result, not modify self or other in place (unless specifically implementing in-place operators like __iadd__ for +=).

Chapter Summary

Concept Decorator / Syntax / Called By Key Characteristics
Instance Method def method(self, ...): Operates on an instance (self). Can access/modify instance and class attributes. Most common method type. Called via object.method().
Class Method @classmethod
def method(cls, ...):
Operates on the class (cls). Can access/modify class attributes but not instance attributes directly. Often used for alternative constructors or class-specific operations. Called via Class.method() or object.method().
Static Method @staticmethod
def method(...):
Doesn’t operate on instance (self) or class (cls). Like a regular function namespaced within the class. Used for utility functions logically related to the class. Called via Class.method() or object.method().
__init__(self, ...) Called automatically when creating an instance: Class(...) The constructor; initializes the new object’s state (instance attributes).
__str__(self) Called by print(object), str(object) Returns an informal, user-friendly string representation. Should return a str. Falls back to __repr__ if not defined.
__repr__(self) Called by REPL output, repr(object), fallback for __str__, used in containers Returns an unambiguous, developer-focused string representation, ideally showing how to recreate the object. Should return a str. Always recommended to implement.
__len__(self) Called by len(object) Returns the “length” of the object (e.g., number of items). Must return a non-negative int. Raises TypeError if not defined.
__eq__(self, other) Called by the equality operator: self == other Defines custom equality comparison. Should return True, False, or NotImplemented. Default compares object identity (memory address).
__add__(self, other) Called by the addition operator: self + other Defines behavior for the + operator. Typically returns a new object representing the sum. Should handle incompatible types (e.g., raise TypeError or return NotImplemented).
  • Classes can have instance methods (operate on self), class methods (@classmethod, operate on cls), and static methods (@staticmethod, operate independently).
  • Class methods are often used as alternative constructors or for operations on class-level data.
  • Static methods are utility functions grouped within a class namespace.
  • Dunder methods (__name__) allow objects to integrate with Python’s built-in functions and operators.
  • __str__(self) provides a user-friendly string representation (for print()).
  • __repr__(self) provides a developer-focused, unambiguous string representation. Implement this first.
  • __len__(self) enables the len() function for your objects.
  • __eq__(self, other) defines the behavior of the equality operator (==).
  • Implementing dunder methods makes custom classes more intuitive and Pythonic.

Exercises & Mini Projects

Exercises

  1. Counter Class: Create a class Counter with a class attribute count initialized to 0. Implement __init__(self) that increments Counter.count. Add a class method get_count() that returns the value of Counter.count. Create a few instances and print the count using the class method.
  2. Temperature Converter: Create a class Temperature with an __init__(self, celsius) that stores temperature in Celsius. Add a static method celsius_to_fahrenheit(celsius) that converts Celsius to Fahrenheit (F = C * 9/5 + 32). Add an instance method get_fahrenheit(self) that uses the static method to return the Fahrenheit equivalent of the instance’s Celsius temperature.
  3. __str__ and __repr__: Add __str__ and __repr__ methods to the Person class created in Chapter 12 Exercise 1. __str__ should return something like “Person: [Name], Age: [Age]”. __repr__ should return something like Person(name='[Name]', age=[Age]). Create an instance and test both print(person) and repr(person).
  4. __len__ for WordList: Create a class WordList that takes a sentence string in its __init__. Store the words (split the sentence) in an instance attribute list called self.words. Implement the __len__(self) method to return the number of words in the list. Create an instance and test len(word_list_object).
  5. __eq__ for Point: Create a simple Point class with __init__(self, x, y) storing x and y coordinates. Implement the __eq__(self, other) method to return True if both the x and y coordinates of self and other (another Point object) are equal, False otherwise. Create several Point objects and test equality (==).

Mini Project: Enhancing the LibraryItem Hierarchy

Goal: Add useful dunder methods and potentially a class method to the LibraryItem hierarchy from Chapter 13’s mini-project.

Steps:

  1. Retrieve Classes: Start with the LibraryItem, Book, and DVD classes from Chapter 13.
  2. Add __str__ and __repr__:
    • Implement __str__(self) in the LibraryItem base class. It should return a user-friendly string like “Item: [Title] ([Identifier])”.
    • Implement __repr__(self) in LibraryItem. It should return something unambiguous, like LibraryItem(title='[Title]', identifier='[Identifier]').
    • Override __repr__ in both the Book and DVD subclasses to provide representations specific to them (including author/pages or director/duration), e.g., Book(title='...', identifier='...', author='...', num_pages=...). You might not need to override __str__ if the base class version is sufficient, but you can if desired.
  3. Add __eq__:
    • Implement __eq__(self, other) in the LibraryItem base class. Two library items should be considered equal if they are of the same type (e.g., both Book or both DVD) AND their identifier attributes are the same. Use isinstance() to check types and compare self.identifier == other.identifier. Return NotImplemented if other is not a LibraryItem.
  4. (Optional) Class Attribute/Method:
    • Add a class attribute total_items = 0 to LibraryItem.
    • Modify the LibraryItem.__init__ to increment LibraryItem.total_items each time any library item (Book, DVD, etc.) is created.
    • Add a class method get_total_items(cls) to LibraryItem that returns the value of cls.total_items.
  5. Instantiate and Test:
    • Create several Book and DVD objects, including some with the same identifier but different titles/authors (to test __eq__).
    • Print the objects (tests __str__).
    • Print a list containing some objects (tests __repr__).
    • Compare objects using == (tests __eq__).
    • If you implemented the optional step, call LibraryItem.get_total_items() to see the total count.

Additional Sources:

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top