31. Advanced Class Features
In Chapter 30, we learned how to create basic classes with instance attributes and methods. Now we'll explore more sophisticated class features that give you fine-grained control over how your objects behave. These features let you create classes that feel like built-in Python types, with natural syntax for operations like addition, comparison, and indexing.
31.1) Class Variables vs Instance Variables
When we create attributes in a class, we have two fundamentally different places to store them: on the class itself or on individual instances. Understanding this distinction is crucial for writing correct object-oriented code.
31.1.1) Understanding Instance Variables
Instance variables are attributes that belong to a specific object. Each instance has its own separate copy of these variables. We've been using instance variables throughout Chapter 30 - they're the attributes we create in __init__ using self:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # Instance variable
self.balance = balance # Instance variable
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
print(account1.balance) # Output: 1000
print(account2.balance) # Output: 500Each BankAccount instance has its own owner and balance. Changing account1.balance doesn't affect account2.balance - they're completely independent.
31.1.2) Understanding Class Variables
Class variables are attributes that belong to the class itself, not to any particular instance. All instances share the same class variable. We define class variables directly in the class body, outside any method:
class BankAccount:
interest_rate = 0.02 # Class variable - shared by all instances
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def apply_interest(self):
self.balance += self.balance * BankAccount.interest_rate
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
print(account1.interest_rate) # Output: 0.02
print(account2.interest_rate) # Output: 0.02
print(BankAccount.interest_rate) # Output: 0.02Notice that we can access interest_rate through instances (account1.interest_rate) or through the class itself (BankAccount.interest_rate). Both refer to the same variable.
Here's what makes class variables powerful - when we change the class variable, all instances see the change:
class BankAccount:
interest_rate = 0.02
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
print(account1.interest_rate) # Output: 0.02
print(account2.interest_rate) # Output: 0.02
# Change the class variable
BankAccount.interest_rate = 0.03
print(account1.interest_rate) # Output: 0.03
print(account2.interest_rate) # Output: 0.03Both instances immediately see the new interest rate because they're all looking at the same class variable.
31.1.3) The Shadowing Trap: When Instance Variables Hide Class Variables
Here's a subtle but important behavior: if you assign to an attribute through an instance, Python creates an instance variable that shadows (hides) the class variable:
class BankAccount:
interest_rate = 0.02
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
# Create an instance variable that shadows the class variable
account1.interest_rate = 0.05
print(account1.interest_rate) # Output: 0.05 (instance variable)
print(account2.interest_rate) # Output: 0.02 (class variable)
print(BankAccount.interest_rate) # Output: 0.02 (class variable)Now account1 has its own interest_rate instance variable that hides the class variable. The class variable still exists, but account1.interest_rate refers to the instance variable instead. This is usually not what you want - if you need to change a class variable, change it through the class name, not through an instance.
31.1.4) Practical Uses for Class Variables
Class variables are useful for data that should be shared across all instances:
class Student:
school_name = "Python High School" # Same for all students
total_students = 0 # Track how many students exist
def __init__(self, name, grade):
self.name = name
self.grade = grade
Student.total_students += 1 # Increment when creating a student
def __str__(self):
return f"{self.name} (Grade {self.grade}) at {Student.school_name}"
student1 = Student("Alice", 10)
student2 = Student("Bob", 11)
student3 = Student("Carol", 10)
print(student1) # Output: Alice (Grade 10) at Python High School
print(f"Total students: {Student.total_students}") # Output: Total students: 3Notice how we use Student.total_students (not self.total_students) in __init__ to make it clear we're modifying the class variable, not creating an instance variable.
31.2) Managing Attributes with @property
Sometimes you want to control what happens when someone accesses or modifies an attribute. For example, you might want to validate that a value is positive, or compute a value on-the-fly rather than storing it. Python's @property decorator lets you write methods that look like simple attribute access.
31.2.1) The Problem: Direct Attribute Access Can't Validate
When attributes are accessed directly, you can't validate or transform the values:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
temp = Temperature(25)
print(temp.celsius) # Output: 25
# Nothing stops us from setting physically impossible temperatures
temp.celsius = -500 # Below absolute zero (-273.15°C)!
print(temp.celsius) # Output: -500
# Or absurdly high values
temp.celsius = 1000000
print(temp.celsius) # Output: 1000000Without validation, we can accidentally set invalid data, leading to bugs later in the program. We could use methods like get_celsius() and set_celsius(), but that's not idiomatic Python. Python developers expect to access attributes directly, not through getter/setter methods like in Java or C++.
31.2.2) Using @property for Computed Attributes
The @property decorator turns a method into a "getter" that's accessed like an attribute:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
"""Convert celsius to fahrenheit on-the-fly"""
return self.celsius * 9/5 + 32
temp = Temperature(25)
print(temp.celsius) # Output: 25
print(temp.fahrenheit) # Output: 77.0 (computed, not stored)Notice we call temp.fahrenheit without parentheses - it looks like accessing an attribute, but it's actually calling the method. The fahrenheit value is computed each time we access it, so it's always in sync with celsius:
temp = Temperature(0)
print(temp.fahrenheit) # Output: 32.0
temp.celsius = 100
print(temp.fahrenheit) # Output: 212.0 (automatically updated)31.2.3) Adding a Setter with @property_name.setter
To allow setting a property, we add a setter method using the @property_name.setter decorator:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def fahrenheit(self):
return self.celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""Convert fahrenheit to celsius when setting"""
self.celsius = (value - 32) * 5/9
temp = Temperature(0)
print(temp.celsius) # Output: 0
print(temp.fahrenheit) # Output: 32.0
# Set temperature using fahrenheit
temp.fahrenheit = 212
print(temp.celsius) # Output: 100.0
print(temp.fahrenheit) # Output: 212.0The setter method receives the new value and can validate or transform it before storing.
31.2.4) Using Properties for Validation
Properties are excellent for enforcing constraints:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self._balance = balance # Underscore suggests "internal use"
@property
def balance(self):
"""Get the current balance"""
return self._balance
@balance.setter
def balance(self, value):
"""Set balance, but only if non-negative"""
if value < 0:
raise ValueError("Balance cannot be negative")
self._balance = value
account = BankAccount("Alice", 1000)
print(account.balance) # Output: 1000
account.balance = 1500 # Works fine
print(account.balance) # Output: 1500
# This raises an error
account.balance = -100
# Output: ValueError: Balance cannot be negativeNotice the naming convention: we store the actual value in _balance (with a leading underscore) and expose it through the balance property. The underscore is a Python convention suggesting "this is an internal implementation detail," though the attribute is still technically accessible. This pattern lets us control access through the property while keeping the actual storage separate.
31.2.5) Read-Only Properties
If you define a property without a setter, it becomes read-only:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
@property
def area(self):
"""Computed read-only property"""
return self.width * self.height
rect = Rectangle(5, 3)
print(rect.area) # Output: 15
rect.width = 10
print(rect.area) # Output: 30 (automatically updated)
# Trying to set area raises an error
rect.area = 50
# Output: AttributeError: property 'area' of 'Rectangle' object has no setterThis is useful for derived values that should be computed, not stored.
31.3) Class Methods with @classmethod
Sometimes you need methods that work with the class itself rather than with instances. Class methods receive the class as their first argument (conventionally named cls) instead of an instance (self).
31.3.1) Defining Class Methods
We create class methods using the @classmethod decorator:
class Student:
school_name = "Python High School"
def __init__(self, name, grade):
self.name = name
self.grade = grade
@classmethod
def get_school_name(cls):
"""Class method - receives the class, not an instance"""
return cls.school_name
# Call on the class itself
print(Student.get_school_name()) # Output: Python High School
# Can also call on an instance (but cls is still the class)
student = Student("Alice", 10)
print(student.get_school_name()) # Output: Python High SchoolThe cls parameter automatically receives the class, just like self automatically receives the instance in regular methods.
31.3.2) Alternative Constructors with Class Methods
One of the most common uses for class methods is creating alternative constructors - different ways to create instances:
class Date:
def __init__(self, year, month, day):
self.year = year
self.month = month
self.day = day
@classmethod
def from_string(cls, date_string):
"""Create a Date from a string like '2024-12-27'"""
year, month, day = date_string.split('-')
return cls(int(year), int(month), int(day))
@classmethod
def today(cls):
"""Create a Date for today (simplified example)"""
# In real code, you'd use the datetime module
return cls(2024, 12, 27)
def __str__(self):
return f"{self.year}-{self.month:02d}-{self.day:02d}"
# Normal constructor
date1 = Date(2024, 12, 27)
print(date1) # Output: 2024-12-27
# Alternative constructor from string
date2 = Date.from_string("2024-12-27")
print(date2) # Output: 2024-12-27
# Alternative constructor for today
date3 = Date.today()
print(date3) # Output: 2024-12-27Notice how from_string and today both return cls(...) - this creates a new instance of the class. Using cls instead of hardcoding Date makes the code work correctly with subclasses (we'll learn about inheritance in Chapter 32).
31.3.3) Class Methods for Factory Patterns
Class methods are useful for creating instances with different configurations:
class DatabaseConnection:
def __init__(self, host, port, database, username):
self.host = host
self.port = port
self.database = database
self.username = username
@classmethod
def for_development(cls):
"""Create a connection configured for development"""
return cls("localhost", 5432, "dev_db", "dev_user")
@classmethod
def for_production(cls):
"""Create a connection configured for production"""
return cls("prod.example.com", 5432, "prod_db", "prod_user")
def __str__(self):
return f"Connection to {self.database} at {self.host}:{self.port}"
# Easy to create pre-configured connections
dev_conn = DatabaseConnection.for_development()
prod_conn = DatabaseConnection.for_production()
print(dev_conn) # Output: Connection to dev_db at localhost:5432
print(prod_conn) # Output: Connection to prod_db at prod.example.com:543231.3.4) Class Methods for Counting Instances
Class methods can work with class variables to track information about all instances:
class Product:
total_products = 0
def __init__(self, name, price):
self.name = name
self.price = price
Product.total_products += 1
@classmethod
def get_total_products(cls):
"""Return the total number of products created"""
return cls.total_products
@classmethod
def reset_count(cls):
"""Reset the product counter"""
cls.total_products = 0
product1 = Product("Laptop", 999)
product2 = Product("Mouse", 25)
product3 = Product("Keyboard", 75)
print(Product.get_total_products()) # Output: 3
Product.reset_count()
print(Product.get_total_products()) # Output: 031.4) Static Methods with @staticmethod
Static methods are methods that don't receive the instance (self) or the class (cls) as their first argument. They're just regular functions that happen to be defined inside a class because they're logically related to that class.
31.4.1) Defining Static Methods
We create static methods using the @staticmethod decorator:
class MathUtils:
@staticmethod
def is_even(number):
"""Check if a number is even"""
return number % 2 == 0
@staticmethod
def is_prime(number):
"""Check if a number is prime (simplified)"""
if number < 2:
return False
for i in range(2, int(number ** 0.5) + 1):
if number % i == 0:
return False
return True
# Call static methods on the class
print(MathUtils.is_even(4)) # Output: True
print(MathUtils.is_even(7)) # Output: False
print(MathUtils.is_prime(17)) # Output: True
print(MathUtils.is_prime(18)) # Output: False
# Can also call on an instance (but it's the same function)
utils = MathUtils()
print(utils.is_even(10)) # Output: TrueStatic methods don't need access to instance or class data - they're self-contained utility functions.
31.4.2) When to Use Static Methods vs Class Methods vs Instance Methods
Here's how to choose:
class Temperature:
# Class variable
absolute_zero_celsius = -273.15
def __init__(self, celsius):
self.celsius = celsius
# Instance method - needs access to instance data (self)
def to_fahrenheit(self):
return self.celsius * 9/5 + 32
# Class method - needs access to class data (cls)
@classmethod
def get_absolute_zero(cls):
return cls.absolute_zero_celsius
# Static method - doesn't need instance or class data
@staticmethod
def celsius_to_kelvin(celsius):
return celsius + 273.15
@staticmethod
def fahrenheit_to_celsius(fahrenheit):
return (fahrenheit - 32) * 5/9
temp = Temperature(25)
# Instance method - uses instance data
print(temp.to_fahrenheit()) # Output: 77.0
# Class method - uses class data
print(Temperature.get_absolute_zero()) # Output: -273.15
# Static methods - just utility functions
print(Temperature.celsius_to_kelvin(25)) # Output: 298.15
print(Temperature.fahrenheit_to_celsius(77)) # Output: 25.0Guidelines:
- Use instance methods when you need access to instance attributes (
self) - Use class methods when you need access to class attributes or want alternative constructors (
cls) - Use static methods when you don't need access to instance or class data, but the function is logically related to the class
Note: Static methods could be standalone functions, but putting them in the class groups related functionality together and avoids cluttering the global namespace.
| Method Type | First Parameter | Use When |
|---|---|---|
| Instance Method | self | Need access to instance data |
| Class Method | cls | Need access to class data or alternative constructors |
| Static Method | (none) | Utility function related to the class |
31.4.3) Practical Example: Validation Utilities
Static methods are great for validation and utility functions:
class User:
def __init__(self, username, password):
if not User.is_valid_username(username):
raise ValueError("Invalid username")
if not User.is_valid_password(password):
raise ValueError("Invalid password")
self.username = username
self._password = password
@staticmethod
def is_valid_username(username):
"""Check if username meets requirements"""
return len(username) >= 3 and username.isalnum()
@staticmethod
def is_valid_password(password):
"""Check if password meets security requirements"""
return len(password) >= 8 and any(c.isdigit() for c in password)
# These validation methods can be used independently
print(User.is_valid_username("alice123")) # Output: True
print(User.is_valid_username("ab")) # Output: False
print(User.is_valid_password("pass1234")) # Output: True
# And they can be used in any method of the class
try:
user = User("ab", "short")
except ValueError as e:
print(f"Error: {e}") # Output: Error: Invalid username31.5) Understanding Special Methods (Magic Methods)
Python's special methods (also called magic methods or dunder methods because they have double underscores) let you customize how your objects behave with Python's built-in operations. We've already used __init__, __str__, and __repr__ in Chapter 30. Now we'll explore many more.
31.5.1) What Special Methods Do
Special methods are called automatically by Python when you use certain syntax or built-in functions:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f"Point({self.x}, {self.y})"
point = Point(3, 4)
# When you call print(), Python calls __str__()
print(point) # Output: Point(3, 4)
# This is equivalent to: print(point.__str__())Special methods let you make your classes behave like built-in types. For example, you can make your objects:
- Support arithmetic operations (
+,-,*,/) - Be comparable (
<,>,==) - Work with
len(),in, and indexing - Act like containers or sequences
31.5.2) Common Special Method Categories
Here are the main categories of special methods:
String Representation (how objects are displayed):
__str__()- forprint()andstr()__repr__()- for the REPL andrepr()
Comparison (comparing objects):
__eq__()- for==__ne__()- for!=__lt__()- for<__le__()- for<=__gt__()- for>__ge__()- for>=
Arithmetic (math operations):
__add__()- for+__sub__()- for-__mul__()- for*__truediv__()- for/
Container/Sequence (collection-like behavior):
__len__()- forlen()__contains__()- forin__getitem__()- for indexingobj[key]__setitem__()- for assignmentobj[key] = value
We'll explore these in detail in the following sections.
31.6) Example 1: Collection Interface (len, contains)
Let's create a class that manages a collection of items and make it work with Python's built-in len() function and in operator.
31.6.1) Implementing len for len()
The __len__() special method is called when you use len() on your object:
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def __len__(self):
"""Return the number of items in the cart"""
return len(self.items)
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
cart.add_item("Orange")
# len() calls __len__()
print(len(cart)) # Output: 3Without __len__(), calling len(cart) would raise a TypeError. By implementing it, our ShoppingCart works just like built-in collections.
31.6.2) Implementing contains for the in Operator
The __contains__() special method is called when you use the in operator:
class ShoppingCart:
def __init__(self):
self.items = []
def add_item(self, item):
self.items.append(item)
def __len__(self):
return len(self.items)
def __contains__(self, item):
"""Check if an item is in the cart"""
return item in self.items
cart = ShoppingCart()
cart.add_item("Apple")
cart.add_item("Banana")
# in operator calls __contains__()
print("Apple" in cart) # Output: True
print("Orange" in cart) # Output: FalseNow our cart supports the natural Python syntax for membership testing.
31.6.3) Building a More Complete Collection Class
Let's create a more realistic collection class that tracks student grades:
class GradeBook:
def __init__(self):
self.grades = {} # student_name: list of grades
def add_grade(self, student, grade):
"""Add a grade for a student"""
if student not in self.grades:
self.grades[student] = []
self.grades[student].append(grade)
def __len__(self):
"""Return the number of students"""
return len(self.grades)
def __contains__(self, student):
"""Check if a student has any grades"""
return student in self.grades
def get_average(self, student):
"""Get a student's average grade"""
if student not in self:
return None
grades = self.grades[student]
return sum(grades) / len(grades)
def __str__(self):
return f"GradeBook with {len(self)} students"
gradebook = GradeBook()
gradebook.add_grade("Alice", 85)
gradebook.add_grade("Alice", 90)
gradebook.add_grade("Bob", 78)
gradebook.add_grade("Bob", 82)
gradebook.add_grade("Bob", 88)
print(gradebook) # Output: GradeBook with 2 students
print(len(gradebook)) # Output: 2
print("Alice" in gradebook) # Output: True
print("Carol" in gradebook) # Output: False
print(f"Alice's average: {gradebook.get_average('Alice')}") # Output: Alice's average: 87.5
print(f"Bob's average: {gradebook.get_average('Bob')}") # Output: Bob's average: 82.66666666666667Notice how get_average() uses if student not in self - this calls our __contains__() method, making the code read naturally.
31.7) Example 2: Operator Overloading (add, eq, lt)
Operator overloading means defining what operators like +, ==, and < do for your custom classes. This makes your objects work naturally with Python's syntax.
31.7.1) Implementing add for Addition
The __add__() special method is called when you use the + operator:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
"""Add two vectors"""
return Vector(self.x + other.x, self.y + other.y)
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
# + operator calls __add__()
v3 = v1 + v2
print(v3) # Output: Vector(4, 6)When Python sees v1 + v2, it calls v1.__add__(v2). The left operand's __add__() method receives the right operand as an argument.
31.7.2) Implementing eq for Equality
The __eq__() special method is called when you use the == operator:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other):
"""Check if two vectors are equal"""
return self.x == other.x and self.y == other.y
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(1, 2)
v3 = Vector(3, 4)
# == operator calls __eq__()
print(v1 == v2) # Output: True
print(v1 == v3) # Output: FalseWithout __eq__(), Python compares object identity (whether they're the same object in memory), not their values. With __eq__(), we define what equality means for our class.
31.7.3) Implementing Comparison Operators
Let's implement comparison operators for a Money class:
class Money:
def __init__(self, amount):
self.amount = amount
def __eq__(self, other):
"""Check if amounts are equal"""
return self.amount == other.amount
def __lt__(self, other):
"""Check if this amount is less than other"""
return self.amount < other.amount
def __le__(self, other):
"""Check if this amount is less than or equal to other"""
return self.amount <= other.amount
def __gt__(self, other):
"""Check if this amount is greater than other"""
return self.amount > other.amount
def __ge__(self, other):
"""Check if this amount is greater than or equal to other"""
return self.amount >= other.amount
def __str__(self):
return f"${self.amount:.2f}"
price1 = Money(10.50)
price2 = Money(15.75)
price3 = Money(10.50)
print(price1 == price3) # Output: True
print(price1 < price2) # Output: True
print(price1 <= price3) # Output: True
print(price2 > price1) # Output: True
print(price2 >= price1) # Output: True31.7.4) Handling Type Mismatches in Operators
When implementing operators, you should handle cases where the other operand isn't the expected type:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
"""Add two vectors or add a scalar to both components"""
if isinstance(other, Vector):
return Vector(self.x + other.x, self.y + other.y)
elif isinstance(other, (int, float)):
return Vector(self.x + other, self.y + other)
else:
return NotImplemented # Let Python try other.__radd__(self)
def __eq__(self, other):
if not isinstance(other, Vector):
return False
return self.x == other.x and self.y == other.y
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Output: Vector(4, 6) (vector addition)
print(v1 + 5) # Output: Vector(6, 7) (scalar addition)
print(v1 == v2) # Output: False
print(v1 == "not a vector") # Output: False (no error)Returning NotImplemented (a special built-in constant) tells Python to try the reflected operation on the other operand. This is important for making operators work correctly with different types.
31.8) Example 3: Sequence Access (getitem, setitem)
The __getitem__() and __setitem__() special methods let you use indexing syntax (obj[key]) with your custom classes. This makes your objects behave like lists, dictionaries, or other sequences.
31.8.1) Implementing getitem for Indexing
The __getitem__() method is called when you use square brackets to access an item:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
"""Get a song by index"""
return self.songs[index]
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
# Indexing calls __getitem__()
print(playlist[0]) # Output: Song A
print(playlist[1]) # Output: Song B
print(playlist[-1]) # Output: Song C (negative indexing works!)Because we delegate to self.songs[index], all list indexing features work automatically: positive indices, negative indices, and even raising IndexError for invalid indices.
31.8.2) Supporting Slicing with getitem
The same __getitem__() method also handles slicing:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
"""Get a song by index or slice"""
return self.songs[index]
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
playlist.add_song("Song D")
# Slicing also calls __getitem__()
print(playlist[1:3]) # Output: ['Song B', 'Song C']
print(playlist[:2]) # Output: ['Song A', 'Song B']
print(playlist[::2]) # Output: ['Song A', 'Song C']When you use slicing, Python passes a slice object to __getitem__(). By delegating to self.songs[index], we automatically support all slice syntax.
31.8.3) Implementing setitem for Assignment
The __setitem__() method is called when you assign to an index:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
return self.songs[index]
def __setitem__(self, index, value):
"""Replace a song at a specific index"""
self.songs[index] = value
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
print(playlist[1]) # Output: Song B
# Assignment calls __setitem__()
playlist[1] = "New Song B"
print(playlist[1]) # Output: New Song B31.8.4) Making Objects Iterable with getitem
An interesting side effect: if you implement __getitem__() with integer indices starting from 0, your object automatically becomes iterable:
class Playlist:
def __init__(self, name):
self.name = name
self.songs = []
def add_song(self, song):
self.songs.append(song)
def __getitem__(self, index):
return self.songs[index]
def __len__(self):
return len(self.songs)
playlist = Playlist("My Favorites")
playlist.add_song("Song A")
playlist.add_song("Song B")
playlist.add_song("Song C")
# for loops work automatically!
for song in playlist:
print(song)
# Output:
# Song A
# Song B
# Song CPython tries to iterate by calling __getitem__(0), then __getitem__(1), and so on until it gets an IndexError. This is an older iteration protocol - we'll learn about the modern iterator protocol in Chapter 35.
31.8.5) Dictionary-Like Access with String Keys
__getitem__() and __setitem__() work with any type of key, not just integers:
class ScoreBoard:
def __init__(self):
self.scores = {}
def __getitem__(self, player_name):
"""Get score for a player"""
return self.scores.get(player_name, 0)
def __setitem__(self, player_name, score):
"""Set score for a player"""
self.scores[player_name] = score
def __contains__(self, player_name):
return player_name in self.scores
def __len__(self):
return len(self.scores)
scoreboard = ScoreBoard()
# Set scores using string keys
scoreboard["Alice"] = 100
scoreboard["Bob"] = 85
# Update a score
scoreboard["Alice"] = 120
# Get scores
print(scoreboard["Alice"]) # Output: 120
print(scoreboard["Bob"]) # Output: 85
print(scoreboard["Carol"]) # Output: 0
print("Alice" in scoreboard) # Output: True
print(len(scoreboard)) # Output: 2This chapter has shown you how to create sophisticated classes that integrate seamlessly with Python's syntax. By implementing class variables, properties, class methods, static methods, and special methods, you can make your custom classes behave like built-in types. In Chapter 32, we'll explore inheritance and polymorphism, which let you build hierarchies of related classes that share and extend behavior.