
本文探讨了Python中因类级别初始化可变数据结构(如列表)而导致的实例间数据共享问题。当此类属性在类定义时被赋值为可变对象时,所有实例将共享同一个对象,导致数据意外累积。解决方案是在类的 __init__ 方法中初始化这些可变属性,确保每个实例拥有独立的副本,从而避免在多实例场景(如测试)中出现数据污染。
问题描述:测试环境中的异常行为
在python开发中,我们有时会遇到一种看似奇怪的现象:一段测试代码在集成开发环境(ide)中运行正常,但通过命令行(如pytest)执行时却出现断言失败,具体表现为某些列表的长度翻倍。这通常发生在类中的可变数据结构(如列表)被意外地在多个实例之间共享时。
以下是一个典型的测试场景和相关代码:
import osfrom datetime import datetimefrom io import StringIOimport pandasfrom pandas import DataFrame# 假设 FhdbTsvDecoder 是待测试的类# ... (FHD_TIME_FORMAT 和 extract_tsv_from_zip 等定义)class TestExtractLegsAndPhase: @staticmethod def extract_tsv() -> str: path: str = (os.path.dirname(os.path.realpath(__file__)) + "/resources/FPFaultHistory.zip") print("extracting from " + path) # 假设 extract_tsv_from_zip 是一个从zip文件提取TSV字符串的函数 return "col1tcol2tcol3tcol4t01/26/2023 07:42:07t5t6n" "0t0t0t0t01/26/2023 07:42:07t0t0n" "col1tcol2tcol3tcol4t01/26/2023 09:48:13t5t6n" "0t0t0t0t01/26/2023 09:48:13t0t0n" # 示例数据 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 列表的长度会变成预期的两倍(例如,57变为114),导致断言失败。然而,legs_and_phase 列表的长度却始终正确。通过调试发现,这些列表中的数据仅仅是简单地重复了一次。
根源分析:Python类属性与实例属性的混淆
问题的核心在于Python中类属性和实例属性的初始化方式,特别是涉及到可变对象(如列表、字典)时。
考虑以下 FhdbTsvDecoder 类的简化版本:
立即学习“Python免费学习笔记(深入)”;
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 # self.session_starts = [] # 修正方案:在此处初始化 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) self.legs_and_phase = [] # 在方法内部初始化,每次调用都会创建新列表 # self.session_ends = [] # 修正方案:在此处初始化,如果未在__init__中完成 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])
在Python中:
类属性:在类定义体内直接声明的属性(如 session_starts: list[datetime] = [])是类属性。这意味着所有该类的实例都将共享同一个 session_starts 列表对象。这个列表在类加载时只创建一次。实例属性:在 __init__ 方法中通过 self.attribute_name = value 声明的属性是实例属性。每个实例都会拥有自己独立的 attribute_name 副本。
对于 session_starts: list[datetime] = [],列表 [] 是一个可变对象。当多个 FhdbTsvDecoder 实例被创建时(例如,在不同的测试用例或集成测试中),它们都引用同一个 [] 列表。如果一个实例修改了这个列表(例如,通过 append 方法),所有其他实例都会看到这些修改。这导致了数据在实例之间被意外共享和累积。
legs_and_phase 之所以没有这个问题,是因为它在 __extract_leg_and_phase 方法内部被显式地重新初始化为 self.legs_and_phase = []。这意味着每次调用该方法时,都会为当前的实例创建一个新的、空的列表,从而避免了共享问题。
至于为什么在IDE和控制台运行时表现不同,这通常与测试框架(如pytest)的运行机制有关。在某些情况下,尤其是在大型测试套件或集成测试中,类可能在不同的测试运行之间被重用或以某种方式保持状态,导致类级别的共享可变对象累积数据。例如,如果一个集成测试先运行并创建了 FhdbTsvDecoder 实例,它会向共享的 session_starts 列表添加数据。随后,单元测试运行时创建的 FhdbTsvDecoder 实例会继承这个已经包含数据的列表,导致数据翻倍。
解决方案:在 __init__ 方法中初始化实例属性
解决此问题的关键在于确保每个类实例都拥有其可变属性的独立副本。这通过在类的 __init__ 方法中初始化这些属性来实现。
将 session_starts 和 session_ends 的初始化从类级别移动到 __init__ 方法中:
from datetime import datetimefrom io import StringIOimport pandasfrom pandas import DataFrameFHD_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 # 确保每个实例都有自己独立的列表对象 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__ 中已经初始化,这里可以省略,或者仅作为额外的清空/重新初始化逻辑 # self.legs_and_phase = [] # 根据需求决定是否需要在此处重新初始化 # self.session_starts = [] # 如果在__init__中初始化,此处不需要 # self.session_ends = [] # 如果在__init__中初始化,此处不需要 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 创建全新的、独立的列表对象。这样,即使在不同的测试运行或多个实例之间,这些列表也不会相互影响,从而解决了数据累积和断言失败的问题。
最佳实践与注意事项
可变对象始终在 __init__ 中初始化:这是Python面向对象编程中的一条黄金法则。对于任何需要每个实例拥有独立状态的可变属性(如列表、字典、集合等),务必在 __init__ 方法中进行初始化。
class MyClass: # 错误示例:可变类属性,所有实例共享 shared_list = [] # 正确示例:在__init__中初始化实例属性 def __init__(self): self.instance_list = []
何时使用类属性:类属性适用于存储:
常量:如 PI = 3.14159。不可变数据:如元组、字符串或数字。所有实例共享且不随实例状态变化的属性:例如,一个计数器,记录创建了多少个实例。
避免函数默认可变参数的陷阱:与类属性类似,Python函数定义中默认参数如果设置为可变对象,也会导致类似的问题。
def add_item(item, my_list=[]): # 错误:my_list在函数定义时只创建一次 my_list.append(item) return my_listprint(add_item(1)) # 输出: [1]print(add_item(2)) # 输出: [1, 2] - 意外地保留了之前的状态def add_item_correct(item, my_list=None): if my_list is None: my_list = [] my_list.append(item) return my_listprint(add_item_correct(1)) # 输出: [1]print(add_item_correct(2)) # 输出: [2] - 每次调用都创建新列表
测试隔离的重要性:在编写测试时,应确保每个测试用例都是独立的,不依赖于其他测试用例的副作用。理解Python的类属性行为有助于避免因意外的数据共享而导致的测试不稳定。如果测试框架在不同测试之间重用模块或类,这种共享问题会更加突出。
总结
Python中可变类属性的意外共享是一个常见的陷阱,尤其是在涉及列表、字典等可变数据结构时。当在类级别初始化这些可变对象时,所有实例将引用同一个对象,导致数据污染和难以调试的错误。解决之道是在类的 __init__ 方法中为每个实例创建独立的属性副本。遵循这一最佳实践,可以显著提高代码的健壮性、可预测性,并避免在测试和生产环境中出现因数据累积而导致的异常行为。
以上就是Python中可变类属性的风险与正确初始化方法的详细内容,更多请关注创想鸟其它相关文章!
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 chuangxiangniao@163.com 举报,一经查实,本站将立刻删除。
发布者:程序猿,转转请注明出处:https://www.chuangxiangniao.com/p/1373515.html
微信扫一扫
支付宝扫一扫