R

ReflexKR

Reflex 官方文档

名牌机场:Python 对象模型

·

课程概览

1

标签不是箱子:名字与对象

2

封口的箱子:可变与不可变

3

箱中箱:容器与复制

4

交出箱子的时候:函数与参数

5

区域与通行证:作用域

6

随身行李:闭包

7

箱子规格书:类与实例

8

机场标准流程:双下划线方法

9

规格书的规格书:继承与 MRO

10

安检口:属性访问的秘密

11

无主的箱子:内存与 GC

12

储物柜号码:哈希与相等,综合

名牌机场:Python 对象模型
第10课

时间 7:30–8:20

安检口:属性访问的秘密

失物报告单

LOST-010

重量为负数的箱子

报告人

称重系统维护工程师 吴主任

丢了什么

秤的记录。明明加了重量检查,记录里却出现了一个 -10kg 的箱子。

结案条件

找出绕过检查的路径,并让无论从哪条路放进来都必须经过安检口。

天空岛国际机场 行李中心

吴主任为了拦住负数重量,让 Bag 在创建时检查重量。可记录里还是出现了一个 -10kg 的箱子。都美兰班长翻着出入记录:“正门设了安检口,它是从侧门进来的。”

今天来看读写 obj.x 的那一刻 Python 要经过的安检口。这也是第 7 课说的“先实例、后类”这条读取规则的全貌。

10-1. @property:给属性设安检口(9 分钟)

这是报告人的代码。检查只写在 __init__ 里。

class Bag:
    def __init__(self, kg):
        if kg < 0:
            raise ValueError("negative weight")
        self.kg = kg

b = Bag(20)
b.kg -= 30
print(b.kg)
# -10

创建时通过了检查,可之后的 b.kg -= 30 直接把值写进了实例的 __dict__。这就是侧门。要给属性写入本身加上检查,就用 @property。

class Bag:
    def __init__(self, kg):
        self.kg = kg
    @property
    def kg(self):
        return self._kg
    @kg.setter
    def kg(self, value):
        if value < 0:
            raise ValueError(f"negative weight: {value}")
        self._kg = value

b = Bag(20)
print(b.kg, vars(b))
# 20 {'_kg': 20}
b.kg -= 30
# ValueError: negative weight: -10
  • 读取 b.kg 时调用第一个 kg 函数,写入 b.kg = v 时调用 @kg.setter 函数。表面上就像普通属性一样用。
  • __init__ 里的 self.kg = kg 也会经过 setter。检查只需要放在一处。
  • 真正的值存放在 _kg 里。kg 这个名字不在实例上,而是类上的一个 property 对象。

不提供 setter,就成了只读属性。适合由其他属性计算出来的值。

class Box:
    def __init__(self, w, h):
        self.w, self.h = w, h
    @property
    def area(self):
        return self.w * self.h

x = Box(3, 4)
print(x.area, type(Box.__dict__["area"]).__name__)
# 12 property
x.area = 5
# AttributeError: property 'area' of 'Box' object has no setter

小钉提示

→ 像 _kg 这样的单下划线,只是“请别碰”的约定,并不是锁。b._kg = -5 是拦不住的。安检口守护的是正常路径(b.kg = …)。

10-2. 描述符:把安检口写进规格书(10 分钟)

报告人的箱子不只有重量(kg),还有长度(cm)。每个属性都复制一份 property,检查代码就会越来越多。可以做一个安检口,立在多个属性上。这就是描述符。

描述符是至少拥有 __get__、__set__(或 __delete__)其中之一的对象。把它作为类属性放着,从实例读写这个名字时,Python 就会调用这些方法。

class Positive:
    def __set_name__(self, owner, name):
        print(f"set_name: {owner.__name__}.{name}")
        self.name = name
    def __get__(self, obj, owner=None):
        if obj is None:
            return self
        return obj.__dict__[self.name]
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name} must be >= 0: {value}")
        obj.__dict__[self.name] = value

