attrs II: Slots

This is the second post in my series on the inner workings of attrs. The series starts here.

Out of the box, attrs can customize your classes in two different, orthogonal ways - make your class a slot class (instead of a normal Python class, which for simplicity's sake we'll refer to as a dict class), and make your class frozen. These features can be combined; you can have a frozen slot class.

You can make your class a slot and/or frozen class by passing the right arguments to attr.s. (Note that all code examples are Python 3.)

@attr.s(slots=True, frozen=True)
class C:
    a = attr.ib()

I use slot classes by default, nowadays. Let's explore what slot classes are for, and exactly what is going on in the background to enable this feature. Frozen classes will be examined in a later installment.

Slots in Theory

Slot classes are a relatively-obscure Python feature that's been available for ages (the docs claim since 2.2). This is not an attrs-specific thing; attrs just exposes the feature in a much more user-friendly way.

Let's forget attrs for a second. Assume a very simple, ordinary, dict (i.e. not slot) pure Python class, and an instance of this class.

class C:
    def __init__(self, x):
        self.x = xlook

>>> i = C(1)

C is the class, i is an instance of C. C is backed by a dictionary; you can get a read-only view of it at C.__dict__. This dictionary contains class-specific objects, such as methods defined on the class (C.__dict__ has a reference to the __init__ we just defined, for example).

i also has a backing dictionary, available at i.__dict__. i.__dict__ isn't a read-only wrapper, it's just an ordinary Python dict.

>>> i.__dict__
{'x': 1}

This dictionary is where the instance state is stored.

Probably the vast majority of Python classes are __dict__-based classes and work like this.

We can create a different kind of class, though. Here's the same class, but as a slot class instead.

class CSlots:
    __slots__ = ('x',)
    
    def __init__(self, x):
        self.x = x

>>> i_slots = CSlots(1)

The only difference right here is that we've had to enumerate exactly which instance-level attributes our class has in the __slots__ tuple.

The class object, CSlots, is still backed by a dictionary. The instance, however, now has no __dict__.

>>> i_slots.__dict__
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'CSlots' object has no attribute '__dict__'

So where is the instance state?

The instance state is hidden on the C level, in a special kind of array. When the class is created, Python generates special descriptors that know how to access this hidden array, and puts them in the CSlots class dictionary.

When you access or change the instance state, like i_slots.x = 3, this happens through these special descriptors (for the attribute x, the CSlots.x descriptor in particular).

Slots in Practice

Alright, the instance state mechanism is different, but what are the exact pros and cons of using slot classes? Why should you care?

The Python docs are quick to mention slot classes generally use up less memory, because dictionaries are less efficient memory-wise than fixed-length arrays. This is true:

>>> import sys
>>> sys.getsizeof(i.__dict__)
112
>>> sys.getsizeof(i_slots)
48

Even with the more efficient dictionaries in 3.6, slot class instances use up less memory. (On CPythons earlier than 3.6, the situation is much worse for the dict classes.) Exactly how much more efficient depends on the amount of instance attributes.

Here's a tiny program you can run yourself to demonstrate the memory usage:

import attr
import resource


def rss():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss


@attr.s(slots=True)  # Change to False to test dict classes.
class C:
    a = attr.ib()


container = []

print(f'Start: RSS {rss()} bytes.')
for batch in range(20):
    container.extend([C(batch+i) for i in range(100000)])
    print(f'{len(container):>7} elements: RSS {rss():>7} bytes.')

On my current computer, a CPython 3.6 process containing 2 million instances of C takes up around 425kB of memory when using ordinary classes, and around 187kB when using slot classes.

Slot classes are a tiny bit faster than ordinary classes. Let's play around with CPython 3.6, attrs and the excellent perf library (pip install perf).

$ pyperf timeit -g --rigorous -s "import attr; C=attr.make_class('C', ['x'])" "C(1)"
.........................................
414 ns: 34 ###############################################################################
420 ns: 34 ###############################################################################
426 ns: 11 ##########################
431 ns: 15 ###################################
437 ns:  8 ###################
443 ns:  4 #########
449 ns:  2 #####
454 ns:  4 #########
460 ns:  2 #####
466 ns:  2 #####
472 ns:  0 |
477 ns:  1 ##
483 ns:  0 |
489 ns:  2 #####
495 ns:  0 |
500 ns:  0 |
506 ns:  0 |
512 ns:  0 |
518 ns:  0 |
523 ns:  0 |
529 ns:  1 ##

Mean +- std dev: 430 ns +- 18 ns

$ pyperf timeit -g --rigorous -s "import attr; C=attr.make_class('C', ['x'], slots=True)" "C(1)"
.........................................
349 ns: 22 ############################################################################
353 ns: 21 ########################################################################
357 ns: 23 ###############################################################################
361 ns: 17 ##########################################################
365 ns: 10 ##################################
369 ns:  5 #################
373 ns:  5 #################
377 ns:  4 ##############
381 ns:  1 ###
384 ns:  4 ##############
388 ns:  3 ##########
392 ns:  1 ###
396 ns:  1 ###
400 ns:  0 |
404 ns:  2 #######
408 ns:  0 |
412 ns:  0 |
416 ns:  0 |
419 ns:  0 |
423 ns:  0 |
427 ns:  1 ###

Mean +- std dev: 364 ns +- 13 ns

