在 JavaScript 中,两个分别创建的对象,即使属性完全一样,a === b 也会得到 false。带着这个经验读 Python,下面的代码就容易让人困惑:
validated_config = self._process_and_validate_model_config(
draft_app_config.model_config
)
if draft_app_config.model_config != validated_config:
self.update(draft_app_config, model_config=validated_config)这段代码看起来想表达:处理配置后,如果配置发生了变化,就保存新配置。但仅凭这几行,还不能确定它是否实现了这个意图。需要知道两个值是什么类型,以及处理函数有没有修改原对象。
理解它的起点是:Python 的 == 使用对象所属类型的相等规则,is 判断两边是否指向同一个对象。 字典已经提供了内容比较,自定义类则不一定。本文从 JavaScript 的对象比较出发,逐步解释这套规则,再回到配置更新的例子。
1. 内容一样,与同一个对象,是两个问题
先在浏览器控制台或 Node.js 中运行:
const a = { model: "demo-model", temperature: 0.8 };
const b = { model: "demo-model", temperature: 0.8 };
const c = a;
console.log(a === b); // false
console.log(a === c); // true
console.log(a == b); // false执行完前三行后,可以把变量和对象的关系画成:
a ──→ 对象 A:{ model: "demo-model", temperature: 0.8 }
c ──↗
b ──→ 对象 B:{ model: "demo-model", temperature: 0.8 }a 和 b 指向分别创建的对象,c = a 则让 c 也指向对象 A,并没有复制对象。对于两边都是对象的情况,JavaScript 的 === 和 == 都判断是否为同一个对象;把三个等号换成两个,也不会变成属性比较。参见 ECMAScript 规范中的 IsStrictlyEqual 与 IsLooselyEqual。
Python 中也存在同样的引用关系。下面每个独立示例都可以保存为 .py 文件,用 python3 文件名.py 运行:
a = {"model": "demo-model", "temperature": 0.8}
b = {"model": "demo-model", "temperature": 0.8}
c = a
print(a == b) # True:两个字典的键值对相等
print(a is b) # False:分别创建了两个字典
print(a is c) # True:c 和 a 指向同一个字典这里的 {...} 是 Python 的 dict(字典)。a == b 为真,并不表示它们已经成为同一个对象。 后续修改 a,b 不会因此跟着变化,而 c 能看到这次修改:
# 接在上一个 Python 示例后面
a["temperature"] = 0.5
print(b["temperature"]) # 0.8
print(c["temperature"]) # 0.5
print(a == b) # False因此,两种语言可以这样对照,但要保留“对象”这个前提:
| 想判断什么 | Python | JavaScript |
|---|---|---|
| 两个变量是否指向同一个对象 | a is b | 两边都是对象时,a === b |
| 是否指向不同对象 | a is not b | 两边都是对象时,a !== b |
| 两个对象是否按某套内容规则相等 | a == b,具体规则取决于类型 | 编写比较函数,或使用适合数据结构的比较工具 |
| 是否按该类型的规则不相等 | a != b | 使用对应的比较函数判断 |
不能把这张表扩展为“Python 的 is 就是 JavaScript 的 ===”。JavaScript 的 === 对数字、字符串等基本类型还会进行值比较;Python 比较数字、字符串的值应使用 ==。
2. 字典已经定义了内容比较
Python 字典判断相等时,看键值对是否相等,不要求插入顺序相同:
old_config = {"model": "demo-model", "temperature": 0.8}
same_config = {"temperature": 0.8, "model": "demo-model"}
changed_config = {"model": "demo-model", "temperature": 0.5}
print(old_config == same_config) # True
print(old_config != same_config) # False
print(old_config != changed_config) # True第二个字典虽然先写 temperature,但仍然包含相等的键和值,所以比较结果为真。第三个字典的温度值不同,所以不相等。规则见 Python 文档:字典比较。
如果原代码里的两个配置都是这种字典,那么 old_config != validated_config 就能判断键值对是否不同,不需要再写一遍字段比较。
对于只有这两个字段的 JavaScript 对象,可以手动表达类似的业务规则:
function sameModelConfig(a, b) {
return a.model === b.model && a.temperature === b.temperature;
}
const oldConfig = { model: "demo-model", temperature: 0.8 };
const newConfig = { model: "demo-model", temperature: 0.8 };
console.log(oldConfig === newConfig); // false
console.log(sameModelConfig(oldConfig, newConfig)); // true这个函数只比较指定的两个字段;如果对象多出第三个字段,它仍然会忽略那个字段。因此它只是当前业务结构的比较函数,并不等价于任意 Python 字典的完整比较。
嵌套内容会继续按各自类型的规则比较
a = {"sampling": {"temperature": 0.8}, "stop": ["END", "STOP"]}
b = {"stop": ["END", "STOP"], "sampling": {"temperature": 0.8}}
print(a == b) # True
b["stop"].reverse()
print(a == b) # False:列表中的元素顺序变了外层字典比较对应的值时,内层字典和列表也会参与比较。字典不要求键的插入顺序一致,列表则要求长度、对应位置的元素相等。参见 Python 文档:序列比较。
这不意味着 Python 会自动遍历任意对象的全部属性。如果字典里放的是自定义实例,比较这些值时仍然要看实例所属类型提供了什么规则。
3. 普通自定义类不会自动按属性比较
把配置写成一个普通类:
class ModelConfig:
def __init__(self, model, temperature):
self.model = model
self.temperature = temperature
a = ModelConfig("demo-model", 0.8)
b = ModelConfig("demo-model", 0.8)
print(a.model == b.model) # True
print(a.temperature == b.temperature) # True
print(a == b) # False
print(a is b) # False
print(a == a) # True这里的 __init__ 只负责初始化属性,没有声明怎样判断两个实例相等。这个直接继承 object、未自定义比较的类,默认表现为按对象身份判断相等,所以两个独立实例不相等。
同样写成 a == b,字典和这个类的结果却不同。决定行为的是类型提供的规则,不能从“它们都是对象”推导出统一的内容比较方式。
用 __eq__ 定义“什么算相等”
下面是一个独立的完整示例:
class ModelConfig:
def __init__(self, model, temperature):
self.model = model
self.temperature = temperature
def __eq__(self, other):
if type(self) is not type(other):
return NotImplemented
return (
self.model == other.model
and self.temperature == other.temperature
)
a = ModelConfig("demo-model", 0.8)
b = ModelConfig("demo-model", 0.8)
c = ModelConfig("demo-model", 0.5)
print(a == b) # True
print(a is b) # False
print(a != b) # False
print(a != c) # True
print(a == 123) # False__eq__ 是 Python 为 == 约定的特殊方法名。当前例子里,self 是参与比较的一个实例,other 是另一个对象。方法先检查双方是否为完全相同的类型,再比较两个字段:模型名相等且温度相等,才返回 True。
这里选择“类型必须完全相同”,是这段代码的设计;也可以根据业务用其他类型兼容规则。NotImplemented 表示“这个方法不处理这组类型的比较”,让解释器继续尝试另一方的规则。它不是 False,也不是要抛出的 NotImplementedError。
== 不能机械替换为 a.__eq__(b)
用 a.__eq__(b) 理解最简单的调用很方便,但完整的运算符协议还包括另一方的方法和后备规则:
- 通常先尝试左边类型的
__eq__;如果右边的类型是左边类型的子类,右边的方法优先。 - 返回
NotImplemented时,解释器可以继续尝试另一方。 - 双方都不支持时,
==最终按is判断,!=最终按is not判断。
!= 对应的是 __ne__。继承自 object 的默认 __ne__ 会调用 __eq__,并在结果不是 NotImplemented 时取反,所以前面的类只写 __eq__ 就够了。但类也能单独定义 __ne__,因此不能把所有 a != b 都解释为 not a.__eq__(b)。这些规则见 Python 数据模型:比较方法。
阅读业务代码时,通常不必手动调用这些特殊方法。需要确认的是:当前类型以及它继承的父类,究竟提供了什么比较行为。
4. dataclass 和 Pydantic 为什么也能比较
dataclass 可以自动生成字段比较
如果类的主要作用是保存字段,可以用标准库的 dataclass:
from dataclasses import dataclass, field
@dataclass
class ModelConfig:
model: str
temperature: float
note: str = field(default="", compare=False)
a = ModelConfig("demo-model", 0.8, note="first")
b = ModelConfig("demo-model", 0.8, note="second")
c = ModelConfig("demo-model", 0.5)
print(a == b) # True
print(a is b) # False
print(a != c) # True@dataclass 默认启用 eq=True,在类没有自行定义 __eq__ 时生成比较方法。生成的规则要求实例类型完全相同,并比较参与比较的字段。这里 note 设置了 compare=False,因此即使备注不同,a 和 b 仍然相等。
这也说明“按内容比较”需要明确范围:并不是对象中所有能访问到的属性都一定参与。若设为 @dataclass(eq=False),装饰器不生成 __eq__,结果要看类本身和父类的实现。参见 Python 文档:dataclasses。
Pydantic 的比较规则要看版本
Pydantic 的 BaseModel 也实现了相等比较,但不能简单理解为“把模型转成字典再比较”。Pydantic V2 要求另一方也是 BaseModel 实例,比较会涉及模型类型、字段值、允许保存的额外字段以及私有属性;泛型模型的类型判断还有专门规则。模型与包含相同数据的普通字典不再相等,这一点与 V1 不同。参见 Pydantic V2 迁移指南:BaseModel 的变化。
所以,看到变量名叫 model_config,不能据此认定它是 Pydantic 模型。它也可能是字典或其他类。先看字段声明、实际运行时类型,以及项目所用库的版本,再解释这个比较。
5. 回到配置更新:比较时要保留处理前的状态
原始代码只展示了比较与更新的调用,没有给出类型和处理函数。下面用普通字典构造一个可运行的例子,演示这类判断成立需要什么条件。
把完整代码保存为 config_equality.py,运行 python3 config_equality.py。示例用打印表示保存动作,不连接数据库:
def normalize_config(config):
result = config.copy()
result.setdefault("temperature", 0.8)
return result
def update_if_changed(old_config):
new_config = normalize_config(old_config)
print("处理前:", old_config)
print("处理后:", new_config)
print("同一个对象:", old_config is new_config)
if old_config != new_config:
print("需要保存:", new_config)
else:
print("无需保存")
update_if_changed({"model": "demo-model"})
print("---")
update_if_changed({"model": "demo-model", "temperature": 0.8})输出:
处理前: {'model': 'demo-model'}
处理后: {'model': 'demo-model', 'temperature': 0.8}
同一个对象: False
需要保存: {'model': 'demo-model', 'temperature': 0.8}
---
处理前: {'model': 'demo-model', 'temperature': 0.8}
处理后: {'model': 'demo-model', 'temperature': 0.8}
同一个对象: False
无需保存关键变化发生在两行代码中:
config.copy()创建新字典,让旧字典保留原来的键值对。setdefault只在新字典缺少temperature时补入0.8;已有这个键时保留原值。
第一次调用时,旧字典缺少温度,新字典多了温度,内容不同,因此需要保存。第二次调用时,新字典虽然仍是另一个对象,但内容没有变化,因此无需保存。这里如果改用 is not,两次都会进入保存分支,失去“只在配置内容变化时保存”的效果。
如果处理函数直接修改传入的字典
def normalize_in_place(config):
config.setdefault("temperature", 0.8)
return config
old_config = {"model": "demo-model"}
new_config = normalize_in_place(old_config)
print(old_config) # {'model': 'demo-model', 'temperature': 0.8}
print(old_config is new_config) # True
print(old_config != new_config) # False这次的状态变化是:
| 时刻 | old_config 指向的内容 | new_config |
|---|---|---|
| 调用前 | 只有 model | 尚未赋值 |
| 函数执行时 | 原字典被补入 temperature | 尚未赋值 |
| 返回后 | 已包含两个字段 | 指向同一个、已修改的字典 |
配置确实被改了,但比较发生时,两个变量都只能看到修改后的状态。!= 比较的是此刻的两个值,不会记住函数调用之前的数据。
对于这个例子,让处理函数生成新字典即可保留旧状态。如果函数必须原地修改,也可以在调用前保存适合该数据结构的快照,再比较处理前后的数据。仅写 before = old_config 不算快照,它只是增加一个指向原对象的变量。
上面的 copy() 是浅拷贝,只新建外层字典。如果处理过程会修改嵌套字典或列表,内层对象仍可能被新旧字典共享。此时需要有针对性地复制会修改的部分,或对适合的数据使用深拷贝;不能仅因为外层 is 为假,就认定旧状态已经完整保留。参见 Python 文档:浅拷贝与深拷贝。
6. 读到对象比较时,按这三个问题判断
- 在问身份,还是在问业务上的相等? 判断是否同一个对象用
is;判断值是否相等用该类型支持的==。判断是否为None通常写value is None。 - 这个类型把什么算作相等? 字典比较键值对,列表比较顺序与元素,普通类可能只比较身份,
dataclass和第三方模型则有各自的字段与类型规则。 - 比较的两边还保留着需要比较的状态吗? 检查处理函数是否原地修改,以及复制后是否仍共享会被修改的嵌套对象。
最后再记住一个边界:Python 的 == 不是“严格检查类型和每个属性完全一致”。例如 1 == 1.0 为真,[1] == [1.0] 也为真;比较方法甚至可以返回非布尔对象,放进 if 时还会进行真值判断。对于陌生的第三方类型,应先读它的比较约定。参见 Python 文档:值比较。
从 JavaScript 转过来,最有用的理解是:Python 允许类型把“相等的定义”接到 == 上;is 始终用于对象身份判断。 这样再读配置更新代码,就能同时检查它比较的规则和处理前后的数据,而不只看运算符长什么样。