Using slots in Python: limit dynamic attribute creation and improve speed

Using slots in Python: limit dynamic attribute creation and improve speed

Limiting attribute creation in Python can be useful to limit the number of mistakes users of our code can make
Aquiles Carattino 2021-03-21 classes object oriented dictionary memory slots

When we create classes, one of the biggest challenges is understanding how to handle dynamic attribute creation. Slots have the benefit of limiting attribute creation at runtime. In this article, we will explore how slots work, including a quick overview of how classes store attributes internally.

Dynamic attribute creation

One of the advantages of objects in Python is that they can store any number of attributes. Moreover, these attributes can be created dynamically when the program runs and not only when the class is defined. Let's start with a simple example to get the gist of it:

class Person:
    def __init__(self, name):
        self.name = name

The class Person can be used like this:

>>> me = Person('Aquiles')
>>> print(me.name)
'Aquiles'

The class Person creates an attribute name when we instantiate it, and we assign any value we want. In the example above, we used 'Aquiles'. We print the attribute name of the object me to show that it is behaving as expected. Python objects allow us to modify the attribute or to create new attributes:

>>> me.name = 'John'
>>> me.last_name = 'Doe'
>>> print(me.name)
'John'
>>> print(me.last_name)
'Doe'

First, we replace the value of name at runtime. Later, we create a new attribute, last_name that is not part of the original design of Person. This pattern can be both handy and dangerous. Imagine we have a complex object, for example, to control a camera. We define an attribute exposure_time when we develop the Camera class. If later on we make a mistake and use exp_time instead, there will be no warning, and the error can go unnoticed until it is too late:

>>> camera = Camera()
>>> camera.exposure_time = '5ms'
>>> camera.exp_time = '10ms'

Quick intro to slots

Limiting the creation of attributes at runtime can be a great advantage, and Python offers a straightforward syntax to achieving it:

class Person:
    __slots__ = 'name'
    def __init__(self, name):
        self.name = name

And this time, if we run the same coda we ran earlier, we will get a different outcome:

>>> me = Person('Aquiles')  
>>> print(me.name)  
'Aquiles'
>>> me.name = 'John'  
>>> print(me.name)  
'John'
>>> me.last_name = 'Doe'
Traceback (most recent call last):
  File "/Users/aquiles/Documents/Web/pftl_code/code/_slots/aa.py", line 11, in <module>
    me.last_name = 'Doe'
AttributeError: 'Person' object has no attribute 'last_name'

We could alter the name attribute at runtime, but last_name gave and AttributeError. If we modify the slots to include last_name, then the output would be different:

class Person:
    __slots__ = 'name', 'last_name'
    def __init__(self, name):
        self.name = name

And this time there will be no AttributeError:

>>> me = Person('Aquiles')
>>>me.last_name = 'Doe'
>>> print(me.last_name)
'Doe'

In the examples above, it is clear that we can limit the dynamic creation of attributes by using slots. However, it is essential to note that classes tend to get much more sophisticated and store many different attributes. Keeping __slots__ up to date can quickly become a hassle.

Two things are worth noting. One is that class attributes (i.e., attributes defined at a class level) automatically become read-only:

class Person:
    __slots__ = 'name', 'last_name'
    age = 35

    def __init__(self, name):
        self.name = name

And if we try to change the age after instantiating the class we get the following error:

>>> me = Person('Aquiles')
>>> print(me.age)
35
>>> me.age = 50
Traceback (most recent call last):
  File "/Users/aquiles/Documents/Web/pftl_code/code/_slots/aa.py", line 16, in <module>
    me.age = 50
AttributeError: 'Person' object attribute 'age' is read-only

We'll dive more into the reasons behind this behavior in the following section.

Where are attributes stored, the dict

Objects must remember the attributes to which they have access to retrieve them or give an error if they can't be found. In Python, this is done through a dictionary called __dict__. We can see how it works with the same example we used earlier:

class Person:
    birth_year = 1986
    def __init__(self, age, name):
        self.age = age
        self.name = name

    def print_name(self):
        print(self.name)

We can inspect the dictionary of both the Person class and of one of its instances:

>>> print(Person.__dict__)
{'__module__': '__main__', 'birth_year': 1986, '__init__': <function Person.__init__ at 0x7fd890028ee0>, 'print_name': <function Person.print_name at 0x7fd89046ca60>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}
>>> me = Person(35, 'Aquiles')
>>> print(me.__dict__)
{'age': 35, 'name': 'Aquiles'}

Note, first, that we are not using slots; it is a plain class. The dictionary of the class stores references to its attributes birth_year and the methods __init__ and print_name. It has no information about age or name because these attributes get created only after the class is instantiated.

On the other hand, the dictionary of the object me has both name and age but nothing else. If we would like to access the value stored in the dictionary, we can do the following:

me.__dict__['name']

If we add slots, however, things will change. Let's what happens to the dictionary in the following example:

class Person:
    __slots__ = 'age', 'name'
    birth_year = 1986

    def __init__(self, age, name):
        self.age = age
        self.name = name

    def print_name(self):
        print(self.name)

We kept the birth_year class attribute, but we added the slots for age and name. If we repeat what we did earlier:

>>> print(Person.__dict__)
{'__module__': '__main__', '__slots__': ('age', 'name'), 'birth_year': 1986, '__init__': <function Person.__init__ at 0x7ffdf0118ee0>, 'print_name': <function Person.print_name at 0x7ffde032ba60>, 'age': <member 'age' of 'Person' objects>, 'name': <member 'name' of 'Person' objects>, '__doc__': None}
>>> me = Person(35, 'Aquiles')
>>> print(me.__dict__)
Traceback (most recent call last):
[...]
AttributeError: 'Person' object has no attribute '__dict__'

The only difference between the earlier code and this one is that we added the __slots__ class attribute. We can notice that the class dictionary is different. Now there are both age': <member 'age' of 'Person' objects> and 'name': <member 'name' of 'Person' objects>. Moreover, if we explore the object me, we see that it has no __dict__. It is fair, therefore, to ask where are these attributes stored, and the answer is close by:

>>> print(me.__slots__)
('age', 'name')

To wrap up what we have just seen, when we use slots, we create objects with no __dict__ associated with them, but rather a __slots__ tuple. It is a significant distinction since tuples are immutable in Python. Moreover, the attributes that we defined in the slots show directly on the class dictionary, and this was not the case before.

We can explore a bit more this additions by looking at what their types are. For example, we can do the following:

>>> print(type(Person.__dict__['birth_year']))
<class 'int'>
>>> print(type(Person.__dict__['age']))
<class 'member_descriptor'>

And this is the quid of the question regarding slots. When we define them in a class, they are automatically created as descriptors. We have written a complete article about descriptors that may be worth checking out to go deeper with the understanding. It is the reason why we get the read-only attributes if we don't specify them in the slots.

Adding the dictionary to the slots

We saw that when we define slots for a class, its objects do not have a __dict__ to store their attributes. Therefore, we could ask ourselves what would happen if we add the dictionary to the list of slots. Let's try it out:

class Person:
__slots__ = 'age', 'name', '__dict__'

def __init__(self, age, name):
    self.age = age
    self.name = name
    self.birth_year = 1986

And we can now use it as we have always done:

>>> me = Person(35, 'Aquiles')
>>> print(me.__dict__)
{'birth_year': 1986}
>>> me.new_var = 10
>>> print(me.__dict__)
{'birth_year': 1986, 'new_var': 10}

We have created a hybrid class in which some attributes are slots, and therefore they do not appear in the dictionary. However, since the class itself has a dictionary, we can dynamically create new attributes. Whether this pattern is useful (or even correct) can be subject to discussion. We are not going to engage with it right now.

Slots and inheritance