class Bag:
    kg = Positive()
    cm = Positive()
    def __init__(self, kg, cm):
        self.kg, self.cm = kg, cm
# set_name: Bag.kg
# set_name: Bag.cm

b = Bag(20, 55)
print(b.kg, b.cm, vars(b))
# 20 55 {'kg': 20, 'cm': 55}
print(type(Bag.kg).__name__)
# Positive
b.cm = -1
# ValueError: cm must be >= 0: -1
什么时候Python 调用的是
类创建完毕时Positive.__set_name__(安检口, Bag, "kg")
读取 b.kg 时Positive.__get__(安检口, b, Bag)
读取 Bag.kg 时Positive.__get__(安检口, None, Bag)
写入 b.kg = 20 时Positive.__set__(安检口, b, 20)
  • __set_name__(3.6)在 class 语句结束的那一刻,告诉描述符它被挂在了哪个名字上。所以同一个 Positive 既能立在 kg 上,也能立在 cm 上。
  • 安检口对象在类上只有一个,所有箱子共用。所以值不是记在安检口里,而是记在每个箱子的 __dict__ 里。
  • 值是以同名 "kg" 放进 __dict__ 的,可读取时走的却不是 __dict__,而是安检口。这个顺序是 10-4 的主题。
  • @property 也是 Python 预先做好的一个描述符。

标签板

步骤 1 / 0

下一行将执行: 0

正在准备实验台…

刚刚变化的

封口的箱子(不可变)

class Bag 盒子里的 kg 和 cm 是各自记着 name 的 Positive 安检口。最后一行里,kg 的值进了箱子的 __dict__,cm = -1 则被安检口拦了下来。

10-3. 函数变成方法的原理(7 分钟)

第 7 课里我们看到 bag.m is bag.m 是 False。原因是函数也是描述符。函数只有 __get__,没有 __set__,是非数据描述符。

class Bag:
    def weigh(self):
        return 20

b = Bag()
f = Bag.__dict__["weigh"]
print(type(f).__name__, hasattr(f, "__get__"), hasattr(f, "__set__"))
# function True False
m = f.__get__(b, Bag)
print(type(m).__name__, m.__self__ is b, m())
# method True 20
print(b.weigh is b.weigh, b.weigh == b.weigh, Bag.weigh is f)
# False True True

每次读取 b.weigh,Python 都会调用 f.__get__(b, Bag),生成一个新的绑定方法,也就是把函数和 b 绑在一起的小对象。所以 is 是 False,== 是 True。b.weigh() 不用传 self 就能调用,也正是这个绑定的功劳。从类上读取(Bag.weigh)时,没有可绑定的实例,得到的就是函数本身。

staticmethod 和 classmethod,是改变了 __get__ 绑定对象的描述符。

class Bag:
    @staticmethod
    def kg_to_lb(kg):
        return round(kg * 2.2046, 1)
    @classmethod
    def empty(cls):
        return cls()

class Suitcase(Bag):
    pass

print(Bag.kg_to_lb(20), Bag().kg_to_lb(20))
# 44.1 44.1
print(type(Suitcase.empty()).__name__, Suitcase.empty.__self__ is Suitcase)
# Suitcase True
print(type(Bag.__dict__["empty"]).__name__)
# classmethod
__get__ 绑定的第一个参数
普通函数实例self
staticmethod什么都不绑无
classmethod类(type(obj))cls

classmethod 接收的是调用方的类,所以 Suitcase.empty() 创建的是 Suitcase。它适合做继承之后也能造出正确规格的“替代构造器”。

10-4. 安检口的通过顺序(9 分钟)

读取 obj.x 时,Python(准确地说是 object.__getattribute__)按这个顺序查看:

顺序看哪里例子
1类(MRO)上的数据描述符(有 __set__ 或 __delete__ 的)property、Positive
2实例 __dict__b.label = "ICN"
3类上的非数据描述符(只有 __get__)函数、staticmethod、classmethod
4类上的普通属性kind = "bag"
5__getattr__(如果有)
6AttributeError

