self.state = WorkerState() 这个调用会触发 WorkerState 类的 __init__ 方法。

让我们来分解一下这个过程:

1. Python 中类的实例化过程

当你写下 ClassName(...) 这样的代码时,Python 解释器会执行以下两个主要步骤:

  1. __new__(cls, *args, **kwargs):

    • 这是一个特殊的静态方法,它的主要工作是创建并返回一个类的空实例
    • 你通常不需要自己去重写 __new__,除非你在做一些非常高级的事情(比如创建单例模式,或者子类化不可变类型如 intstr)。
    • Python 的基类 object 提供了默认的 __new__ 实现。
  2. __init__(self, *args, **kwargs):

    • __new__ 创建并返回一个实例后,这个实例会作为第一个参数 self 传递给 __init__ 方法。
    • __init__ 的作用不是“创建”对象,而是“初始化”这个已经创建好的空对象。它负责给这个对象设置初始属性值(比如 self.step = -1)。
    • __init__ 方法没有返回值(或者说,它应该返回 None)。

所以,self.state = WorkerState() 的完整流程是:

  1. 调用 WorkerState.__new__(WorkerState) 来创建一个 WorkerState 的空对象。
  2. 然后,用这个新创建的空对象作为 self,调用 WorkerState.__init__(self)

2. WorkerState 类中的 __init____post_init__

现在我们来看 WorkerState 类的具体定义:

import dataclasses
# ...
from dataclasses import dataclass

@dataclass
class WorkerState:
    step: int = -1
    log_history: List[Dict[str, float]] = None
    kv: Dict[str, Union[float, Dict]] = None

    def __post_init__(self):
        if self.log_history is None:
            self.log_history = []
        if self.kv is None:
            self.kv = {}

这里的关键是 @dataclass 装饰器。

@dataclass 是 Python 3.7+ 引入的一个非常有用的功能。当你用它来装饰一个类时,它会自动为你生成一些特殊方法,其中就包括 __init__

@dataclass 做了什么?

它会读取你在类中定义的带有类型注解的属性(step: int, log_history: List[...] 等),并自动生成一个像下面这样的 __init__ 方法:

# 这是 @dataclass 自动生成的 __init__ 方法(示意代码)
def __init__(self, step: int = -1, log_history: List[Dict[str, float]] = None, kv: Dict[str, Union[float, Dict]] = None):
    self.step = step
    self.log_history = log_history
    self.kv = kv

所以,当你调用 self.state = WorkerState() 时,实际上是在调用这个由 @dataclass 自动生成的 __init__ 方法。因为所有参数都有默认值,所以你可以不提供任何参数。此时:

  • step 会被设置为默认值 -1
  • log_history 会被设置为默认值 None
  • kv 会被设置为默认值 None

3. __post_init__ 的作用

@dataclass 还提供了一个特殊的钩子方法:__post_init__

这个方法会在 __init__ 方法执行完毕后立即被自动调用

它的主要用途是处理一些需要在所有字段都被初始化之后才能进行的逻辑。在 WorkerState 中,它的作用是:

  1. 检查 self.log_history 是否为 None。如果是,就把它设置成一个空的列表 []
  2. 检查 self.kv 是否为 None。如果是,就把它设置成一个空的字典 {}

为什么需要 __post_init__

这是一个很好的编程实践,特别是为了避免一个常见的陷阱:在函数默认参数中使用可变类型(如列表或字典)

如果你把 log_history 的默认值直接写成 []

@dataclass
class BadWorkerState:
    log_history: List[Dict[str, float]] = [] # 陷阱!

所有不提供 log_history 参数而创建的 BadWorkerState 实例将会共享同一个列表对象。修改一个实例的 log_history 会意外地影响到其他所有实例。

WorkerState 的设计通过 default=None__post_init__ 完美地避开了这个问题。每个实例都会在 __post_init__ 中创建自己独立的空列表和空字典,确保了实例之间的状态隔离。

总结

self.state = WorkerState() 这个调用,依次触发了以下过程:

  1. WorkerState.__new__(...): 创建一个 WorkerState 的空实例。
  2. @dataclass 自动生成的 __init__(...): 被调用,使用默认值初始化实例的 step, log_history, 和 kv 属性(此时 log_historykv 都是 None)。
  3. __post_init__(self): 在 __init__ 之后被自动调用,它进一步处理实例,将 log_historykvNone 变成各自独立的 []{}

所以,最终答案是:它调用了@dataclass 装饰器自动生成的 __init__ 方法,紧接着又调用了 __post_init__ 方法来完成最终的初始化。

不,None 不是一个可变对象。None 是一个不可变 (immutable) 的对象。

这是一个非常关键且常被误解的点,我们来详细梳理一下。

不可变对象 (Immutable Objects)

不可变对象指的是一旦创建,其内部状态(值)就永远不能被改变的对象。如果你试图“修改”它,实际上会创建一个新的对象。

常见的不可变类型包括:

  • 数值类型: int, float, complex
  • 字符串: str
  • 元组: tuple
  • 布尔值: True, False
  • None: NoneType 类型的唯一实例

