This tutorial is for intermediate Python learners who are already familiar with simple Python syntax, such as if...else
statements.
What is @property
?
The Python @property
decorator is used to turn a method into a property. This means you can interact with the method as if it were an attribute. For instance, you can assign a value to it with person.salary = 4500
or access its value without using parentheses, like print(person.salary)
. This is all possible if the method has been decorated with @property
.
Why Would You Want to Use @property
?
The @property
decorator is particularly useful when you have an attribute that requires validation or sanitization to prevent corruption. For example, imagine you have a percentage value that needs to be displayed on a user interface. To fit your UI design, you might want to ensure it always has a maximum of two decimal places. You could add extra code to trim the value accordingly:
percent = 3/2346 * 100 # 0.1278772378516624
percent = float(f'{percent:.2f}') # Corrected to use f-string for formatting
You could encapsulate this logic in a function or method:
def calc_percent(old) -> float:
return float(f'{old:.2f}') # Corrected to use f-string for formatting
But what if you could set a new value to it and access it just like a variable?
calc_percent = 15
print(calc_percent)
This can be achieved by using the @property
decorator on a class method. You will need two methods: one that Python uses to set the value (the setter) and another to get the value (the getter). We will discuss this in the "How" section.
When Should You Use @property
?
There are various instances where using a @property
is beneficial. Some common use cases include when you want to:
- Make a property read-only.
- Prevent the deletion of a property.
- Ensure all elements in a collection are homogeneous.
- Prevent your code from breaking because a property doesn't exist or a list is empty.
How to Use @property
To use the @property
decorator, you must work with methods inside a class.
Turning a Method into a Property
With the @property
decorator, you can make a method behave like an attribute.
class ProgressReport:
def __init__(self) -> None:
self._progress = 19
@property
def progress(self):
return self._progress
reporter = ProgressReport()
print(reporter.progress)
Output:
19
In the code above, progress
becomes a property. When you access reporter.progress
, it calls the progress
method and returns the value of self._progress
. The _progress
attribute is treated as a private property by convention. The leading underscore signals to other developers that it should not be set directly.
Printing the type of the property shows no trace that it is a method:
print(type(reporter.progress))
Output:
<class 'int'>
The property
decorator also allows you to define a "setter" method, which is called whenever you assign a value to the property. This enables you to control how values are set.
class ProgressReport:
def __init__(self) -> None:
self._progress = 19
@property
def progress(self):
return self._progress
@progress.setter
def progress(self, value):
if 0 <= value <= 100: # More Pythonic range check
self._progress = value
else:
raise ValueError("Progress must be a value between 0 and 100") # More specific exception
reporter = ProgressReport()
reporter.progress = 500
print(reporter.progress)
Output:
Traceback (most recent call last):
File "C:\Users\Ampofo\property.py", line 19, in <module>
reporter.progress = 500
File "C:\Users\Ampofo\property.py", line 16, in progress
raise ValueError("Progress must be a value between 0 and 100")
ValueError: Progress must be a value between 0 and 100
In this example, we ensure that the property can only be set if the new value is between 0 and 100.
Making a Property Read-Only
You can make a property read-only by defining a setter that always raises an exception, preventing its value from being changed directly. However, you can still modify the underlying private attribute within the class.
Here, we create a method, update_progress
, that we use to update the value of the property internally.
class ProgressReport:
def __init__(self) -> None:
self._progress = 19
@property
def progress(self):
return self._progress
@progress.setter
def progress(self, value):
raise AttributeError("progress is read-only") # More appropriate exception
def update_progress(self, value):
# You might add validation here as well
if 0 <= value <= 100:
self._progress = value
else:
raise ValueError("Progress must be a value between 0 and 100")
reporter = ProgressReport()
reporter.update_progress(96)
print(reporter.progress)
reporter.progress = 99
Output:
96
Traceback (most recent call last):
File "C:\Users\ampofo\property.py", line 20, in <module>
reporter.progress = 99
File "C:\Users\ampofo\property.py", line 12, in progress
raise AttributeError("progress is read-only")
AttributeError: progress is read-only
If you do not intend to allow any modifications, you can omit the setter and any internal update methods entirely.
Controlling Deletion
You can also control the deletion of a property using @progress.deleter
.
class ProgressReport:
def __init__(self) -> None:
self._progress = 19
@property
def progress(self):
return self._progress
@progress.setter
def progress(self, value):
raise AttributeError("progress is read-only")
@progress.deleter
def progress(self):
print('Performing housekeeping before deletion...')
del self._progress
reporter = ProgressReport()
del reporter.progress
print(reporter.progress)
Output:
Performing housekeeping before deletion...
Traceback (most recent call last):
File "C:\Users\ampofo\property.py", line 25, in <module>
print(reporter.progress)
File "C:\Users\ampofo\property.py", line 8, in progress
return self._progress
AttributeError: 'ProgressReport' object has no attribute '_progress'
As you can see, we performed some housekeeping tasks before deleting the property. If we wanted to prevent its deletion entirely, we could have simply not included the del self._progress
line in the deleter method.
When Not to Use @property
In instances where you want to control the behavior of a container like a list
or a dict
, the @property
decorator is not the right tool. But that shouldn't stop us! As developers, we find solutions to every problem. Python allows you to customize any type by subclassing it. We can subclass list
and dict
and add our modifications.
Customizing Lists
Normally, trying to access a non-existent index in a list results in an error:
list1 = [1, 2, 3]
print(list1[5])
Output:
Traceback (most recent call last):
File "C:\Users\ampofo\property.py", line 2, in <module>
print(list1[5])
IndexError: list index out of range
By subclassing list
, you can create your own list class that changes this behavior. This way, you can prevent your code from breaking when an invalid index is accessed and instead return None
.
class MyList(list):
def __getitem__(self, index):
try:
return super().__getitem__(index)
except IndexError:
return None
list2 = MyList([1, 2, 3])
print(list2[5])
Output:
None
In the code above, we subclass list
by creating a MyList
class. We override the __getitem__
method, which is called whenever an item of the list is accessed (e.g., names[0]
). Our new implementation returns None
rather than raising an IndexError
.
You can further customize other methods, like append
, to enforce specific rules.
class MyHomogeneousList(list):
def append(self, value):
if not isinstance(value, int): # isinstance is the preferred way to check types
print('This list only accepts integers.')
else:
super().append(value)
list2 = MyHomogeneousList([1, 2, 3])
list2.append('4')
list2.append(4)
print(list2)
Output:
This list only accepts integers.
[1, 2, 3, 4]
Customizing Dicts
If you want to modify the behavior of a dict
, your best option is also to subclass it and introduce your alterations.
class MyDict(dict):
def __setitem__(self, key, value) -> None:
print(f"Inserting {key}")
return super().__setitem__(key, value)
def __getitem__(self, key):
if key not in self:
print(f"Key: '{key}' does not exist.")
return None
return super().__getitem__(key)
def __delitem__(self, key) -> None:
if key == 'admin':
print("You can't delete the 'admin' entry.")
return
return super().__delitem__(key)
dict1 = MyDict({'admin': 'active'})
print(dict1['johndoe'])
dict1['janeaustin'] = 'inactive'
print(dict1)
del dict1['admin']
print(dict1) # Added to show the entry was not deleted
Output:
Key: johndoe doesn't exits
None
Inserting janeaustin
{'admin': 'active', 'janeaustin': 'inactive'}
You can't delete admin entry
Read more here
Buy my book on How not to become a broke software engineer here
Top comments (0)