4

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__ = ()?

MSeifert
  • 145,886
  • 38
  • 333
  • 352
Aran-Fey
  • 39,665
  • 11
  • 104
  • 149
  • Background: I'll be instantiating *a lot* of `A` objects, so I'm using `__slots__` to reduce memory consumption. `A` is also sitting at the top of a large class hierarchy with many mixins and subclasses, so I would like to avoid writing `__slots__ = ()` in every single subclass. – Aran-Fey Jun 13 '19 at 11:23
  • how about https://stackoverflow.com/questions/1816483/python-how-does-inheritance-of-slots-in-subclasses-actually-work ? – Devesh Kumar Singh Jun 13 '19 at 11:25
  • 2
    @DeveshKumarSingh I don't see how that answers my question - I understand how `__slots__` interacts with inheritance, and I want to find a way to change how it works. – Aran-Fey Jun 13 '19 at 11:32
  • If you only want to spare memory, is it a solution for you to just dynamically empty `__dict__` like `B.__dict__ = ()`when instantiating? – Markus Jun 13 '19 at 12:03
  • @Markus For my specific use case? Maybe. But let's stick to answering the question I asked. We want this thread to be useful for other readers as well, after all. – Aran-Fey Jun 13 '19 at 12:59
  • Just to be clear: the subclasses never add some instance attributes? Or to put it differently, do you need an opt-out mechanism for adding the dict or extending slots? – MSeifert Jun 13 '19 at 13:17
  • 1
    @MSeifert No, there might be some subclasses that add new instance attributes. This question is only about those subclasses that don't define `__slots__` at all - those classes should default to having `__slots__` set to `()`. Classes that *do* define `__slots__` should be left alone. There's also no need for an opt-out mechanism because that already exists: a class that wants a dict can just set `__slots__ = ('__dict__',)`. – Aran-Fey Jun 13 '19 at 13:28
  • @Aran-Fey Okay thanks for the clarification. It wasn't obvious from the question so I thought I ask. :) – MSeifert Jun 13 '19 at 13:36

2 Answers2

10

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.

MSeifert
  • 145,886
  • 38
  • 333
  • 352
  • 1
    It's already been said in a comment on the question, but I think it's worth pointing out that `del __slots__` isn't the only way to give your class a dict: You can also explicitly create a slot for the dict, like `__slots__ = ('__dict__',)`. There's a minor difference between the two options: `del __slots__` will give your class not only a `__dict__`, but also a `__weakref__` slot. – Aran-Fey Jun 13 '19 at 17:08
  • I found that it also works just by overriding `type.__new__` in the metaclass instead of `__prepare__`, though the fact that this works at all surprises me. – Iguananaut Jun 01 '23 at 14:18
2

How about a metaclass like this and the __prepare__() hook?

import sys


class InheritSlots(type):
    def __prepare__(name, bases, **kwds):
        # this could combine slots from bases, I guess, and walk the base hierarchy, etc
        for base in bases:
            if base.__slots__:
                kwds["__slots__"] = base.__slots__
                break
        return kwds


class A(metaclass=InheritSlots):
    __slots__ = ("foo", "bar", "quux")


class B(A):
    pass


assert A.__slots__
assert B.__slots__ == A.__slots__
assert "__dict__" not in dir(A)
assert "__dict__" not in dir(B)

print(sys.getsizeof(A()))
print(sys.getsizeof(B()))

For some reason, this still does print 64, 88 – maybe an inherited class's instances are always a little heavier than the base class itself?

AKX
  • 152,115
  • 15
  • 115
  • 172
  • 1
    Shouldn't the slots in child classes _not_ have the same things as their parent? From the docs: "unless they also define `__slots__` (which should only contain names of any _additional_ slots)." – aneroid Jun 13 '19 at 12:17
  • Nice idea, but I'm not a big fan of the implementation. Like aneroid said, the child class shouldn't duplicate its parent's `__slots__` - the correct solution would be to set it to an empty tuple. And I think it's quite questionable of your `__prepare__` method to turn keyword arguments into class attributes. I've opted to implement the logic in the metaclass's `__new__` instead, with a simple `cls_dict.setdefault('__slots__', ())`. – Aran-Fey Jun 13 '19 at 12:55
  • That's apparently how `__prepare__` should work, that kwds thing. (I actually hadn't used `__prepare__` before this, I just looked it up in the manual and relevant PEP.) – AKX Jun 13 '19 at 13:00
  • Hmm, I'm not really sure to be honest. But if it is, I wouldn't do it manually - I'd just make a `super().__prepare__` call. – Aran-Fey Jun 13 '19 at 13:05