Python实践提升-数据模型与描述符

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 章 enterexit with obj: 定义对象作为上下文管理器时的行为
第 6 章 iternext for _ in obj 定义对象被迭代时的行为
第 8 章 call obj() 定义被调用时的行为
第 8 章 new obj_class() 定义创建实例时的行为
  从表 12-1 中可以发现,所有与数据模型有关的方法,基本都以双下划线开头和结尾,它们通常被称为魔法方法(magic method)。

在本章中,除了这些已经学过的魔法方法外,我将介绍一些与 Python 数据模型相关的实用知识。比如,如何用 dataclass 来快速创建一个数据类、如何通过 getset 来定义一个描述符对象等。

在本章的案例故事里,我将介绍如何巧妙地利用数据模型来解决真实需求。

要写出 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):
        # “不等”运算的结果一般会直接对“等于”取反
     
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值