Python实践提升-数据模型与描述符
在 Python 中,数据模型(data model)是个非常重要的概念。我们已经知道,Python 里万物皆对象,任何数据都通过对象来表达。而在用对象建模数据时,肯定不能毫无章法,一定需要一套严格的规则。
我们常说的数据模型(或者叫对象模型)就是这套规则。假如把 Python 语言看作一个框架,数据模型就是这个框架的说明书。数据模型描述了框架如何工作,创建怎样的对象才能更好地融入 Python 这个框架。
也许你还不清楚,数据模型究竟如何影响我们的代码。为此,我们从一个最简单的问题开始:当用 print 打印某个对象时,应该输出什么?
假设我定义了一个表示人的对象 Person:
class Person:
"""人
:param name: 姓名
:param age: 年龄
:param favorite_color: 最喜欢的颜色
"""
def __init__(self, name, age, favorite_color):
self.name = name
self.age = age
self.favorite_color = favorite_color
当我用 print 打印一个 Person 对象时,输出如下:
>>> p = Person('piglei', 18, 'black')
>>> print(p)
<__main__.Person object at 0x10d1e4250>
可以看到,打印 Person 对象会输出类名(Person)加上一长串内存地址(0x10d1e4250)。不过,这只是普通对象的默认行为。当你在 Person 类里定义 str 方法后,事情就会发生变化:
class Person:
...
def __str__(self):
return self.name
再试着打印一次对象,输出如下:
>>> print(p)
piglei
>>> str(d) ➊
'piglei'
>>> "I'm {}".format(p)
"I'm piglei"
❶ 除了 print() 以外,str() 与 .format() 函数同样也会触发 str 方法
上面展示的 str 就是 Python 数据模型里最基础的一部分。当对象需要当作字符串使用时,我们可以用 str 方法来定义对象的字符串化结果。
虽然从本章标题看来,数据模型似乎是一个新话题,但其实在之前的章节里,我们已经运用过非常多与数据模型有关的知识。表 12-1 整理了其中一部分。
表 12-1 本书前 11 章中出现过的数据模型有关内容
位置 方法名 相关操作 说明
第 3 章 __getitem++ obj[key] 定义按索引读取行为
第 3 章 __setitem++ obj[key] = value 定义按索引写入行为
第 3 章 __delitem++ del obj[key] 定义按索引删除行为
第 4 章 len len(obj) 定义对象的长度
第 4 章 bool bool(obj) 定义对象的布尔值真假
第 4 章 eq obj == another_obj 定义 == 运算时的行为
第 5 章 enter、exit with obj: 定义对象作为上下文管理器时的行为
第 6 章 iter、next for _ in obj 定义对象被迭代时的行为
第 8 章 call obj() 定义被调用时的行为
第 8 章 new obj_class() 定义创建实例时的行为
从表 12-1 中可以发现,所有与数据模型有关的方法,基本都以双下划线开头和结尾,它们通常被称为魔法方法(magic method)。
在本章中,除了这些已经学过的魔法方法外,我将介绍一些与 Python 数据模型相关的实用知识。比如,如何用 dataclass 来快速创建一个数据类、如何通过 get 与 set 来定义一个描述符对象等。
在本章的案例故事里,我将介绍如何巧妙地利用数据模型来解决真实需求。
要写出 Pythonic 的代码,恰当地使用数据模型是关键之一。接下来我们进入正题。
12.1 基础知识
12.1.1 字符串魔法方法
在本章一开始,我演示了如何使用 str 方法来自定义对象的字符串表示形式。但其实除了 str 以外,还有两个与字符串有关的魔法方法,一起来看看吧。
repr
当你需要把一个 Python 对象用字符串表现出来时,实际上可分为两种场景。第一种场景是非正式的,比如用 print() 打印到屏幕、用 str() 转换为字符串。这种场景下的字符串注重可读性,格式应当对用户友好,由类型的 str 方法所驱动。
第二种场景则更为正式,它一般发生在调试程序时。在调试程序时,你常常需要快速获知对象的详细内容,最好一下子就看到所有属性的值。该场景下的字符串注重内容的完整性,由类型的 repr 方法所驱动。
要模拟第二种场景,最快的办法是在命令行里输入一个 Person 对象,然后直接按回车键:
>>> p = Person('piglei', 18, 'black')
>>> str(p) ➊
'piglei'
>>> p ➋
<__main__.Person object at 0x10d993250>
>>> repr(p) ➌
'<__main__.Person object at 0x10d993250>'
❶ 接着前面的例子,Person 类已定义了 str 方法
❷ 直接输入对象后,你仍然能看到包含一长串内存地址的字符串
❸ 和 str() 类似,repr() 可以用来获取第二种场景的字符串
要让对象在调试场景提供更多有用的信息,我们需要实现 repr 方法。
当你在 repr 方法里组装结果时,一般会尽可能地涵盖当前对象的所有信息,假如其他人能通过复制 repr() 的字符串结果直接创建一个同样的对象,就再好不过了。
下面,我试着给 Person 加上 repr 方法:
class Person:
...
def __str__(self):
return self.name
def __repr__(self):
return '{cls_name}(name={name!r}, age={age!r}, favorite_color={color!r})'.format( ➊
cls_name=self.__class__.__name__, ➋
name=self.name,
age=self.age,
color=self.favorite_color,
)
❶ 在字符串模板里,我使用了 {name!r} 这样的语法,变量名后的 !r 表示在渲染字符串模板时,程序会优先使用 repr() 而非 str() 的结果。这么做以后,self.name 这种字符串类型在渲染时会包含左右引号,省去了手动添加的麻烦
❷ 类名不直接写成 Person 以便更好地兼容子类
再来试试看效果如何:
>>> p = Person('andy', 18, 'black')
>>> print(p)
andy
>>> p
Person(name='andy', age=18, favorite_color='black')
当对象定义了 repr 方法后,它便可以在任何需要的时候,快速提供一种详尽的字符串展现形式,为程度调试提供帮助。
假如一个类型没定义 str 方法,只定义了 repr,那么 repr 的结果会用于所有需要字符串的场景。
format
如前面所说,当你直接把某个对象作为 .format() 的参数,用于渲染字符串模板时,默认会使用 str() 化的字符串结果:
>>> p = Person('andy', 18, 'black')
>>> "I'm {}".format(p)
"I'm andy"
但是,Python 里的字符串格式化语法,其实不光只有上面这种最简单的写法。通过定义 format 魔法方法,你可以为一种对象定义多种字符串表现形式。
继续拿 Person 举例:
class Person:
...
def __format__(self, format_spec):
"""定义对象在字符串格式化时的行为
:param format_spec: 需要的格式,默认为 ''
"""
if format_spec == 'verbose':
return f'{
self.name}({
self.age})[{
self.favorite_color}]'
elif format_spec == 'simple':
return f'{
self.name}({
self.age})'
return self.name
上面的代码给 Person 类增加了 format 方法,并在里面实现了不同的字符串表现形式。
接下来,我们可以在字符串模板里使用 {variable:format_spec} 语法,来触发这些不同的字符串格式:
>>> print('{p:verbose}'.format(p=p)) ➊
piglei(18)[black]
>>> print(f'{
p:verbose}') ➋
piglei(18)[black]
>>> print(f'{
p:simple}') ➌
piglei(18)
>>> print(f'{
p}')
andy
❶ 此时传递的 format_spec 为 verbose
❷ 模板语法同样适用于 f-string
❸ 使用不同的格式
假如你的对象需要提供不同的字符串表现形式,那么可以使用 format 方法。
12.1.2 比较运算符重载
比较运算符是指专门用来对比两个对象的运算符,比如 ==、!=、> 等。在 Python 中,你可以通过魔法方法来重载它们的行为,比如在第 4 章中,我们就通过 eq 方法重载过 == 行为。
包含 eq 在内,与比较运算符相关的魔法方法共 6 个,如表 12-2 所示。
表 12-2 所有用于重载比较运算符的魔法方法
方法名 相关运算 说明
lt obj < other 小于(less than)
le obj <= other 小于等于(less than or equal)
eq obj == other 等于(equal)
ne obj != other 不等于(not equal)
gt obj > other 大于(greater than)
ge obj >= other 大于等于(greater than or equal)
一般来说,我们没必要重载比较运算符。但在合适的场景下,重载运算符可以让对象变得更好用,代码变得更直观,是一种非常有用的技巧。
举个例子,假如我有一个用来表示正方形的类 Square,它的代码如下:
class Square:
"""正方形
:param length: 边长
"""
def __init__(self, length):
self.length = length
def area(self):
return self.length ** 2
虽然 Square 看上去挺好,但用起来特别不方便。具体来说,假如我有两个边长一样的正方形 x 和 y,在进行等于运算 x == y 时,会返回下面的结果:
>>> x = Square(4)
>>> y = Square(4)
>>> x == y
False
看到了吗?虽然两个正方形边长相同,但在 Python 看来,它们其实是不相等的。因为在默认情况下,对两个用户定义对象进行 == 运算,其实是在对比它俩在内存里的地址(通过 id() 函数获取)。因此,两个不同对象的 == 运算结果肯定是 False。
通过在 Square 类上实现比较运算符魔法方法,我们就能解决上面的问题。我们可以给 Square 类加上一系列规则,比如边长相等的正方形就是相等,边长更长的正方形更大。这样一来,Square 类可以变得更好用。
增加魔法方法后的代码如下:
class Square:
"""正方形
:param length: 边长
"""
def __init__(self, length):
self.length = length
def area(self):
return self.length ** 2
def __eq__(self, other):
# 在判断两个对象是否相等时,先检验 other 是否同为当前类型
if isinstance(other, self.__class__):
return self.length == other.length
return False
def __ne__(self, other):
# “不等”运算的结果一般会直接对“等于”取反