Search code examples
pythonclassinheritancemetaprogrammingslots

How can I force subclasses to have __slots__?


I have a class with __slots__:

class A:
    __slots__ = ('foo',)

If I create a subclass without specifying __slots__, the subclass will have a __dict__:

class B(A):
    pass

print('__dict__' in dir(B))  # True

Is there any way to prevent B from having a __dict__ without having to set __slots__ = ()?


Solution

  • The answer of @AKX is almost correct. I think __prepare__ and a metaclass is indeed the way this can be solved quite easily.

    Just to recap:

    • If the namespace of the class contains a __slots__ key after the class body is executed then the class will use __slots__ instead of __dict__.
    • One can inject names into the namespace of the class before the class body is executed by using __prepare__.

    So if we simply return a dictionary containing the key '__slots__' from __prepare__ then the class will (if the '__slots__' key isn't removed again during the evaluation of the class body) use __slots__ instead of __dict__. Because __prepare__ just provides the initial namespace one can easily override the __slots__ or remove them again in the class body.

    So a metaclass that provides __slots__ by default would look like this:

    class ForceSlots(type):
        @classmethod
        def __prepare__(metaclass, name, bases, **kwds):
            # calling super is not strictly necessary because
            #  type.__prepare() simply returns an empty dict.
            # But if you plan to use metaclass-mixins then this is essential!
            super_prepared = super().__prepare__(metaclass, name, bases, **kwds)
            super_prepared['__slots__'] = ()
            return super_prepared
    

    So every class and subclass with this metaclass will (by default) have an empty __slots__ in their namespace and thus create a "class with slots" (except the __slots__ are removed on purpose).

    Just to illustrate how this would work:

    class A(metaclass=ForceSlots):
        __slots__ = "a",
    
    class B(A):  # no __dict__ even if slots are not defined explicitly
        pass
    
    class C(A):  # no __dict__, but provides additional __slots__
        __slots__ = "c",
    
    class D(A):  # creates normal __dict__-based class because __slots__ was removed
        del __slots__
    
    class E(A):  # has a __dict__ because we added it to __slots__
        __slots__ = "__dict__",
    

    Which passes the tests mentioned in AKZs answer:

    assert "__dict__" not in dir(A)
    assert "__dict__" not in dir(B)
    assert "__dict__" not in dir(C)
    assert "__dict__" in dir(D)
    assert "__dict__" in dir(E)
    

    And to verify that it works as expected:

    # A has slots from A: a
    a = A()
    a.a = 1
    a.b = 1  # AttributeError: 'A' object has no attribute 'b'
    
    # B has slots from A: a
    b = B()  
    b.a = 1
    b.b = 1  # AttributeError: 'B' object has no attribute 'b'
    
    # C has the slots from A and C: a and c
    c = C()
    c.a = 1
    c.b = 1  # AttributeError: 'C' object has no attribute 'b'
    c.c = 1
    
    # D has a dict and allows any attribute name
    d = D()  
    d.a = 1
    d.b = 1
    d.c = 1
    
    # E has a dict and allows any attribute name
    e = E()  
    e.a = 1
    e.b = 1
    e.c = 1
    

    As pointed out in a comment (by Aran-Fey) there is a difference between del __slots__ and adding __dict__ to the __slots__:

    There's a minor difference between the two options: del __slots__ will give your class not only a __dict__, but also a __weakref__ slot.