How Control Object Attributes In Python
Private methods and attributes in Python
Unlike Java, which enforces access restrictions on methods and attributes, Python takes the view that we are all adults and should be allowed to use the code as we see fit. Nevertheless, the language provides a few facilities to indicate which methods and attributes are public and which are private, and some ways to dissuade people from accessing and using private things.
Normal attribute access
Let’s take a look at how normal attribute access works.
class Foo(object):
def __init__(self):
self.bar = 1
foo = Foo()
foo.bar
# Output: 1
foo.bar = 2
foo.bar
# Output: 2
foo.__dict__
# Output: {'bar': 2}
As we can see, there are no restrictions on accessing or assigning to the bar attribute of our instance. The attribute is also included in __dict__
.
Making it private
Now let’s make bar “private”. We can do that by adding two leading underscores to the name.
class Foo(object):
def __init__(self):
self.__bar = 1
foo = Foo()
foo.__bar
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Foo' object has no attribute '__bar'
What has happened here is that the name of __bar
has been changed by the interpreter so that it is not easily accessible outside the class. If we take a look at __dict__
again, we will see that it has been renamed to _Foo__bar
, and can be accessed and assigned using that name. This is called name mangling. Attributes whose names start with two underscores are renamed in the format _classname__attrname
.
>>> foo.__dict__
{'_Foo__bar': 1}
>>> foo._Foo__bar
1
>>> foo._Foo__bar = 2
>>> foo._Foo__bar
2
A word about single underscores
So far we have dealt with names that start with two underscores, but it’s quite common to see names that start with a single underscore. They are not private in the same sense. Name mangling does not occur. A single underscore is mostly just a weak indication that the thing in question is meant to be used internally and is not part of the public interface of the class, module, etc., that it is inside.
In classes, attributes and methods that start with a single underscore are treated normally.
Getters and setters
After learning about private attributes, sometimes new Python programmers get the idea that they can use getters and setters to manage accessing and assigning attributes
Data integrity can be secured by controlling attribute values and their access. When a class is created, we may want to ensure all future objects have attributes of the correct data type, and/or of sensible/appropriate values. We may also want to ensure once attributes are set their values cannot be changed or deleted. To achieve this, read-only and deletion proof attributes must be created.
An Employee class
, that accepts name, age and length of service as arguments, may want to ensure that the name is a string, and the age and length of service are integers before they are set as attributes in the objects created. This class may also want to ensure attributes like names cannot be altered or deleted.
Attribute validation can help ensure valid data entry, even in cases where the user of a class may input less than ideal user-information, for example, a name in lower case. This tutorial article will explore 5 different ways to ensure attribute validation, namely via magic/dunder methods and decorators. Attribute integrity is important when the attributes themselves are used in methods defined in the class. This is why, before an attribute gets set in an object, its value should be checked.
dunder __setattr__
and __delattr__
The magic or dunder __setattr__ and __delattr__ methods in Python represent one way the programmer can exert control over attributes. To illustrate, let's initialise a class called Employees. This class will take 3 arguments and set them as attributes in the objects.
To showcase how we can exert control over attributes, let's establish the behaviour we would like to implement for our Employees object.
-
The
_name
attribute value, should be capitalised, regardless of whether the user chooses to input the name attribute in capitalised format or not. -
The
_name
attribute should only be allowed to be set once. The name attribute will become read-only once it is set. -
The
_age
and_service_length
attributes should be integers. If they are not a TypeError exception will be raised. -
The
_name
attribute cannot be deleted
__setattr__
The dunder __setattr__
method takes the object and the key and the value we would like to set in the object as arguments. We first check to see if the object with the attribute '_name' already has the attribute, name. If it does, we will raise an AttributeError because we do not want the name attribute to be re-set. In the following elif statement, if the object does not have a name attribute we will set it, and capitalise it using the string title method. We use a standard python hasattr
function.
def __setattr__(self, key, value):
if key == '_name' and hasattr(self, '_name'):
raise AttributeError
elif key == '_name':
self.__dict__[key] = value.title()
In the first if statement, we first check to see if the attribute, ‘_name’ already has been set. If it already has been set in the __init__
method, the user will not be able to re-set it, and it will be then become a read-only attribute.
The logic in the first if and elif blocks of the setattr method solves points 1 and 2 above. Now, let's check the data type of the age and service length attributes for point number 3.
For both the _age
and the _service_length
attributes, we check they are of type int, using the built-in Python isinstance
function. If they are of any type besides int, a TypeError exception will be raised.
.... # section of setattr method
if key == '_age' or key == '_service_length':
if not isinstance(value, int):
raise TypeError()
self.__dict__[key] = value
# section of setattr method ....
__delattr__
Finally, let's address point 4, and implement code to prevent the deletion of the name attribute. For the sake of argument, let's assume, that once a name has been set, it cannot be deletion and exists permanently.
As such, when we attempt to del the name attribute from our object, an AttributeError exception will be raised, informing the user, that the attribute cannot be deleted. Under the hood, the del keyword calls the __delattr__
method. Since we have defined it in our class, it will call our custom version of __delattr__
. We will however, still permit behaviour which enables the deletion of any other attribute.
To note, in the code snippets shown, we set and delete the attributes as shown below to avoid recursive errors.
# How to set and delete attributes
# Do this:
self.__dict__[key] = value
del self.__dict__[key]
# Not this:
# this will cause a recursive error, where the call below,
# will attempt to call the method we are already in,
# e.g. setattr, and a recurive cycle will begin
self[key] = value
Full Example Code for dunder functions
class Employees(object):
def __init__(self, name, age, service_length):
self._name = name
self._age = age
self._service_length = service_length
def __setattr__(self, key, value):
if key == '_name' and hasattr(self, '_name'):
raise AttributeError('This attribute can only be set once in the init method')
elif key == '_name':
self.__dict__[key] = value.title()
else:
if key == '_age' or key == '_service_length':
if not isinstance(value, int):
raise TypeError()
self.__dict__[key] = value
def __delattr__(self, key):
if key == '_name':
raise AttributeError('This attribute cannot be deleted')
else:
del self.__dict__[key]
To test the custom behaviour we have implemented, let's create an instance of the Employees class and check to see if the name attribute can be set, cannot be re-set, and cannot be deleted. In addition, we can check the type of the age and service length attributes.
Re-setting the name attribute failed and informs the user of why the program terminated.
class Employees(object):
def __init__(self, name, age, service_length):
self._name = name
self._age = age
self._service_length = service_length
def __setattr__(self, key, value):
if key == '_name' and hasattr(self, '_name'):
raise AttributeError('This attribute can only be set once in the init method')
elif key == '_name':
self.__dict__[key] = value.title()
else:
if key == '_age' or key == '_service_length':
if not isinstance(value, int):
raise TypeError()
self.__dict__[key] = value
def __delattr__(self, key):
if key == '_name':
raise AttributeError('This attribute cannot be deleted')
else:
del self.__dict__[key]
>>> emp = Employees('john', 29, 5)
>>> emp._name = 'new_name'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 8, in __setattr__
AttributeError: This attribute can only be set once in the init method
When we loop through the dictionary of attributes, and print their type, we can see the age and length of service are of type int.
>>> print(emp.__dict__)
{'_name': 'John', '_age': 29, '_service_length': 5}
>>>
>>> for type_check in emp.__dict__:
... print(type(emp.__dict__[type_check]))
...
<class 'str'>
<class 'int'>
<class 'int'>
Finally, when we try to delete the attribute name, an AttributeError exception is raised, with the string message informing the user that the attribute cannot be deleted.
>>> del emp._name
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 18, in __delattr__
AttributeError: This attribute cannot be deleted
Alternative ways to control attributes in an object
The following examples represent alternative ways to control attributes in Python. As a proviso, I do not attest to them being the best way to control attributes, rather alternatives means that can add to your arsenal of tools to use in Python.
The section to follow will introduce an example-led guide to using decorators to manage attributes.
Using @staticmethod Decorator
For continuity, the same Employees class will be initialised in the proceeding examples. Here, the staticmethod decorator can be used to check the attribute value in an object. Specifically, here, we want both the age and service_length attribute values to be of type int.
To do this, we can define a data_type_check method in our class, and decorate it with the staticmethod decorator. When the attributes are being set in the init constructor, we call the method data_type_check on our object, and pass in either age or service_length as arguments. If age, and service length are of type int, they will be returned and set as attributes in our object. If they are not of type int, a type error exception will be raised.
One thing that is really nice about using the staticmethod decorator, is its re-usability. If, for example, we added an annual bonus argument to our init method, we could simply call the staticmethod decorator again and pass bonus as an argument.
class Employees(object):
def __init__(self, name, age, service_length):
self._name = name
self._age = self.data_type_check(age)
self._service_length = self.data_type_check(service_length)
@staticmethod
def data_type_check(value):
if not isinstance(value, int):
raise TypeError('Invalid Type Entered, Int type Expected')
return value
emp = Employees('Stephen', 29, 5)
print(emp.__dict__)
for attr in emp.__dict__:
print(type(emp.__dict__[attr]))
######################################
# Output
# {'_name': 'Stephen', '_age': 29, '_service_length': 5}
# <class 'str'>
# <class 'int'>
# <class 'int'>
Using a Custom Decorator
Another way to control the value of attributes is via a custom decorator. In keeping with our theme, let's create the same class, but this time decorate the init constructor method, with a function called attr_check.
When we create an instance of our Employee class, the init method will be called automatically. As the attr_check function decorates the init method, this function will be called. attr_check will take the init method as an argument, and return inner. When we then call inner with our object ref, age and service, we can perform type checking on both age and service. If age and service are of type int, we will return the original init method with the object, name, age and service and arguments.
This implementation is a nice way to control attribute values in Python because we can choose what goes into the body of inner. In this case, we did not perform any type checking on the name or any range allowance on the age, but we could simply add these checks in, by expanding the code defined within the inner function.
def attr_check(method):
def inner(ref, name, age, service):
for attr in [age, service]:
if not isinstance(attr, int):
raise TypeError('age and service must be of type int')
return method(ref, name, age, service)
return inner
class Employees(object):
@attr_check
def __init__(self, name, age, service_length):
self._name = name
self._age = age
self._service_length = service_length
emp1 = Employees('stephen', 29, 5)
print(emp1.__dict__)
for attr in emp1.__dict__:
print(type(emp1.__dict__[attr]))
################################
# Output
# {'_name': 'stephen', '_age': 29, '_service_length': 5}
# <class 'str'>
# <class 'int'>
# <class 'int'>
Using the @property/setter/deleter decorators
Yet another way to control attribute values in Python is through the use of the property decorator and its corresponding setter and deleter methods.
Let's create our Employees class once more. When the user attempts to access the name attribute through the object.attribute syntax, the name method decorated with @property will be called, and the name will be returned capitalised.
This time if the user would like to change the name attribute, let's ensure whatever string they input will be capitalised. We can achieve this by defining a simple name method alongside corresponding setter and deleter methods.
We define the name method and decorate it with @name.setter. When the user wants to set a new name, using the object.attribute syntax, the new name set in the object will now be capitalised.
Note, the underlying name of the name attribute ‘_name’, is different to the name of the method to avoid recursive errors. We could not have self.name = new_name.title() in the body of our setter method.
class Employees(object):
def __init__(self, name, age, service_length):
self.name = name
self.age = age
self.service_length = service_length
@property
def name(self):
return self._name.title()
@name.setter
def name(self, new_name):
self._name = new_name.title()
@name.deleter
def name(self):
del self._name
emp1 = Employees('stephen', 29, 5)
print(emp1.name)
emp1.name = 'matthew'
print(emp1.name)
print(emp1.__dict__)
####################################
# Output
# Stephen
# Matthew
# {'_name': 'Matthew', '_age': 29, '_service_length': 5}
Conditional checking in the init Constructor
To finish this article on attribute control, the simplest way to implement attribute integrity, may in fact be to include conditional checking in the init constructor of our class itself. Here, we allow the age of the employee to be greater than or equal to 18, and less than or equal to 100. If the condition is satisfied, we can set the age supplied as an argument in the init method as an attribute value in our object. When an instance is created that fails to satisfy this condition, an AttributeError exception is raised, with a useful string message informing the user of the conditions required.
class Employees(object):
def __init__(self, name, age, service_length):
self._name = name
if 18 <= age <= 100:
self._age = age
else:
raise AttributeError('The age range must be between 18 and 100')
self._service_length = service_length
emp1 = Employees('stephen', 29, 5)
emp2 = Employees('Matthew', 2, 5)
#########################################
# Output
# AttributeError: The age range must be between 18 and 100
Summary
Thanks for reading, and I hoped you liked the different ways to control attribute access and their values. While I have mainly focused on data-type checking and range allowance, any conditional imaginable can be imposed on an attribute value. An email address may undergo validation using regex before being set, to comply with data integrity.
I have deliberately used basic examples here to help convey how the multiple ways to control attribute values and access work. Furthermore, Descriptors are another way to manage attributes in Python, and I will hopefully write a tutorial piece on how to use them.