
我去年在评审一个内部项目时遇到一个很典型的bug一个Python类在类体里直接写了items []作为默认数据容器结果线上多个实例的数据互相污染一个用户创建的任务跑到另一个用户的列表里去了。排查到最后问题就出在类实例属性的定义与访问机制上——很多人写Python写了几年对obj.attr背后那套查找逻辑、类属性和实例属性的边界、__dict__和描述符协议的关系其实并没有真正吃透。这篇文章我就围绕“Python类实例属性定义与访问”这个主题把底层机制、各种定义姿势、访问拦截、私有化、内存优化和实战中的坑一次性讲透。不管你是刚入门Python、正在看类相关的教程还是写过一段时间代码但总在属性上踩坑这篇都值得花十分钟认真读完。1. 属性查找链为什么两个实例会共用一个列表1.1 实例字典与类字典先查谁Python的对象属性存储核心是两个字典实例的__dict__和类的__dict__。当代码里执行obj.attr时解释器并不是直接去某个地方取数据而是按一条固定链路查找先找type(obj).__mro__里所有类看有没有同名数据描述符后面专门讲描述符。再查obj.__dict__也就是实例自己的属性字典。再查类及所有父类的__dict__。最后找非数据描述符都没有就抛AttributeError。这里最关键的是第2步和第3步的顺序实例字典优先于类字典。也就是说同名属性在实例上赋值之后实例字典里的值会“遮住”类字典里的值但类字典里的值并没有消失。我用一个最简单的例子验证class User: role visitor # 类属性 u1 User() u2 User() print(u1.role) # visitor实例字典没有去类字典找 u1.role admin # 往u1的实例字典里写了一个新键值 print(u1.role) # admin实例字典命中 print(u2.role) # visitoru2实例字典没有还是取类字典 print(User.__dict__[role]) # visitor类属性一动不动很多初学者以为u1.role admin会“修改类属性”实际上它只是往u1.__dict__里塞了一个键User类上的role完全没变。这个认知不清后面就会犯共享可变对象的错。1.2obj.__dict__是普通字典你能看到一切实例的__dict__就是一个普通的dict你可以直接读、直接改、直接删。这在调试时特别好用print(u1.__dict__) # {role: admin}如果出于某种需要还可以绕过类属性直接操作__dict__来定义属性这在某些序列化、ORM映射、动态注入场景里很常见。但注意直接改__dict__不会触发__setattr__拦截也不会有属性合法性校验属于“走后门”操作常规业务代码里尽量少干。现在回到开头那个bug。如果你在类体里写class TaskManager: tasks []那么所有TaskManager实例共享的是同一个list对象。你往t1.tasks里append数据因为t1.__dict__里没有tasks这个键查找落到类字典返回的是同一个list。于是t2.tasks看到的数据也被改了。正确的做法是把可变对象放到__init__里作为实例属性创建class TaskManager: def __init__(self): self.tasks []这样每个实例都有自己独立的tasks列表。记住一句话不可变对象放类属性问题不大可变对象放类属性就是埋雷。2. 定义实例属性的正确姿势五种写法的取舍2.1 在__init__里初始化最推荐的主路径最常规也最不容易出错的方式是在__init__里给实例绑定属性class Order: def __init__(self, order_id, amount): self.order_id order_id self.amount amount self.status pending为什么推荐这种写法三个原因。第一构造逻辑集中任何实例创建后属性都是齐全的不会出现“有的实例有order_id有的没有”这种薛定谔状态。第二可以顺便做类型转换、默认值、合法性校验。第三IDE和类型检查器能准确推断出实例有哪些属性自动补全和静态检查都好用。配合类型注解可读性再上一个台阶class Order: def __init__(self, order_id: int, amount: float) - None: self.order_id: int order_id self.amount: float amount self.status: str pending2.2 动态添加属性灵活但要有节制Python允许在实例创建之后任意追加新属性o Order(1001, 99.9) o.discount 0.8 # 动态添加 print(o.discount)这在写脚本、做原型、接第三方数据时非常方便。但是动态添加属性带来的问题也很明显对象的属性集合不固定不同代码路径创建的实例可能拥有不同的属性读一个不存在的属性会直接抛AttributeError类被继承时子类到底有哪些属性完全靠自觉。我见过一个项目为了兼容多种数据源同一个模型类在不同调用方手里被挂上各种不同的属性最后代码里全是if hasattr(obj, xxx)的防御判断非常痛苦。所以我的建议是动态属性只用于临时脚本、外部数据的灵活承载核心业务模型一定要把属性定义清楚。如果实在需要“灵活的实例”又不想完全裸奔可以重写__getattr__和__setattr__做一层受控动态属性后面第3章详细讲。2.3 类体直接定义区分“常量”和“默认值”类体里直接写的变量是类属性它适合放两类东西不可变常量比如状态枚举、归一化参数、类级别的配置项。所有实例共享的标记比如version 1.0。class Payment: MAX_AMOUNT 10000 SUPPORTED_CURRENCIES (CNY, USD) version 1.0注意第二行SUPPORTED_CURRENCIES是元组不可变所以共享没有副作用。如果有人改成list那就危险了。此外类属性也经常用作“默认值”——但只限于不可变类型。如果你需要每个实例独立拥有这个字段还是得在__init__里赋值。2.4dataclass现代Python里定义纯数据类的首选Python 3.7引入的dataclasses模块把“定义数据类”这件事的代码量砍掉了大半from dataclasses import dataclass, field dataclass class Product: name: str price: float tags: list field(default_factorylist) on_sale: bool False这里有两个关键点tags: list field(default_factorylist)这解决了可变默认值的大坑。default_factory会在每个实例创建时调用一次list()保证每个实例拿到独立的list而不是共享同一个。on_sale: bool False普通默认值只适合不可变类型。dataclass会自动生成__init__、__repr__、__eq__等一堆样板方法同时配合类型注解既有现代感又省心。很多新项目里能看见的类基本都是dataclass或pydantic模型。2.5 用__slots__限制属性集合如果类重了__slots__实例就不再有__dict__属性只能限制为__slots__里列出的那些名字动态添加属性会直接报错。这个机制的细节我在第6章专门展开这里先提一嘴__slots__既是内存优化工具也是属性约束工具。3. 属性访问的底层钩子getattr与描述符协议3.1__getattribute__、__getattr__、__setattr__的分工访问实例属性时Python默认通过object.__getattribute__完成前面说的那套查找流程。如果你想拦截“每次属性读取”可以重写__getattribute__但它非常容易被误用一个不小心就无限递归class BadDemo: def __getattribute__(self, name): return self.__dict__.get(name) # 错误写法self.__dict__ 又会触发 __getattribute__这里访问self.__dict__时又会调用__getattribute__于是无限递归。正确做法是用object.__getattribute__(self, name)去取class GoodDemo: def __getattribute__(self, name): print(f读取属性: {name}) return object.__getattribute__(self, name)与__getattribute__相对的是__getattr__它只在正常查找失败时才会被调用。所以它非常适合做“懒惰属性”和“动态属性的兜底”。举个例子class LazyConfig: def __init__(self): self._data {} def __getattr__(self, name): # 只有属性不存在时才走到这里 return self._data.setdefault(name, default)写入拦截用__setattr__里面同样要小心递归。比较稳的写法是class ValidatedUser: def __setattr__(self, name, value): if name age and not isinstance(value, int): raise TypeError(age 必须是整数) object.__setattr__(self, name, value)3.2 描述符协议property、staticmethod的背后是靠同一个机制描述符是Python属性系统的核心机制。一个类如果定义了__get__、__set__、__delete__中的任意一个它的实例就可以作为另一个类的类属性并在属性访问时接管控制权。property、classmethod、staticmethod、super在底层都是描述符。描述符分两类数据描述符同时实现__get__和__set__它优先于实例字典。非数据描述符只实现__get__实例字典优先于它。这解释了为什么obj.attr xxx时如果类上有同名数据描述符赋值会被描述符的__set__拦截而不会写进实例字典。property就是数据描述符。我写一个极简描述符展示它的工作过程class PositiveNumber: def __set_name__(self, owner, name): self.private_name _ name def __get__(self, obj, objtypeNone): if obj is None: return self return getattr(obj, self.private_name) def __set__(self, obj, value): if value 0: raise ValueError(必须是正数) setattr(obj, self.private_name, value) class Account: balance PositiveNumber() def __init__(self, balance): self.balance balance当执行a.balance 100时因为Account类上存在balance这个数据描述符Python不会往a.__dict__里写balance而是调用PositiveNumber.__set__由它去写_balance。读取时走__get__返回_balance的值。这就是描述符“接管属性”的全过程。理解了描述符再看property就非常简单了它本质上就是一个帮你造描述符的内置工厂函数。4. “私有”属性只是约定名称改写机制与真实约束4.1 双下划线开头的属性发生了什么Python里写self.__secret 1并不会真的把属性变成其他语言那种不可访问的private。解释器会在编译阶段把它重写为self._ClassName__secret这就是名称改写class SecretHolder: def __init__(self): self.__secret hidden s SecretHolder() print(s.__dict__) # {_SecretHolder__secret: hidden} print(s._SecretHolder__secret) # hidden print(s.__secret) # AttributeError从外面直接访问s.__secret会报错原因仅仅是名字已经被改掉了不是权限被禁止。你只要知道改写后的名字照样能访问。那这个机制存在的意义是什么四个字防止误触。特别是在继承场景里父类和子类如果都定义了同名的普通属性子类的赋值会直接覆盖父类的。双下划线则能把属性名加上类名前缀避免被子类意外覆盖class Base: def __init__(self): self.__value 10 def get_value(self): return self.__value class Child(Base): def __init__(self): super().__init__() self.__value 20 # 实际存的是 _Child__value不会覆盖父类的这就是为什么我建议写类库、写框架时内部状态属性用双下划线普通应用代码里单下划线加约定就够用。4.2 单下划线、双下划线和__all__单下划线开头的属性比如self._internal纯粹是“君子协定”Python解释器不会做任何处理IDE和一些工具会把它标记为“内部使用”。双下划线加名称改写则提供了一层防误触保护但也会带来调试时名字不可见的麻烦dir()返回的名字是改写后的初学者容易被搞晕。如果你是写公共库的除了属性命名还要善用模块级__all__控制from module import *导出的名字列表。属性层面用_或__约束调用方模块层面用__all__约束导入面两层配合接口才算干净。需要特别注意的坑是不要在__init__里用双下划线属性然后父类又要访问它。比如父类写了self.__data子类也写了self.__data两者根本不是同一个属性这种隐蔽的“同名不同物”问题排查起来非常痛苦。如果你没法百分之百确定继承关系优先用单下划线加清晰命名代替双下划线。5. 用property把属性访问变成可控的接口5.1 从裸属性到property的三步演进裸属性写起来最爽class User: def __init__(self, age): self.age age但问题也很直接如果以后要加校验、要计算型返回值、要在修改时触发副作用所有调用方全部要改。步骤是这样演进的。第一步先加方法class User: def __init__(self, age): self._age age def get_age(self): return self._age def set_age(self, value): if value 0: raise ValueError(age不能为负数) self._age value第二步外部调用从u.age改成u.get_age()改完所有地方都要动。第三步用property把方法伪装成属性class User: def __init__(self, age): self._age age property def age(self): return self._age age.setter def age(self, value): if value 0: raise ValueError(age不能为负数) self._age value调用方还是写u.age但赋值时自动走校验逻辑。这就是我说的“把属性访问变成可控接口”——语法不变行为可控。5.2 property本质是描述符setter和deleter配套使用property在Python源码里就是实现了__get__、__set__、__delete__的描述符类。property装饰一个方法生成一个只读描述符再用age.setter装饰同名方法给这个描述符绑定写逻辑age.deleter绑定删除逻辑。用得比较多的场景有三个计算属性比如订单的total_price price * quantity不需要单独存字段。数据校验与类型转换比如设置birth_year时顺便算出age。兼容旧接口重构时保持外部调用不变。但property也不是越多越好。我见过有人把几十个属性全部写成property一个类里getter/setter堆了上百行读起来非常累。原则是只在需要控制读写行为时才用property纯数据存储直接用普通属性。而且property的getter里别写耗时操作否则每次访问都像调用一个隐藏函数性能问题会被“属性访问”的表象掩盖掉。6.slots该用的时候再用6.1 __slots__改变了实例的存储结构默认情况下实例属性都存在__dict__里字典本身是哈希表内存开销不小。如果类定义了__slots__实例就不再创建__dict__取而代之的是一组固定的描述符属性值被存进内部数组访问速度更快内存占用也明显更小。class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y做个粗略对比同样一百万次创建和访问__slots__类的实例内存占用通常在普通类的40%到60%之间。对于大量对象驻留内存的场景比如游戏实体、大批量数据解析、ORM查询结果对象这个优化非常可观。但代价是实例失去了__dict__不能再动态添加属性了p Point(1, 2) p.z 3 # AttributeError: Point object has no attribute z6.2 哪些场景应该用哪些场景千万别用适合用__slots__的场景需要创建海量实例内存敏感。属性集合完全固定不需要动态扩展。作为数据容器的轻量类比如坐标点、配置项、事件对象。不适合用__slots__的场景需要给实例动态挂载属性比如接第三方数据、做插件系统。依赖__dict__做序列化或反射的框架比如某些ORM、序列化库。类层级复杂子类父类都要声明__slots__漏一个就容易出现奇葩行为。有个容易被忽略的点定义了__slots__的类如果没有在__slots__里包含__dict__那么这个类的实例就没有__dict__属性很多默认用__dict__的库函数会失效。另外子类必须重新定义__slots__如果子类没写子类实例还是会带有__dict__前面省的内存又还回去了。所以一般只在继承链底层的叶子类上用__slots__中间抽象类反而不要加否则整个继承体系到处是坑。7. 实战复盘高频坑与属性设计checklist7.1 高频踩坑记录第一个坑是可变类属性前面讲过class X: data []导致所有实例共享数据。这不是Python的bug而是对属性查找链理解不到位。解决办法是移动到__init__或者用dataclass的field(default_factorylist)。第二个坑是__getattr__拼写错误。有人想拦截“属性不存在”却写成了__getattr__少了一个下划线或者把__getattr__拼成__getattribute__导致所有属性访问都走了兜底逻辑线上性能直接崩掉。这两个名字只差几个字母含义差很远我建议每次写完都打两行测试确认拦截行为符合预期。第三个坑是__slots__和property同名冲突。如果在__slots__里声明了某个名字同时又定义了同名property运行时可能报“attribute is read-only”之类的问题。原因就是描述符和slot内部机制打架。解决办法是名字错开比如slot名用_xproperty名用x。第四个坑是动态添加属性时的拼写错误。动态属性的容错性也是一种隐患一个单词拼错Python不会在赋值时报错只会在读取时报错而且报错信息经常出现在很远的调用方。最好的防御是在关键位置加类型注解和静态检查mypy / pyright提前把属性名写错这类问题暴露出来。7.2 我自己的属性设计checklist现在设计一个类时我基本按下面这个顺序过一遍属性用途分类哪些是实例独立状态哪些是类级共享常量哪些是计算属性。实例状态放__init__类级常量放类体计算属性用property。可变默认值检查默认值是list、dict、set的任何属性一律不能在类体里直接初始化。访问控制需求需要校验/副作用/兼容旧接口的用property需要防子类覆盖的内部状态用双下划线只是内部约定用单下划线。动态属性需求如果这个类的实例需要大量动态挂载属性考虑是否引入__getattr__兜底还是改用字典/自定义容器。内存和性能优化批量创建对象且属性固定才考虑__slots__日常业务代码不要为了“规范”强行加。类型标注完整性所有属性标注类型__init__参数标注类型配合静态检查工具跑一遍。这套checklist看起来繁琐但真养成习惯之后写类、改类、加属性的时候都不太会出问题。最后再分享一个小技巧调试类的属性问题时别急着加日志。先用dir(instance)看属性全貌再用instance.__dict__和type(instance).__dict__对比很多时候一眼就能看出属性是定义在实例上还是类上。搞清楚这两层存储Python类的属性问题就已经解决了一大半。