为 __init__ 中的用户 类 设置 default/empty 属性

Setting default/empty attributes for user classes in __init__

我的编程水平不错,并且从这里的社区中获得了很多价值。然而,我从来没有接受过太多编程方面的学术教学,也没有在真正有经验的程序员旁边工作过。因此,我有时会与 'best practice'.

作斗争

我找不到更好的地方来解决这个问题,尽管可能有人讨厌这类问题,但我还是发布了这个。如果这让您不高兴,我们深表歉意。我只是想学习,不是来惹你生气的。

问题:

当我创建一个新的 class 时,我是否应该在 __init__ 中设置所有实例属性,即使它们是 None 并且实际上后来在 [=40= 中分配了值] 方法?

有关 MyClassresults 属性,请参见下面的示例:

class MyClass:
    def __init__(self,df):
          self.df = df
          self.results = None

    def results(df_results):
         #Imagine some calculations here or something
         self.results = df_results

我在其他项目中发现,当 class 属性只出现在 class 方法中时,它们可能会被掩埋,并且有很多事情要做。

那么对于经验丰富的专业程序员来说,标准做法是什么?为了可读性,您会在 __init__ 中定义所有实例属性吗?

如果有人有任何关于我在哪里可以找到这些原则的材料链接,请将它们放在答案中,我们将不胜感激。我知道 PEP-8 并且已经在上面搜索了我的问题几次,但找不到任何人涉及这个。

谢谢

安迪

为了理解在 __init__ 中初始化属性的重要性(或不重要),让我们以您的 class MyClass 的修改版本为例。 class 的目的是在给定学生姓名和分数的情况下计算科目的成绩。您可以在 Python 解释器中跟进。

>>> class MyClass:
...     def __init__(self,name,score):
...         self.name = name
...         self.score = score
...         self.grade = None
...
...     def results(self, subject=None):
...         if self.score >= 70:
...             self.grade = 'A'
...         elif 50 <= self.score < 70:
...             self.grade = 'B'
...         else:
...             self.grade = 'C'
...         return self.grade

这个 class 需要两个位置参数 namescore。必须提供这些参数 来初始化 class 实例。没有这些,class 对象 x 无法实例化,并且会引发 TypeError

>>> x = MyClass()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: __init__() missing 2 required positional arguments: 'name' and 'score'

至此,我们了解到我们必须至少提供学生的 name 和科目的 score,但 grade 目前并不重要因为稍后会在 results 方法中计算。所以,我们只使用 self.grade = None 并且不将其定义为位置参数。让我们初始化一个 class instance(object):

>>> x = MyClass(name='John', score=70)
>>> x
<__main__.MyClass object at 0x000002491F0AE898>

<__main__.MyClass object at 0x000002491F0AE898> 确认 class 对象 x 已在给定内存位置成功创建。现在,Python 提供了一些有用的内置方法来查看创建的 class 对象的属性。其中一种方法是__dict__。您可以阅读更多相关信息 here:

>>> x.__dict__
{'name': 'John', 'score': 70, 'grade': None}

这清楚地给出了所有初始属性及其值的 dict 视图。请注意,grade__init__ 中分配了一个 None 值。

让我们花点时间了解一下 __init__ 的作用。有许多 answers 和在线资源可用于解释此方法的作用,但我将总结一下:

__init__ 一样,Python 有另一个名为 __new__() 的内置方法。当你创建一个像这样的 x = MyClass(name='John', score=70) 的 class 对象时,Python 首先在内部调用 __new__() 来创建 class MyClass 的新实例,然后调用 __init__ 来初始化属性 namescore。当然,在这些内部调用中,当 Python 找不到所需位置参数的值时,它会引发错误,如我们在上面看到的那样。换句话说,__init__ 初始化属性。您可以像这样为 namescore 分配新的初始值:

>>> x.__init__(name='Tim', score=50)
>>> x.__dict__
{'name': 'Tim', 'score': 50, 'grade': None}

也可以像下面这样访问单个属性。 grade 什么都不给,因为它是 None.

>>> x.name
'Tim'
>>> x.score
50
>>> x.grade
>>>

