0%

流畅的 Python 第 2 版(1):Python 数据类型

Python 入门相对简单,但要写好 Python 代码绝对不易。几年前阅读过《流畅的 Python》第一版,让我对 Python 语言的各个细节有了较为深入的理解,收获颇丰。最近因为投入 AI 相关工作,重新阅读大量 Python 开源代码,发现很多新特性比较陌生,虽然借助 LLM 可以快速了解这些新知识的内容,但总还是碎片化的。正好发现《流畅的 Python》也有了第二版,内容做了全面升级,可以帮助我学习这些 modern python 特性(modern 一词不独属于 C++ 了,眨眼~~)。

第二版在第一版的基础上,将 Python 更新到了 Python3.10(本书第一版基于 Python3.4)。Python3.10 官方教程的开头是这样写的:​Python是一门既容易上手又强大的编程语言。​这句话本身并无大碍,但需要注意的是,正因为它既好学又好用,所以很多 Python 程序员只用到了其强大功能的一小部分

Python 的质量保障得益于一致性。使用 Python 一段时间之后,便可以根据自己掌握的知识,正确地猜出新功能的作用。可以把 Python 视为一个框架,而数据模型就是对框架的描述,规范语言自身各个组成部分的接口,确立序列、函数、迭代器、协程、类、上下文管理器等部分的行为。如果想让对象支持以下基本的语言结构并与其交互,就需要实现 特殊方法

  • 容器
  • 属性存取
  • 迭代(包括使用 async for 的异步迭代)​
  • 运算符重载
  • 函数和方法调用
  • 字符串表示形式和格式化
  • 使用 await 的异步编程
  • 对象创建和析构
  • 使用 with async with 语句管理上下文

一摞 Python 风格的纸牌

如下代码展示了实现 __getitem____len__ 两个特殊方法之后得到的强大功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 可以构建只有属性而没有自定义方法的类对象
  • 与标准的 Python 容器一样,可以通过调用 len() 函数,获取纸牌个数
  • 得益于 FrenchDeck 类实现了 __getitem_ 方法,因此该类型支持索引、切盼、迭代、in 运算、等功能
  • 如果想随机选一张牌,可以直接使用 Python 已经提供的从序列中随机获取一项的函数,即 random.choice
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
>>> deck = FrenchDeck()
>>> len(deck)
52
>>> deck[0]
Card(rank='2', suit='spades')
>>> deck[-1]
Card(rank='A', suit='hearts')

>>> deck[:3]
[Card(rank='2', suit='spades'), Card(rank='3', suit='spades'), Card(rank='4', suit='spades')]

>>> for card in deck:
... print(card)
...
Card(rank='2', suit='spades')
Card(rank='3', suit='spades')
......

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

可以看到,通过特殊方法利用 Python 数据模型,这样做有两个优点。

  • 类的用户不需要记住标准操作的方法名称(例如 .size()、.length() 还是其他)
  • 可以充分利用 Python 标准库,例如 random.choice 函数,无须重新发明轮子

甚至还可以实现排序:

1
2
3
4
5
6
7
8
9
10
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]

>>> for card in sorted(deck, key=spades_high):
... print(card)
Card(rank='2', suit='clubs')
Card(rank='2', suit='diamonds')
......

虽然 FrenchDeck 类隐式继承 object 类,但是前者的多数功能不是继承而来的,而是源自数据模型和组合模式。

  • 实现 __len____getitem__ 两个特殊方法后,FrenchDeck 的行为就像标准的 Python 序列一样,从语言核心特性(例如迭代和切片)和标准库中受益
  • __len____getitem__ 的实现利用组合模式,把所有工作委托给一个 list 对象,即self._cards。

特殊方法是如何使用的

首先要明确一点,特殊方法供 Python 解释器调用,而不是你自己。例如不要使用 deck.__len__(),而要使用 len(deck),让 Python 解释器内部去调用你实现的 __len__ 方法。

很多时候,特殊方法是隐式调用的。我们在编写代码时一般不直接调用特殊方法,除非涉及大量元编程。即便如此,大部分时间也是实现特殊方法,很少显式调用。唯一例外的是 __init__ 方法,为自定义的类实现 __init__ 方法时经常直接调用它调取超类的初始化方法

