06 · 对象模型:引用语义、可变性与上下文管理器¶
先讲给朋友听(如果你只有一分钟)¶
Java 的变量像"遥控器":每个变量手里握着一个明确的对象,复制遥控器,各按各的电视。Python 的变量像"标签贴":对象才是本体,变量只是贴在对象上的一张便利贴。一张电视上可以贴两张"客厅电视"的标签——你撕其中一张,发现电视没变,但你以为贴的是两台电视时,事故就发生了。
你的 Aha Moment¶
史上最著名的 Java→Python 头七天地狱,来,亲手按下去:
第一次跑出这个结果的人,表情管理基本失控:"我把货卸 b 车里,凭什么 a 的后备箱也满了?!"
答案就藏在比喻里:b = a 不是"把 a 的内容复制给 b",而是把 a 这张便利贴撕下来,原封不动贴到了同一个列表对象上。之后 b.append(4) 动的是那个列表本体——a 也贴在同一个本体上,它"看见"一切。
那为什么 x = 1; y = x; y = 2 之后 x 没变?因为 y = 2 不是"改 y 指向的对象",而是把 y 标签撕下来贴到新对象 2 上——整数是不可变对象,谁也别想原地动它。
Aha 语法细节:b = a 让两个标签共享本体;y = 2 只是给 y 换贴新对象。 前者是"引用同一对象",后者是"重新绑定名字"。分不清这两件事,你会在 RAG 实战里把文档对象改得面目全非还以为有鬼。
不讲术语的版本:一张便利贴的宇宙¶
第一层:一切皆对象,变量皆标签。
Python 没有 Java 的"原始类型"(int/double),1 和 "abc" 都是对象,都住在堆里。变量(name)只是绑定到对象的便利贴。理解这句话,异常行为少一半。
第二层:可变与不可变的分界线。
| 不可变(贴上就别想动) | 可变(随时原地改) |
|---|---|
int float str bool tuple frozenset |
list dict set 自定义对象 |
判断口诀:不可变对象被修改时,其实是"换了个新对象再贴标签"。s += "x" 看似原地追加,实际是新造了个字符串。这个特性是 hashable(能进 set / 当 dict 键)的入场券——后面的缓存、去重全建立在上面。
第三层:别名(alias)是万恶之源。
两个 list 贴同一张脸的事故,函数入参、返回值、默认参数三个位置全是高发区。Java 的 shield(每次赋值复制引用、但修改互相可见——其实 Java 引用类型也有 alias 问题)只是没把"可变默认参数"这种坑原样奉上。
第四层:with 语句——try-with-resources 的亲儿子。
Java 7 的 try-with-resources 是工程史上的进步,Python 的 with 是更早的同类智慧,且更通用:任何对象只要实现 __enter__/__exit__,就能被 with 托管。开关文件、拿数据库连接、锁并发、甚至是给 LLM 调用计时/计费——第 22 篇的 token_meter 会把 Timer 升级成“计时+计费”二合一。
Java 老司机对照室¶
| Java 8 的你以为 | Python 的真相 | 心法迁移 |
|---|---|---|
| 原始类型在栈上 | 全部在堆上,int 也是对象 |
值语义只存在于心理层面 |
str 不可变 |
str 同样不可变 |
难得的共识,放心用 |
| 对象引用赋值 → alias 同样存在 | 一模一样,但语法糖更诱人 b = a |
alias 是跨语言公敌 |
clone() / new 拷贝 |
copy.copy() 浅拷贝 / copy.deepcopy() 深拷贝 |
显式到必须 import copy |
try (var r = open()) {} |
with open(...) as f: |
Python 版出生于 2006 年,早 Java 五年 |
record Point(int x, int y) |
@dataclass class Point: |
dataclass 默认可变,record 默认不可变,反的 |
equals() / hashCode() |
__eq__() / __hash__() |
魔术方法命名约定 |
Objects.equals(a, b) |
a == b 即内容比较 |
str/list/dict 的 == 全是内容比较 |
判断同一对象 == |
is(就是比的身份证) |
a is None 才是正确打开方式 |
倒数第二行值得划线:Java 的 == 比身份,Python 的 == 比内容——所以 "abc" == "abc" 两边都 True;要判"是不是同一个对象",Python 用 is。新人写 x == None 是经典事故现场(虽然能跑)。
动手实验室¶
实验 1:亲眼制造一次"灵异事件"¶
a = [1, 2, 3]
b = a
b.append(4)
print("a =", a) # a = [1, 2, 3, 4] 案发现场
print("a is b:", a is b) # True 实锤:同一个身份证
import copy
a = [[1], [2]] # 嵌套结构:两个"抽屉"
c = copy.copy(a) # 浅拷贝:复制最外层列表,内层对象共享
d = copy.deepcopy(a) # 深拷贝:连内层的对象一起复制
a[0].append(99) # 改的是内层对象
print("a =", a) # [[1, 99], [2]]
print("c =", c) # [[1, 99], [2]] 浅拷贝被波及:内层是同一个对象
print("d =", d) # [[1], [2]] 深拷贝与世隔绝
浅拷贝 vs 深拷贝一句话:浅拷贝复制桌面,深拷贝复制桌面+抽屉+抽屉里的盒子。 注意别被"浅=差"的直觉骗了——浅拷贝复制最外层,所以外层增删不受影响(上面例子里给 a 整体 append 元素,c 感知不到);真正的坑在内层共享。RAG 实战里 document.chunks 这种嵌套结构,只动外层用浅拷,改 chunk 内容必须深拷。
实验 2:可变默认参数的连续作案¶
def collect(items=[]):
items.append("污染")
return items
print(collect()) # ['污染']
print(collect()) # ['污染', '污染']
print(collect()) # ['污染', '污染', '污染']
解药只有一个,社区规范写法:
def collect(items=None):
if items is None:
items = []
items.append("干净")
return items
print(collect(), collect()) # ['干净'] ['干净']
把 def collect(items=[]) 的手感从肌肉记忆里抠掉,换成 None 哨兵。 这是 01 篇留的债,今天连本带利还清。
实验 3:with——资源管理的体面写法¶
import time
class Timer:
"""一个给 LLM 调用计时的上下文管理器"""
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.elapsed = time.perf_counter() - self.start
return False # 异常照常抛出,不吞
with Timer() as t:
time.sleep(0.1) # 假装这是 LLM API 调用
print(f"本次调用耗时 {t.elapsed:.2f}s") # 约 0.10s
__enter__ 进门、__exit__ 出门(异常也走这个门),return False 意味着"有事说事,异常别拦"。第 22 篇的 token_meter 就是把 Timer 升级成“计时 + 计费”二合一的完全体——同一个 with 结构,加几行就是工程化的起点。
顺带认个亲:Python 标准库 contextlib 里有现成的 @contextmanager,用 yield 一行写出上下文管理器——05 篇那个"暂停键",在这里从语法糖变成了工程基建:
import time
from contextlib import contextmanager
@contextmanager
def timer(label):
"""用 yield 写上下文管理器 = 05 篇生成器的社会实践"""
start = time.perf_counter()
try:
yield # 暂停键:执行权在这里交回 with 块
finally:
print(f"{label} 耗时 {time.perf_counter() - start:.3f}s")
with timer("假装调 LLM"):
time.sleep(0.05)
# yield 出来的东西,就是 as 后面的变量:第 22 篇 token_meter 的骨架
@contextmanager
def token_meter():
budget = {"tokens": 0}
class Meter:
def add(self, n):
budget["tokens"] += n
yield Meter() # with ... as m 的 m,就是这个对象
print(f"本次消耗 {budget['tokens']} tokens")
with token_meter() as m:
m.add(120)
m.add(80)
作者机器实测输出:
两句话看懂它:
yield之前是进门、之后是出门,异常也走finally这条路——所以@contextmanager版本天然比手写__exit__少一个"别忘了return False"的坑。yield出来的对象就是as m。上面这个token_meter已经是第 22 篇计费组件的完整心脏了:with进来开账本,m.add()记流量,出门自动结账。你要做的只是把time.sleep换成真 LLM 调用。
至此 05 和 06 篇正式合流:yield 既是懒数据的传送带(05),也是资源管理的开关(06)。 同一个语法,两副面孔。
实验 4:dataclass——Java record 的孪生兄弟(但性格相反)¶
from dataclasses import dataclass, field
@dataclass
class Document:
content: str
source: str = "unknown"
score: float = 0.0
tags: list = field(default_factory=list) # 可变默认值的正确姿势
d1 = Document(content="年假入职满一年享有")
d2 = Document(content="年假入职满一年享有")
print(d1) # Document(content='...', source='unknown', score=0.0, tags=[])
print(d1 == d2) # True:dataclass 自动生成 __eq__,比内容
print(d1.source) # unknown
d1.tags.append("HR") # 独立 mutable,不串味
Java record 是不可变的(想改只能 new);dataclass 默认可变,更像个轻量 POJO。field(default_factory=list) 是 None 哨兵的正式编制版。 第 21 篇评测集会用 @dataclass 批量定义评测用例(输入 + 期望 + 类别),第 13 篇的文档块、检索结果也常用这个造型承载,提前认脸。
实验 5:slots——几十万文档块的内存瘦身术¶
还记得实验 4 那个 Document 吗?第 13 篇切完公司文档,你手里会有几万个甚至几十万个这种对象。普通实例每个都拖着一个小字典(__dict__)装属性,内存哗哗地流;__slots__ 把字典换成定长槽位,内存直接瘦一圈:
import sys
class Chunk:
def __init__(self, content, source, score):
self.content = content; self.source = source; self.score = score
class ChunkSlots:
__slots__ = ("content", "source", "score")
def __init__(self, content, source, score):
self.content = content; self.source = source; self.score = score
n = 100_000
a = [Chunk("年假满一年5天", "员工手册-年假", 0.71) for _ in range(n)]
b = [ChunkSlots("年假满一年5天", "员工手册-年假", 0.71) for _ in range(n)]
sa = sum(sys.getsizeof(o) + sys.getsizeof(o.__dict__) for o in a[:1000]) / 1000
sb = sum(sys.getsizeof(o) for o in b[:1000]) / 1000
print(f"对象数 n = {n:,}")
print(f"普通实例单对象内存 {sa:.0f} B,slots {sb:.0f} B,省 {100 - 100 * sb / sa:.0f}%")
print(f"总内存对比(MB):{sa * n / 1e6:.1f} vs {sb * n / 1e6:.1f}")
try:
b[0].new_attr = 1
except AttributeError as e:
print("slots 拦住动态加属性:", e)
# dataclass 的正式编制版
from dataclasses import dataclass
@dataclass(slots=True)
class DocSlots:
content: str
source: str = "unknown"
score: float = 0.0
d = DocSlots(content="年假满一年5天")
print(d, d.content)
try:
d.nope = 1
except AttributeError as e:
print("dataclass(slots=True) 同样拦截:", e)
作者机器实测输出(CPython 3.13.13,macOS arm64):
对象数 n = 100,000
普通实例单对象内存 144 B,slots 56 B,省 61%
总内存对比(MB):14.4 vs 5.6
slots 拦住动态加属性: 'ChunkSlots' object has no attribute 'new_attr' and no __dict__ for setting new attributes
DocSlots(content='年假满一年5天', source='unknown', score=0.0) 年假满一年5天
dataclass(slots=True) 同样拦截: 'DocSlots' object has no attribute 'nope' and no __dict__ for setting new attributes
十万个对象省出将近 9 MB——听起来不多,但这是唯一一处"改一行属性声明就见效"的优化,而且它顺便堵住了一个真实的生产事故源:动态往对象上挂临时属性(doc.tmp = xxx),代码一散就没人记得它从哪来的。@dataclass(slots=True) 是正式编制版,Python 3.10+ 可用,dataclass 该有的照样有。
代价也要说清楚:slots 类不能随便加新属性,继承链上加 slots 容易踩坑。所以只对"定型了的海量小对象"用,别对三天两头改结构的配置类用。
实验 6:call 与 getattr——对象也能当函数,属性也能玩赖的¶
这两个是 Python 元编程的门把手,你看框架源码时天天见,今天一次认完。
__call__:让对象可被调用。 第 13 篇你要写一个检索器,它天然该长这样:
class Retriever:
"""可调用对象 = 第 13 篇 retrieve() 的身份证"""
def __init__(self, threshold=0.6):
self.threshold = threshold
self.calls = 0
def __call__(self, query):
self.calls += 1
print(f" 检索「{query}」阈值 {self.threshold},第 {self.calls} 次调用")
return [("员工手册-年假", "年假入职满一年享有5天", 0.71)]
retriever = Retriever()
print("callable(retriever):", callable(retriever))
hits = retriever("年假几天") # 对象当函数用,第 13 篇您就调它
print("命中:", hits)
实测输出:
为什么框架爱用可调用对象而不是裸函数?因为函数只有逻辑,对象还能带配置和状态——threshold 是配置,calls 是状态(顺手把调用次数记下来,第 21 篇做评测统计时你会感谢它)。Java 里这叫"带状态的功能接口实现",Python 里一个 __call__ 就完事。
__getattr__:按需加载的赖皮管家。 只在属性找不到时被触发,很多重型库用它做懒加载:
class LazyLib:
"""__getattr__:真身用到才加载(属性名拼错也能抓住)"""
ALLOWED = {"array", "zeros", "ones"}
def __init__(self, name):
object.__setattr__(self, "_name", name)
object.__setattr__(self, "_loaded", None)
def _load(self):
print(f" [lazy] 第一次访问才真加载 {self._name} ...")
return {"np": "numpy-2.x", "pd": "pandas-2.x"}[self._name]
def __getattr__(self, item):
if item.startswith("_") or item not in LazyLib.ALLOWED:
raise AttributeError(item)
return f"{self._load()}.{item}"
np = LazyLib("np")
print("还没碰 np,什么都没发生")
print("现在访问 np.array ->", np.array)
try:
np.nope
except AttributeError as e:
print("拼错照样报 AttributeError:", e)
实测输出:
np 在构造时不干正事,第一次 .array 才加载真身——"用到了才付代价",这正是第 11 篇你敢在脚本里 import 一堆 SDK 的底气。注意 __init__ 里用 object.__setattr__ 而不是 self.xxx = ...:后者会再触发一次 __getattr__ 绕圈子,这类细节是翻车重灾区。
顺带一个零成本的工程习惯:给类写 __repr__。上面那个 Timer 对象 print(t) 只会得到 <__main__.Timer object at 0x10f3a2b40>,加上 __repr__ 返回 <Timer 运行了 0.055s>,日志里一眼认出它是谁。第 22 篇排查问题时你会回来谢这个习惯。
费曼复述卡¶
- 用"便利贴 vs 遥控器"的比喻,向同事解释:为什么
b = a后改 b,a 也跟着变?而y = 2改 y,x 不变? - 说出浅拷贝和深拷贝的区别,并举一个 RAG 实战里非用深拷贝不可的场景。
with Timer() as t:这段代码,等价于 Java 的什么语法?__exit__的返回值控制什么行为?- 用"暂停键"解释:
@contextmanager里yield之前和之后的代码,分别在什么时刻执行?
三题过不了,回到"不讲术语的版本"第一层。便利贴宇宙是 Python 的"世界观",方法论比语法重要——世界观不换,写三年也是 Java 翻译腔。
今日一句话¶
Java 的变量握遥控器,Python 的变量贴便利贴——
b = a贴的是同一张脸,is查户口,==比长相,深拷贝连抽屉一起搬。