在 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

因此,两种语言可以这样对照,但要保留“对象”这个前提:

想判断什么PythonJavaScript
两个变量是否指向同一个对象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
无需保存

关键变化发生在两行代码中:

  1. config.copy() 创建新字典,让旧字典保留原来的键值对。
  2. 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. 读到对象比较时,按这三个问题判断

  1. 在问身份,还是在问业务上的相等? 判断是否同一个对象用 is;判断值是否相等用该类型支持的 ==。判断是否为 None 通常写 value is None。
  2. 这个类型把什么算作相等? 字典比较键值对,列表比较顺序与元素,普通类可能只比较身份,dataclass 和第三方模型则有各自的字段与类型规则。
  3. 比较的两边还保留着需要比较的状态吗? 检查处理函数是否原地修改,以及复制后是否仍共享会被修改的嵌套对象。

最后再记住一个边界:Python 的 == 不是“严格检查类型和每个属性完全一致”。例如 1 == 1.0 为真,[1] == [1.0] 也为真;比较方法甚至可以返回非布尔对象,放进 if 时还会进行真值判断。对于陌生的第三方类型,应先读它的比较约定。参见 Python 文档:值比较。

从 JavaScript 转过来,最有用的理解是:Python 允许类型把“相等的定义”接到 == 上;is 始终用于对象身份判断。 这样再读配置更新代码,就能同时检查它比较的规则和处理前后的数据,而不只看运算符长什么样。