数据描述符排在实例 __dict__ 前面,所以安检口会先于箱子里的值被调用。这就是 10-2 的 Positive 用同名往 __dict__ 里放值也没问题的原因。反过来,函数排在 __dict__ 后面,所以在实例上用同名,方法就会被遮住。

在下面的流程图里逐个选属性看看。它会显示在第几步得出答案。

安检流程图

1

class Positive:

2

def __set_name__(self, owner, name):

3

self.name = name

4

def __get__(self, obj, owner):

5

return obj.__dict__[self.name]

6

def __set__(self, obj, value):

7

obj.__dict__[self.name] = value

8

class Bag:

9

kg = Positive()

10

kind = "bag"

11

def weigh(self):

12

return self.kg

13

def __repr__(self):

14

return "Bag()"

15

b = Bag()

16

b.kg = 20

17

b.label = "ICN"

b.

1

类上有数据描述符(__get__ + __set__)吗?

2

在实例的 __dict__ 里吗?

3

类上有非数据描述符(像函数一样只有 __get__)吗?

4

类上有普通属性吗?

5

有 __getattr__ 吗?

6

AttributeError

结果: b. →

kg 虽然 __dict__ 里也有,但在第 1 步(数据描述符)就先得出了答案。label 在第 2 步,weigh 在第 3 步(函数),kind 在第 4 步,color 哪里都没有,所以是 AttributeError。

这次故意往实例 __dict__ 里放同名的值。最后一行是为了避开安检口而直接写进 __dict__ 的“侧门”。

安检流程图

1

class Bag:

2

kind = "bag"

3

def weigh(self):

4

return 20

5

@property

6

def tag(self):

7

return "T-" + self.kind

8

def __getattr__(self, name):

9

return f"<no {name}>"

10

b = Bag()

11

b.weigh = "shadowed"

12

b.kind = "suitcase"

13

b.__dict__["tag"] = "fake"

b.

1

类上有数据描述符(__get__ + __set__)吗?

2

在实例的 __dict__ 里吗?

3

类上有非数据描述符(像函数一样只有 __get__)吗?

4

类上有普通属性吗?

5

有 __getattr__ 吗?

6

AttributeError

结果: b. →

tag 即使 __dict__ 里有 'fake',也是 property(第 1 步)赢。weigh 和 kind 被 __dict__(第 2 步)遮住了函数和类属性。color 哪里都没有,由 __getattr__(第 5 步)来回答。
  • 没有 setter 的 property 也是数据描述符。property 总是有 __set__,没有 setter 时就在里面抛出 AttributeError。所以它没法被 __dict__ 遮住。
  • 写入(b.x = v)的顺序更短:类上有带 __set__ 的描述符就调用它,没有就写进实例 __dict__。

小钉提示

→ 把顺序记成一行:“数据描述符 > 实例 __dict__ > 函数这类非数据描述符 > 类属性 > __getattr__”。

10-5. __getattr__、__getattribute__,以及 type(5 分钟)

这两个名字相近的把手,被调用的时机不同。

  • __getattribute__:每一次读取属性时总会被调用。10-4 的整个顺序都在 object.__getattribute__ 里面。自己重写它很容易陷入无限递归,所以几乎不用。
  • __getattr__:只在上面的顺序里找不到时才被调用,是最后的救援队。
class Loud:
    def __init__(self):
        self.kg = 20
    def __getattribute__(self, name):
        print("getattribute", name)
        return super().__getattribute__(name)
    def __getattr__(self, name):
        print("getattr", name)
        return None

x = Loud()
v = x.kg
# getattribute kg
v = x.color
# getattribute color
# getattr color

最后,既然类也是对象,类的类型是什么?是 type。class 语句其实等同于调用 type(名字, 父类们, 属性 dict)。

class Bag:
    kind = "bag"

print(type(Bag()).__name__, type(Bag), type(type))
# Bag <class 'type'> <class 'type'>
Dyn = type("Dyn", (Bag,), {"kind": "dyn"})
print(Dyn.kind, [c.__name__ for c in Dyn.__mro__])
# dyn ['Dyn', 'Bag', 'object']