示例:

x = 10
print(id(x))  # 打印 x 的内存地址

x = x + 1  # 这不是在“修改”原来的 10,而是创建了一个新的值为 11 的对象
print(id(x))  # 内存地址会改变

s = "hello"
print(id(s))

s = s + " world" # 创建了一个新的字符串 "hello world"
print(id(s)) # 内存地址会改变

可变对象 (Mutable Objects)

可变对象指的是创建后,其内部状态(内容)可以被原地修改 (in-place modification),而不会创建新对象。

常见的可变类型包括:

  • 列表: list
  • 字典: dict
  • 集合: set
  • 自定义的类实例 (除非你特意将它们设计成不可变的)

示例:

my_list = [1, 2, 3]
print(id(my_list)) # 打印列表的内存地址

my_list.append(4) # 这是在“原地”修改列表,在其末尾添加一个元素
print(my_list) # 输出 [1, 2, 3, 4]
print(id(my_list)) # 内存地址不会改变!

my_dict = {"name": "Alice"}
print(id(my_dict))

my_dict["age"] = 30 # 原地修改字典
print(my_dict) # 输出 {'name': 'Alice', 'age': 30}
print(id(my_dict)) # 内存地址不会改变!

为什么默认参数使用 None 是安全的?

现在回到最初的问题,为什么 log_history: List[Dict[str, float]] = None 是安全的,而 log_history: List[Dict[str, float]] = [] 是危险的。

危险情况: default = []

def bad_function(items=[]):
    items.append(1)
    print(items)

bad_function() # 输出: [1]
bad_function() # 输出: [1, 1]  <-- 意料之外的结果!
bad_function() # 输出: [1, 1, 1]

原因:函数定义在被解释器加载时,默认参数 [] 作为一个列表对象被创建一次。之后所有对 bad_function 的调用,如果没有提供 items 参数,都会共享这同一个列表对象。因此,对它的修改会累积下来。

安全情况: default = None

def good_function(items=None):
    if items is None:
        items = []  # 在函数体内创建新的列表
    items.append(1)
    print(items)

good_function() # 输出: [1]
good_function() # 输出: [1]
good_function() # 输出: [1]

原因

  1. 默认参数 None 是一个不可变对象。你不能“修改” None 本身。
  2. 在函数 good_function每次调用中,代码都会检查 items 是否是 None
  3. 如果是,items = [] 这行代码会在每次调用时都创建一个全新的、独立的空列表对象
  4. 因此,每次调用都是在操作一个崭新的列表,它们之间互不影响。

WorkerState 中的应用

WorkerState__post_init__ 方法中,正是应用了这种安全的模式:

@dataclass
class WorkerState:
    # 默认值是不可变的 None
    log_history: List[Dict[str, float]] = None 
    kv: Dict[str, Union[float, Dict]] = None

    def __post_init__(self):
        # 在对象初始化时,为每个实例创建新的可变对象
        if self.log_history is None:
            self.log_history = [] 
        if self.kv is None:
            self.kv = {}
  • __init__ (由 @dataclass 生成):
    • s1 = WorkerState() -> s1.log_history 被设为 None
    • s2 = WorkerState() -> s2.log_history 也被设为 None
  • __post_init__:
    • s1 调用 __post_init__ -> s1.log_history 变成一个新的空列表 [] (地址 0x...A)。
    • s2 调用 __post_init__ -> s2.log_history 变成另一个新的空列表 [] (地址 0x...B)。

这样就确保了 s1.log_historys2.log_history 是两个完全独立的对象,修改一个不会影响另一个。

总结:None 本身是不可变的,这使得它成为在函数或方法签名中充当可变类型默认值的完美“占位符”。真正的可变对象是在运行时(函数体或 __post_init__ 内部)为每个实例或每次调用动态创建的。

你提出了一个非常好的问题,这确实是 Python 中一个经典且容易让人困惑的“陷阱”。你说得对,s1s2两个 BadWorkerState 实例本身确实不是同一个对象,它们有各自独立的内存地址。

然而,它们内部的 log_history 属性却指向了同一个列表对象

让我们用一个生动的比喻和代码来彻底解释清楚这件事。

比喻:共享的“公共记事本”

想象一下,你开了一家公司,为每一位新员工 (BadWorkerState 实例) 分配工位。

  • 员工 s1 (id(s1)): 张三,工位号是 A01
  • 员工 s2 (id(s2)): 李四,工位号是 B02
  • 张三和李四是两个不同的人,工位也不同。这对应了 s1s2 是两个不同的实例对象。

现在,你对公司的工作流程做了如下规定(这对应了 BadWorkerState 类的定义):
“所有员工,如果我没有特别为你指定私人记事本,你们就都去用挂在墙上的那个公共记事本log_history 属性)来记录工作日志。”

这个“公共记事本”在公司成立(类被定义和加载)的那一刻,就已经被挂在墙上了,并且只有一本

