Lesson 2 - Class Methods, Properties, and Encapsulation

Introduction

In the previous lesson, we learned about instance methods—functions that work with individual objects using self. But Python gives us more tools to control how classes behave. In this lesson, we’ll explore:

  • Class methods that work with the class itself rather than instances
  • Static methods that don’t need access to the class or instance
  • Properties that let us control how attributes are accessed and modified
  • Encapsulation principles for protecting data

These tools help us write more sophisticated, maintainable code.

Class Methods vs Instance Methods

Let’s start by understanding the difference between instance methods and class methods.

Instance methods (what we’ve used so far) work with specific objects:

class Book:
    def __init__(self, title, price):
        self.title = title
        self.price = price

    def display_info(self):  # Instance method
        print(f"{self.title}: ${self.price}")

book = Book("Python Basics", 29.99)
book.display_info()  # Works with this specific book object

Class methods work with the class itself, not individual objects. They’re useful for:

  • Creating alternative constructors
  • Working with class-level data
  • Factory methods that create objects

Here’s how to define a class method using the @classmethod decorator:

class Book:
    discount_rate = 0.10  # Class variable (shared by all instances)

    def __init__(self, title, price):
        self.title = title
        self.price = price

    @classmethod
    def set_discount_rate(cls, new_rate):
        cls.discount_rate = new_rate

    def get_discounted_price(self):
        return self.price * (1 - Book.discount_rate)

# Class method works on the class, not an instance
Book.set_discount_rate(0.15)

book1 = Book("Python Basics", 100)
book2 = Book("Advanced Python", 150)

print(book1.get_discounted_price())  # 85.0
print(book2.get_discounted_price())  # 127.5

Notice:

  • @classmethod decorator marks the method as a class method
  • cls is used instead of self (represents the class, not an instance)
  • We can call it on the class: Book.set_discount_rate(0.15)

Class Methods as Alternative Constructors

A powerful use of class methods is creating alternative constructors—different ways to create objects:

class Book:
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price

    @classmethod
    def from_string(cls, book_string):
        """Create a book from a string formatted as 'title,author,price'"""
        title, author, price = book_string.split(',')
        return cls(title, author, float(price))

    @classmethod
    def from_dict(cls, book_dict):
        """Create a book from a dictionary"""
        return cls(
            book_dict['title'],
            book_dict['author'],
            book_dict['price']
        )

    def display_info(self):
        print(f"'{self.title}' by {self.author} - ${self.price}")

# Different ways to create book objects
book1 = Book("Python Basics", "John Doe", 29.99)

book2 = Book.from_string("Advanced Python,Jane Smith,49.99")

book3 = Book.from_dict({
    'title': 'Data Science Handbook',
    'author': 'Jake VanderPlas',
    'price': 44.99
})

book1.display_info()
book2.display_info()
book3.display_info()

Output:

'Python Basics' by John Doe - $29.99
'Advanced Python' by Jane Smith - $49.99
'Data Science Handbook' by Jake VanderPlas - $44.99

This is incredibly useful when working with data from different sources (CSV files, APIs, databases).

Static Methods

Static methods don’t need access to the instance (self) or the class (cls). They’re like regular functions that happen to belong to a class because they’re logically related.

Use @staticmethod when the method:

  • Doesn’t need to access instance or class data
  • Is logically related to the class
  • Could be a standalone function, but you want to group it with the class
class Book:
    def __init__(self, title, price):
        self.title = title
        self.price = price

    @staticmethod
    def is_valid_isbn(isbn):
        """Check if an ISBN number is valid (simplified check)"""
        # Remove hyphens and spaces
        isbn = isbn.replace('-', '').replace(' ', '')

        # ISBN-10 should be 10 digits, ISBN-13 should be 13 digits
        return len(isbn) in [10, 13] and isbn.isdigit()

    @staticmethod
    def format_price(price):
        """Format a price with currency symbol"""
        return f"${price:.2f}"

# Static methods can be called on the class without creating an instance
print(Book.is_valid_isbn("978-0-13-110362-7"))  # True
print(Book.is_valid_isbn("123"))  # False

