This tutorial will guide you through the use of Python's property()
function which is designed to allow you to work with managed attributes when building and maintaining classes. It is most often called as the @property
decorator in a class, and is used to convert a method within the class to a property or managed attribute.
Introduction to Managed Attributes
In order to understand the concept of a property or managed attribute, you first need to be familiar with the difference between an attribute and a method within a class.
Class Construction: Methods and Attributes
Classes are comprised of methods and attributes.
- A method is a function that is stored within the body of class.
- An attribute is a value stored within an instance of a class, and is accessed using Python's dot notation.
In the Person
class below there are two attributes: name
and age
, and a single method details
which when called will return a string of the person's name and age.
class Person:
def __init__(self,name,age):
self.name = name # attribute name
self.age = age # attribute age
def details(self): # method
return f"My name is {self.name} and I'm {self.age} years old."
If we now create a new Person
object it's easy to demonstrate the difference between a method and an attribute. Note that to execute the details
method we need to use a pair of brackets.
>>> newperson = Person("Bob",28)
>>> print(newperson.name) # print attribute name
"Bob"
>>> print(newperson.age) # print attribute age
28
>>> print(newperson.details()) # call details method
"My name is Bob and I'm 28 years old."
Managed Attributes
In our Person
class example above, we defined an instance of the Person
class which we named newperson
. We assigned the name "Bob" to the name attribute of newperson
. There is now nothing stopping us changing "Bob's" name.
>>> newperson.name = "Sandy"
>>> print(newperson.details())
"My name is Sandy and I'm 28 years old."
Bob has now become Sandy! We have exposed our name
and age
attributes directly to the user which means they can be accessed and changed (mutated) at will. There is also nothing stopping a user instantiating the class incorrectly - what happens if the user reverses name and age when creating a class instance?
>>> myperson = Person(34,"Rex")
>>> print(myperson.details())
"My name is 34 and I'm Rex years old."
A more robust approach when working with classes is to manage the attribute by housing it in a method. Then if you ever need to change the internal implementation of the attribute you can do so without breaking any previous iteration of the class. This is important, especially if you release your code into the wild where people may be working with different versions of your codebase.
- A Property or Managed Attribute is somewhere between an attribute and a method. It allows you to create a method within your class but then access it as you would any attribute.
In our myperson
example we inadvertantly mixed the name and age around. Had we used a managed attribute for both name
and age
then we could have added some data validation to reduce the likelyhood of incorrect inputs being made.
Python's Approach to Managing Attributes
Public and Non-Public Attributes
Python makes a disctinction between public and non-public attributes through the convention of a leading underscore prefixed to the attribute you wish to make non-public.
It is important to note that nothing stops a user from actually accessing these non-public attributes, the standard Python dot notation still works on these attributes. It is however, poor programming practice to do so and so should be avoided.
class Person:
def __init__(self,name,age):
self.name = name # attribute name
self.age = age # attribute age
self._email = f"{self.name.lower()}@company.com" # non-public attribute
>>> p1 = Person("John",23)
>>> print(p1.name)
"John"
>>> print(p1._email) # can still access the non-public attribute!
"john@company.com"
Getter and Setter Methods
In many programming languages you are encouraged not to expose your attributes directly to users for the reasons discussed in the two sections above. Rather the convention is to write getter
and setter
methods to retrieve and set the attributes respectively. Some languages, like C++ for example, allow you to define attributes as either public, private, or protected. A getter
or setter
method makes sense in this situation, as you can then define the attribute to be private, and use a method to retrieve or set a new value for the attribute.
We have already shown that all attributes in Python are accessible to the user, even if we make use of the leading underscore convention to indicate when an attribute is non-public. Therefore it does not really make sense to define non-public attributes in a Python class, and then write a method to set or access them, unless we plan on doing further work (validation, calculation etc) on them, or would like to potentially alter the implementation of the attribute in the future.
This doesn't mean that there isn't a place for getter
and setter
type functionality within the Python environment - only that we shouldn't needlessly add complexity to the class for simple attributes.
There are many instances in Python classes where we do need to make use of a managed attribute: managing permissions, creating computed attributes, data validation etc. Python gives us all this functionality through the property()
function which is typically used as a decorator within our class.
Introduction to the Property Function
The property()
function is used to transform vanilla attributes into managed attributes (properties). The function can be called directly within a class and consists of four arguments representing getter
, setter
and deleter
functionality along with a docstring
.
property(fget=None, fset=None, fdel=None, doc=None)
# fget - getter functionality to return an attribute value
# fset - setter functionality to set an attribute value
# fdel - deleter functionality to delete an attribute stored value
# doc - provision for a property docstring
Since property()
is a function (actually a class that looks and works like a function) we can call it within a class as you would any other function. We'll demonstrate this implementation first and then move on to the more common practice of using property
as a decorator.
Using Property as a Function
To demonstrate the use of property
let's extend our Person
class to include a last name and an email address.
- Last name will need
getter
andsetter
type functionality to allow us to set/modify and view the last name. - Email address in this example only requires
getter
type functionality as we'll define the email address internally as firstname.lastname@company.com.
Since we are going to set our last name through the property
function, the lastname
argument that is initialized when creating a new instance of the class will be made non-public. The methods within the class to generate an email address and to get and set the last name are also non-public. All non-public entities are named with a leading underscore as per Python convention.
Let's write out the updated class and then discuss how it was put together.
class Person:
def __init__(self,firstname,lastname,age):
self.firstname = firstname
self._lastname = lastname # non-public attribute
self.age = age
def _get_lastname(self):
return self._lastname
def _set_lastname(self,value):
print(f"Last name changed to {value}.")
self._lastname = value
lastname = property( # property that is called like an attribute
fget = _get_lastname,
fset = _set_lastname,
)
def _get_email(self):
return f"{self.firstname.lower()}.{self.lastname.lower()}@company.com"
email = property(fget=_get_email) # property that is called like an attribute
We have removed the name
attribute from the class and added a firstname
and lastname
attribute instead. Since this is just for demonstration purposes we will leave firstname
as a normal attribute and only turn lastname
into a property.
During the initialization of the class we create self._lastname
to indicate that this is a non-public attribute. Our managed attribute will be called lastname
which we define using the property
function.
Recall that property
takes four optional arguments:
property(fget=None, fset=None, fdel=None, doc=None)
In the case of lastname
we need to supply methods for fget
and fset
to access and set the last name respectively.
_get_lastname
will return the last name._set_lastname
will set the last name.
Finally we need to put the managed attribute lastname
together by calling property
and assigning our non-public methods to the applicable arguments.
lastname = property(
fget = _get_lastname,
fset = _set_lastname,
)
The methodology behind accessing the email address is much the same. Here we don't want to give a user of our class the ability to set their own email address - rather the address will be set as a combination of the first and last name. In reality of course we could run into some problems if this is how we handled email addresses as it is possible that the organization may employ two people with the same first and last name.
def _get_email(self):
return f"{self.firstname.lower()}.{self.lastname.lower()}@company.com"
email = property(fget=_get_email) # property that is called like an attribute
To see this in action let's create a person and check her email address before and after changing her last name.
>>> p2 = Person('Penny','Porpoise',45)
>>> print(p2.lastname)
'Porpoise'
>>> print(p2.email)
'penny.porpoise@company.com'
# now last name changes
>>> p2.lastname = 'Tortoise'
'Last name changed to Tortoise'
>>> print(p2.email)
'penny.tortoise@company.com'
It works! Let's clean up the implementation a bit and use the more popular decorator implementation of property
.
Using Property as a Decorator
In practice, writing out property
as a function as demonstrated in the section above is rarely used. A far neater implementation of the same functionality can be had by making use of the decorator @property
. Decorators were covered in far more detail in this article which is recommended reading before continuing here.
Let's rewrite the Person
class but this time use the @property
decorator rather than calling property like a function. The syntax is perhaps a little non-intuitive for first-time users, so before we apply it to our class, let's first show how it's written and implemented.
@property # this is equivalent to fget
def managed_attribute_name(self):
"""Here is the docstring i.e 'doc' argument """
return self._attribute
@managed_attribute_name.setter # equivalent to fset
def managed_attribute_name(self,value):
self._attribute = value
@managed_attribute_name.deleter # equivalent to fdel
def managed_attribute_name(self):
del self._attribute
managed_attribute_name
is the name of the property: in our example this will belastname
.- Adding the
@property
decorator above thedef managed_attribute_name()
appliesfget
to thatdef
. - Adding a
docstring
to thefget
function sets thedocstring
for the entire property/managed attribute. - We then use the name of the managed attribute as a part of our
setter
anddel
decorator, making sure that thedef managed_attribute_name()
is correctly named to apply thefset
anddel
methods respectively.
Let's apply this to our Person class and see it in action.
class Person:
def __init__(self,firstname,lastname,age):
self.firstname = firstname
self._lastname = lastname
self.age = age
@property
def lastname(self):
"""Last name managed attribute."""
return self._lastname
@lastname.setter
def lastname(self,value):
self._lastname = value
@lastname.deleter
def lastname(self):
print(f'Last name: {self.lastname} deleted.')
del self._lastname
@property
def email(self):
try:
return f"{self.firstname.lower()}.{self.lastname.lower()}@company.com"
except:
return f"{self.firstname.lower()}@company.com"
Since we've added the ability to delete the last name, I have made a small modification to the email
property so as not to throw out an error when trying to create the email address without a last name. If there is no last name present in the instance of the Person
object then the email address will simply revert to firstname@company.com. If you want to learn more about handling exceptions then head over to our guide on errors and exception handling.
Let's test out the functionality of our class.
>>> p3 = Person('Penny','Porpoise',45)
>>> print(p3.email)
'penny.porpoise@company.com'
>>> p3.lastname = "Tortoise"
>>> print(p3.email)
'penny.tortoise@company.com'
>>> del p3.lastname
'Last name: Tortoise deleted'
>>> print(p3.email)
'penny@company.com'
If you copy this class into your own workspace and run through the example, you'll see that it is working as we'd expect. By making use of the property
decorator we are able to treat the email
and lastname
as attrbutes even though there have methods underlying their implementation. Updating or removing the last name also updates the email address as expected.
Let's now take what we have learnt and apply it to managing permissions on our attributes.
Using Property to Manage Permissions
The property
decorator provides us with an easy means to control read and write permissions on our object attributes. We'll go through an example on how to build in these permissions now.
Read-Only
The fget
method is analogous to a getter
method in other languages. If we build a class with an attribute that we'd like to set at the object instantiation and thereafter make it read-only, we can use fget
to do so. Remember that we call fget
using the @property
decorator as shown in the example below.
Our example here is a simple class that stores the two-dimensional coordinates of a point. Once the coordinates of a point are defined we do not want to be able to modify that point.
class Coordinate:
def __init__(self,x,y):
self._x = x
self._y = y
@property
def x(self):
return self._x
@property
def y(self):
return self._y
>>> mypoint = Coordinate(10,13)
>>> print(f"The coordinates of the point (x,y) are ({mypoint.x},{mypoint.y}).")
"The coordinates of the point (x,y) are (10,13)."
Let's try and modify the x-coordinate.
# now try change the x-coordinate to 3
# This won't work as the setter method not defined.
>> mypoint.x = 3
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\1067078135.py in <module>
4 # now try change x to 12
5 # This won't work as the setter method not defined.
----> 6 mypoint.x = 12
AttributeError: can't set attribute
Write-Only
To make a variable write-only we need to make use of a setter
function (fset
in the property
function) while disallowing access to fget
. We can do so by raising an attribution error when fget
is accessed. You may be wondering when would we ever need to make an attribute write-only?
One good example is if you are building in password functionality into an application. The user needs to create a password in a human readable format, but this should never be saved in plain-text on the server. The password input would be hashed and stored as a hash value which is not human readable.
class User:
def __init__(self, name, password):
self.name = name
self.password = password
@property
def password(self):
raise AttributeError("Password is write-only")
@password.setter
def password(self, plaintext):
# here you would hash the password and store
# the hash value as a non-public attribute.
If you try to access the password
property you will raise an AttributeError
.
aw = User('aw','swpass')
print(aw.password)
---------------------------------------------------------------------------
AttributeError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\1175680226.py in <module>
1 aw = User('aw','swpass')
----> 2 aw.password
~\AppData\Local\Temp\ipykernel_6080\1883024096.py in password(self)
12 @property
13 def password(self):
---> 14 raise AttributeError("Password is write-only")
15
16 @password.setter
AttributeError: Password is write-only
Read & Write
We have already created a class where we specified read-write attributes. Take another look at the section Using Property as a Decorator to familiarise yourself again with the fget
and fset
arguments of the property
function.
Additional Property Examples
We've shown how to use @property
to manage read and write permissions and discussed the function's importance when it come to creating managed attributes rather than class attributes. Let's look at a few more applications (by no means the only applications) where making use of properties in your classes are a good idea.
Computed Attributes
A computed attribute is a managed attribute that is calculated when the attribute is called. A good example is the area of a circle which is calculated based on that circle's radius. Since computed attributes are calculated only when called, the area value will use the current radius and so will always provide an up-to-date result. This however can be a bit of a double-edge sword, as calculating the value on demand can become computationally expensive if called often and may slow down the program significantly. In these instances it would be better to create a cached attribute and only update the value when the cached value is outdated.
Let's first implement the class without caching.
import math
class Circle:
def __init__(self,radius):
self.radius = radius
@property
def area(self):
return round(math.pi*self.radius**2,2)
@property
def perimeter(self):
return round(2*math.pi*self.radius,2)
>>> circle = Circle(10)
>>> print(circle.area)
314.16
>>> print(circle.perimeter)
62.83
Caching
Now we'll extend our Circle
class to include caching on the area
attribute.
If you have created a computed attribute that you use frequently it becomes computationally expensive to perform that same calculation each time the attribute is called if the inputs to the calculation remains unchanged. In this instance, it is preferable to cache the result in a non-public attribute and reuse it later. When the input values into the computed attribute change then the attribute should be automatically recalculated (when called) so as to be up-to-date.
I have included a counter in this caching example in order to show that the caching is working correctly. Each time the radius is set or reset, the _area
attribute is automatically set to None
. This indicates that the area must be calculated when called. If the area is not None
then an up-to-date value for area exists and can be used without recalculation. The radius
attribute must be made public so that the @radius.setter
functionality is run when the class instance is initialised.
import math
class Circle:
def __init__(self,radius):
self.radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._area = None # reset area when radius changes
self._count = 0 # reset counter
self._radius = value
@property
def area(self):
if self._area == None:
self._area = round(math.pi*self.radius**2,2)
print(f"Area: {self._area} cached for the 1st time.")
else:
self._count +=1 # increment the counter
print(f"Number of times cache accessed: {self._count} time(s).")
return self._area
@area.setter
def area(self):
self._area = self.area
To demonstrate our cache in action let's create a circle of radius 10 and call area
three times on it before increasing the area to 20, and calling it again a few times.
circle = Circle(10)
print(circle.area)
print(circle.area)
print(circle.area)
circle = Circle(20)
print(circle.area)
print(circle.area)
print(circle.area)
Output:
'Area: 314.16 cached for the 1st time.'
314.16
'Number of times cache accessed: 1 time(s).'
314.16
'Number of times cache accessed: 2 time(s).'
314.16
'Area: 1256.64 cached for the 1st time.'
1256.64
'Number of times cache accessed: 1 time(s).'
1256.64
'Number of times cache accessed: 2 time(s).'
1256.64
Validation of Input Values
Property methods are very useful when data validation is required on an input argument. The trick is to force the argument to be run through the setter
method on initialization to ensure that the validation is performed. To to this you need to initialise the attribute as public rather than non-public during initialization and then perform the validation on the setter
method.
This is easier to follow with an example. Let's return again to our Person
class and this time add some data validation on the age
attribute. In this case we want the age to be both an integer and positive.
class Person:
def __init__(self,firstname,lastname,age):
self.firstname = firstname
self.lastname = lastname
self.age = age
@property
def age(self):
return self._age
@age.setter
def age(self,value):
try:
self._age = int(value)
if not self._age > 0:
raise Exception('Age must be a positive number!')
except ValueError:
raise ValueError("'Age' is not a valid input") from None
Let's test our class with two examples.
First we incorrectly add a string to the age
attribute.
p4 = Person('Chip','Chop','chow')
print(p4.age)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\738327868.py in <module>
18 raise ValueError("'Age' is not a valid input") from None
19
---> 20 p4 = Person('Chip','Chop','chow')
21 print(p4.firstname)
22 print(p4.age)
~\AppData\Local\Temp\ipykernel_6080\738327868.py in __init__(self, firstname, lastname, age)
3 self.firstname = firstname
4 self.lastname = lastname
----> 5 self.age = age
6
7 @property
~\AppData\Local\Temp\ipykernel_6080\738327868.py in age(self, value)
16 raise Exception('Age must be a positive number!')
17 except ValueError:
---> 18 raise ValueError("'Age' is not a valid input") from None
19
20 p4 = Person('Chip','Chop','chow')
ValueError: 'Age' is not a valid input
It's worth remembering that if you had in fact added an integer number as a string then a valid result would have been returned as the int(value)
line in the setter would have been able to transform the str
to int
.
If we try to input a negative age then our own exception will be raised.
p4 = Person('Chip','Chop',-26)
print(p4.age)
---------------------------------------------------------------------------
Exception Traceback (most recent call last)
~\AppData\Local\Temp\ipykernel_6080\1477774832.py in <module>
18 raise ValueError("'Age' is not a valid input") from None
19
---> 20 p4 = Person('Chip','Chop',-26)
21 print(p4.firstname)
22 print(p4.age)
~\AppData\Local\Temp\ipykernel_6080\1477774832.py in __init__(self, firstname, lastname, age)
3 self.firstname = firstname
4 self.lastname = lastname
----> 5 self.age = age
6
7 @property
~\AppData\Local\Temp\ipykernel_6080\1477774832.py in age(self, value)
14 self._age = int(value)
15 if not self._age > 0:
---> 16 raise Exception('Age must be a positive number!')
17 except ValueError:
18 raise ValueError("'Age' is not a valid input") from None
Exception: Age must be a positive number!
Attribute validation is a powerful tool when creating classes and one that is made easy through the use of property
.
Wrapping Up
This has been a long tutorial and we've covered a lot of ground. Let's summarise what we've learnt as we wrap up this guide to the property
function.
- A property or a managed attribute allows you to create methods in your classes that act like attributes. This is very useful as it means that you can modify the underlying implementation of the managed attribute without actually changing the public API of the class.
- Python provides us with the
property
function to get, set, and delete managed attributes. You can also set adocstring
fromproperty
. - The recommended way of using
property
is through the decorator@property
. Add the decorator above your managed attributedef
to access theproperty
functionality. - There are many instances when you should consider using managed attributes in your classes. We just looked at a few of the common cases:
- We looked at how read and write permissions can be managed using
@property
. - We discussd computed attributes and caching.
- We finished off with an example of how to perform field validation using
@property
.
- We looked at how read and write permissions can be managed using
Thanks for going through this tutorial and sticking around to the end. Properties are a powerful way to add additional functionality to your classes and it is well worth taking the time to familiarize yourself with how they work and when to use them.