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