# They can also be called on instances
book = Book("Python Basics", 29.99)
print(book.format_price(book.price))  # $29.99

Properties: Controlled Access to Attributes

Properties let you add logic when getting or setting attribute values. They look like regular attributes but are actually methods in disguise.

The Problem with Direct Attribute Access

class Book:
    def __init__(self, title, price):
        self.title = title
        self.price = price

book = Book("Python Basics", 29.99)
book.price = -50  # Oops! Negative price is invalid

We can’t prevent invalid data from being set directly. Properties solve this.

Using the @property Decorator

class Book:
    def __init__(self, title, price):
        self.title = title
        self._price = price  # Use underscore to indicate "internal" attribute

    @property
    def price(self):
        """Get the price"""
        return self._price

    @price.setter
    def price(self, value):
        """Set the price with validation"""
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

    def display_info(self):
        print(f"{self.title}: ${self.price}")

book = Book("Python Basics", 29.99)

# Getting looks like normal attribute access
print(book.price)  # 29.99

# Setting also looks normal, but validation happens
book.price = 34.99  # OK
print(book.price)  # 34.99

# This will raise an error
try:
    book.price = -10
except ValueError as e:
    print(f"Error: {e}")  # Error: Price cannot be negative

The beauty of properties is that they maintain a simple interface (book.price) while adding validation and logic behind the scenes.

Computed Properties

Properties can also calculate values on-the-fly instead of storing them:

class Book:
    tax_rate = 0.08  # 8% tax

    def __init__(self, title, price):
        self.title = title
        self.price = price

    @property
    def price_with_tax(self):
        """Calculate price including tax"""
        return self.price * (1 + Book.tax_rate)

    @property
    def display_title(self):
        """Return title in uppercase"""
        return self.title.upper()

book = Book("Python Basics", 100)

print(f"Base price: ${book.price}")
print(f"Price with tax: ${book.price_with_tax:.2f}")
print(f"Display title: {book.display_title}")

# Change the price, and price_with_tax automatically updates
book.price = 150
print(f"New price with tax: ${book.price_with_tax:.2f}")

Output:

Base price: $100
Price with tax: $108.00
Display title: PYTHON BASICS
New price with tax: $162.00

Encapsulation: Public, Protected, and Private

Encapsulation is about controlling access to data. Python uses naming conventions to indicate how attributes should be accessed:

  1. Public attributes: Normal names (self.price)
  2. Protected attributes: Single underscore prefix (self._price)
  3. Private attributes: Double underscore prefix (self.__price)

Protected Attributes (Single Underscore)

A single underscore is a convention meaning “this is internal, but you can access it if needed”:

class Book:
    def __init__(self, title, price):
        self.title = title
        self._price = price  # Protected - internal use
        self._sales_count = 0

    def sell(self):
        self._sales_count += 1

    def get_sales_count(self):
        return self._sales_count

book = Book("Python Basics", 29.99)
book.sell()
book.sell()

# You CAN access protected attributes, but the underscore signals you shouldn't
print(book._sales_count)  # Works, but discouraged
print(book.get_sales_count())  # Better approach

Private Attributes (Double Underscore)

Double underscore triggers name mangling—Python changes the name to make it harder to access:

class Book:
    def __init__(self, title, price):
        self.title = title
        self.__cost = price * 0.5  # Private attribute

    def get_profit_margin(self):
        return self.price - self.__cost

    @property
    def price(self):
        return self.__cost * 2

book = Book("Python Basics", 29.99)

print(book.price)  # 29.99 (works through property)

# This will cause an error
try:
    print(book.__cost)
except AttributeError as e:
    print(f"Error: {e}")

# Python actually renamed it to _Book__cost (name mangling)
print(book._Book__cost)  # 14.995 (works but very discouraged!)

Best Practice: Use single underscore for “internal” attributes and properties/methods to control access.

A Practical Example: Book Inventory System

Let’s combine everything into a comprehensive example:

