Slots

In a previous post I joked about how misleading dynamic assignments of attributes can be when using descriptors like property. Something that’s a useful way to ensure you don’t (accidentally or otherwise) create attributes on the fly is by using __slots__.

When you do so you’ll override the automatice creation of the class __dict__ and __weakref__ and replace it with __slots__ which is more memory efficient and also less dynamic.

For example - Say we want an abstract sort of class which we only inherit and don’t actually instantiate. You can create a class like Animal below doing something trivial (e.g. How about we override things like __add__ in order to enable adding instances of classes together?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Animal():
    __slots__ = ()

    def __init__(self, animal: set = {}, animal_count: int = None) -> None:
        self.animal = set(animal)
        self.animal_count = animal_count
    
    def __add__(self, n) -> None:
        self.animal.update(n.animal)
        n.animal = self.animal

        self.animal_count += n.animal_count
        n.animal_count = self.animal_count

    def __sub__(self, n) -> None:
        self.animal.remove(n.animal)
        n.animal = self.animal

        self.animal_count -= n.animal_count
        n.animal_count = self.animal_count

    def __call__(self) -> None:
        print(f"{self.animal=}, {self.animal_count=}")

If I try to create an instance of this class I’ll get an error - Because I’ve set instance attributes within the __init__ even though none are in the slots, and slots prevents creation of attributes that aren’t specified.

1
x = Animal()
1
2
3
4
5
6
7
8
Traceback (most recent call last):
  File "slots3.py", line 36, in <module>
    x = Animal()
        ^^^^^^^^
  File "slots3.py", line 6, in __init__
    self.animal = set(animal)
    ^^^^^^^^^^^
AttributeError: 'Animal' object has no attribute 'animal'

On the other hand I can now inherit this class - and I can either call slots - or not.

1
2
3
4
5
6
7
8
9
class A(Animal):
    __slots__ = "animal", "animal_count"

    def __init__(self):
        super().__init__("a", 1)
    
class B(Animal):
    def __init__(self):
        super().__init__("b", 1)

In example A we’ve specified the slots - Note that if you specify one, you’ll get an error if you don’t specify the rest of the attributes used in the inherited class.

Now if we call A() we’ll see the output of __call__ and we can see that slots holds what we expect:

1
2
3
a = A()
a()
print(a.__slots__)
1
2
self.animal={'a'}, self.animal_count=1
('animal', 'animal_count')

Upon trying a.__dict__ we’ll get an AttributeError: 'A' object has no attribute '__dict__'. Did you mean: '__dir__'?. Equally, upon trying a dynamic assignment e.g.

1
a.foo = "foo"

We’ll get

1
AttributeError: 'A' object has no attribute 'foo'

B on the other hand has no constraints adding attributes and has the expected class dict as per below

1
2
3
4
b = B()
b()
b.foo = "foo"
print(b.__dict__)

Outputting

1
2
self.animal={'b'}, self.animal_count=1
{'animal': {'b'}, 'animal_count': 1, 'foo': 'foo'}

Now, why might we use this?

There are a few benefits and I admit I find the insurance of knowing which attributes exist to be helpful! But the main one is actually time and memory.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sys
import timeit

def check(obj):
    obj.animal = "snake"
    obj.animal
    del obj.animal

print("size a")
print(sys.getsizeof(a))
print("size b")
print(sys.getsizeof(b))
print("time a")
print(min(timeit.repeat('check(a)', globals=globals())))
print("time b")
print(min(timeit.repeat('check(b)', globals=globals())))

You see that a is both a bit smaller and a bit faster! Which doesn’t matter too much in the grand scale of my example, but can add up

1
2
3
4
5
6
7
8
size a
48
size b
56
time a
0.036912125011440367
time b
0.04000041700783186