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
_name
attribute is prefixed with an underscore to indicate it's intended for internal use - The
@property
decorator transforms thename()
method into a getter - We access
person.name
without 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:
@property
defines the getter method@name.setter
defines how the attribute is set, with validation@name.deleter
defines 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_number
read-only - Prevent direct modification of the
balance
attribute - 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:
@property
creates getter methods that are accessed like attributes@attribute.setter
defines how attributes are set@attribute.deleter
defines the behavior when deleting attributes- Properties help maintain encapsulation while preserving intuitive syntax
Exercises
-
Create a
Rectangle
class with properties forwidth
andheight
that validate the values are positive. Add a property calledarea
that calculates the rectangle's area. -
Extend the
Person
class to include properties forage
with validation (must be between 0 and 120) andfull_name
that combines first and last name properties. -
Create a
File
class that opens a file when initialized, provides properties to access file content, and automatically closes the file when the object is deleted.
Additional Resources
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)