Timothy had mastered instance methods—functions that operated on individual objects through self
. But his Book
class had needs that didn't fit the instance method pattern. He wanted factory methods to create books from different data sources. He needed utility functions related to books but not tied to any specific book. He wanted controlled access to attributes with validation.
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
# Want: Create book from CSV string
# But this doesn't fit - needs string, not a book instance
# Want: Validate ISBN format
# But this doesn't need any book data at all
# Want: Validate pages when setting
# But direct attribute access bypasses validation
book.pages = -100 # Should be prevented!
Margaret found him wrestling with these patterns. "You need the Method Workshop," she explained, leading him to a room with three distinct workbenches—one for class methods, one for static methods, and one for properties. "Python provides three other method types, each solving different problems."
Instance Methods: The Foundation
Margaret reviewed what Timothy already knew:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
def get_reading_time(self):
# Instance method - operates on THIS book
return self.pages * 2
dune = Book("Dune", "Herbert", 1965, 412)
print(dune.get_reading_time()) # 824
"Instance methods receive self
—the specific object," Margaret explained. "They work with that object's data. But not all methods need a specific instance."
Class Methods: Factory Patterns
Margaret showed Timothy the @classmethod
decorator:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
@classmethod
def from_csv(cls, csv_string):
# Class method - receives the class itself, not an instance
title, author, year, pages = csv_string.split(',')
return cls(title, author, int(year), int(pages))
@classmethod
def from_dict(cls, book_dict):
# Another factory method
return cls(
book_dict['title'],
book_dict['author'],
book_dict['year'],
book_dict['pages']
)
# Create books from different sources
csv_book = Book.from_csv("Dune,Herbert,1965,412")
dict_book = Book.from_dict({'title': 'Foundation', 'author': 'Asimov', 'year': 1951, 'pages': 255})
print(csv_book.title) # "Dune"
print(dict_book.title) # "Foundation"
"Class methods receive cls
—the class itself," Margaret explained. "They're perfect for factory patterns—alternative constructors that create instances in different ways."
Class Methods with Inheritance
Timothy discovered class methods worked beautifully with inheritance:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
@classmethod
def from_csv(cls, csv_string):
# cls is whatever class this was called on
title, author, year, pages = csv_string.split(',')
return cls(title, author, int(year), int(pages))
class Audiobook(Book):
def __init__(self, title, author, year, pages, narrator, duration_minutes):
super().__init__(title, author, year, pages)
self.narrator = narrator
self.duration_minutes = duration_minutes
# Calling on Audiobook creates an Audiobook!
# But wait - from_csv only has 4 fields, not 6
# This shows the limitation - factory methods need to match __init__
"The cls
parameter is the actual class called," Margaret noted. "This lets factory methods work with subclasses automatically—if the factory method fits the subclass's __init__
."
Class Methods for Class-Level Operations
Timothy learned class methods could work with class attributes:
class Book:
total_books = 0
all_books = []
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
# Track all books
Book.total_books += 1
Book.all_books.append(self)
@classmethod
def get_total_books(cls):
# Access class attribute
return cls.total_books
@classmethod
def get_average_pages(cls):
# Operate on all instances
if not cls.all_books:
return 0
total_pages = sum(book.pages for book in cls.all_books)
return total_pages / len(cls.all_books)
dune = Book("Dune", "Herbert", 1965, 412)
foundation = Book("Foundation", "Asimov", 1951, 255)
print(Book.get_total_books()) # 2
print(Book.get_average_pages()) # 333.5
"Class methods can access and modify class-level state," Margaret explained. "They work with the class as a whole, not individual instances."
Static Methods: Utility Functions
Margaret showed Timothy the @staticmethod
decorator:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
@staticmethod
def is_valid_isbn(isbn):
# Static method - no self, no cls
# Just a utility function in the Book namespace
if not isinstance(isbn, str):
return False
# Remove hyphens and spaces
isbn = isbn.replace('-', '').replace(' ', '')
# Check length (ISBN-10 or ISBN-13)
if len(isbn) not in (10, 13):
return False
# Check all characters are digits (simplified validation)
return isbn.isdigit()
@staticmethod
def calculate_reading_time(pages, words_per_page=250, words_per_minute=200):
# Another utility - doesn't need any book data
total_words = pages * words_per_page
return total_words / words_per_minute
# Call without creating an instance
print(Book.is_valid_isbn("978-0441013593")) # True
print(Book.calculate_reading_time(412)) # 515.0 minutes
"Static methods don't receive self
or cls
," Margaret explained. "They're regular functions that live in the class namespace. Use them for utilities logically related to the class but not needing class or instance data."
Margaret warned Timothy about a common limitation:
class Book:
library_name = "Grand Library" # Class attribute
@staticmethod
def get_library():
# WRONG - static methods can't access class attributes!
# return Book.library_name # Hardcoded class name, breaks inheritance
pass
@classmethod
def get_library_correct(cls):
# RIGHT - use classmethod when you need class data
return cls.library_name
"If you need class attributes or the class itself," Margaret cautioned, "use @classmethod
, not @staticmethod
. Static methods are truly independent—they can't see the class or instance."
When to Use Each Method Type
Margaret clarified the distinctions:
class Book:
total_books = 0 # Class attribute
def __init__(self, title, author, year, pages):
self.title = title # Instance attributes
self.author = author
self.year = year
self.pages = pages
Book.total_books += 1
# INSTANCE METHOD - needs specific book's data
def get_reading_time(self):
return self.pages * 2
# CLASS METHOD - creates instances or works with class data
@classmethod
def from_csv(cls, csv_string):
title, author, year, pages = csv_string.split(',')
return cls(title, author, int(year), int(pages))
@classmethod
def get_total_books(cls):
return cls.total_books
# STATIC METHOD - utility function, needs no book or class data
@staticmethod
def is_valid_isbn(isbn):
isbn = isbn.replace('-', '').replace(' ', '')
return len(isbn) in (10, 13) and isbn.isdigit()
Use instance methods when: You need data from a specific instance.
Use class methods when: You need alternative constructors (factory methods) or work with class-level state.
Use static methods when: You have utility functions logically related to the class but needing no instance or class data.
Properties: Computed Attributes
Margaret showed Timothy the @property
decorator:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self._pages = pages # Private attribute with underscore
@property
def pages(self):
# Getter - called when accessing book.pages
return self._pages
@pages.setter
def pages(self, value):
# Setter - called when setting book.pages = value
if value < 0:
raise ValueError("Pages cannot be negative")
self._pages = value
@property
def is_lengthy(self):
# Computed property - no setter, read-only
return self._pages > 400
dune = Book("Dune", "Herbert", 1965, 412)
# Access like an attribute, but calls the getter
print(dune.pages) # 412
# Set like an attribute, but calls the setter with validation
dune.pages = 500 # OK
print(dune.pages) # 500
# This raises ValueError
# dune.pages = -100
# Computed property
print(dune.is_lengthy) # True
"Properties look like attributes but are actually methods," Margaret explained. "The getter runs when you access the attribute. The setter runs when you assign to it. Properties let you add validation, computation, or logging without changing how code uses your class."
Properties for Lazy Computation
Timothy learned properties could defer expensive calculations:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
self._summary = None # Cache for expensive computation
@property
def summary(self):
# Lazy computation - only calculate once
if self._summary is None:
# Expensive operation (simulated)
self._summary = f'"{self.title}" by {self.author} ({self.year}) - {self.pages} pages'
return self._summary
dune = Book("Dune", "Herbert", 1965, 412)
print(dune.summary) # Calculates and caches
print(dune.summary) # Returns cached value
"Properties can cache computed values," Margaret noted. "Calculate once, return the cached value thereafter. This is lazy evaluation."
Modern Lazy Evaluation with cached_property
Margaret showed Timothy a better approach for Python 3.8+:
from functools import cached_property
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self.pages = pages
@cached_property
def expensive_summary(self):
# Computed once, automatically cached
print("Computing summary...")
return f'"{self.title}" by {self.author} - {self.pages} pages'
dune = Book("Dune", "Herbert", 1965, 412)
print(dune.expensive_summary) # Prints "Computing summary..."
print(dune.expensive_summary) # Uses cache, no print
"The @cached_property
decorator handles caching automatically," Margaret explained. "No need for manual if self._cache is None
checks. Modern Python makes lazy evaluation cleaner."
Properties for Backward Compatibility
Margaret showed Timothy properties could maintain interfaces:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self._page_count = pages # Renamed internal attribute
@property
def pages(self):
# Old interface still works
return self._page_count
@pages.setter
def pages(self, value):
self._page_count = value
# Code using book.pages still works!
# Even though internally it's _page_count
"When you need to change internal implementation," Margaret explained, "properties let you keep the public interface unchanged. Add validation or logging without breaking existing code."
Read-Only Properties
Timothy learned to create attributes that couldn't be modified:
class Book:
def __init__(self, title, author, year, pages, isbn):
self.title = title
self.author = author
self.year = year
self.pages = pages
self._isbn = isbn # Private
@property
def isbn(self):
# Read-only - no setter defined
return self._isbn
dune = Book("Dune", "Herbert", 1965, 412, "978-0441013593")
print(dune.isbn) # "978-0441013593"
# This raises AttributeError
# dune.isbn = "new-isbn"
"Omit the setter for read-only properties," Margaret advised. "The attribute can be accessed but not modified from outside the class."
Properties with Deleters
Margaret showed Timothy the complete property pattern:
class Book:
def __init__(self, title, author, year, pages):
self.title = title
self.author = author
self.year = year
self._pages = pages
@property
def pages(self):
return self._pages
@pages.setter
def pages(self, value):
if value < 0:
raise ValueError("Pages cannot be negative")
self._pages = value
@pages.deleter
def pages(self):
# Called when: del book.pages
print("Deleting pages")
del self._pages
dune = Book("Dune", "Herbert", 1965, 412)
del dune.pages # Calls the deleter
"Deleters are rare," Margaret noted, "but they complete the property protocol. Most properties only need getter and setter."
Real-World Example: Temperature Converter
Margaret demonstrated a practical property pattern:
class Temperature:
def __init__(self, celsius):
self._celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero")
self._celsius = value
@property
def fahrenheit(self):
# Computed from celsius
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
# Convert and store as celsius
self.celsius = (value - 32) * 5/9
@property
def kelvin(self):
return self._celsius + 273.15
@kelvin.setter
def kelvin(self, value):
self.celsius = value - 273.15
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
print(temp.kelvin) # 298.15
# Set any scale, others update automatically
temp.fahrenheit = 32
print(temp.celsius) # 0.0
print(temp.kelvin) # 273.15
"Properties let you maintain consistency," Margaret explained. "Store data in one format, expose it in multiple views. Changes to any view update the underlying data."
Properties vs Getters and Setters
Margaret contrasted Python's approach with other languages:
# Java/C++ style - explicit getters and setters
class Book:
def __init__(self, title, pages):
self._pages = pages
def get_pages(self):
return self._pages
def set_pages(self, value):
if value < 0:
raise ValueError("Pages cannot be negative")
self._pages = value
book = Book("Dune", 412)
book.set_pages(500) # Explicit method call
print(book.get_pages()) # Explicit method call
# Python style - properties
class Book:
def __init__(self, title, pages):
self._pages = pages
@property
def pages(self):
return self._pages
@pages.setter
def pages(self, value):
if value < 0:
raise ValueError("Pages cannot be negative")
self._pages = value
book = Book("Dune", 412)
book.pages = 500 # Looks like attribute access
print(book.pages) # Looks like attribute access
"Python properties provide validation and computation with attribute syntax," Margaret explained. "You get Java's control with Python's simplicity. Start with simple attributes, add properties later if needed—existing code doesn't break."
The property() Function Form
Margaret showed Timothy an alternative to decorators:
class Book:
def __init__(self, title, pages):
self._pages = pages
def get_pages(self):
return self._pages
def set_pages(self, value):
if value < 0:
raise ValueError("Pages cannot be negative")
self._pages = value
def del_pages(self):
del self._pages
# Create property without decorators
pages = property(get_pages, set_pages, del_pages, "Number of pages")
# getter setter deleter docstring
book = Book("Dune", 412)
print(book.pages) # Calls get_pages
book.pages = 500 # Calls set_pages
"The property()
function creates the same result as decorators," Margaret explained. "Use this form when you need to reuse getter/setter methods or prefer explicit syntax."
When NOT to Use Properties
Margaret emphasized important limitations:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
@property
def distance_from_origin(self):
# Expensive computation
return (self.x ** 2 + self.y ** 2) ** 0.5
# This looks cheap but is EXPENSIVE in loops!
points = [Point(i, i) for i in range(1000)]
for point in points:
d = point.distance_from_origin # Recalculated every time!
# Looks like attribute access, but isn't
# Better: Method makes the cost explicit
def calculate_distance(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
# Or use @cached_property if the value doesn't change
"Don't use properties when," Margaret cautioned:
The computation is expensive (unless using @cached_property
): Properties look like cheap attribute access—expensive operations mislead readers.
The operation has side effects: Properties should be read-only observations, not actions that modify state elsewhere.
It might raise exceptions: Properties that frequently fail break the "attribute access" mental model.
You need to pass arguments: Properties can't take parameters—use methods instead.
Properties and Inheritance
Timothy discovered a critical inheritance gotcha:
class Book:
def __init__(self, title, pages):
self._pages = pages
@property
def pages(self):
return self._pages
@pages.setter
def pages(self, value):
if value < 0:
raise ValueError("Pages cannot be negative")
self._pages = value
class Audiobook(Book):
@property
def pages(self):
# Override getter only - audiobooks have no pages
return 0
# WARNING: Overriding only the getter does NOT inherit the setter!
# The setter from parent is lost
audio = Audiobook("Dune", 0)
print(audio.pages) # 0
# audio.pages = 100 # AttributeError: can't set attribute!
# Must redefine entire property (getter AND setter) in child
"When you override a property's getter," Margaret warned, "you must redefine the entire property—getter, setter, and deleter. The parent's setter and deleter are not inherited separately."
Properties Cannot Be Class or Static Methods
Margaret clarified a common mistake:
class Book:
_total_books = 0
# This DOESN'T work!
# @property
# @classmethod
# def total_books(cls):
# return cls._total_books
# Properties are instance descriptors only
# Use regular classmethod for class-level access
@classmethod
def get_total_books(cls):
return cls._total_books
"Properties work only with instances," Margaret explained. "You cannot combine @property
with @classmethod
or @staticmethod
. For class-level computed attributes, use regular class methods."
Timothy's Method Types Wisdom
Through exploring the Method Workshop, Timothy learned essential principles:
Instance methods receive self: They work with specific object data.
Class methods receive cls: They create instances (factories) or work with class-level state.
Static methods receive neither: They're utility functions in the class namespace.
Static methods can't access class data: Use @classmethod
when you need class attributes or the class itself.
Use @classmethod for factories: Alternative constructors that return instances.
Use @staticmethod for utilities: Functions related to the class but needing no class or instance data.
Properties look like attributes: But they're methods with getter/setter/deleter.
Properties add validation: Control how attributes are accessed and modified.
Properties compute values: Calculate attributes on-the-fly without storage.
Use @cached_property for expensive computations: Modern Python (3.8+) handles caching automatically.
Properties enable lazy evaluation: Compute once, cache the result.
Properties maintain interfaces: Change internals without breaking external code.
Read-only properties omit setters: Access allowed, modification prevented.
property() function is an alternative to decorators: Useful when reusing getter/setter methods.
Don't use properties for expensive operations: Unless cached, they mislead about cost.
Properties shouldn't have side effects: They should observe, not modify other state.
Properties can't take arguments: Use methods when you need parameters.
Overriding property getter loses parent setter: Must redefine entire property in child classes.
Properties are instance-only: Can't combine @property
with @classmethod
or @staticmethod
.
Start with attributes, add properties later: Refactor without breaking code.
Properties are Pythonic: Prefer them over explicit getters and setters.
Python's Method Versatility
Timothy had discovered Python's method versatility—instance methods for objects, class methods for factories and class-level operations, static methods for utilities that need no data, and properties for controlled attribute access.
The Method Workshop had revealed that not all methods are created equal—each type serves a specific purpose.
He learned that static methods can't access class state, that properties should avoid expensive computations unless cached, and that modern Python's @cached_property
simplifies lazy evaluation.
He discovered that overriding a property's getter in a child class loses the parent's setter, requiring the entire property to be redefined.
Most importantly, he understood that choosing the right method type—whether @classmethod
, @staticmethod
, @property
, or plain instance method—makes code clearer, more maintainable, and more Pythonic.
Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.
Top comments (0)