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 objectClass 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.5Notice:
@classmethoddecorator marks the method as a class methodclsis used instead ofself(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.99This 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.99Properties: 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 invalidWe 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 negativeThe 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.00Encapsulation: Public, Protected, and Private
Encapsulation is about controlling access to data. Python uses naming conventions to indicate how attributes should be accessed:
- Public attributes: Normal names (
self.price) - Protected attributes: Single underscore prefix (
self._price) - 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 approachPrivate 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 negativeRead-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.