接下来发生了什么?

  1. 张三 (s1) 入职:你告诉他:“你的记事本就是墙上那本。” 于是,张三的“记事本”指针就指向了那本公共记事本。
  2. 张三在记事本上写下了“完成了任务X”。
  3. 李四 (s2) 入职:你也告诉他:“你的记事本就是墙上那本。” 于是,李四的“记事本”指针也指向了同一本公共记事本。
  4. 李四翻开记事本,他不仅能看到自己要写的内容,还能看到张三已经写下的“完成了任务X”。
  5. 李四接着写下了“完成了任务Y”。
  6. 现在,如果张三再去看记事本,他会发现上面有他自己写的“任务X”,还有李四写的“任务Y”。

结论:尽管张三和李四是两个独立的员工,但他们共享了同一个工作资源——那本独一无二的公共记事本。


代码层面解释

让我们用代码和内存地址 (id()) 来验证这个过程。

from dataclasses import dataclass, field
from typing import List, Dict

# 这是“危险”的设计
@dataclass
class BadWorkerState:
    # 这个 [] 在类被定义时创建一次,并且只有一个
    log_history: List[Dict[str, float]] = field(default_factory=list) # 错误的用法,等价于 []

# 更直接的错误演示
class BadClass:
    def __init__(self, log_history=[]):
        self.log_history = log_history

print("--- 演示 BadClass ---")
# 1. 创建两个不同的实例
s1 = BadClass()
s2 = BadClass()

# 2. 验证两个实例本身是不同的对象
print(f"s1 的内存地址: {id(s1)}")
print(f"s2 的内存地址: {id(s2)}")
print(f"s1 和 s2是同一个对象吗? {s1 is s2}") # 输出: False

print("-" * 20)

# 3. 验证它们内部的 log_history 属性是否指向同一个列表对象
print(f"s1.log_history 的内存地址: {id(s1.log_history)}")
print(f"s2.log_history 的内存地址: {id(s2.log_history)}")
print(f"s1.log_history 和 s2.log_history 是同一个对象吗? {s1.log_history is s2.log_history}") # 输出: True

print("-" * 20)

# 4. 修改其中一个实例的 log_history
s1.log_history.append({"step": 1, "loss": 0.5})
print(f"修改 s1 后,s1.log_history 是: {s1.log_history}")
print(f"修改 s1 后,s2.log_history 是: {s2.log_history}") # s2 的也跟着变了!

运行结果会是:

--- 演示 BadClass ---
s1 的内存地址: 140502123456784
s2 的内存地址: 140502123456896
s1 和 s2是同一个对象吗? False
--------------------
s1.log_history 的内存地址: 140502123500160
s2.log_history 的内存地址: 140502123500160
s1.log_history 和 s2.log_history 是同一个对象吗? True
--------------------
修改 s1 后,s1.log_history 是: [{'step': 1, 'loss': 0.5}]
修改 s1 后,s2.log_history 是: [{'step': 1, 'loss': 0.5}]

结果分析:

  1. s1s2 的内存地址不同,证明它们是两个独立的实例对象。
  2. s1.log_historys2.log_history 的内存地址完全相同,证明这两个属性指向的是同一个列表对象
  3. 当我们对 s1.log_history 进行 .append() 操作时,我们是在修改那个共享的列表对象。因此,当我们通过 s2.log_history 去查看时,看到的是同一个被修改过的列表。

为什么会这样?—— Python 的函数默认参数求值时机

这个行为的根源在于:Python 中,函数的默认参数值在函数被定义时只计算一次,而不是在函数被调用时计算。

对于 __init__(self, log_history=[])

  • 当 Python 解释器读取到 class BadClass: 这段代码并定义这个类时,它会解析 __init__ 方法。
  • 它看到 log_history 有一个默认参数 []
  • 于是,它在内存中创建了一个空列表对象,并把这个对象的引用作为 log_history 的默认值。
  • 这个过程只发生一次!
  • 之后,每当你调用 BadClass() (不带参数),Python 都会把那个预先创建好的、唯一的列表对象的引用赋给新实例的 self.log_history

正确的做法 (default_factory)

dataclasses 模块意识到了这个问题,并提供了一个完美的解决方案:default_factory

from dataclasses import dataclass, field

@dataclass
class GoodWorkerState:
    # default_factory 接收一个可调用对象 (如 list 或 dict)
    # 它会在每次创建新实例时被调用,从而生成新的对象
    log_history: List[Dict[str, float]] = field(default_factory=list)

g1 = GoodWorkerState()
g2 = GoodWorkerState()

print(f"g1.log_history 和 g2.log_history 是同一个对象吗? {g1.log_history is g2.log_history}")
# 输出: False

default_factory=list 告诉 @dataclass:“不要在类定义时创建一个共享的列表。而是在每次需要创建 GoodWorkerState 实例时,都去调用 list() 这个函数来生成一个全新的、独立的空列表。” 这就解决了共享问题。

WorkerState 中使用 default=None 加上 __post_init__ 的方法,其效果与 default_factory 完全相同,都是为了确保每个实例拥有自己独立的可变对象属性。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