On this computer, given a trivial class with a single attribute, class instantiation is ~15% faster if the class is a slot class. Attribute access (i.x) and mutation (i.x = 2) are also significantly faster (15% or more) for slot classes.

They're faster; great. But what are we giving up if we use slot classes?

It's not possible to assign additional attributes to instances of slot classes; the only attributes a slot instance can have are the ones in the __slot__ tuple we've defined at class creation time.

>>> i_slots = CSlots(1)
>>> i_slots.y = 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'CSlots' object has no attribute 'y'

Personally, I don't mind this at all because I like having classes with well-defined lists of attributes and it works as light-weight validation. It might be a problem for some use-cases, though.

Slot classes don't support multiple inheritance of other slot classes. This is an implementation limitation. If you use multiple inheritance slot classes won't work for you.

Slot classes are kind of annoying to declare; you have to repeat yourself once again in the __slots__ tuple. Luckily the solution is simple: use attrs!

Slot classes lose a bunch of their advantages if the inheritance chain contains even a single dict class, because there will be an instance dictionary associated with every instance. Slot classes all the way!

Slot classes, by the details of their implementation, make certain useful Python tricks that depend on instance lookup falling through to the class impossible or much harder. For example, the @cached_property decorator from the popular cached_property package cannot work on slot classes. A cached_property implementation for slot classes is something we might offer later in attrs, but it'll likely require Cython parts to be performant on CPython. Another trick that goes out the window is using class attributes as defaults for unset instance attributes.

Slots in attrs

Making slot classes using attrs is easy; just say slots=True!

@attr.s(slots=True)
class C:
    """I'm a slot class! Yay!""""
    a = attr.ib()

Of course we don't make you repeat all your attribute names in an ugly __slots__ attribute; that'd be just mean. However, if you've read the first installment in this series, you know there is something complicated going on. Fundamentally attrs works by having the attr.s class decorator do its magic on a normal class it's applied do. Before attr.s is applied to C in the given example, C is most certainly not a slot class. So how does attrs add slotness to it?

It doesn't; it's not really possible to convert an ordinary class into a slot class. What attrs can do, and does, is make a new class with the same methods as the old class but with slotness added.

In theory, no one should know the difference. In practice, there are a few subtle differences that we're aware of.

Hashing

Consider two supposedly equivalent classes, one ordinary and one slots, but without a __hash__ function generated. As of attrs 17.2, these two classes react differently to hashing.

@attr.s(hash=False)
class C:
    a = attr.ib()

@attr.s(slots=True, hash=False)
class CSlots:
    a = attr.ib()

>>> hash(C(1))
-9223363260181144362

>>> hash(CSlots(1))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'CSlots'

So why the difference?

attr.s(hash=False) generally means "leave my hash alone". Since we haven't defined __hash__ ourselves in either case, C and CSlots truly don't have hash functions. object, the root class from which all other classes inherit, does have __hash__ defined, however.

>>> C.__hash__ is object.__hash__
True

So C is inheriting its hashing behavior from object. On a side note, this means it's using identity-based hashing, which is less useful.

>>> hash(C(1))
8776673631450
>>> hash(C(1))
-9223363260181144355  # The hash changed?
>>> C(1) in {C(1)}
False

Why isn't the slot class inheriting its hasher from object, though? Let's take a look.

>>> CSlots.__hash__
>>> # It's set to None.

In Python, if you want a class to be unhashable, you set its __hash__ to None. But wait a minute, who set CSlots.__hash__ to None?

Python did. The official docs clearly state:

A class that overrides __eq__() and does not define __hash__() will have its __hash__() implicitly set to None.

Oh yeah, attrs will generate __eq__() for us by default, in both cases. So the slots behavior is technically correct; we defined a class with __eq__ but no __hash__, so Python made our class unhashable.

This occurs at the moment the class is defined. At the moment of definition, C has neither __eq__ nor __hash__, so Python doesn't make it unhashable. Then attrs comes along and sticks __eq__ on it, but doesn't touch the __hash__, so the class ends up hashable. The same happens for CSlots, except attrs recreates the class and that's when Python makes it unhashable.

We're considering recreating the class in all cases, to be more consistent. We also consider the slot behavior to be more correct.

super() in Slot Classes

Try this while using attrs <= 17.2. (It's fixed in later versions.)

@attr.s(slots=True)
class C:
    a = attr.ib()
    
    def test(self):
        return super().__hash__()

>>> C(1).test()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 6, in test
TypeError: super(type, obj): obj must be an instance or subtype of type

This is issue #102, and it happens because of PEP 3135.

PEP 3135 is a Python 3 PEP that enables features like the no-arg super and bare __class__ statements in class methods (i.e. not self.__class__, just __class__). This is done by having the compiler bake a reference to the class being defined into a closure cell on the method making use of this.

>>> C.test.__closure__[0].cell_contents
<class '__main__.C'>
>>> C.test.__closure__[0].cell_contents is C
False

The compiler baked in the reference to the original class but attrs created a new class for us and just moved the methods over, so the contents of the cell are now incorrect. Newer versions of attrs solve this by rewriting the cell contents with the proper reference, but this involves a little bit of black magic, especially on CPython versions prior to 3.7. (Brave souls gaze over here.)

Tin
Zagreb, Croatia