使用Python数据模型编写Pythonic代码

特殊方法

这种明显的奇特现象只是冰山一角,深入理解后,它是我们所称之为“Pythonic”的一切的关键。这个冰山被称为Python数据模型,它描述了您可以用来使自己的对象与最具惯用特性的语言特性良好互动的API。 - Luciano Ramalho(《流利的Python:清晰、简洁和有效的编程》)

有人可能会问,Python数据模型有什么特别之处。与其给出个人的回答,不如我们深入探讨一下,看看通过理解数据模型我们能实现什么。数据模型简单地讲述了数据是如何表示的。在Python中,数据由对象表示,或者说得更技术一点,对象是Python对数据的抽象。数据模型为我们提供了一个API,使我们的对象能够与Python编程的“底层”良好互动。

在我们对Python数据模型的简要探讨中,我们将特别关注特殊方法。特殊方法是具有特殊名称的类函数,通过特殊语法调用。在我们的类定义中定义这些特殊方法,可以为我们的类实例赋予一些非常酷的Python特性,例如迭代、运算符重载、与上下文管理器(’with’关键字)良好协作、正确的字符串表示和格式化等。为了向您展示如何将这些特殊函数实现到您的类中,我们将考虑两个示例,这些示例中使用这些特殊函数将使我们的代码更加清晰和符合Python风格。

第一个示例是我为在Python中创建一个简单的石头-剪刀-布游戏而想出的一个有些超出常规的解决方案,第二个示例将稍微带有数学性质,但我会逐行带您走过每一行代码

一个简单的石头剪刀布游戏

如果你对剪刀石头布游戏不太熟悉,它最初是一种通常由两人进行的手势游戏,涉及做出石头、布或剪刀的手势。了解整个游戏的历史并不重要,重要的是知道如何判定胜者。在传统的规则中,石头的手势总是能战胜剪刀,但会输给布;剪刀的手势能战胜布,但会输给石头;显然,布会输给剪刀,但能战胜石头。我们可以将其总结如下:

剪刀石头布游戏图

在我们这个游戏的Python仿真中,我们将把玩家人数限制为仅两人,一个玩家是计算机,另一个是用户。此外,这不是一篇关于机器学习或计算机视觉的文章,我们的用户仍然需要在终端中输入一个选项,选择石头、剪刀或布,以便我们的程序正常工作。
在我们进入实际编码之前,回过头来考虑一下我们希望Python脚本的样子是很有必要的。对于我这个挑战的解决方案,我将使用random模块来使计算机随机选择石头、剪刀或布中的一个选项。为了实现我们的代码如何评估赢家,我将做出以下假设:

我还将采用面向对象编程(OOP)的方法;我们的石头、剪刀和布将被视为对象,而不是字符串变量。与其为每个选项创建三个单独的类,不如只创建一个可以表示它们中的任何一个的类。这种方法还将让我向您展示特殊方法如何使生活变得更简单。现在进入有趣的部分!

类定义

将我们的类命名为 RPS 可能听起来有些奇怪,但我发现这个名字“RPS”非常合适,因为每个字母都来自于其首字母,R 代表石头(Rock),P 代表纸(Paper),而 S 代表剪刀(Scissors)。这里需要注意的是,创建我们类的实例需要两个参数:pick 和 name。我们已经说明了脚本的用户需要在终端中输入他们选择的选项,与其让用户输入“Paper”(这可能会让他们感到很有压力),不如让用户输入“P”(或“p”)来选择“Paper”,这就是 pick 的含义。name 属性是实际的名称,例如“Paper”。所以现在我们知道每个参数的用途后,可以通过创建一个实例来检查我们的类。

>>> p = RPS('P', 'Paper') # 创建一个实例
>>> p.name
# 返回 : Paper
>>> p.pick
# 返回 : P
>>> print(p)
# 返回 : <__main__.RPS object at 0x...>

我们的类实例已经创建,并且具有正确的属性,但请注意,当我们尝试打印保存我们类实例的变量内容时,会得到什么。在深入探讨我们的类实例如何返回这个奇怪字符串的技术细节之前,让我们通过添加一个特殊函数来更新我们的类定义,并看看有什么不同。

现在让我们创建一个实例,并再次尝试打印我们的类实例

>>> p = RPS('P', 'Paper')
>>> print(p)
# 返回 : RPS(P, Paper)

正如我们所看到的,通过定义repr方法,我们可以获得更好的显示效果。让我们对类定义再进行一次更改。

现在让我们创建一个实例并再次进行测试。

>>> p = RPS('P', 'Paper')
>>> p
# 返回 : RPS(P, Paper)
>>> print(p)
# 返回 : Paper
>>> str(p)
# 返回 : 'Paper'
>>> repr(p)
# 返回 : 'RPS(P, Paper)'

要了解这里发生了什么,我们需要对打印函数有一点了解。打印函数使用内置的 Python 类 str 将所有非关键字参数(如我们的 p 变量)转换为字符串。如果对我们的变量调用 str() 失败,Python 会回退到内置的 repr 函数。当对我们的对象调用 str 时,它会查找 __str__ 方法,如果没有找到,它会失败并接着查找 __repr__ 方法。__str____repr__ 方法都是用于对象字符串表示的特殊方法。__repr__ 方法提供了对象的官方字符串表示,而 __str__ 方法则提供了对象的友好字符串表示。我通常会说 __repr__ 方法就像是在与另一位开发者交谈,它通常展示如何调用我们的类,而 __str__ 则像是在与我们程序的用户(在这个例子中是玩家)交谈,你通常只想返回一个简单的字符串,比如 “Paper”,以向用户展示他们选择了什么。虽然我在我们的类定义中提到了 __repr____str__ 这两个特殊函数,但实际上还有第三个特殊方法,没错,它是最常见的 __init__ 函数。它用于初始化我们的类,并在返回类实例之前由 __new__ 特殊方法调用。我刚刚提到的另一个特殊方法是我们尚未定义的?没错,我提到了。你可能还会感兴趣的是,Python 会自动为我们的类添加一些其他特殊方法。你可以通过在我们的类实例上调用内置函数 dir 来查看它们,如下所示

>>> dir(p)
# 返回 : ['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', 'name', 'pick']

 

特殊函数或方法可以通过其命名方式来识别,它们总是以双下划线 ‘‘ 开头,并以双下划线 ‘‘ 结尾。由于这种特殊的命名方式,这些方法通常被称为 dunder 方法(双下划线 + 下划线 = DUNDER)。因此,如果一个方法名以双下划线开头,它很可能是一个特殊方法,但并不一定。为什么不一定呢?这仅仅是因为 Python 并不阻止我们使用 dunder 语法定义自己的方法。好了,回到我们的游戏脚本。

现在我们要做的就是让我们的脚本知道如何判断胜者。如前所述,我将使用比较来评估胜者。

# 比较逻辑
石头 > 剪刀
剪刀 > 纸
纸 > 石头

 

为了实现这个解决方案,我将添加一个字典并使用双下划线大于(daunder greater_than)方法。字典的键将是“石头”、“剪刀”和“布”的首字母。每个键的值将是该键可以击败的唯一其他元素。

注意新增加的代码行,首先是选项字典,然后是__gt__方法的定义。通过这些新代码行,让我们看看我们的代码现在具备了什么新功能。

# 创建一个石头实例
>>> r = RPS('R', 'Rock')

# 创建一个布实例
>>> p = RPS('P', 'Paper')

# 创建一个剪刀实例
>>> s = RPS('S', 'Scissors')

>>> print(r,p,s)

# 返回:剪刀石头布

>>> p > r # 纸胜过石头
# 返回:True

>>> r > s # 石头胜过剪刀
# 返回:True

>>> s > p # 剪刀胜过纸
# 返回:True

>>> p < s # 纸输给剪刀
# 返回:True

>>> p < r # 纸输给石头
# 返回:False

>>> p < s < r # 纸输给剪刀,而剪刀又输给石头
# 返回:True

>>> p >= r 纸胜过或平局于石头
# 返回:Traceback (most recent call last): 
  File "<stdin>", line 1, in <module> 
TypeError: '>=' not supported between instances of 'RPS' and 'RPS'

仅仅通过添加 __gt__ 特殊方法,我们的类实例便获得了神奇的能力(双下划线方法有时被称为魔法方法,这一点我们可以理解)。通过实现双下划线 gt 方法,我们的类实例现在可以很好地与 >< 符号进行比较,但无法与 符号进行比较。原因在于 < 只是 > 的否定。< 的特殊方法是 __lt__(self, x),它可以简单地通过调用 __gt__(self, x) 的否定来实现。

 

对于 ≥ 符号,其特殊方法是 __ge__(self, x),必须为我们的对象定义该方法,以便与 符号良好配合。但在这个程序中,我们可以不使用它。

 

另一个缺失的部分是检查两个独立的 Paper 实例是否相等。

 

 

>>> p1 = RPS("P", "Paper")
>>> p2 = RPS("P", "Paper")
>>> p3 = p1
>>> p1 == p2
# return : False

>>> p1 == p3
# return : True

>>> id(p1)
# return : 140197926465008

>>> id(p2)
# return : 140197925989440

>>> id(p3)
# return : 140197926465008

>>> id(p1) == id(p3)
# return : True

>>> id(p2) == id(p1)
# return False

 

等号比较符的默认操作是比较对象的 id。p1 和 p2 是不同的类实例,恰好具有相同的属性,但它们的 id 不同,因此不相等。当我们将一个变量赋值给一个类实例时,我们使该变量指向实例的地址,这就是我们观察到的 p3,它与 p1 具有相同的 id。我们可以通过定义和实现自己的 __eq__(self, other) 方法来覆盖对象的相等比较方式。但在这个脚本中,我将使用它们的 pick 属性来比较两个实例。现在我们已经定义了我们的类并了解它的工作原理,我们准备好查看 Python 脚本的完整实现。

将所有内容整合在一起

让我带你逐步了解代码。我们已经熟悉了RPS类的定义。如果你还记得,我们的代码旨在让计算机随机选择选项,这就是random模块的用途。random模块提供了choice函数,它允许从可迭代对象(例如Python中的列表)中“随机”选择一个元素。在这种情况下,列表是option_list。由于我们的类是为了使用大写字母进行比较,因此我们必须始终用大写字母初始化我们的对象的pick属性。这就是为什么我们首先将用户的输入转换为大写(第34行)使用.upper()。用户也可能输入一个意外的字符,比如’Q’,所以我们必须通过检查大写字符是否是option_list中的有效选项来验证用户输入。映射字典使我们能够在验证后快速将用户的输入转换为相应的RPS实例。evaluate_winner函数利用比较符号来确定胜者。因为我们希望代码在找到胜者之前一直运行在循环中,所以我们使用while循环,当找到胜者时,evaluate_winner函数返回True,这将中断循环并退出游戏。

这是运行代码的各种结果之一

运行python脚本的图像

我们的Python代码运行如预期,尽管可能还有一些改进或新功能可以添加。最重要的是,我们看到在类定义中使用特殊方法使我们的代码更具Python风格。假设我们采用不同的方法,例如使用嵌套的if语句,我们的evaluate_winner方法可能看起来像这样

def evaluate_winner(user_choice, comp_choice):
    # 检查用户选择是否为'R'
    if user_choice == 'R':
        # 检查comp_choice是否为'R'
        if comp_choice == 'R':
            # 平局
            ...
        elif comp_choice == 'S':
            # 用户胜利
            ...
        else:
          # 计算机胜利
          ...
    if ... 
     # 对于用户选择为'S'和用户选择为'P'时做同样的处理

这种方法的一个问题除了代码冗长之外,就是如果我们想要添加一个新的元素——钻石,它可以击败石头和剪刀,但不能击败纸(原因未知),我们的 if 语句会显得非常尴尬。而在我们的面向对象编程(OOP)方法中,我们所需要做的只是像这样修改选项字典

options = {"R" : ["S"], "P" : ["R"], "S" : ["P"], "D" : ["R", "S"]}

 

然后我们将 __gt__ 中的 if 语句修改为

def __gt__(self,x):    
    if x.pick in self.options[self.pick]:
        return True
    else:
        return False


我们可以使语句更简洁

 

def __gt__(self, x):
   return True if x.pick in self.options[self.pick] else False

最后,这里有一些关于使用特殊方法的注意事项:

  • 你几乎(或从不)直接调用它们,让 Python 为你调用
  • 在定义使用双下划线命名语法的函数时,你应该考虑到 Python 未来可能会定义这样的函数并赋予其不同的含义。这可能会破坏你的代码或导致其以意想不到的方式运行
  • 您当然不必实现所有特殊方法。只需实现您真正需要的几个即可。请记住,简单优于复杂。如果有更简单的方法,您应该使用那个。

 

希望您喜欢这篇文章!

更多