继承 type、改变类本身的创建过程,这叫元类。大多数事情用 __init_subclass__(第 9 课)和描述符就够了,这门课里记住这个名字就好。

结案(10 分钟)

把检查搬到 Positive 安检口上,无论是创建、修改,还是用 -= 减少,都要经过同一个安检口。

class Positive:
    def __set_name__(self, owner, name):
        self.name = name
    def __get__(self, obj, owner=None):
        return self if obj is None else obj.__dict__[self.name]
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError(f"{self.name} must be >= 0: {value}")
        obj.__dict__[self.name] = value

class Bag:
    kg = Positive()
    cm = Positive()
    def __init__(self, kg, cm):
        self.kg, self.cm = kg, cm

b = Bag(20, 55)
for change in (lambda: setattr(b, "kg", -1), lambda: Bag(-3, 40)):
    try:
        change()
    except ValueError as e:
        print("blocked:", e)
# blocked: kg must be >= 0: -1
# blocked: kg must be >= 0: -3
b.kg -= 30
# ValueError: kg must be >= 0: -10

用 setattr 放进去、新创建、用 -= 减少,统统被拦下了。b.kg -= 30 分两步:读取 b.kg(__get__),再把减后的值写回去(__set__),所以躲不开安检口。

吴主任重新跑了一遍秤的记录,再也没有出现负数重量。班长说:“与其把门堵上,不如让每条路都经过安检口。”

结案报告 — LOST-010。重量检查只在 init 里,之后的 b.kg -= 30 绕过了检查。把检查移到数据描述符(Positive)上,让所有写入都经过安检口,问题解决。

课后练习

  1. 修改 Positive,让它接收一个下限值(kg = AtLeast(0)、cm = AtLeast(10))。把在 __init__ 中收到的值用于检查即可。
  2. 在 10-4 第一个流程图的代码里加上 b.weigh = "x",b.weigh 会在第几步得出答案?b.kg = "x" 呢?
  3. 在只定义了 __getattr__ 的类里,读取在 __init__ 中设置的属性时,__getattr__ 会不会被调用?用 print 确认一下。

班长的工作手册

  • 用 @property 给属性的读写加检查。检查放在 setter 一处。没有 setter 就是只读。

  • 描述符是拥有 __get__/__set__ 的类属性。通过 __set_name__ 知道自己挂在哪个名字上,可在多个属性上复用。

  • 函数是只有 __get__ 的非数据描述符。每读一次 b.m,就生成一个新的绑定方法。

  • staticmethod 什么都不绑,classmethod 绑定类。

  • 读取顺序:数据描述符 > 实例 __dict__ > 非数据描述符 > 类属性 > __getattr__。

  • __getattribute__ 总会被调用,__getattr__ 只在找不到时调用。类的类是 type。

← 上一课

第9课 · 规格书的规格书:继承与 MRO

下一课 →

第11课 · 无主的箱子:内存与 GC

订阅新课时和新课程通知

本课程新增课时或推出后续课程时,我们会发邮件通知你。

收集项目:电子邮箱 · 用途:本课程的新课时和上线通知 · 保存期限:直到你要求删除。不会用于其他用途,也不会提供给他人。回复任意通知邮件,我们会立即删除。

问答

关于本课有任何疑问都可以在这里留言。所有语言版本页面的提问会汇集在一起。

以纯文本显示,不支持链接和格式。

R

ReflexKR

动手学 Python 和 Reflex。所有示例均已在 Reflex 0.10.0 上实际运行验证。

ReflexKR 免费,没有横幅广告。如果对你有帮助,欢迎请我们喝杯咖啡。 ☕ 请我们喝杯咖啡

联盟链接与赞助说明
한국어English日本語简体中文EspañolDeutsch

© 2026 ReflexKR · Since 2026 · CC BY-NC 4.0

Built with Reflex