你真的会用python的__init__方法吗

VOL.111110 views

1

Mar. 2024

什么是__init__方法

Python是一种面向对象的语言。通常可以在类的__init__方法中定义了如何创建新对象。下面是一个简单的类,可以实现两个实例变量存储的功能:

class MyClass:
    def __init__(self, attr1, attr2):
        self.attr1 = attr1
        self.attr2 = attr2

    def get_variables(self):
        return self.attr1, self.attr2


my_object = MyClass("value1", "value2")
my_object.get_variables()  # -> ("value1", "value2")

创建对象的语法为() 。在这种情况下,__init__ 方法接受两个参数,这些参数被存储为实例变量。创建对象后,可以调用在对象上使用此数据的方法。

然而,大多数对象要复杂得多。通常,对象需要存储的数据不是直接可用的,需要从其他输入中派生。而我们希望能够从不同类型的输入创建相同的对象。

我在许多 Python 代码库中看到相同的错误,所有这些逻辑都堆叠到__init__ 方法中。虽然可能会在某个辅助的_initialize() 方法中隐藏__init__ 中的一些操作,但结果是一样的:Python 对象的创建逻辑变得难以理解

让我们看一个例子。我们有一个表示某个配置集合的对象,通常用于从文件中加载该配置。代码如下:

class Configuration:
    def __init__(self, filepath):
        self.filepath = filepath
        self._initialize()

    def _initialize(self):
        self._parse_config_file()
        self._precompute_stuff()

    def _parse_config_file(self):
        # 解析self.filepath中的文件,并将数据存储在一些变量self.<attr>中
        ...

    def _precompute_stuff(self):
        # 使用在self._parse_config_file中定义的变量计算和设置新的实例变量
        ...

这有什么问题呢?

  1. 很难推理对象在创建时的状态。哪些实例变量被定义了,它们的值是什么?为了找出这一点,我们必须遍历整个初始化函数的层次结构,并注意任何 self.的赋值。在这个简单的示例中,这可能还是可行的,但我见过调用__init__ 的代码超过 1000 行,并且包括从超类调用的方法的示例。
  2. 创建逻辑现在是硬编码的。创建 Configuration 对象的唯一方法是提供文件路径,因为要创建对象,必须始终通过__init__ 方法。虽然现在可能总是从文件创建 Configuration 对象,但谁能保证将来仍然如此?此外,虽然真实的应用程序可能只需要一种实例化方式,但为了进行测试,可能方便创建一个不依赖于单独文件的虚拟 Configuration 对象。

这些问题通常在开发的后期阶段显现出来。许多 Python 开发人员试图通过使情况变得更糟来解决问题。例如:

  • 允许输入变量具有多种类型,然后使用 isinstance 检查输入的类型,并根据结果在初始化逻辑中选择不同的分支。在我们的例子中,我们可以将输入变量 filepath 更改为 config,并允许它是字符串或字典,我们将解释为文件路径或已解析的文件。
  • 添加相互覆盖的参数。例如,我们可以同时接受 config 和 filepath 作为参数,并在提供 config 时忽略 filepath。
  • 添加可以接受布尔值或可枚举值的参数,在初始化逻辑中选择分支。例如,如果我们有同一配置文件的多个版本,我们只需在__init__ 中添加一个 version 参数。
  • 在__init__ 中添加*args或**kwargs,因为这样一来,__init__ 的签名就不需要更改,但实现逻辑可以根据需要更改。

解决方案

为什么说所有这些都是糟糕的解决方案?主要是因为它们都是对问题2)的补丁,同时它们都会使问题1)变得更糟。如果你发现自己在处理初始化逻辑并使用上述策略之一,考虑退后一步,采用另一种方法。

为了解决问题1),我尝试将几乎每个类都视为数据类(dataclass)或 NamedTuple(即使不直接使用这些原语)。这意味着我们应该将对象视为仅仅是相关数据的集合。类定义了数据字段的名称和类型,并可选择实现操作该数据的方法。__init__方法不应该做任何其他事情,只需将这些数据分配给相应的实例变量。许多其他语言都为此概念提供了内置构造:结构体(struct)。

为什么优先考虑这种方法而不是普通的Python对象?

  • 它迫使你思考对象真正需要的数据来发挥功能。它防止在__init__中“以防万一”设置一堆无用的实例变量,或者更糟糕的是,在不同的分支中设置不同的实例变量。
  • 它将状态放在阅读代码的其他人的首要位置,并将其与任何数据操作逻辑分离。它清楚地表明了对象上定义了哪些属性。一切都被组织在一起。在对象的初始化中没有魔法。
  • 它使得通过不同的路径更容易创建对象,例如通过定义工厂方法或构建器。这也将使得测试更容易。

为了说明这一点,让我们看一下Configuration类的另一种实现方式:

class Configuration:
    def __init__(self, attr1, attr2):
        self.attr1 = attr1
        self.attr2 = attr2
        
    @classmethod
    def from_file(cls, filepath):
        parsed_data = cls._parse_config_file(filepath)
        computed_data = cls._precompute_stuff(parsed_data)
        return cls(
            attr1=parsed_data,
            attr2=computed_data,
        )

    @classmethod
    def _parse_config_file(cls, filepath):
        # 解析filepath中的文件并返回数据    
        ...

    @classmethod
    def _precompute_stuff(cls, data):
        # 使用从配置文件解析的数据计算新数据
        ...

在这里,__init__方法尽可能简洁。很明显,Configuration需要存储两个属性。如何获取这两个属性的数据不是__init__方法关心的问题。

我们现在不再将文件路径传递给构造函数,而是将其实现为一个类方法的from_file工厂方法。我们还将解析和计算方法转换为类方法,这些方法现在接受输入并返回结果。这些方法返回的数据传递给构造函数,并返回生成的对象。

这种方法的优点是:

更容易理解和推理状态。在实例化后,立即清楚对象上定义了哪些实例属性。
更容易测试。我们的构造函数是纯函数,可以独立调用,不依赖于对象中已存在的状态。
更易于扩展。我们可以轻松地实现其他工厂方法,以替代方式创建Configuration对象,例如从字典中。
更易于保持一致性。在大多数类中遵循这种方法比不断重新发明复杂的自定义初始化逻辑要容易得多。
您还可以考虑将创建代码完全与类本身分离,例如将逻辑移动到函数或工厂类中。

总结

最好将类的__init__ 方法保持简单,并将类视为结构体。将对象构建逻辑移动到工厂方法或构建器中。这将使您的代码更易读、更易测试,并且更易于将来扩展,并编写更健壮的 Python 代码。