
在python编程中,我们有时会遇到这样的情况:一个类的实例属性在不同的运行环境下(例如,在ide中运行测试与在命令行中运行测试)表现出不一致的行为。具体表现为,某些列表类型的属性在命令行下运行时,其长度会意外地翻倍,而相同代码在ide中却能正常通过测试。这种现象的根源在于python处理类属性和实例属性的机制,特别是当可变对象(如列表、字典、集合)被用作类属性的默认值时。
问题示例:列表意外翻倍
考虑以下Python测试代码和被测试类FhdbTsvDecoder的片段:
# test_fhdb_tsv_decode.py
class TestExtractLegsAndPhase:
tsv: str = ... # 从文件中提取的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被测试类FhdbTsvDecoder的简化结构如下:
# fhdb_tsv_decoder.py
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] # 未初始化,将在__init__中处理
def __init__(self, tsv: str):
self.tsv = tsv
# 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 = []
# self.session_starts = [] # 如果在这里初始化,则不会有问题
self.session_ends = [] # 在这里初始化,所以 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])在上述代码中,session_starts属性在类定义体中被初始化为[],而session_ends和legs_and_phase则是在__extract_leg_and_phase方法(或__init__方法)中被重新赋值为新的空列表。当在命令行中运行测试时,session_starts列表的长度变为预期值的两倍(例如,57变为114),这表明其内容被重复添加了。
立即学习“Python免费学习笔记(深入)”;
要理解这个问题,需要区分Python中的类属性(Class Attributes)和实例属性(Instance Attributes)。
当一个可变对象(如列表、字典、集合)在类定义体中被初始化为类属性时,所有实例都会引用同一个内存中的可变对象。这意味着,如果一个实例修改了这个可变对象,其他所有实例都会看到这个修改。
在我们的例子中:
class FhdbTsvDecoder:
# ...
session_starts: list[datetime] = [] # 这是一个类属性
# ...session_starts被定义为一个类属性。当第一次加载FhdbTsvDecoder类时,Python会创建一个空的列表对象[],并让FhdbTsvDecoder.session_starts指向它。之后,无论创建多少个FhdbTsvDecoder实例,它们都会共享这同一个session_starts列表。
如果你的测试环境或应用逻辑导致FhdbTsvDecoder类被实例化了多次(例如,一个集成测试在单元测试之前运行,也创建了FhdbTsvDecoder的实例),那么每次调用__extract_leg_and_phase并向self.session_starts追加数据时,都是在向同一个共享列表追加数据。这就解释了为什么列表内容会翻倍。
相比之下,legs_and_phase和session_ends在__extract_leg_and_phase方法中被显式地重新初始化为self.legs_and_phase = []和self.session_ends = []。这些语句确保了每次创建FhdbTsvDecoder实例并调用该方法时,都会为该实例创建全新的、独立的列表对象,并赋值给self.legs_and_phase和self.session_ends,从而避免了共享问题。
解决这类问题的核心原则是:对于需要在每个实例中拥有独立副本的可变属性,务必在类的__init__方法中进行初始化。
修改FhdbTsvDecoder类,将session_starts的初始化从类级别移动到__init__方法中:
# fhdb_tsv_decoder.py (修正后)
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__已经处理了
# self.legs_and_phase = []
# 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])通过在__init__方法中将self.session_starts赋值为[],我们确保了每次创建FhdbTsvDecoder的新实例时,都会为其分配一个全新的、独立的session_starts列表对象。这样,即使创建多个FhdbTsvDecoder实例,它们各自的session_starts列表也不会相互影响。
始终在__init__中初始化可变实例属性: 这是避免此类问题的黄金法则。任何需要在每个实例中保持独立状态的可变对象(如列表、字典、集合),都应该在__init__方法中通过self.attribute_name = default_value的形式进行初始化。
理解类属性的适用场景: 类属性并非一无是处。它们适用于:
避免函数默认参数中的可变对象陷阱: 与类属性类似,Python函数默认参数中的可变对象也会导致类似的问题。如果函数定义为def func(arg: list = []),那么每次调用不带arg参数的func时,都会使用同一个列表对象。正确的做法是使用None作为默认值,并在函数体内部进行检查和初始化:def func(arg: list = None): if arg is None: arg = []。
测试环境差异: 不同的测试运行器(如Pytest、unittest)或IDE(如IntelliJ、VS Code)可能以不同的方式加载、缓存或重新加载Python模块和类。这可能导致在某些环境下问题不显现,而在另一些环境下却暴露出来。例如,IDE可能在每次测试运行时重新加载模块,而命令行工具可能只加载一次,并在多次测试执行中重用类定义。因此,即使在IDE中没有问题,也应遵循最佳实践。
代码审查: 在代码审查过程中,应特别关注类定义体中是否存在可变类型的默认值。这是一个常见的陷阱,容易被忽视。
Python中将可变对象作为类属性的默认值是一个常见的陷阱,它会导致所有实例共享同一个可变对象,从而引发数据污染和意外行为。解决此问题的关键在于理解Python的类属性与实例属性机制,并始终在类的__init__方法中初始化所有实例特有的可变属性。遵循这一最佳实践,可以有效避免此类问题,确保代码的健壮性和可预测性。
以上就是Python类属性陷阱:可变对象默认值导致实例间共享问题解析与防范的详细内容,更多请关注php中文网其它相关文章!
每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号