一个让我差点把电脑砸了的下午
先说我遇到过的一个事儿。
当时在写一个用户管理系统,有个 User 类,需要限制用户年龄在 0 到 150 之间。我觉得用 @property 特别合适——既有普通属性的访问方式,又能加校验逻辑,完美。
于是我写了这样的代码:
class User:
def __init__(self, name, age):
self.name = name
self.age = age
@property
def age(self):
return self.age
@age.setter
def age(self, value):
if not 0 <= value <= 150:
raise ValueError("年龄必须在 0 到 150 之间")
self.age = value
看起来完全没问题,对吧?属性访问和赋值用起来和普通属性一样,背后还有校验逻辑。
然后我创建了一个用户:
u = User("张三", 25)
然后程序直接崩溃了。
RecursionError: maximum recursion depth exceeded
递归深度超限。我盯着屏幕看了五分钟,完全不知道发生了什么。才创建了一个对象就爆栈?这代码里哪里有递归?
后来我才想明白——self.age = value 这一行,在 setter 内部调用了 setter 自身。
当时我真的恨不得把电脑砸了。原来在 @age.setter 里面写 self.age = value,不是赋值,是递归调用自己。
@property 的工作原理:它把“属性”变成了“方法”
在搞懂为什么 setter 里写 self.age = value 会递归之前,先看看 @property 到底在干什么。
@property 装饰器的作用是——把方法伪装成属性。
class Person:
def __init__(self, name):
self._name = name
@property
def name(self):
return self._name
这样你就可以用 p.name 来获取名字,而不是 p.name()。它看起来像个属性,用起来也像个属性,但实际上它是个方法。
而 @name.setter 是配套的装饰器,用来处理赋值操作:
@name.setter
def name(self, value):
self._name = value
现在 p.name = "李四" 会触发这个 setter 方法。
这个机制本身很强大——你可以控制属性的读取和赋值行为,加校验、加日志、加缓存,啥都行。
但问题在于:当你在 setter 里面写 self.age = value 的时候,Python 会认为你又触发了 setter,于是再次调用它。setter 里面又调用了 setter,无限循环,直到栈溢出。
这是一个经典的逻辑错误,但用 @property 的时候特别容易掉进去——因为你觉得 self.age = value 是“给属性赋值”,但实际上是“调用 setter 方法”。
为什么 _ 前缀才是真正的“私密约定”
现在回到我之前写的那段错误代码。如果我当时这样写:
class User:
def __init__(self, name, age):
self._age = age # 注意:用 _age,不是 age
self.name = name
@property
def age(self):
return self._age # 返回的是内部存储
@age.setter
def age(self, value):
if not 0 <= value <= 150:
raise ValueError("年龄必须在 0 到 150 之间")
self._age = value # 设置的是内部存储
这样就不会递归了。setter 里赋值给 self._age,而 _age 只是一个普通属性,不会触发 setter。
这就是 _ 前缀的真正用途——把内部存储和公开属性区分开。
Python 里没有真正意义上的“私有”属性。你用 _name 还是 __name,都只是约定和名称修饰,不是真正的访问控制。但 _ 前缀的作用不在于“防止外部访问”,而在于 “这个是我的内部存储,外部不应该直接碰它”。
回到属性装饰器的场景,_age 是真正的存储变量,age 是公开的访问接口。这个区分非常重要——如果你把存储变量和属性接口混用一个名字,递归就在那里等着你。
那 __name 双下划线呢?
Python 里还有一种“私有”写法——双下划线前缀:
class Person:
def __init__(self):
self.__name = "张三"
def get_name(self):
return self.__name
双下划线会触发 “名称修饰”——Python 会把 __name 改成 _Person__name。这样做的主要目的是防止子类意外覆盖父类的属性,而不是真正的私有。
但双下划线有它自己的麻烦。比如你在调试的时候,属性名变成了 _Person__name,你打印 dir(obj) 看到一堆带类名的属性名,会很困惑。而且子类访问起来也很别扭。
所以很多 Python 开发者更倾向于用单下划线 _——它是约定俗成的“内部使用”,不会触发名称修饰,调试起来也清爽。
_ 前缀在 Python 社区里是一个共识:看到 _ 开头的东西,就知道“这是内部实现细节,外部不应该依赖它”。它不是强制的,但大家都遵守这个默契。
其实还有一个坑:property 和继承的关系
再补充一个顺带踩过的坑——@property 在继承里的表现和你想的也不一样。
class Parent:
@property
def name(self):
return "Parent"
class Child(Parent):
@property
def name(self):
return "Child"
子类重写父类的 property,这个没问题,和普通方法重写一样。
但如果你想重写父类 property 的 setter,事情就微妙了:
class Parent:
@property
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
class Child(Parent):
@name.setter # 这里会报错——找不到 name
def name(self, value):
self._name = value.upper()
这种情况下,你不能直接用 @name.setter,因为父类的 name property 在子类作用域里访问起来比较绕。
正确的做法是重写整个 property,或者用更复杂的 super() 配合。但大多数情况下,如果你需要在子类里修改父类 property 的行为,直接用普通方法比 property 更省心。
这也是 property 的一个局限——它适合简单的封装,但在复杂的继承链里,行为会变得不那么直观。
什么时候用 property,什么时候不用?
踩完这些坑之后,我给自己定了一套使用规则:
用 @property 的场景:
- 需要加校验逻辑(比如年龄范围、邮箱格式)
- 需要做惰性计算(比如第一次访问时才去查数据库)
- 需要把内部数据结构的变化对用户隐藏(比如把列表改成字典,但外部接口不变)
不用 @property 的场景:
- 只是一个普通的值存储,没有任何附加逻辑——直接用普通属性
- 赋值操作有复杂的副作用(比如修改一个属性要同时更新多个地方)——用普通方法更清晰
- 继承层次复杂,子类需要频繁重写行为——用普通方法更可控
最重要的原则是:**@property 是用来“封装”的,不是用来“包装”的。** 如果你只是把 self.age 包装成 @property 然后直接返回 self._age,啥额外的逻辑都没有,那就纯属多此一举。直接写 self.age = value 就完了,别给自己找麻烦。
写在最后
那次爆栈之后,我每次写 @property 的 setter,都会下意识地看一眼里面有没有 self.xxx = value——如果有,立刻改成 self._xxx = value。
这个习惯帮我避免了很多次同样的错误。
其实说白了,@property 的递归问题和 _ 前缀的约定,本质上都是同一个问题:你混淆了“接口”和“存储”。
- 接口是
age——外部用这个来读写,背后可能有一堆逻辑 - 存储是
_age——真正的数据放在这儿,不对外暴露
把这两个分开,你就能安全地使用 @property。混在一起,递归就在前面等着你。
至于“私密约定”——_ 前缀不是用来“防止”别人访问的,而是用来“提醒”别人的。它说:“哥们,这个是我的内部数据,你别直接动。你真要动我也不拦你,但你得自己承担后果。”
Python 的哲学就是这样的——大家都是成年人,自己对自己负责。
希望你的代码里,永远不会出现 RecursionError。