29.1 @property: Computed Attributes and Encapsulation
The @property decorator in Python provides an elegant mechanism for creating computed attributes and enforcing encapsulation within classes. It allows a method to be accessed like an attribute, blurring the line between simple data access and method invocation while maintaining a clean, object-oriented interface. This is a cornerstone of the “uniform access principle,” which states that clients should be able to access a class’s services without knowing whether they are implemented via storage or computation.
The Core Concept: From Method to Attribute
At its heart, @property transforms a method into a getter for a virtual attribute. The method’s name becomes the attribute name you interact with. This is powerful because it allows you to change the internal implementation of a class without breaking its public API. Consider a class representing a circle where the area is computed from the radius.
class Circle:
def __init__(self, radius):
self.radius = radius
@property
def area(self):
print("Calculating area...")
return 3.14159 * self.radius ** 2
# Usage
my_circle = Circle(5)
print(my_circle.area) # Output: Calculating area... 78.53975
Notice that area is accessed without parentheses, like a simple attribute. However, the underlying method runs each time it’s accessed, performing the live calculation. This is the fundamental behavior of a property: it computes its value on the fly.
The Full Property Trio: Getter, Setter, Deleter
A property is not limited to just getting a value. It can define a full suite of accessors: getter, setter, and deleter. The @property decorator itself creates the getter. The setter and deleter are then defined using decorators named after the property.
class Temperature:
def __init__(self, celsius=0):
self._celsius = celsius # Internal storage in Celsius
@property
def celsius(self):
"""Getter for Celsius temperature."""
return self._celsius
@celsius.setter
def celsius(self, value):
"""Setter for Celsius temperature with validation."""
if value < -273.15:
raise ValueError("Temperature below absolute zero is not possible.")
self._celsius = value
@property
def fahrenheit(self):
"""Computed property for Fahrenheit temperature."""
return (self._celsius * 9 / 5) + 32
@fahrenheit.setter
def fahrenheit(self, value):
"""Setter allows setting temperature via Fahrenheit."""
self.celsius = (value - 32) * 5 / 9 # Reuse the validated setter
# Usage
temp = Temperature(25)
print(temp.celsius) # 25
print(temp.fahrenheit) # 77.0
temp.fahrenheit = 32
print(temp.celsius) # 0.0
try:
temp.celsius = -300 # Triggers validation in the setter
except ValueError as e:
print(e) # Temperature below absolute zero is not possible.
This example demonstrates the primary why: encapsulation and validation. The internal state is stored as _celsius (a convention for a “private” attribute). The public celsius property controls all access to this value, ensuring it never becomes invalid. The fahrenheit property provides a computed view of the same data and even includes a setter that seamlessly converts back to Celsius, leveraging the existing validation logic.
Common Pitfalls and Best Practices
A significant pitfall is the temptation to perform expensive operations inside a property getter. Because properties are accessed like attributes, a user might not expect a performance cost and could access it repeatedly in a loop, causing severe performance degradation. Properties should be cheap and free of side effects. If a computation is costly, use a regular method to make the cost explicit to the caller.
Another critical best practice is to avoid overly complex property methods. The logic should be simple; if it requires complex branching or multiple steps, it’s often better as a regular method. Properties are meant to make an interface simpler, not to hide complex behavior.
A subtle edge case involves property inheritance. When a property is inherited, the getter, setter, and deleter are inherited as a single unit. If a subclass overrides just the getter using @property, it will inadvertently override the entire property, removing the base class’s setter and deleter. To extend a property in a subclass, you must re-decorate all the accessors you wish to keep.
class Base:
def __init__(self):
self._x = None
@property
def x(self):
return self._x
@x.setter
def x(self, value):
self._x = value
class Derived(Base):
@property
def x(self):
# This OVERRIDES the entire property from Base!
return super().x * 2 # Correctly uses Base's getter
# But the setter from Base is now gone!
# We must explicitly re-add it if we want it.
@x.setter
def x(self, value):
# Re-add the setter logic
self._x = value / 2
# Usage
d = Derived()
d.x = 10
print(d.x) # Output: 10.0
In this case, the intention was to modify the getter behavior, but doing so without also redefining the setter would have broken the ability to set x entirely. Always be mindful of this when working with properties in an inheritance hierarchy.