理解 Python 类属性 __slots__

网络上有一篇比较有名的文章叫 Saving 9 GB of RAM with Python’s __slots__,文章示例中仅对 Image 类添加了一个 __slots__属性就为服务器节省了 9G 的内存占用。如果有同学看过一些开源的 Python ORM,如 SQLAlchemy, peewee 等,或是看过一些进行 IO 处理的包,你就会在其中发现很多 __slots__ 的身影。

那么,这个 __slots__ 到底是什么?

下面的解释整理自 Python 官方文档:

object.__slots__ 是一个类变量,可赋值为字符串、可迭代对象或由实例使用的变量名构成的字符串序列。其允许我们显式地声明数据成员(如特征属性),为已声明的变量保留空间,并禁止为每个实例创建 __dict____weakref__

举个例子:

class X:
    def __init__(self, a, b):
        self.a = a
        self.b = b

class Y:
    __slots__ = ('a', 'b')

    def __init__(self, a, b):
        """
        此时如果你声明一个 __slots__ 中没有的属性,如
        self.c = 1
        pylint 等就会提示错误:
        [pylint] [Error] Assigning to attribute 'c' not defined in class slots
        当然如果你执意要写的话,初始化实例的时候会引发 AttributeError:
        AttributeError: 'Y' object has no attribute 'c'
        """

        self.a = a
        self.b = b
>>> import weakref

>>> x = X(7, 8)
>>> x.a
7
>>> x.c = 9
>>> x.__dict__
{'a': 7, 'b': 8, 'c': 9}
>>> rx = weakref.ref(x)
>>> rx
<weakref at 0x107a9e278; to 'X' at 0x107618f98>


>>> y = Y(7, 8)
>>> y.a
7
>>> y.c = 9
AttributeError: 'Y' object has no attribute 'c'
>>> y.__dict__
AttributeError: 'Y' object has no attribute '__dict__'
>>> ry = weakref.ref(y)
TypeError: cannot create weak reference to 'Y' object

那使用 __slots__ 到底有什么作用呢?🤔

答案是__dict__ 相比,使用 __slots__ 的方式可以显著地节省内存空间,提升属性的查找速度。

之所以会在 ORM 等项目里频繁使用,正是因为这些项目中存在特别多大量创建实例的操作,使用 __slots__ 会明显减少内存的使用,提升速度。并且随着实例数目的增加,其效果会更加显著。

几点疑问

至此,你可能会有几点疑问:

  1. 为什么 __slots__ 可以节省内存,提高速度的?
  2. 咋通过 __slots__ 来实现属性的存储与访问的?
  3. 使用了 __slots__ 的类怎么实现动态赋值,如果需要实例弱引用支持怎么搞?
  4. 使用了 __slots__ 的类继承与被继承时的表现?

针对这几个问题作答:

1. 通常情况下,类实例使用 __dict__来存储其属性数据,好处是允许我们在运行时动态的设置实例属性,然而 dict 哈希表本身的数据结构决定了它需要更多的内存,当创建的实例越多,或者实例的属性越多时,内存的耗费将更加严重。__slots__ 保证了解释器在编译时期就知道这个类具有什么属性,以分配固定的空间来存储已知的属性。

2. 使用 __slots__ 时,会将属性的存储从实例的 __dict__ 改为类的 __dict__ 中:

>>> Y.__dict__
mappingproxy({'__module__': '__main__',
              '__slots__': ('a', 'b'),
              '__init__': <function __main__.Y.__init__(self, a, b)>,
              'a': <member 'a' of 'Y' objects>,
              'b': <member 'b' of 'Y' objects>,
              '__doc__': None})

属性的访问是通过在类层级上为每个 slot 变量创建和 实现描述器(descriptor) 实现的,该描述器知道属性值在实例列表中的唯一位置。关于描述器与属性的访问在我的 走进 Python 类的内部 一文中均有详细的解释,感兴趣的同学可前去阅读。另外,这篇 how __slots__ are implemented 也许可以帮助你的理解,尽管我看它写于很多年前,但至今依然有借鉴意义。

3.. 怎么实现动态赋值和弱引用支持?答案是:在 __slots__ 中加上 __dict____weakref__

class Y:
    __slots__ = ('a', 'b', '__dict__', '__weakref__')

    def __init__(self, a, b):
        self.a = a
        self.b = b
>>> import weakref
>>> y = Y(7, 8)
>>> y.a
7
>>> y.b
8
>>> y.c = 9
>>> y.__dict__
{'c': 9}
>>> ry = weakref.ref(y)
>>> ry
<weakref at 0x107d17d68; to 'Y' at 0x107a4d480>

4. 当类继承自一个未定义 __slots__ 的类时,实例的 __dict____weakref__ 属性将总是可访问的。

class X:
    def __init__(self):
        self.a = 7

class Y(X):
    __slots__ = ('b', 'c')

    def __init__(self):
        super().__init__()
        self.b = 8
        self.c = 9
>>> y = Y()
>>> y.a
7
>>> y.b
8
>>> y.__dict__
{'a': 7}

5. 在父类中声明的 __slots__ 在其子类中同样可用。不过,子类将会获得 __dict____weakref__,除非它们也定义了 __slots__

class X:
    __slots__=('a', 'b')

    def __init__(self):
        self.a = 7
        self.b = 8

class Y(X):
    """没有定义 __slots__"""

class Z(X):
    __slots__ = ()
>>> y = Y()
>>> y.a
7
>>> y.b
8
>>> y.c = 9
>>> y.__dict__
{'c': 9}

>>> z = Z()
>>> z.a
7
>>> z.b
8
>>> z.c = 9
AttributeError: 'Z' object has no attribute 'c'

所以,由 4,5 可知如果需要使用 __slots__, 那么从基类到每个继承的子类都要定义 __slots__

注意事项

  • 非空的 __slots__ 不适用于派生自「可变长度」内置类型(如 intstrtuple 的派生类)。
  • __class__ 赋值仅在两个类具有相同的 __slots__ 时才会起作用。

结论

尽管 __slots__ 可以节省内存空间,提高属性的访问速度,但也存在局限性和副作用,并不是用了就是好的,那么到底达到多大的实例规模的类推荐使用呢?这个我也没有具体做过实验,感兴趣的同学可以自行搜索相关论文。在不同的业务场景下,衡量方式也会不同,绝不会是说用 __slots__ 就是好的,必须是需要根据具体场景来决定。