如果需要调用特殊方法,则最好调用相应的内置函数,例如 len、iter、str 等。这些内置函数不仅调用对应的特殊方法,通常还提供额外服务,而且对于内置类型来说,速度比调用方法更快(处理内置类型时,Python 解释器可能有自己的优化方式)。

特殊方法的几个重要用途包括:

  • 模拟数值类型
  • 对象的字符串表示形式
  • 对象的布尔值
  • 实现容器

数值模拟

有几个特殊方法可以让用户对象响应 +* 等数学运算符。如下实现一个二维向量类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
"""
vector2d.py:一个简单的类,演示一些特殊方法
加法::

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

绝对值::

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

标量积::

>>> 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)
  • __add____mul__ 分别实现了 +* 运算,这两个方法创建并返回一个新 Vector 实例,没有修改运算对象。这是中缀运算符的预期行为,即创建新对象,不修改运算对象

字符串表示形式

特殊方法 __repr__ 供内置函数 repr 调用,获取对象的字符串表示形式。如未定义 __repr__ 方法,Vector 实例在 Python 控制台中显示为 <Vector object at 0x10e100070> 形式。

交互式控制台和调试器在表达式求值结果上调用 repr 函数,处理方式与使用 % 运算符处理经典格式化方式中的 %r 占位符、str.format 方法处理新字符串格式化句法中的 !r 转换字段一样。

注意,Vector 类 __repr__ 方法实现中的 f 字符串使用 !r 以标准的表示形式显示属性,这样做比较好。因为 __repr__ 方法返回的字符串应当没有歧义,如果可能,最好与源码保持一致,方便重新创建所表示的对象。

与此形成对照的是,__str__ 方法由内置函数 str() 调用,在背后供 print 函数使用,返回对终端用户友好的字符串。有时,__repr__ 方法返回的字符串足够友好,无须再定义 __str__ 方法,因为继承自object 类的实现最终会调用 __repr__ 方法。在 Python 中,如果必须二选一的话,请选择 __repr__ 方法。

自定义类型的布尔值

Python 有一个 bool 类型,在需要布尔值的地方处理对象,例如 ifwhile 语句的条件表达式,或者and、or 和 not 的运算对象。为了确定 x 表示的值为真或为假,Python调用 bool(x),返回 True 或 False。

默认情况下,用户定义类的实例都是真值,除非实现了 __bool____len__ 方法。Python 解释器优先尝试调用 __bool__(必须返回一个布尔值),如果没有实现,则继续尝试调用 __len__,如果该方法返回零值,则 bool 函数返回 False,否则返回 True。

Vector.__bool__ 方法也可以像下面这样简单定义:

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

容器 API

Python 语言中基本容器类型的接口如下所示:

顶部 3 个抽象基类均只有一个特殊方法。抽象基类 Collection 统一了这 3 个基本接口,每一个容器类型均应实现如下事项:

  • Iterable 要支持 for、拆包和其他迭代方式
  • Sized 要支持内置函数 len
  • Container 要支持 in 运算符

Collection 有 3 个十分重要的专用接口:

  • Sequence 规范 list 和 str 等内置类型的接口
  • Mapping 被 dict、collections.defaultdict 等实现
  • Set 是 set 和 frozenset 两个内置类型的接口

只有 Sequence 实现了 Reversible,因为序列要支持以任意顺序排列内容,而 Mapping 和 Set 不需要。

特殊方法概述

Pyton 语言参考手册第 3 章 列出了 80 多个特殊方法名称,其中一半以上用于实现算术运算符、按位运算符和比较运算符。下标列举了这些特殊方法:

小结

借助特殊方法,自定义对象的行为可以像内置类型一样,让我们写出更具表现力的代码,符合社区所认可的 Python 风格。另外值得说明,Python 文档中使用 Python 数据模型​ 来解释本章所讨论的内容,而一些书籍可能采用的是 Python 对象模型 这一术语。

另外,解释一下 元对象协议元对象 指构成语言自身的基本对象。在这个语境下,​协议 等同于 接口​。所以​ 元对象协议 就是对象模型的高级说法,指语言核心构件的 API。一套丰富的元对象协议能让我们扩展语言,支持新的编程范式。