The Method Workshop: Class Methods, Static Methods, and Properties

 

The Method Workshop: Class Methods, Static Methods, and Properties





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.

Comments

Popular posts from this blog

The New ChatGPT Reason Feature: What It Is and Why You Should Use It

Raspberry Pi Connect vs. RealVNC: A Comprehensive Comparison

Insight: The Great Minimal OS Showdown—DietPi vs Raspberry Pi OS Lite