跳到主要内容

使用 __slots__

普通 Python 实例通常通过 __dict__ 保存属性,因此可以在创建后随时添加新字段。这种灵活性很方便,但每个实例都要维护一个字典,会占用额外内存,也可能让对象拥有不可预测的属性集合。

__slots__ 可以声明实例允许使用的属性名称。使用它后,实例通常不再创建 __dict__,从而限制动态属性并减少一部分开销。它是对象布局和接口约束工具,不是安全机制,也不一定让所有程序都更快。

本文示例适用于 Python 3.10 及以上版本。学完后,你应该能够:

  • 写出基本的 __slots__ 声明并理解它如何限制属性;
  • 解释 __dict__、__weakref__ 与槽位描述器的关系;
  • 在继承、数据类和需要动态属性的场景中做出合适选择;
  • 识别默认值、重复槽位、多继承等常见问题。

先看一个完整例子​

class Point:
__slots__ = ("x", "y")

def __init__(self, x: float, y: float):
self.x = x
self.y = y


point = Point(3, 4)
print(point.x, point.y) # 3 4

# point.color = "red"
# AttributeError: 'Point' object has no attribute 'color'

x 和 y 是允许的实例属性,color 没有出现在 __slots__ 中,因此不能直接添加。

__slots__ 到底改变了什么​

普通实例依赖 __dict__​

class User:
def __init__(self, name: str):
self.name = name


user = User("小林")
user.age = 18 # 可以动态添加
print(user.__dict__) # {'name': '小林', 'age': 18}

实例字典以“属性名 → 属性值”的形式保存状态,使用简单但有字典本身的存储成本。

槽位由类声明​

class User:
__slots__ = ("name", "age")

def __init__(self, name: str, age: int):
self.name = name
self.age = age


user = User("小林", 18)
print(user.name)
print(hasattr(user, "__dict__")) # False

类创建时,Python 会根据槽位名称建立描述器;实例值按这些名称存储,而不是放在普通字典中。槽位本身不是一个可以遍历的实例字典:

print(User.__slots__) # ('name', 'age')
# vars(user) # TypeError:没有 __dict__

__slots__ 只影响实例属性,不会阻止读取类属性、调用方法,也不会改变 isinstance() 和继承的基本语义。

如何声明槽位​

使用字符串序列​

通常使用元组,也可以使用列表或其他可迭代对象:

class Product:
__slots__ = ("name", "price")

槽位名称必须是字符串。名称可以包含单下划线或双下划线;双下划线仍会按类的名称改写规则处理:

class Account:
__slots__ = ("__token",)

def __init__(self, token: str):
self.__token = token

空槽位:只想禁止实例字段​

如果类不需要保存实例状态,可以写空元组。它也常用于不希望给子类增加实例字典的 mixin:

class LogMixin:
__slots__ = ()

def log(self, message: str) -> None:
print(message)

给槽位属性设置默认值​

不要在类体中再次给槽位同名属性赋值。槽位描述器会与该类属性冲突,类定义可能直接抛出 ValueError:

# class Wrong:
# __slots__ = ("count",)
# count = 0 # 与槽位冲突

把默认值放在 __init__() 中最清楚:

class Counter:
__slots__ = ("value",)

def __init__(self, value: int = 0):
self.value = value

如果属性是可变对象,也应在初始化时为每个实例创建独立对象:

class Basket:
__slots__ = ("items",)

def __init__(self):
self.items: list[str] = []

不能把 items = [] 当作槽位默认值;这既会冲突,也无法达到“每个实例一份列表”的目的。

需要动态属性时怎么办​

如果对象确实需要接收未预先声明的属性,可以显式把 "__dict__" 加入槽位:

class FlexibleUser:
__slots__ = ("name", "__dict__")

def __init__(self, name: str):
self.name = name


user = FlexibleUser("小林")
user.nickname = "Lin"
print(user.__dict__) # {'nickname': 'Lin'}

这样会恢复实例字典的存储成本,因此“限制属性”和“保留动态扩展”只能部分兼得。若没有明确需求,通常不要加入 __dict__。

弱引用:__weakref__​

弱引用不会阻止对象被垃圾回收。带非空槽位的类通常不能直接创建弱引用;需要时,把 "__weakref__" 加入槽位:

import weakref


class CacheEntry:
__slots__ = ("key", "__weakref__")

def __init__(self, key: str):
self.key = key


entry = CacheEntry("user:1")
reference = weakref.ref(entry)
print(reference().key) # user:1

只有确实要被 weakref.ref()、某些缓存或观察者机制引用时才需要它;否则不必为每个类都添加。

__slots__ 与继承​

子类也应声明槽位​

父类的槽位会被子类继承。若子类不声明 __slots__,子类实例通常会重新获得 __dict__:

class Base:
__slots__ = ("id",)


class Child(Base):
pass


child = Child()
child.id = 1
child.extra = "允许" # Child 没有声明 __slots__,因此有 __dict__

