
1. 问题现象:测试中列表数据意外翻倍
在进行 python 单元测试时,开发者可能会遇到一种奇怪的现象:某些列表属性在集成开发环境(如 intellij)中运行测试时表现正常,但在控制台直接运行或在集成测试中被多次实例化时,其长度会意外翻倍,内容也随之重复。
例如,考虑以下测试代码片段:
# 示例测试代码片段
import os
from datetime import datetime
from io import StringIO
import pandas
from pandas import DataFrame
FHD_TIME_FORMAT = '%m/%d/%Y %H:%M:%S'
# 假设 FhdbTsvDecoder 是待测试的类
# 简化后的 FhdbTsvDecoder 类定义,其中包含问题代码
class FhdbTsvDecoder:
tsv: str
legs_and_phase: list[tuple[datetime, int, int]]
session_starts: list[datetime] = [] # 问题所在:在类级别初始化可变列表
session_ends: list[datetime] # 另一个潜在问题,如果不在 __init__ 中初始化
def __init__(self, tsv: str):
self.tsv = tsv
# self.session_starts = [] # 如果在此处初始化,则正常
# self.session_ends = [] # 如果在此处初始化,则正常
self.__extract_leg_and_phase()
def __extract_leg_and_phase(self) -> None:
df: DataFrame = pandas.read_csv(StringIO(self.tsv), sep='\t', header=None,
converters={4: lambda x: datetime.strptime(x, FHD_TIME_FORMAT)},
skiprows=0)
# 此处初始化 legs_and_phase,使其每次都是新的实例属性
self.legs_and_phase = []
# 如果 session_starts 和 session_ends 在 __init__ 中未初始化,
# 且在类级别被初始化为共享列表,则此处操作的是共享列表
# self.session_starts = [] # 如果在此处初始化,则正常
self.session_ends = [] # 此处初始化,使其每次都是新的实例属性
iterator = df.iterrows()
for index, row in iterator:
list.append(self.legs_and_phase, (row[4], row[5], row[6]))
if row[1] == row[2] == row[3] == row[5] == row[6] == 0:
self.session_ends.append(row[4])
# 注意:next(iterator) 会消耗下一行数据
self.session_starts.append(next(iterator)[1][4])
class TestExtractLegsAndPhase:
# 假设 extract_tsv() 和 extract_tsv_from_zip() 已定义并返回有效的TSV字符串
@staticmethod
def extract_tsv() -> str:
# 实际路径和内容省略
return "mock_tsv_content"
tsv: str = extract_tsv()
def test_extract_leg_and_phase(self):
to: FhdbTsvDecoder = FhdbTsvDecoder(self.tsv)
legs_and_phase: list[tuple[datetime, int, int]] = to.legs_and_phase
assert len(legs_and_phase) == 4926 # 始终通过
session_ends: list[datetime] = to.session_ends
assert len(session_ends) == 57 # 在控制台运行时可能失败,实际为114
session_starts: list[datetime] = to.session_starts
assert len(session_starts) == 57 # 在控制台运行时可能失败,实际为114在上述例子中,session_ends 和 session_starts 列表的断言在控制台运行时可能会失败,其长度显示为 114 而非预期的 57,内容是原始数据的重复。然而,legs_and_phase 列表的断言却始终通过。进一步的调试发现,问题在于 session_starts 列表在类定义时被初始化,而 legs_and_phase 则在 __extract_leg_and_phase 方法内部被显式初始化为新的空列表。
2. 根本原因:Python 类属性与实例属性的混淆
这种现象的根源在于 Python 中类属性和实例属性的工作机制,特别是当类属性被赋予可变默认值时。
2.1 类属性与实例属性
- 类属性 (Class Attributes): 在类定义体中直接定义的属性,它们属于类本身,并由该类的所有实例共享。当一个类属性被修改时,所有实例都会看到这个修改。
- 实例属性 (Instance Attributes): 在 __init__ 方法或其他实例方法中,通过 self.attribute_name 定义的属性。每个实例都有其独立的副本,一个实例对自身实例属性的修改不会影响其他实例。
2.2 可变默认参数陷阱
当一个可变对象(如列表 []、字典 {}、集合 set())被用作类属性的默认值时,这个可变对象在类被定义和加载时只创建一次。所有该类的实例,如果它们没有在 __init__ 方法中显式地为该属性创建新的实例级副本,就会引用这个同一个共享的可变对象。
立即学习“Python免费学习笔记(深入)”;
在我们的例子中:
class FhdbTsvDecoder:
# ...
session_starts: list[datetime] = [] # 问题所在
# ...这行代码在 FhdbTsvDecoder 类被加载到内存时,创建了一个空的列表对象,并将其赋值给 FhdbTsvDecoder.session_starts 这个类属性。每次创建 FhdbTsvDecoder 的新实例时,如果 __init__ 方法没有显式地为 self.session_starts 赋值一个新的列表,那么 self.session_starts 将会引用这个由所有实例共享的类属性列表。
当测试或集成测试创建了多个 FhdbTsvDecoder 实例(例如,一个集成测试运行后又运行了另一个测试,或者测试框架在不同阶段创建了实例),并且这些实例都调用 __extract_leg_and_phase 方法向 self.session_starts 追加数据时,它们实际上都在向同一个列表追加,导致数据累积和重复。
而 legs_and_phase 列表之所以没有问题,是因为在 __extract_leg_and_phase 方法中,self.legs_and_phase = [] 这行代码总是会为当前实例创建一个新的空列表,并将其赋值给 self.legs_and_phase,从而覆盖了任何可能的类属性引用,确保了每个实例都拥有独立的列表副本。
3. 解决方案:在 __init__ 方法中初始化可变实例属性
解决这个问题的关键在于,确保每个类实例都拥有其独立的、不与其他实例共享的可变属性副本。这通常通过在类的构造函数 __init__ 方法中显式地初始化这些属性来实现。
3.1 正确做法
将所有可变实例属性的初始化逻辑从类定义体移动到 __init__ 方法中。
from datetime import datetime
from io import StringIO
import pandas
from pandas import DataFrame
FHD_TIME_FORMAT = '%m/%d/%Y %H:%M:%S'
class FhdbTsvDecoder:
tsv: str
legs_and_phase: list[tuple[datetime, int, int]]
session_starts: list[datetime]
session_ends: list[datetime]
def __init__(self, tsv: str):
self.tsv = tsv
# 在 __init__ 方法中初始化所有可变实例属性
self.legs_and_phase = []
self.session_starts = []
self.session_ends = []
self.__extract_leg_and_phase()
def __extract_leg_and_phase(self) -> None:
df: DataFrame = pandas.read_csv(StringIO(self.tsv), sep='\t', header=None,
converters={4: lambda x: datetime.strptime(x, FHD_TIME_FORMAT)},
skiprows=0)
# 移除或调整方法内部的列表初始化,因为它们已在 __init__ 中完成
# 如果方法可能被多次调用且需要清空列表,则可以保留清空逻辑
# 但首次初始化应由 __init__ 负责
# self.legs_and_phase = [] # 如果 __init__ 中已初始化,此处可移除或改为 clear()
# self.session_starts = [] # 移除此行
# self.session_ends = [] # 移除此行
iterator = df.iterrows()
for index, row in iterator:
list.append(self.legs_and_phase, (row[4], row[5], row[6]))
if row[1] == row[2] == row[3] == row[5] == row[6] == 0:
self.session_ends.append(row[4])
self.session_starts.append(next(iterator)[1][4])
通过上述修改,每次创建 FhdbTsvDecoder 实例时,__init__ 方法都会为 self.legs_and_phase、self.session_starts 和 self.session_ends 创建全新的、独立的列表对象。这样,即使创建多个实例,它们各自的列表属性也是相互隔离的,一个实例对自身列表的修改不会影响其他实例,从而彻底解决了数据重复的问题。
4. 最佳实践与注意事项
为了避免未来再次遇到类似的问题,请遵循以下最佳实践:
- 通用原则: 永远不要在类定义中将可变对象(如列表、字典、集合)作为默认值。这些默认值只在类加载时创建一次,并被所有实例共享。
-
不可变默认值是安全的: 对于不可变对象(如数字、字符串、元组、None),作为类属性的默认值通常是安全的,因为它们的值无法被修改,只能被重新绑定。例如:
class MyClass: count = 0 # 不可变,共享是安全的 name = "default" # 不可变,共享是安全的 -
何时使用类属性:
- 存储常量(例如 PI = 3.14159)。
- 存储所有实例共享的配置或元数据。
- 实现计数器等需要跨实例共享状态的机制(但要注意多线程/并发环境下的同步问题)。
-
何时使用实例属性:
- 存储每个实例特有的数据。
- 所有可变数据结构(列表、字典、集合等)都应作为实例属性在 __init__ 方法中初始化。
-
调试技巧:
- 当遇到数据意外累积、状态混乱或测试在不同环境下行为不一致的问题时,首先检查类定义中是否存在可变默认参数。
- 使用 Python 内置的 id() 函数可以帮助你判断两个变量是否指向内存中的同一个对象。例如,如果你怀疑两个实例共享了一个列表,可以打印 id(instance1.my_list) 和 id(instance2.my_list)。如果 id 值相同,则它们共享同一个对象。
5. 总结
在 Python 编程中,正确区分和使用类属性与实例属性至关重要,尤其是在处理可变数据类型时。将可变对象作为类属性的默认值是一个常见的陷阱,它会导致所有实例意外共享同一个对象,从而引发数据完整性问题。遵循在 __init__ 方法中初始化所有可变实例属性的原则,可以有效避免此类问题,确保每个对象拥有独立的属性副本,从而提升代码的健壮性、可预测性和可维护性。理解这一核心概念是编写高质量 Python 代码的关键一步。