results 方法中,您会注意到 subject "variable" 被定义为 None,一个位置参数。此变量的范围仅在此方法内。出于演示目的,我在此方法中明确定义了 subject,但它也可以在 __init__ 中初始化。但是如果我尝试用我的对象访问它呢:

>>> x.subject
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'MyClass' object has no attribute 'subject'

Python 在无法在 class 的命名空间中找到属性时引发 AttributeError。如果您没有在 __init__ 中初始化属性,则当您访问可能仅对 class 的方法而言是本地的未定义属性时,可能会遇到此错误。在此示例中,在 __init__ 中定义 subject 可以避免混淆,并且这样做是完全正常的,因为它也不需要任何计算。

现在,让我们调用 results 看看我们得到什么:

>>> x.results()
'B'
>>> x.__dict__
{'name': 'Tim', 'score': 50, 'grade': 'B'}

这会打印分数的等级,当我们查看属性时请注意,grade 也已更新。从一开始,我们就对初始属性及其值如何变化有了清晰的认识。

但是subject呢?如果我想知道 Tim 在数学上的得分是多少以及分数是多少,我可以像我们之前看到的那样轻松访问 scoregrade 但我如何知道主题?因为,subject 变量在 results 方法的范围内是局部的,所以我们可以 return subject 的值。更改results方法中的return语句:

def results(self, subject=None):
    #<---code--->
    return self.grade, subject

我们再打电话给results()。我们得到了一个包含预期成绩和主题的元组。

>>> x.results(subject='Math')
('B', 'Math')

要访问元组中的值,让我们将它们分配给变量。在 Python 中,可以将集合中的值赋给同一表达式中的多个变量,前提是变量的数量等于集合的长度。在这里,长度只有两个,所以我们可以在表达式的左边有两个变量:

>>> grade, subject = x.results(subject='Math')
>>> subject
'Math'

所以,我们已经有了它,尽管它需要一些额外的代码行来获得 subject。使用 x.<attribute> 访问属性时,仅使用点运算符一次访问所有属性会更直观,但这只是一个示例,您可以尝试使用 subject 在 [=36 中初始化=].

接下来,假设有很多学生(比如 3 个),我们需要数学的姓名、分数和成绩。除了主题,所有其他的都必须是某种集合数据类型,如 list 可以存储所有姓名、分数和等级。我们可以像这样初始化:

>>> x = MyClass(name=['John', 'Tom', 'Sean'], score=[70, 55, 40])
>>> x.name
['John', 'Tom', 'Sean']
>>> x.score
[70, 55, 40]

乍一看这似乎很好,但是当你再看看(或其他程序员)在 __init__scoregrade 的初始化时=],没有办法告诉他们需要一个集合数据类型。这些变量也被命名为单数,这使得它们可能只是一些可能只需要一个值的随机变量变得更加明显。程序员的目的应该是通过描述性的变量命名、类型声明、代码注释等方式使意图尽可能明确。考虑到这一点,让我们更改 __init__ 中的属性声明。在我们满足于行为良好良好定义声明之前,我们必须注意如何声明默认参数。


编辑:可变默认参数的问题:

现在,在声明默认参数时,我们必须注意一些 'gotchas'。考虑以下初始化 names 并在对象创建时附加随机名称的声明。回想一下,列表是 Python 中的可变对象。

#Not recommended
class MyClass:
    def __init__(self,names=[]):
        self.names = names
        self.names.append('Random_name')

让我们看看当我们从这个 class 创建对象时会发生什么:

>>> x = MyClass()
>>> x.names
['Random_name']
>>> y = MyClass()
>>> y.names
['Random_name', 'Random_name']

该列表随着每个新对象的创建而不断增长。这背后的原因是默认值是 always 在调用 __init__ 时计算。多次调用 __init__,继续使用相同的函数对象,从而附加到前一组默认值。您可以自己验证这一点,因为 id 对于每个对象创建都保持不变。

>>> id(x.names)
2513077313800
>>> id(y.names)
2513077313800

那么,定义默认参数的正确方法是什么,同时还要明确属性支持的数据类型?最安全的选择是将默认参数设置为 None,并在参数值为 None 时初始化为空列表。以下是声明默认参数的推荐方式:

#Recommended
>>> class MyClass:
...     def __init__(self,names=None):
...         self.names = names if names else []
...         self.names.append('Random_name')

让我们检查一下行为:

>>> x = MyClass()
>>> x.names
['Random_name']
>>> y = MyClass()
>>> y.names
['Random_name']

现在,这种行为正是我们要寻找的。该对象不会 "carry over" 旧行李并在没有值传递给 names 时重新初始化为空列表。如果我们将一些有效名称(当然作为列表)传递给 y 对象的 names arg,Random_name 将简单地附加到此列表。同样,x 对象值不会受到影响:

>>> y = MyClass(names=['Viky','Sam'])
>>> y.names
['Viky', 'Sam', 'Random_name']
>>> x.names
['Random_name']

也许,关于这个概念最简单的解释也可以在Effbot website. If you'd like to read some excellent answers: “Least Astonishment” and the Mutable Default Argument.

上找到

基于对默认参数的简短讨论,我们的 class 声明将修改为:

class MyClass:
    def __init__(self,names=None, scores=None):
        self.names = names if names else []
        self.scores = scores if scores else []
        self.grades = []
#<---code------>

这更有意义,所有变量都有复数名称,并在对象创建时初始化为空列表。我们得到与之前类似的结果:

>>> x.names
['John', 'Tom', 'Sean']
>>> x.grades
[]

grades 是一个空列表,明确表示在调用 results() 时将为多个学生计算成绩。所以,我们的results方法也要修改一下。我们所做的比较现在应该在分数数字(70、50 等)和 self.scores 列表中的项目之间进行,同时 self.grades 列表也应该用各个等级进行更新。将 results 方法更改为:

def results(self, subject=None):
    #Grade calculator 
    for i in self.scores:
        if i >= 70:
            self.grades.append('A')
        elif 50 <= i < 70:
            self.grades.append('B')
        else:
            self.grades.append('C')
    return self.grades, subject

我们现在应该在调用 results():

时获取成绩列表
>>> x.results(subject='Math')
>>> x.grades
['A', 'B', 'C']
>>> x.names
['John', 'Tom', 'Sean']
>>> x.scores
[70, 55, 40]

这看起来不错,但想象一下,如果列表很大,要弄清楚 score/grade 谁属于谁绝对是一场噩梦。在这里,使用正确的数据类型初始化属性很重要,这些数据类型可以以易于访问的方式存储所有这些项目,并清楚地显示它们之间的关系。这里最好的选择是字典。

我们可以有一个字典,其中包含最初定义的名称和分数,results 函数应该将所有内容放在一个包含所有分数、等级等的新字典中。我们还应该正确和明确地注释代码尽可能在方法中定义参数。最后,我们可能不再需要 __init__ 中的 self.grades,因为正如您将看到的那样,成绩并未附加到列表中,而是明确分配。这完全取决于问题的要求。

最终代码:

class MyClass:
"""A class that computes the final results for students"""

    def __init__(self,names_scores=None):

        """initialize student names and scores
        :param names_scores: accepts key/value pairs of names/scores
                         E.g.: {'John': 70}"""

        self.names_scores = names_scores if names_scores else {}     

    def results(self, _final_results={}, subject=None):
        """Assign grades and collect final results into a dictionary.

       :param _final_results: an internal arg that will store the final results as dict. 
                              This is just to give a meaningful variable name for the final results."""

        self._final_results = _final_results
        for key,value in self.names_scores.items():
            if value >= 70:
                self.names_scores[key] = [value,subject,'A']
            elif 50 <= value < 70:
                self.names_scores[key] = [value,subject,'B']
            else:
                self.names_scores[key] = [value,subject,'C']
        self._final_results = self.names_scores #assign the values from the updated names_scores dict to _final_results
        return self._final_results

请注意 _final_results 只是一个内部参数,用于存储更新后的字典 self.names_scores。目的是 return 函数中的一个更有意义的变量,清楚地告知 意图 。这个变量开头的 _ 表示它是一个内部变量,按照惯例。

让我们给它一个最终的 运行:

>>> x = MyClass(names_scores={'John':70, 'Tom':50, 'Sean':40})
>>> x.results(subject='Math')  

  {'John': [70, 'Math', 'A'],
 'Tom': [50, 'Math', 'B'],
 'Sean': [40, 'Math', 'C']}

这样可以更清楚地了解每个学生的成绩。现在任何学生都可以轻松访问 grades/scores:

>>> y = x.results(subject='Math')
>>> y['John']
[70, 'Math', 'A']

结论:

虽然最终的代码需要一些额外的努力,但这是值得的。输出更精确,并提供有关每个学生成绩的清晰信息。该代码更具可读性,并清楚地告知 reader 创建 class、方法和变量的意图。以下是本次讨论的主要内容:

  • 预期在 class 方法之间共享的变量(属性)应在 __init__ 中定义。在我们的示例中,results() 需要 namesscores,可能还有 subject。这些属性可以由另一种方法共享,例如 average 计算分数的平均值。
  • 应使用适当的数据类型 初始化属性。这应该在冒险进入基于 class 的问题设计之前事先决定。
  • 使用默认参数声明属性时必须小心。如果封闭的 __init__ 导致每次调用时属性发生变化,则可变默认 args 可以改变属性的值。最安全的做法是将默认参数声明为 None 并在默认值为 None.
  • 时重新初始化为一个空的可变集合
  • 属性名称应该明确,遵循 PEP8 准则。
  • 一些变量应该只在 class 方法的范围内初始化。例如,这些可能是计算所需的内部变量或不需要与其他方法共享的变量。
  • __init__ 中定义变量的另一个令人信服的理由是避免由于访问 unnamed/out-of-scope 属性而可能发生的 AttributeErrors。 __dict__ 内置方法提供了此处初始化的属性的视图。
  • 在 class 实例化中为属性(位置参数)赋值时,应明确定义属性名称。例如:

    x = MyClass('John', 70)  #not explicit
    x = MyClass(name='John', score=70) #explicit
    
  • 最后,目标应该是通过评论尽可能清楚地传达意图。 class,它的方法和属性应该有很好的注释。对于所有属性,一个简短的描述和一个例子,对于第一次遇到你的 class 及其属性的新程序员来说非常有用。

我认为您应该避免这两种解决方案。仅仅是因为您应该避免创建未初始化或部分初始化的对象,除了一种情况,我将在稍后概述。

看看你的 class 的两个稍微修改的版本,一个 setter 和一个 getter:

class MyClass1:
    def __init__(self, df):
          self.df = df
          self.results = None

    def set_results(self, df_results):
         self.results = df_results

    def get_results(self):
         return self.results

class MyClass2:
    def __init__(self, df):
          self.df = df

    def set_results(self, df_results):
         self.results = df_results

    def get_results(self):
         return self.results

MyClass1MyClass2 之间的唯一区别是第一个在构造函数中初始化 results,而第二个在 set_results 中初始化。您的 class 的用户来了(通常是您,但并非总是如此)。每个人都知道你不能信任用户(即使是你):

MyClass1("df").get_results()
# returns None

MyClass2("df").get_results()
# Traceback (most recent call last):
# ...
# AttributeError: 'MyClass2' object has no attribute 'results'

你可能会认为第一种情况更好,因为它不会失败,但我不同意。在这种情况下,我希望程序能够快速失败,而不是进行长时间的调试以找出发生了什么。因此,第一个答案的第一部分是:不要将未初始化的字段设置为 None,因为您丢失了快速失败提示

但这不是全部答案。无论您选择哪个版本,都会遇到一个问题:该对象未被使用,也不应该被使用,因为它没有完全初始化。您可以将文档字符串添加到 get_results"""Always use set_results **BEFORE** this method"""。不幸的是,用户也不阅读文档字符串。

你的对象中有未初始化字段的主要原因有两个:1.你不知道(目前)字段的值; 2. 您想避免扩展操作(计算、文件访问、网络……),又名“惰性初始化”。这两种情况在现实世界中都会遇到,并且与仅使用完全初始化对象的需求相冲突。

令人高兴的是,这个问题有一个有据可查的解决方案:设计模式,更准确地说 Creational patterns。在您的情况下,工厂模式或建造者模式可能就是答案。例如:

class MyClassBuilder:
    def __init__(self, df):
          self._df = df # df is known immediately
          # GIVE A DEFAULT VALUE TO OTHER FIELDS to avoid the possibility of a partially uninitialized object.
          # The default value should be either:
          # * a value passed as a parameter of the constructor ;
          # * a sensible value (eg. an empty list, 0, etc.)

    def results(self, df_results):
         self._results = df_results
         return self # for fluent style
         
    ... other field initializers

    def build(self):
        return MyClass(self._df, self._results, ...)

class MyClass:
    def __init__(self, df, results, ...):
          self.df = df
          self.results = results
          ...
          
    def get_results(self):
         return self.results
    
    ... other getters
         

(你也可以使用 Factory,但我发现 Builder 更灵活)。让我们再给用户一次机会:

>>> b = MyClassBuilder("df").build()
Traceback (most recent call last):
...
AttributeError: 'MyClassBuilder' object has no attribute '_results'
>>> b = MyClassBuilder("df")
>>> b.results("r")
... other fields iniialization
>>> x = b.build()
>>> x
<__main__.MyClass object at ...>
>>> x.get_results()
'r'

优势一目了然:

  1. 检测和修复创建失败比后期使用失败更容易;
  2. 您不会在野外发布您的对象的未初始化(因此可能具有破坏性)版本。

Builder 中存在未初始化的字段并不矛盾:这些字段在设计上是未初始化的,因为 Builder 的作用是初始化它们。 (实际上,这些字段是 Builder 的某种 forein 字段。)这就是我在介绍中谈到的情况。在我看来,它们应该设置为默认值(如果存在)或保持未初始化以在您尝试创建不完整的对象时引发异常。

我回答的第二部分:使用创建模式来确保对象被正确初始化

旁注:当我看到带有 getter 和 setter 的 class 时,我非常怀疑。我的经验法则是:始终尝试将它们分开,因为当它们相遇时,物体会变得不稳定。

经过大量研究并与经验丰富的程序员进行讨论后,请参阅下面我认为对这个问题最 Pythonic 的解决方案。我先包括了更新的代码,然后是叙述:

class MyClass:
    def __init__(self,df):
          self.df = df
          self._results = None

    @property
    def results(self):
        if self._results is None:
            raise Exception('df_client is None')
        return self._results

    def generate_results(self, df_results):
         #Imagine some calculations here or something
         self._results = df_results

描述我所学到的、改变的以及原因:

  1. 所有 class 属性都应包含在 __init__(初始化程序)方法中。这是为了确保可读性和辅助调试。

  2. 第一个问题是不能在Python中创建私有属性。一切都是 public,因此可以访问任何部分初始化的属性(例如设置为 None 的结果)。表示私有属性的约定是在前面放置一个前导下划线,所以在这种情况下我将其更改为 self.resultsself._results.

    记住这只是约定,self._results仍然可以直接访问。但是,这是处理 pseudo-private 属性的 Pythonic 方法。

  3. 第二个问题是有一个部分初始化的属性设置为 None。由于这设置为 None,正如下面的 @jferard 所解释的,我们现在丢失了 fail-fast 提示并添加了一层用于调试代码的混淆。

    为了解决这个问题,我们添加了一个 getter 方法。这可以看作上面的函数 results(),上面有 @property 装饰器。

    这是一个在调用时检查 self._results 是否为 None 的函数。如果是这样,它将引发异常(fail-safe 提示),否则它将 return 对象。 @property 装饰器将调用样式从函数更改为属性,因此用户必须在 MyClass 的实例上使用 .results 就像任何其他属性一样。

    (我将设置结果的方法的名称更改为 generate_results() 以避免混淆并释放 .results 用于 getter 方法)

  4. 如果你在 class 中有其他方法需要使用 self._results,但只有在正确分配时,你才可以使用 self.results,并且fail-safe 提示的方式如上所述。

我还建议阅读@jferard 对这个问题的回答。他深入探讨了问题和一些解决方案。我添加答案的原因是,我认为在很多情况下,以上就是您所需要的(以及 Pythonic 的实现方式)。