class Book:
    # Class variable
    inventory_count = 0

    def __init__(self, title, author, price, quantity=0):
        self.title = title
        self.author = author
        self._price = price
        self._quantity = quantity
        Book.inventory_count += 1

    # Property for price with validation
    @property
    def price(self):
        return self._price

    @price.setter
    def price(self, value):
        if value < 0:
            raise ValueError("Price cannot be negative")
        self._price = value

    # Property for quantity with validation
    @property
    def quantity(self):
        return self._quantity

    @quantity.setter
    def quantity(self, value):
        if value < 0:
            raise ValueError("Quantity cannot be negative")
        self._quantity = value

    # Computed property
    @property
    def total_value(self):
        """Calculate total value of this book in inventory"""
        return self.price * self.quantity

    # Instance method
    def sell(self, amount):
        if amount > self.quantity:
            raise ValueError(f"Not enough stock. Only {self.quantity} available.")
        self.quantity -= amount
        return self.price * amount

    # Class method - alternative constructor
    @classmethod
    def from_csv_line(cls, csv_line):
        """Create a book from a CSV line: title,author,price,quantity"""
        parts = csv_line.strip().split(',')
        return cls(parts[0], parts[1], float(parts[2]), int(parts[3]))

    # Static method
    @staticmethod
    def calculate_discount(price, discount_percent):
        """Calculate discounted price"""
        return price * (1 - discount_percent / 100)

    def display_info(self):
        print(f"\n{self.title} by {self.author}")
        print(f"  Price: ${self.price:.2f}")
        print(f"  Quantity: {self.quantity}")
        print(f"  Total Value: ${self.total_value:.2f}")

# Create books using different methods
book1 = Book("Python Crash Course", "Eric Matthes", 39.99, 10)
book2 = Book.from_csv_line("Automate the Boring Stuff,Al Sweigart,29.99,5")

book1.display_info()
book2.display_info()

# Use static method
discounted = Book.calculate_discount(39.99, 20)
print(f"\n20% off $39.99 = ${discounted:.2f}")

# Sell some books
revenue = book1.sell(3)
print(f"\nSold 3 books for ${revenue:.2f}")
book1.display_info()

# Check class variable
print(f"\nTotal books in system: {Book.inventory_count}")

# Try to set invalid price
try:
    book1.price = -10
except ValueError as e:
    print(f"\nValidation error: {e}")

Output:

Python Crash Course by Eric Matthes
  Price: $39.99
  Quantity: 10
  Total Value: $399.90

Automate the Boring Stuff by Al Sweigart
  Price: $29.99
  Quantity: 5
  Total Value: $149.95

20% off $39.99 = $31.99

Sold 3 books for $119.97

Python Crash Course by Eric Matthes
  Price: $39.99
  Quantity: 7
  Total Value: $279.93

Total books in system: 2

Validation error: Price cannot be negative

Read-Only Properties

Sometimes you want a property that can be read but not set from outside the class:

class Book:
    def __init__(self, title, price):
        self.title = title
        self.price = price
        self._sales_count = 0

    def sell(self):
        self._sales_count += 1

    @property
    def sales_count(self):
        """Read-only property"""
        return self._sales_count

    # No setter defined, so it's read-only

book = Book("Python Basics", 29.99)
book.sell()
book.sell()

print(book.sales_count)  # 2 - works

# This will raise an error
try:
    book.sales_count = 100
except AttributeError as e:
    print(f"Error: can't set attribute")

Summary

In this lesson, we learned powerful tools for controlling how classes work:

  • Class methods (@classmethod) work with the class itself, useful for alternative constructors
  • Static methods (@staticmethod) don’t need instance or class access, but are logically related
  • Properties (@property) let us add validation and computation while keeping simple syntax
  • Encapsulation uses naming conventions: public, _protected, __private

These tools help us write cleaner, safer code:

  • Class methods give us flexible ways to create objects
  • Static methods organize related functionality
  • Properties validate data and compute values on-demand
  • Encapsulation protects internal implementation details

In the next lesson, we’ll explore inheritance and polymorphism—how to create classes that build on other classes.