Using Slots with Python

by John | December 02, 2020

 

In this article we will discuss the usage of slots in Python classes. As is this case with most things, Python makes using slots very easy! We will give some examples of using slots and discuss some of the benefits.

 

First in order to have something to compare a slotted class to let's create a reference class for comparison. The class below Point2D takes an x and y coordinate, in a similar way to we usually create classes. Take note of the fact that the __dict__ attribute returns the instance dictionary. Also note that we can assign variables dynamically, take p.a below notice that we didn't include an attribute 'a' in the constructor, but we can still assign it after instantiation (this is dynamic assignment). 

 

class Point2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point2D(10,20)

p.x
#10
p.y
# 20

p.x = 5
p.y =10
p.a = 1000 # assigning variable dynamically

p.__dict__ 
#{'x': 5, 'y': 10, 'a': 1000}

 

So hopefully there is nothing new there, let's rewrite the class above using slots and compare the two. 

 

class Point2DSlotted:
    __slots__ = ['x', 'y'] # ('x', 'y') also acceptable
    
    def __init__(self, x, y):
        self.x = x
        self.y = y
   

ps = Point2DSlotted(10,20)

ps.x
ps.y

ps.x = 5
ps.y = 10

try:
    ps.a = 100
except Exception as ex:
    print(ex)

# 'Point2DSlotted' object has no attribute 'a'

try:
    ps.__dict__
except Exception as ex:
    print(ex)

#'Point2DSlotted' object has no attribute '__dict__'

 

So there are some important things we can take away from this about slots.

1) Classes which have slots do not support dynamic assignment of new variables

2) Point 1 is due to the fact that slotted classes don't have an instance dictionary. 

3) We can still access and modify slotted variables using the dot notation i.e ps.x = some_int 

 

 

So why would we use slots in our classes?   

 

Memory Usage

Well, you may be thinking that including slots only restricts us unnecessarily, however, this restriction may be exactly what we want. Since we aren't creating an instance dictionary each time this results in memory savings. Let's check that out to see what the difference is. We will use the tracemalloc module to keep track of the size for creating n instance of each of the classes we defined above. The following helper function will count the memory usage. 

 

import tracemalloc

def testmemory(obj, n):
    objs = []
    for i in range(n):
        objs.append(obj(1,1))
    stats = tracemalloc.take_snapshot().statistics('lineno')
    print(stats[0].size, 'bytes', f'for {objs[0].__class__.__name__}', 
          f' for {n} objects')
  

 

We will run the testmemory function above for a range of n to see if there is any difference. 

 

def run_test(n_list, obj):
    tracemalloc.clear_traces()
    tracemalloc.start()
    for n in n_list:
        testmemory(obj, n)
    tracemalloc.stop()
    
    
nobjs = [1, 10, 100, 1000 , 10_000, 100_000, 1_000_000]
print('-------------------------------')         
run_test(nobjs, Point2D)      
print('-------------------------------') 
run_test(nobjs, Point2DSlotted)   
print('-------------------------------') 

 

In the output below we see that once we go over 10 objects the slotted class takes up significantly less memory than the non-slotted class. It seems that the memory saving is somewhere in the region of 70-75%, which is quite a bit! 

 

-------------------------------
88 bytes for Point2D  for 1 objects
1088 bytes for Point2D  for 10 objects
10192 bytes for Point2D  for 100 objects
147528 bytes for Point2D  for 1000 objects
1119568 bytes for Point2D  for 10000 objects
11199352 bytes for Point2D  for 100000 objects
111998416 bytes for Point2D  for 1000000 objects
-------------------------------
88 bytes for Point2DSlotted  for 1 objects
1088 bytes for Point2DSlotted  for 10 objects
6448 bytes for Point2DSlotted  for 100 objects
64960 bytes for Point2DSlotted  for 1000 objects
647560 bytes for Point2DSlotted  for 10000 objects
6424400 bytes for Point2DSlotted  for 100000 objects
64697400 bytes for Point2DSlotted  for 1000000 objects
-------------------------------

 

Performance

When using slots we can get slightly better performance for accessing the attributes from instances. The example below shows somewhere in the region of a 10% improvement when using the slotted version of the point class. Although not as a dramatic improvement as there was with memory usage, faster code is always nice! 

 

import time

def attr_access_time(obj):
    obj.x = 10
    obj.y = 10
    obj.x, obj.y
    del obj.x
    del obj.y


p = Point2D(5,20)

%timeit [attr_access_time(p) for _ in range(100_000)]
#29.7 ms ± 29.9 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

p2 = Point2DSlotted(5, 20)

%timeit [attr_access_time(p2) for _ in range(100_000)]
#26.8 ms ± 50 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

 

 

Flexibility

If we want to remove slots from our class it is as simple as removing the __slots__ = [var1, var2] from the class. So if we initially want to use slots but then later want to change back to regular attribute storage we can just remove the that one line of code.

Another pretty cool thing is that we can an instance dictionary into the __slots__ to allow dynamic assignment. See example below. Therefore we can have a mix of slotted and non-slotted attributes if we so wish. 

 

class Point2DSlotted:
    __slots__ = ['x', 'y', '__dict__']
    
    def __init__(self, x, y):
        self.x = x
        self.y = y
        

ps = Point2DSlotted(10,20)


ps.z = 100

ps.__dict__
# {'z': 100}

 

 

 

Slots with Inheritance

We can of course also include slots in inheritance chains, let's take a simple example to demonstrate. 

 

class Product:
    __slots__ = ['identifier', 'price', 'manufacturer']
    
    def __init__(self, identifier, price, manufacturer):
        self.identifier = identifier
        self.price = price
        self.manufacturer = manufacturer
    


class ElectronicsProduct(Product):
    # we don't need to declare slots here again 
    # but we can make brand a slot if we want
    def __init__(self, identifier, price, manufacturer, brand):
        super().__init__(identifier, price, manufacturer)
        self.brand = brand 
        


class FoodProduct(Product):
    
    __slots__ = ['expiry']
    
    def __init__(self, identifier, price, manufacturer, expiry):
        super().__init__(identifier, price, manufacturer)
        self.expiry = expiry
        
        
e = ElectronicsProduct(14001, 79.99, 'China', 'Sony')

print(e.__dict__)
#{'brand': 'Sony'}

f = FoodProduct(13002, 2.65, 'Brazil', '01/01/2022')
try: 
    f.__dict__
except Exception as ex:
    print(ex)
#'FoodProduct' object has no attribute '__dict__'

 

So nothing too surprising there, but note that if we add attributes to the child class the child class with have an instance dictionary as illustrated by the ElectronicsProduct class we created above, however, if we define another slot as we do in FoodProduct then the instance dictionary isn't created. 

 

 

Summary

- Slots can prevent users from dynamically setting attributes, due to the fact that there is no instance dictionary when using only slots in our classes.

- Slots can save significant memory and also a decent increase in performance. 

- Slots are flexible, they are easy to add or remove!

 

 


Join the discussion

Share this post with your friends!