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 byprint()
).__repr__
: For unambiguous, developer-focused string representation.__len__
: To allowlen()
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
orClassName.class_attribute
). - Defined without any special decorator.
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.
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
orcls
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.
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)
ormy_object.class_method(arg)
(calling on an instance still passes the class ascls
) - Static methods:
MyClass.static_method(arg1, arg2)
ormy_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 aTypeError
.
4. __eq__(self, other):
- Purpose: Defines behavior for the equality operator (
==
). Comparesself
withother
. - Called By:
self == other
. - Return Value: Should return
True
if objects are considered equal,False
otherwise. Can also returnNotImplemented
if comparison isn’t supported for the givenother
type (allowing Python to tryother.__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
# 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) usesself.raise_amount
. If we wanted it to always use the current class raise amount, it should useEmployee.raise_amount
orself.__class__.raise_amount
.set_raise_amount
(@classmethod
) takescls
and modifies the class attributecls.raise_amount
, affecting all future calculations that use it.from_string
(@classmethod
) acts as an alternative way to createEmployee
instances, parsing a string and callingcls(first, last, pay)
which is equivalent toEmployee(first, last, pay)
.is_workday
(@staticmethod
) performs a calculation based only on its input (day
) and doesn’t needself
orcls
. It’s logically grouped withEmployee
but doesn’t depend on employee data or the employee count.
Example 2: Implementing Dunder Methods
# 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 simpleVector(x, y)
format for printing.__repr__
provides a more detailedVector2D(x=..., y=...)
format, useful for debugging and showing how the object was likely created.__len__
allowslen(vector_object)
to work, returning the integer part of the vector’s magnitude.__eq__
defines that twoVector2D
objects are equal if their correspondingx
andy
components are equal. It returnsNotImplemented
if compared with a non-Vector2D
type.__add__
defines vector addition using the+
operator, returning a newVector2D
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 implicitself
parameter being passed (or expected but not received).Confusing cls and self:
Usingself
in a class method orcls
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 ofother
(isinstance(other, ClassName)
) can lead toAttributeError
if you try to access attributes onother
that don’t exist (e.g., comparing your object to an integer). ReturningNotImplemented
is crucial for interoperability.- Modifying Objects in Dunder Methods (Unexpectedly): Methods like
__add__
should generally return a new object representing the result, not modifyself
orother
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 oncls
), 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 (forprint()
).__repr__(self)
provides a developer-focused, unambiguous string representation. Implement this first.__len__(self)
enables thelen()
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
Counter
Class: Create a classCounter
with a class attributecount
initialized to 0. Implement__init__(self)
that incrementsCounter.count
. Add a class methodget_count()
that returns the value ofCounter.count
. Create a few instances and print the count using the class method.Temperature
Converter: Create a classTemperature
with an__init__(self, celsius)
that stores temperature in Celsius. Add a static methodcelsius_to_fahrenheit(celsius)
that converts Celsius to Fahrenheit (F = C * 9/5 + 32
). Add an instance methodget_fahrenheit(self)
that uses the static method to return the Fahrenheit equivalent of the instance’s Celsius temperature.__str__
and__repr__
: Add__str__
and__repr__
methods to thePerson
class created in Chapter 12 Exercise 1.__str__
should return something like “Person: [Name], Age: [Age]”.__repr__
should return something likePerson(name='[Name]', age=[Age])
. Create an instance and test bothprint(person)
andrepr(person)
.__len__
forWordList
: Create a classWordList
that takes a sentence string in its__init__
. Store the words (split the sentence) in an instance attribute list calledself.words
. Implement the__len__(self)
method to return the number of words in the list. Create an instance and testlen(word_list_object)
.__eq__
forPoint
: Create a simplePoint
class with__init__(self, x, y)
storing x and y coordinates. Implement the__eq__(self, other)
method to returnTrue
if both the x and y coordinates ofself
andother
(anotherPoint
object) are equal,False
otherwise. Create severalPoint
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:
- Retrieve Classes: Start with the
LibraryItem
,Book
, andDVD
classes from Chapter 13. Add __str__ and __repr__:
- Implement
__str__(self)
in theLibraryItem
base class. It should return a user-friendly string like “Item: [Title] ([Identifier])”. - Implement
__repr__(self)
inLibraryItem
. It should return something unambiguous, likeLibraryItem(title='[Title]', identifier='[Identifier]')
. - Override
__repr__
in both theBook
andDVD
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.
- Implement
Add __eq__:
- Implement
__eq__(self, other)
in theLibraryItem
base class. Two library items should be considered equal if they are of the same type (e.g., bothBook
or bothDVD
) AND theiridentifier
attributes are the same. Useisinstance()
to check types and compareself.identifier == other.identifier
. ReturnNotImplemented
ifother
is not aLibraryItem
.
- Implement
- (Optional) Class Attribute/Method:
- Add a class attribute
total_items = 0
toLibraryItem
. - Modify the
LibraryItem.__init__
to incrementLibraryItem.total_items
each time any library item (Book, DVD, etc.) is created. - Add a class method
get_total_items(cls)
toLibraryItem
that returns the value ofcls.total_items
.
- Add a class attribute
- Instantiate and Test:
- Create several
Book
andDVD
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.
- Create several
Additional Sources: