Python进阶系列(流畅的 Python 第二版)一:数据模型

龟叔(Guido)对语言设计美学的理解非常厉害。我见过很多语言设计者,他们可以写出理论上很美的编程语言,但使用者寥寥,而龟叔是那类奇人,他们创建的语言在理论上不那么美,但人们很乐意使用它来编程。

-Jim Hugunin,Jython作者、AspectJ联合作者、.Net DLR架构师

Python最好的一点是其连贯性。在使用了一段时间Python后,你就会很自然地推测出一些新的特性。

但是对于那些学过其它面向对象语言的人来说,可能会觉得使用len(collection)而不是collection.len()很奇怪。这种奇怪只是冰山-角,进行掌握就是人们称之为的Pythonic.核心。这座冰山叫做Python数据模型,它是我们将自建对象与大部分语言特性良好结合的API。

可以把数据模型看成是Python框架的描述。它规范了语言自身组成部分的接口,比如序列、函数、迭代器、协程、类、上下文管理器等。

在使用一个框架时,我们花费大量时间编写框架所调用的方法。在使用Python数据模块创建新类时也一样。Python解释器调用专有方法(special method)来执行基础对象运算,这通常由特殊语法所触发。专有方法的名称前后都会有双下划线。例如,obj[key]__getitem__专有方法提供支持。在运行my_collection[key]时,解释器会调用my_collection.__getitem__(key)

在希望对象支持并使用下列基本语言结构时我们要实现这些专有方法:

  • 容器(Collections)
  • 属性访问
  • 迭代(包含使用async for的异步迭代)
  • 运算符重载
  • 函数和方法调用
  • 字符串显示和格式化
  • 使用await的异步编程
  • 对象创建和销毁
  • 使用withasync with语句管理上下文

魔法方法和DUNDER

魔法方法是专有方法的俗称,但我们要怎么描述像__getitem__这样的具体方法呢?《Python Web Programming》等书的作者Steve Holden使用了“dunder-getitem”。Dunder是double underscore before and after的简写。所以在英文中你会看到dunder methods的说法。Python 语言参考手册在词法分析一章中写道:“所有不按遵照文档对 __*__ 的使用,都可能导致警告的崩溃。”

一个Pythonic的卡牌程序

下例非常简单,但展示了仅用两个魔法,__getitem____len__,所能产生的威力。

示例1-1: 一组有序的扑克牌(spade ♠️ diamond ♦️ club ♣️ heart ♥️)

import collections

Card = collections.namedtuple('Card', ['rank', 'suit'])

class FrenchDeck:
    ranks = [str(n) for n in range(2, 11)] + list('JQKA')
    suits = 'spades diamonds clubs hearts'.split()

    def __init__(self):
        self._cards = [Card(rank, suit) for suit in self.suits
                                        for rank in self.ranks]

    def __len__(self):
        return len(self._cards)

    def __getitem__(self, position):
        return self._cards[position]

首先要注意的是这里使用了collections.namedtuple来构造了一个简单的类用于表示每张扑克牌。我们使用namedtuple来创建无自定方法的一组属性的对象,就像数据库记录。在示例中,我们使用它来表示一副扑克牌,参见:

>>> beer_card = Card('7', 'diamonds')
>>> beer_card
Card(rank='7', suit='diamonds')

但本例主要是展示FrenchDeck类的用法。很简短,也打包了很多功能。首先和标准的Python容器一样,它可以通过len()返回一副牌的张数:

>>> deck = FrenchDeck()
>>> len(deck)
52

由于有__getitem__方法,读取一副牌中的某一张也很轻松,如第一张或最后一张:

>>> deck[0]
Card(rank='2', suit='spades')
>>> deck[-1]
Card(rank='A', suit='hearts')

那要不要创建方法来随机选一张扑克牌呢?完全不需要。Python已经内置了从序列中获取随机项的函数:random.choice。可以对deck实例进行使用:

>>> from random import choice
>>> choice(deck)
Card(rank='3', suit='hearts')
>>> choice(deck)
Card(rank='K', suit='spades')
>>> choice(deck)
Card(rank='2', suit='clubs')

我们已经看到使用Python数据模型魔法方法的两个好处:

  • 类的使用者无需记住标准运算的方法名(如何获取子项数量?是用.size().length()还其它的什么)
  • 更易于使用Python标准库中丰富的方法,无需重复造轮子,就像上例是的random.choice函数。

但好处远不止于此。

因为__getitem__代理了self._cards[]运算符,我们的类自动支持了切片。下面是如何查看一新牌最上面的3张,以及通过下标12每次跳过13张牌获取到所有的Ace:

>>> deck[:3]
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'),
Card(rank='4', suit='spades')]
>>> deck[12::13]
[Card(rank='A', suit='spades'), Card(rank='A', suit='diamonds'),
Card(rank='A', suit='clubs'), Card(rank='A', suit='hearts')]

通过魔法方法__getitem__,这个类也可以进行遍历:

>>> for card in deck:  # doctest: +ELLIPSIS
...   print(card)
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
Card(rank='4', suit='spades')
...

也可以反向对deck进行遍历:

>>> for card in reversed(deck):  # doctest: +ELLIPSIS
...   print(card)
Card(rank='A', suit='hearts')
Card(rank='K', suit='hearts')
Card(rank='Q', suit='hearts')
...

文章中尽可能地从doctests中抽取控制台内容以确保精确性。在输出过长时,省略掉的代码在最后一行以省略号(...)进行标记,就像上例中那样。这时我会使用# doctest: +ELLIPSIS来保证测试通过。如果读者在交互式控制台中进行测试的话,可以不添加doctest注释。doctest 执行示例:python -m doctest xxx.py -v,如无需显示详情可省去-v

遍历通常是隐式的。如果一个容器没有__contains__方法,in运算符会进行顺序扫描。我们的FrenchDeck类可以使用in是因为其可迭代。如:

>>> Card('Q', 'hearts') in deck
True
>>> Card('7', 'beasts') in deck
False

那排序呢?常用的扑克牌大小排序是先按照大小(Ace最大)、然后按照花色,由高到低:黑、红、方、梅。下面的函数按照这一规则进行排名,方块2返回0,黑桃Ace返回51

suit_values = dict(spades=3, hearts=2, diamonds=1, clubs=0)

def spades_high(card):
    rank_value = FrenchDeck.ranks.index(card.rank)
    return rank_value * len(suit_values) + suit_values[card.suit]

有了spades_high,我们现在就可以按大小的升序打印出扑克牌了:

>>> for card in sorted(deck, key=spades_high):  # doctest: +ELLIPSIS
...      print(card)
Card(rank='2', suit='clubs')
Card(rank='2', suit='diamonds')
Card(rank='2', suit='hearts')
... (46 cards omitted)
Card(rank='A', suit='diamonds')
Card(rank='A', suit='hearts')
Card(rank='A', suit='spades')

虽然FrenchDeck是隐式地继承了object类,其大部分功能不是通过继承而来,而是通过数据模型和组合而获取的。通过实现专有方法__len____getitem__FrenchDeck可以像标准的Python序列一样使用语言的核心特性(如遍历和切片),也享受标准库的诸多功能,如示例是使用的random.choicereversedsorted。借助组合,__len____getitem__实现可以将所有的任务委托给list对象,self._cards

如何洗牌呢?
截至目前,还不能对FrenchDeck进行洗牌操作,因为它是不可变的(immutable),即扑克牌的顺序不能改变,除出违反封装规则、直接对_cards进行处理。在进阶系列十三中,我们会通过添加__setitem__方法来修复这个问题。

专有方法是如何被使用的?

首先应知道 是专有方法是给Python解释器而不是程序员调用的。我们不这么写my_object.__len__()。而会写len(my_object),并且如果my_object是用户定义类的实例的话,那么Python会调用你所实现的__len__方法。

但解释器在处理liststrbytearray等内置类型或NumPy数组这类扩展时使用了捷径。C所编写的Python可变大小容器包含一个名为PyVarObject的结构体,它包含一个存储容器子项数量的字段ob_size。因此,如果my_object是这些内置类型的实例时,那么len(my_object)会获取ob_size字段的值,这比调用方法的速度更快。

此外,魔法方法的调用是隐式的。例如,for i in x:语句实现上调用了iter(x),如果x.__iter__()存在又会调用它,或者像FrenchDeck示例中那样使用x.__getitem__()

通常,不应在代码中直接调用魔法方法。除非你在进行大量的元编程,否则一般都只是实现这些魔法方法,而不会进行显式调用。唯一一个经常在程序中调用的是__init__,用于在__init__中调用其父类的初始化方法。

如果需要调用魔法方法,最好是使用其对应的内置函数(如leniterstr等)。这些内置函数调用相应的专有方法,但通常提供其它服务并且对于内置类型来说比方法调用更快速。参见进阶系列十七中的对可调用对象使用iter

在接下来的小节中,我们会学习魔法方法的一些最重要的应用:

  • 数字类型仿真
  • 对象的字符串表达
  • 对象的布尔值
  • 实现容器

数字类型仿真

一些魔法方法允许用户的对象使用+这样的运算符。我们会在进阶系列十六中做更详细的探讨,但这里希望通过其它的简单示例说明魔法方法的用法。

我们会实现一个表示二维向量的类,类似在数学和物理中使用的欧式向量。

图1-1 二维向量加法示例,Vector(2, 4) + Vector(2, 1) 得 Vector(4, 5)

图1-1 二维向量加法示例,Vector(2, 4) + Vector(2, 1) 得 Vector(4, 5)

置的complex类型可用于表示二维向量,但我们的类可以扩展为n维向量,在进阶系列十七中我们就会这么做。

我们会通过书写控制台模拟会话来为这个类设计API,稍后在doctest中也可以使用。下面的脚本测试图1-1中的向量加法运算:

>>> v1 = Vector(2, 4)
>>> v2 = Vector(2, 1)
>>> v1 + v2
Vector(4, 5)

注意+运算得到了一个新Vector,在控制台中的显示也很友好。

内置的abs函数返回整型和浮点型的绝对值,以及复数的模(magnitude),为保持一致性,我们使用abs来计算向量的大小。

>>> v = Vector(3, 4)
>>> abs(v)
5.0

我们还可以实现*运算符来做标量的乘法(即把向量乘上一个数字,得到一个新向量,方向不变,模值翻倍):

>>> v * 3
Vector(9, 12)
>>> abs(v * 3)
15.0

示例1-2中的Vector通过魔法方法__repr____abs____add____mul__实现了上述的运算。

示例1-2: 一个简单的二维向量类

"""
vector2d.py: a simplistic class demonstrating some special methods

It is simplistic for didactic reasons. It lacks proper error handling,
especially in the ``__add__`` and ``__mul__`` methods.

This example is greatly expanded later.

Addition::

    >>> v1 = Vector(2, 4)
    >>> v2 = Vector(2, 1)
    >>> v1 + v2
    Vector(4, 5)

Absolute value::

    >>> v = Vector(3, 4)
    >>> abs(v)
    5.0

Scalar multiplication::

    >>> v * 3
    Vector(9, 12)
    >>> abs(v * 3)
    15.0

"""


import math

class Vector:

    def __init__(self, x=0, y=0):
        self.x = x
        self.y = y

    def __repr__(self):
        return f'Vector({self.x!r}, {self.y!r})'

    def __abs__(self):
        return math.hypot(self.x, self.y)

    def __bool__(self):
        return bool(abs(self))

    def __add__(self, other):
        x = self.x + other.x
        y = self.y + other.y
        return Vector(x, y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

除了我们熟悉的__init__外共实现了5个魔法方法。注意在类中以及doctest中均未直接调用这些方法。前面提到过,Python解释器是大多数魔法方法的唯一调用方。

示例1-2实现了两个运算符:+*,用于说明__add____mul__的基本用法。这两个方法都创建并返回了一个新的Vector实例,对计算项未进行任何修改,对selfother 仅进行读取。中间运算符就该这样:新建对象而不修改计算项。在进阶系列十六中会做更多的阐述。

在示例1-2中实现的乘法支持Vector乘上一个数,但不支持数字乘以Vector,这违背了乘法的交换律。我们会在进阶系列十六中使用魔法方法__rmul__修复这一问题。

在下节中,我们来讨论Vector中的其它魔法方法的代码。

字符串表达

__repr__魔法方法由内置函数repr调用,用于获取对象的字符串表达供查看。如未添加__repr__,Python控制台中会将Vector实例显示为

交互式控制台和调试器对表达式运算结果调用repr,像传统的格式化运算符%%r占位符一样,还有新格式化字符串语法f字符串f-string)中!r字段转换使用str.format方法。

注意在我们的__repr__f字符串使用!r来获取所显示属性的标准表示。这是一种良好实践,因为展现了Vector(1, 2)Vector('1', '2')之间的重要差别,后者在本例中无法使用,因为构造方法的参数应为数字,而非字符串。

__repr__所返回的字符串应当是不含糊的,尽可能匹配重建表达对象的源码。这也是为什么Vector的表示与调用该类的构造方法一样(即Vector(3, 4))。

相较的__str__由内置方法str()调用,在使用print函数时也会隐式地使用到。它应当返回更适合终端用户查看的字符串。

有时__repr__返回的字符串一样友好,则无需编写__str__,因为实现所继承的object类把__repr__作为替补方法。

之前在其它语言使用toString方法的程序员会倾向于实现__str__ 而非 __repr__。如果你只实现其中一个方法的话,请选择__repr__

__str____repr__ 之间的不同在Stack Overflow上Pythonistas Alex Martelli 和 Martijn Pieters的回答都很值得一看。

自定义类型的布尔值

虽然Python有bool类型,但它在布尔上下文中接收任意对象,例如ifwhile字符串控制语句,或andornot的子项。要获知x的值是真还是假,Python中使用bool(x),它返回TrueFalse

默认,用户定义类的实例被视为真,除非实现了__bool____len__方法。基本上bool(x) 调用 x.__bool__() 并使用其结果值。如未实现__bool__,Python会尝试调用x.__len__(),如其返回0,bool 返回 False,否则返回True

我们对__bool__的实现很简单,在向量的模为0时返回False,其余返回True。我们使用bool(abs(self))将模转换为布尔值,因为__bool__要求返回一个布尔值。在__bool__方法外部,很少需要显示调用bool(),因为在布尔上下文中可以使用任意对象。

注意魔法方法__bool__允许对象遵循Python标准库文档内置类型的真值测试规则。

Vector.__bool__的一个快速实现是:

def __bool__(self):
    return bool(self.x or self.y)

这样更难阅读,但了使用abs__abs__、平方和平方根。需要对bool的显式转换,因为__bool__必须返回布尔值,or会将运算项之一原样返回:x or y 在为真进返回 x,否则均返回y,不论其是真还是假。

容器API

图1-2记录了语言中基本容器类型的接口。图中所有的类都是抽象基类(ABCs—abstract base classes)。抽象基类以及collections.abc模块在进阶系列十三中进行讲解。这个简短的小节全景展现一下Python中最重要的容器接口,以及它们是如何与魔法方法的关系。

图1-2:基本集合类型的UML图。斜体方法为抽象方法,因此必须由具体的子类如list和dict所实现。其余的方法都有具体实现,因此子类可以直接继承它们。

图1-2: 基本容器类型的UML图。斜体方法为抽象方法,因此必须由具体的子类如list和dict所实现。其余的方法都有具体实现,因此子类可以直接继承它们。

每个抽象基础类都有一个魔法方法。Python 3.6中新增的Collection 类统一了每个容器需实现的3个基本接口:

  • Iterable支持for、 解包和其它形式的迭代
  • Sized支持内置函数len
  • Container支持in运算符

Python并不要求具体实继承这些抽象基础类,任意实现了__len__的类即可使用Sized接口。

Collection三个非常重要的部分:

  • Sequence:规范化内置类型liststr的接口
  • Mapping:由dictcollections.defaultdict等实现
  • Set:内置类型setfrozenset的接口

Sequence可倒排序,因为序列支持对其内容的自定义排序,而映射和集合则不支持。\

Python 3.7开始,dict类型正式“有序”了,但这仅指保留了键的插入顺序。无法对dict中的键进行重排。

Set中的所有魔法方法实现中间运算符。例如a & b 计算集合ab的交集,并由__and__魔法方法实现。

接下来的两篇文章会详细讲解标准库序列、映射和集合。

我们接着来学习Python数据模型中定义的魔法方法的主要分类。

魔法方法总览

Python语言手册的数据模型一章列举了80多种魔法方法的名称。一半以上实现了算术、比特位、比较运算符。参见下面的表格进行总览。

表1-1中排除了用于实现中间运算符或abs这样的核心数学函数的魔术方法。这里的大部分方法在本系列中都会进行讲解,包括异步魔术方法如__anext__(Python 3.5中新增)以及类自定义钩子如__init_subclass__(Python 3.6新增)。

类型 方法名
字符串/字节表示 __repr__ __str__ __format__ __bytes__ __fspath__
转换为数字 __bool__ __complex__ __int__ __float__ __hash__ __index__
容器仿真 __len__ __getitem__ __setitem__ __delitem__ __contains__
迭代 __iter__ __aiter__ __next__ __anext__ __reversed__
可调用或协程执行 __call__ __await__
上下文管理器 __enter__ __exit__ __aexit__ __aenter__
实例创建和销毁 __new__ __init__ __del__
属性管理 __getattr__ __getattribute__ __setattr__ __delattr__ __dir__
属性描述符 __get__ __set__ __delete__ __set_name__
抽象基类 __instancecheck__ __subclasscheck__
类元编程 __prepare__ __init_subclass__ __class_getitem__ __mro_entries__

