Python Property Decorators
In the world of Python object-oriented programming, property decorators stand out as an elegant way to manage access to class attributes. They allow you to add behavior when getting, setting, or deleting attributes, all while maintaining a clean and intuitive syntax.
Introduction to Property Decorators
When building classes in Python, you often need to control how attributes are accessed and modified. Instead of using explicit getter and setter methods as in other languages, Python offers property decorators that let you achieve the same functionality with a more Pythonic approach.
Property decorators allow you to:
- Control access to class attributes
- Validate data before assigning to attributes
- Compute attribute values on the fly
- Implement "read-only" attributes
- Add side effects when attributes are modified
The property Decorator Basics
At its core, the property decorator transforms a method into an attribute-like accessor. Let's start with a simple example:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
# Using the property
person = Person("Alice")
print(person.name) # Accesses the property like an attribute
Output:
Alice
In this example, name becomes a "getter" property that provides controlled access to the _name attribute. Note the following:
- The
_nameattribute is prefixed with an underscore to indicate it's intended for internal use - The
@propertydecorator transforms thename()method into a getter - We access
person.namewithout parentheses, as if it were a regular attribute
Adding Setter and Deleter Methods
Properties become more powerful when adding setter and deleter methods:
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
"""Get the person's name."""
return self._name
@name.setter
def name(self, value):
"""Set the person's name with validation."""
if not isinstance(value, str):
raise TypeError("Name must be a string")
if len(value) < 2:
raise ValueError("Name is too short")
self._name = value
@name.deleter
def name(self):
"""Delete the person's name."""
print(f"Deleting name: {self._name}")
self._name = None
# Using the property
person = Person("Bob")
print(person.name) # Using the getter
person.name = "Charlie" # Using the setter
print(person.name)
try:
person.name = 123 # Will raise TypeError
except TypeError as e:
print(f"Error: {e}")
del person.name # Using the deleter
print(person.name) # Now returns None
Output:
Bob
Charlie
Error: Name must be a string
Deleting name: Charlie
None
Here's what's happening:
@propertydefines the getter method@name.setterdefines how the attribute is set, with validation@name.deleterdefines what happens when the attribute is deleted- We access, set, and delete the property using natural attribute syntax
Practical Applications of Property Decorators
Example 1: Temperature Converter
Let's create a class that converts between Celsius and Fahrenheit:
class Temperature:
def __init__(self, celsius=0):
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 is not possible")
self._celsius = value
@property
def fahrenheit(self):
# Convert from celsius to fahrenheit
return self._celsius * 9/5 + 32
@fahrenheit.setter
def fahrenheit(self, value):
# Convert from fahrenheit to celsius
self.celsius = (value - 32) * 5/9
# Using the temperature class
temp = Temperature(25)
print(f"Celsius: {temp.celsius}°C")
print(f"Fahrenheit: {temp.fahrenheit}°F")
temp.fahrenheit = 68
print(f"Celsius: {temp.celsius}°C")
try:
temp.celsius = -300 # Below absolute zero
except ValueError as e:
print(f"Error: {e}")
Output:
Celsius: 25°C
Fahrenheit: 77.0°F
Celsius: 20.0°C
Error: Temperature below absolute zero is not possible
In this example, the properties provide:
- Data validation (preventing temperatures below absolute zero)
- Automatic conversion between units
- A clean interface for users of the class
Example 2: Banking Account with Transaction Logging
Let's implement a bank account class that logs changes to the balance:
from datetime import datetime
class BankAccount:
def __init__(self, account_number, initial_balance=0):
self._account_number = account_number
self._balance = initial_balance
self._transactions = []
if initial_balance > 0:
self._log_transaction("Initial deposit", initial_balance)
def _log_transaction(self, transaction_type, amount):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self._transactions.append({
"timestamp": timestamp,
"type": transaction_type,
"amount": amount,
"balance": self._balance
})
@property
def account_number(self):
"""Get the account number (read-only property)"""
return self._account_number
@property
def balance(self):
"""Get current balance"""
return self._balance
@balance.setter
def balance(self, value):
"""Setting balance directly is not allowed"""
raise AttributeError("Cannot set balance directly. Use deposit() or withdraw() methods.")
def deposit(self, amount):
"""Deposit money into the account"""
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self._balance += amount
self._log_transaction("Deposit", amount)
return self._balance
def withdraw(self, amount):
"""Withdraw money from the account"""
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
self._log_transaction("Withdrawal", -amount)
return self._balance
@property
def transaction_history(self):
"""Get a copy of the transaction history"""
return self._transactions.copy()
# Using the bank account
account = BankAccount("12345", 1000)
print(f"Account: {account.account_number}, Balance: ${account.balance}")
account.deposit(500)
account.withdraw(200)
try:
account.balance = 5000 # This will raise an error
except AttributeError as e:
print(f"Error: {e}")
print(f"Final balance: ${account.balance}")
# Print transaction history
print("\nTransaction History:")
for transaction in account.transaction_history:
print(f"{transaction['timestamp']} - {transaction['type']}: ${abs(transaction['amount'])} - Balance: ${transaction['balance']}")
Output:
Account: 12345, Balance: $1000
Error: Cannot set balance directly. Use deposit() or withdraw() methods.
Final balance: $1300
Transaction History:
2023-10-15 14:32:10 - Initial deposit: $1000 - Balance: $1000
2023-10-15 14:32:10 - Deposit: $500 - Balance: $1500
2023-10-15 14:32:10 - Withdrawal: $200 - Balance: $1300
In this example, properties help:
- Make
account_numberread-only - Prevent direct modification of the
balanceattribute - Force users to use the
deposit()andwithdraw()methods - Provide a safe way to access transaction history
Property Decorator vs. Property Function
There are two ways to define properties in Python:
Using the Property Decorator (modern approach)
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
@property
def area(self):
import math
return math.pi * self._radius ** 2
Using the Property Function (classic approach)
class Circle:
def __init__(self, radius):
self._radius = radius
def get_radius(self):
return self._radius
def set_radius(self, value):
if value <= 0:
raise ValueError("Radius must be positive")
self._radius = value
def get_area(self):
import math
return math.pi * self._radius ** 2
radius = property(get_radius, set_radius)
area = property(get_area)
Both achieve the same result, but the decorator syntax is more readable and widely used in modern Python code.
Best Practices for Using Properties
-
Use properties for computed attributes: If an attribute value depends on other attributes or involves calculation, make it a property.
-
Add validation in setters: Use setter methods to ensure attribute values meet requirements.
-
Follow naming conventions: Use a single underscore prefix (
_attribute_name) for the internal attribute to indicate it's intended for private use. -
Keep property methods simple: If complex logic is needed, consider moving it to separate methods.
-
Consider read-only properties: For attributes that shouldn't be changed directly, omit the setter.
-
Document your properties: Use docstrings to explain what properties represent and any validation rules.
Summary
Property decorators provide a clean, Pythonic way to implement getters, setters, and deleters for class attributes. They enable data validation, computed attributes, and attribute access control without sacrificing Python's elegant syntax.
Key points to remember:
@propertycreates getter methods that are accessed like attributes@attribute.setterdefines how attributes are set@attribute.deleterdefines the behavior when deleting attributes- Properties help maintain encapsulation while preserving intuitive syntax
Exercises
-
Create a
Rectangleclass with properties forwidthandheightthat validate the values are positive. Add a property calledareathat calculates the rectangle's area. -
Extend the
Personclass to include properties foragewith validation (must be between 0 and 120) andfull_namethat combines first and last name properties. -
Create a
Fileclass that opens a file when initialized, provides properties to access file content, and automatically closes the file when the object is deleted.
Additional Resources
💡 Found a typo or mistake? Click "Edit this page" to suggest a correction. Your feedback is greatly appreciated!