要让子类继续使用固定布局,应明确声明新增槽位;没有新增属性时使用空元组:

class Child(Base):
__slots__ = ("name",)


class Leaf(Child):
__slots__ = ()

不要重复声明同名槽位​

子类重复声明父类已有的槽位名称,会创建两个不同的描述器,浪费空间并造成令人困惑的属性行为。应让父类负责该字段,子类只声明自己的字段。

多继承可能发生布局冲突​

多个父类都拥有非空槽位时,Python 可能无法合并它们的实例布局并抛出 TypeError。多继承场景通常让 mixin 使用 __slots__ = (),只让一个基类负责实际数据槽位;设计复杂层次前应在目标 Python 版本中运行最小示例验证。

与 property、方法和类属性一起使用​

槽位属性可以配合 property 做校验,方法也不受影响:

class Temperature:
__slots__ = ("_celsius",)

def __init__(self, celsius: float):
self.celsius = celsius

@property
def celsius(self) -> float:
return self._celsius

@celsius.setter
def celsius(self, value: float) -> None:
if value < -273.15:
raise ValueError("温度不能低于绝对零度")
self._celsius = float(value)

类属性仍然属于类本身,不计入实例槽位:

class Request:
__slots__ = ("path",)
method = "GET"

Request.method 可以正常读取;但 request.method = "POST" 会因为没有名为 method 的实例槽位而失败。若需要每个实例拥有不同的方法,应把 method 加入槽位并在初始化时赋值。

数据类中的 slots=True​

Python 3.10 起,数据类可以自动生成槽位:

from dataclasses import dataclass


@dataclass(slots=True)
class Point:
x: float
y: float


point = Point(1, 2)
# point.label = "起点" # AttributeError

这会保留数据类的初始化、比较等便利,同时采用槽位布局。若还需要弱引用,可在支持该参数的 Python 版本中使用 weakref_slot=True;否则手写 __slots__ 或调整设计。使用数据类时,仍要检查继承和第三方库是否依赖 __dict__。

优点、代价与适用场景​

主要优点​

  • 减少内存开销:大量创建、字段固定的小对象通常更受益;节省多少取决于 Python 实现、字段数量和对象规模。
  • 限制拼写错误:把 user.nmae = ... 之类的误写尽早变成 AttributeError。
  • 表达固定数据模型:让类的实例字段更明确,便于维护和静态分析。

主要代价​

  • 失去动态扩展:插件、调试字段、序列化框架可能需要 __dict__。
  • 继承更复杂:子类、重复槽位和多继承都有额外约束。
  • 工具兼容性风险:依赖 vars(obj)、直接读写 obj.__dict__ 的代码需要改造。
  • 不保证性能提升:属性访问速度、整体吞吐和内存结果应通过实际基准测试确认。

适合使用的典型对象包括:大量创建的点、坐标、消息、令牌、AST 节点等固定结构对象。不适合为了“看起来高级”而给经常扩展、依赖动态字段或需要复杂多继承的业务模型强行加上。

常见检查方式与误区​

不要假设所有对象都有 __dict__​

通用工具应兼容槽位对象:

def instance_data(value: object) -> dict[str, object]:
attributes = getattr(value, "__dict__", None)
return dict(attributes) if attributes is not None else {}

这个函数只能读取字典中的字段,不能自动列出所有槽位值。需要序列化槽位对象时,应根据明确的字段列表读取,或由类提供 to_dict() 等公开接口。

__slots__ 不是安全边界​

它不能阻止修改已有属性,也不能保护秘密;熟悉类定义的人仍可通过类和描述器进行反射操作。安全问题应交给权限控制、秘密管理和输入校验解决。

不要只凭直觉判断节省了多少内存​

可以用 sys.getsizeof() 做初步比较,但它通常不包含被引用对象的递归大小,也不代表完整应用的实际占用。对真实数据规模进行基准测试,比单个对象的数字更有参考价值。

选择决策​

可以按下面的顺序快速判断:

  1. 实例字段是否固定,而且实例数量是否足够多?不是时,普通类往往更简单。
  2. 是否需要动态属性、vars() 或第三方库读写 __dict__?需要时保留普通实例,或显式加入 __dict__。
  3. 是否存在继承和多继承?先设计父子类的槽位,再用最小运行示例验证布局。
  4. 是否只是想表达数据模型?优先考虑 @dataclass(slots=True),同时检查库兼容性。

小结​

  • __slots__ 声明实例允许使用的属性,通常会移除实例 __dict__;
  • 槽位值应在 __init__() 中初始化,不能把同名类属性当作默认值;
  • 需要动态字段时加入 __dict__,需要弱引用时加入 __weakref__;
  • 子类不声明槽位会重新获得字典,重复槽位和多继承可能带来问题;
  • @dataclass(slots=True) 可以自动生成槽位;
  • 它适合固定结构且数量较大的对象,但不是安全机制,也不保证性能一定更好。