The final important topic to cover about slots is how they behave with inheritance. Let's start creating a new class that inherits from Person and see what happens:

class Person:
__slots__ = 'age', 'name'
def __init__(self, age, name):
    self.age = age
    self.name = name

class Student(Person):
    def __init__(self, age, name, course):
        super(Student, self).__init__(age, name)
        self.course = course

In the example above, Studen inherits from Person but it does not define new slots. Therefore, it will behave like any other class:

>>> me = Student(35, 'Aquiles', 'Physics')    
>>> print(me.__dict__)
{'course': 'Physics'}
>>> me.new_var = 10
>>> print(me.__dict__)
{'course': 'Physics', 'new_var': 10}
>>> print(me.__slots__)
('age', 'name')

Student handles dynamic attribute creation, and we still have access to the slots defined in the parent class age and name. This means that if we inherit from a class, we should not worry whether it defined slots or not. The child class will behave precisely as we design it to behave.

The other possibility is to define slots in the child class but not in the parent class, like this:

class Person:
def __init__(self, age, name):
    self.age = age
    self.name = name

class Student(Person):
    __slots__ = 'course'
    def __init__(self, age, name, course):
        super(Student, self).__init__(age, name)
        self.course = course

And, surprisingly, the code above still works:

>>> me = Student(35, 'Aquiles', 'Physics')
>>> print(me.__dict__)
{'age': 35, 'name': 'Aquiles'}
>>> me.new_var = 10
>>> print(me.__dict__)
{'age': 35, 'name': 'Aquiles', 'new_var': 10}
>>> print(me.__slots__)
course

Therefore, if either the parent or the child defines the __dict__, then the objects will also have a dict and will be able to accept dynamically created attributes. The only way in which can leverage slots is if both parent and child define them:

class Person:
    __slots__ = 'age', 'name'
    def __init__(self, age, name):
        self.age = age
        self.name = name

class Student(Person):
    __slots__ = 'course'
    def __init__(self, age, name, course):
        super(Student, self).__init__(age, name)
        self.course = course

And then we'll see the behavior that would be expected from having slots:

>>> me = Student(35, 'Aquiles', 'Physics')
>>> print(me.__slots__)
course
>>> me.new_var = 10
Traceback (most recent call last):
[...]
AttributeError: 'Student' object has no attribute 'new_var'

Impact of slots on size and speed of code

One of the reasons behind adding slots to Python was to have faster attribute access. This was discussed in this blog post from 2010, summarizing all the improvements done to classes. Essentially, slots were added to overcome potential impacts in performance. By defining slots, the programs can have a faster lookup of the data stored. Whether an increase of lookup times of around 15% impacts the overall code will depend on how often we perform the task.

The other impact of slots is lower memory usage. Since slots prevent creating a dictionary and a weakref (which didn't discuss in this article), each object created will require less memory. For code creating few hundreds of objects, the impact may be negligible. Still, if we are creating millions of objects, the effect can be tremendous. For example, SQLAlchemy has a measurable effect on the use of slots.

Conclusions: Deciding when is worth using slots

Slots are a feature worth keeping in mind when developing code and, more importantly, when studying other's code. Using them for speed and memory improvements is likely to be far-fetched for most programs. It is essential to keep in mind that if a program was optimized using slots, we could easily ruin those optimizations by careless inheritance.

On the other hand, limiting the attribute creation at runtime can have a broad set of application contexts, even if it was not the original motivation for implementing slots in Python. For example, when developing code to control hardware, such as a camera, users may make mistakes such as using exposure instead of exposure_time to change a parameter. Due to dynamic attribute creation, there won't be any error shown on the screen.

Slots are not the only way of solving these issues, but they are straightforward to implement.

Article written by Aquiles Carattino
Join our newsletter!
If you liked the content, sign up to never miss an update.

Share your thoughts with us!

Support Us

If you like the content of this website, consider buying a copy of the book Python For The Lab