表1-1:魔法方法名称(排除运算符)

中间及数字运算符由表1-2中的魔法方法所支持。里面最新名称__matmul____rmatmul____imatmul__在Python 3.5添加,用于支持矩阵乘法的中音运算符@,在进阶系列十六中会进行讲解。

运算符分类 符号 方法名
单元数学运算 - + abs() __neg__ __pos__ __abs__
比较运算 < <= == != > >= __lt__ __le__ __eq__ __ne__ __gt__ __ge__
数学运算 + - * / // % @ divmod() round() ** pow() __add__ __sub__ __mul__ __truediv__ __floordiv__ __mod__ __matmul__ __divmod__ __round__ __pow__
反向数学运算 运算项互调后的数学运算 __radd__ __rsub__ __rmul__ __rtruediv__ __rfloordiv__ __rmod__ __rmatmul__ __rdivmod__ __rpow__
加强赋值数学运算 += -= *= /= //= %= @= **= __iadd__ __isub__ __imul__ __itruediv__ __ifloordiv__ __imod__ __imatmul__ __ipow__
比特位运算 & ^ << >> ~ __and__ __or__ __xor__ __lshift__ __rshift__ __invert__
反向比特位运算 操作项对调后的比特位运算 __rand__ __ror__ __rxor__ __rlshift__ __rrshift__
加强赋值比特位运算 &= = ^= <<= >>= __iand__ __ior__ __ixor__ __ilshift__ __irshift__

表1-2:运算符的魔法方法名称及符号\

注: 在无法对第一个运算项使用魔法方法时,Python对第二个运算项调用反向运算魔法方法。增强赋值是结合了中间运算符和变量赋值的简写,如a += b
进阶系列十六中会详细讲解反向运算符和增强赋值。

len 为什么不是一个方法

Luciano Ramalho曾在2013年问过核心开发者Raymond Hettinger这一问题。得到的回答中关键点来自对Python之禅的引用:“实用胜于纯粹”。在专有方法是如何被使用的? 中我们讲到了在x为内置类型时len(x) 是怎样快速运行的。对于CPython的内置对象没有调用方法:长度从C结构体的一个字段中直接进行读取。获取容器中项目数是一个常用操作,因此对于str, list, memoryview这样的基本类型必须要保证高效。
换句话说,len没有按方法调用,因为它由Python数据模型做了特殊处理,像abs一样。但是由于有魔术方法__len__,也可以让len用在你的自定义对象中。这是对内置对象效率和语言层面一致性的一种折中。另一句Python之禅的话:“特殊情况也不能特殊到破坏规则”。

如果把abslen看成是一元运算符,可能会更能接受其设计不同于面向对象语言中的调用语法。事实上ABC语言(Python的亲爸爸)首创了这类特性,#等价于len(写作#s)。在用作中间运算符时,写作x#s,它计算sx出现的次数,而在Python对于序列s使用s.count(x)

小结

通过实现专有方法,对象可以像内置类型一样,使用社区认可为Pythonic的代码样式。

对Python对象的基本要求是其自身的字符串表达,一个用于调试和日志,另一个用于向终端用户展示。这也是在数据模型中存在__repr____str__的原因。

FrenchDeck示例中使用的序列仿真,是魔法方法的一种常用方式。例如,数据库通常以序列(类似容器)返回查询结果。生成大部分已有序列类型是进阶系列二的主题。实现自己的序列在进阶系列十二中讲解,届时我们会创建一个Vector类的多维扩展。
由于有运算符重载,Python提供了数据类型的多种选择,从内置类型到decimal.Decimalfractions.Fraction,均支持中间算术运算符。NumPy数据科学库支持矩阵和张量的中间运算符。实现运算符(包括反向运算符和增强运算)会在进阶系列十六的Vector加强版示例中进行讲解。

Python数据模型的其它大部分魔法方法的使用和实现在本系列中均会讲解。

文章首发地址:Alan Hou 的个人博客

你可能感兴趣的:(Python进阶系列(流畅的 Python 第二版)一:数据模型)