Python深入-5-数据类

一、引入数据类

形容一个人的信息,例如,姓名、年龄、性别、地址等。用什么数据类型来实现。
可以是字典,也可以是元组。

# 字典
user= {'name''张三''age'30'address':'china beijing'}

# 元组
userTuple=('张三',30,'china beijing')

也可以使用类

class User:
    def __init__(self,name,age,address):
        self.name=name
        self.age=age
        self.address=address

不管使用哪种方式,都感觉有太多样板代码的感觉。对于类而言,在__init__方法中编写的代码,当属性比较多的时候,就比较麻烦了。
如果这个类,仅仅只是这些字段的容器,几乎没有额外的功能,那写这么多样板代码就有一些不合适了。
此时,就有了数据类的概念。

二、典型的具名元组

collections.namedtuple是一个工厂函数,用于构建增强的tuple子类,具有字段名称、类名和提供有用信息的__repr__方法。namedtuple构建的类可在任何需要元组的地方使用。其实,为了方便,以前 Python 标准库中返回元组的很多函数,现在都返回具名元组,这对用户的代码没有任何影响。
namedtuple构建的类,其实例占用的内存量与元组相同,因为字段名称存储在类中。

# 定义并使用一个具名元组类型
from collections import namedtuple

# 创建具名元组需要指定两个参数:一个类名和一个字段名称列表。后一个参数可以是产生字符串的可迭代对象,也可以是一整个以空格分隔的字符串
User = namedtuple('User''name age address love')

# 字段的值必须以单个位置参数传给构造函数(而 tuple 构造函数接受单个可迭代对象)
zs = User('zhangsan'30'beijing', ('篮球''羽毛球'))
print(zs)  # User(name='zhangsan', age=30, address='beijing', love=('篮球', '羽毛球'))

# 可以通过名称或位置访问字段。
print(zs.name)  # zhangsan

作为 tuple 的子类,User 继承了一些有用的方法,例如__eq__,以及比较运算符背后的特殊方法__lt__等,可用于排序 User 实例构成的列表。
除了从tuple继 承,具名元组还有几个额外的属性和方法。下面代码 演示了几个最有用的属性和方法:类属性_fields、类方法 _make(iterable) 和实例方法 _asdict()

# 定义并使用一个具名元组类型
from collections import namedtuple

# 创建具名元组需要指定两个参数:一个类名和一个字段名称列表。后一个参数可以是产生字符串的可迭代对象,也可以是一整个以空格分隔的字符串
User = namedtuple('User''name age address love')

# 字段的值必须以单个位置参数传给构造函数(而 tuple 构造函数接受单个可迭代对象)
zs = User('zhangsan'30'beijing', ('篮球''羽毛球'))

# ._fields 属性的值是一个元组,存储类的字段名称。
print(User._fields)  # ('name', 'age', 'address', 'love')

lisi_data = ('lisi'20'nanjing', ('足球''羽毛球'))
# ._make() 方法根据可迭代对象构建 User 实例,与 User(*lisi_data) 作用相同
lisi = User._make(lisi_data)

# ._asdict() 方法返回根据具名元组实例构建的 dict 对象
print(lisi._asdict())  # {'name': 'lisi', 'age': 20, 'address': 'nanjing', 'love': ('足球', '羽毛球')}

import json

# ._asdict() 方法可把数据序列化成 JSON 格式。
print(json.dumps(
    lisi._asdict()))  # {"name": "lisi", "age": 20, "address": "nanjing", "love": ["u8db3u7403", "u7fbdu6bdbu7403"]}

在 Python 3.7 之前,asdict 方法返回一个 OrderedDict 对象。从 Python 3.8 开始,它返回一个简单的 dict 对象,因为现在键的插入顺序得以保留,所以影响不大。如果你还想得OrderedDict 对象,_asdict 文档建议根据返回结果自行构建,即OrderedDict(x._asdict())。 从 Python 3.7 开始,namedtuple 接受 defaults 关键字参数,值为一个产生 _N 项的可迭代对象,为从右数的 N 个字段指定默认值。示例 5-6 定义具名元组 Coordinate,为 reference 字段指定默认值。
例如:构建一个具名元组,为字段指定默认值

from collections import namedtuple

# defaults中的列表,从右到左,为字段进行附魔认知
User = namedtuple('User''name age address love', defaults=['上海', ('football''pingpang')])
ww = User('wangwu'11)
print(ww)  # User(name='wangwu', age=11, address='上海', love=('football', 'pingpang'))

具名元组也能用于增加方法,只是过程有点曲折。

# 定义并使用一个具名元组类型
from collections import namedtuple

# 定义一个具名元组
User = namedtuple('User''name age address')


# 定义一个方法,第一个参数不必命名为 self,但是调用时指代的就是接收方
def show_info(user):
    return user.name + " " + user.address


# 把方法依附到User类型上
User.showInfo = show_info

# 实现一个对象,并调用依附的方法,这样就成功了
zs = User('zhangsan'20'beijing')
print(zs.showInfo())

三、带类型的具名元组

from typing import NamedTuple


# 定义一个类,父类为NamedTuple
class User(NamedTuple):
    # 每个实例字段都要注解类型
    name: str
    age: int
    address: str
    # 最后一个可以写默认值
    love: tuple = ('okok')


zs = User('zhangsan'20'beijing', ('football''baskball'))
print(zs)  # User(name='zhangsan', age=20, address='beijing', love=('football', 'baskball'))

lisi = User('lisi'20'nanjing')
print(lisi)  # User(name='lisi', age=20, address='nanjing', love='okok')

使用typing.NamedTuple构建的类,拥有的方法并不比collections.namedtuple生成的更多,而且同样也从tuple继承方法。唯一的区别是多了类属性__annotations__,而在运行时,Python 完全忽略该属性。

四、类型提示入门

类型提示(也叫类型注解)声明函数参数、返回值、变量和属性的预期类型。
关于类型提示,首先你要知道,Python 字节码编译器和解释器根本不强制你提供类型信息。

4.1 运行时没有作用

Python 类型提示可以看作“供 IDE 和类型检查工具验证类型的文档”。 这是因为,类型提示对 Python 程序的运行时行为没有影响。参考如下代码

import typing


class User(typing.NamedTuple):
    name: str
    age: int


zs = User("hello", ('zs''lisi'))
print(zs) # User(name='hello', age=('zs', 'lisi')) 看到这个输出,就说明,运行时,是不检查类型的

类型提示主要为第三方类型检查工具提供支持,例如 Mypy 和 PyCharm IDE 内置的类型检查器。这些是静态分析工具,在“静止”状态下检查 Python 源码,不运行代码。

4.2 变量注解句法

typing.NamedTuple@dataclass使用 PEP 526 定义的句法注解变量。本节简要介绍在 class 语句中定义属性的注解句法。
变量注解的基本句法如下所示。

var_name: some_type

允许使用的类型在 PEP 484 中的“Acceptable type hints”一节规定,不过定义数据类时,最常使用以下类型。

  • 一个具体类,例如 strFrenchDeck

  • 一个参数化容器类型,例如 list[int]tuple[str, float] 等。

  • typing.Optional,例如Optional[str],声明一个字段的类型可以是strNone

另外,还可以为变量指定初始值。在typing.NamedTuple@dataclass声明中,指定的初始值作为属性的默认值,防止调用构造函数时没有提供对应的参数。

var_name: some_type = a_value

4.3 变量注解的意义

类型提示在运行时没有作用。然而,Python 在导入时(加载模块时)会读取类型提示,构建__annotations__字典,供typing.NamedTuple@dataclass使用,增强类的功能。

class DemoPlainClass:
    # a 出现在 __annotations__ 中,但被抛弃了,因为该类没有名为 a 的属性
    a: int
    # b 作为注解记录在案,而且是一个类属性,值为 1.1。
    b: float = 1.1
    # ❸ c 是普通的类属性,没有注解
    c = 'spam'


print(DemoPlainClass.__annotations__)  # {'a': <class 'int'>, 'b': <class 'float'>}

# 下面代码会报错 AttributeError: type object 'DemoPlainClass' has no attribute 'a'
# print(DemoPlainClass.a)

print(DemoPlainClass.b)  # 1.1
print(DemoPlainClass.c)  # spam

注意,特殊属性__annotations__由解释器创建,记录源码中出现的类型提示,即使是普通的类。
a 只作为注解存在,不是类属性,因为没有绑定值。 bc 存储为类属性,因为它们绑定了值。
这 3 个属性都不出现在DemoPlainClass 的实例中。使用 o = DemoPlainClass() 创建一个对象,o.a 抛出 AttributeError,而 o.b 和 o.c 检索类属性,值分别为 1.1 和 ‘spam’,行为与常规的 Python 对象相同。

研究一个 typing.NamedTuple 类

现在来研究一个使用 typing.NamedTuple 构建的类

import typing


class DemoNTClass(typing.NamedTuple):
    # a 是注解,也是实例属性
    a: int
    # 同样,b 是注解,也是实例属性,默认值为 1.1。
    b: float = 1.1
    # c 是普通的类属性,没有注解
    c = 'spam'


print(DemoNTClass.__annotations__)  # {'a': <class 'int'>, 'b': <class 'float'>}

# 下面代码会报错 AttributeError: type object 'DemoPlainClass' has no attribute 'a'
print(DemoNTClass.a)  # _tuplegetter(0, 'Alias for field number 0')

print(DemoNTClass.b)  # _tuplegetter(1, 'Alias for field number 1')
print(DemoNTClass.c)  # spam

可以看到,a 和 b 的注解与示例 5-10 一样。但是,typing.NamedTuple 创建了类属性 a 和 b。c 是
普通的类属性,值为 ‘spam’。类属性 a 和 b 是描述符。这是高级功能。现在可以把描述符理解为特性
(property)读值(getter)方法,即不带调用运算符() 的方法,用于读取实例属性。实际上,这意味着 a 和 b 是只读实例属性。这一点不难理解,因为 DemoNTClass 实例是某种高级的元组,而元组是不可变的。

研究一个使用 dataclass 装饰的类

from dataclasses import dataclass


@dataclass
class DemoDataClass:
    # a 是注解,也是受描述符控制的实例属性。
    a: int
    # 同样b 是注解,也是受描述符控制的实例属性,默认值为 1.1
    b: float = 1.1
    # c 是普通的类属性,没有注解
    c = 'spam'


print(DemoDataClass.__annotations__)  # {'a': <class 'int'>, 'b': <class 'float'>}

# 下面代码会报错 AttributeError: type object 'DemoDataClass' has no attribute 'a'
# print(DemoDataClass.a)

print(DemoDataClass.b)  # 1.1
print(DemoDataClass.c)  # spam

print(DemoDataClass.__doc__)  # DemoDataClass(a: int, b: float = 1.1)

__annotations____doc__没什么让人意外的。然而,DemoDataClass 没有名为 a 的属性。
相比之下,上面例子中 DemoNTClass 有可从实例中获取只读属性 a 的描述符(那个神秘的<_collections._tuplegetter>)。这是因为,a 属性只在 DemoDataClass 实例中存在。如果冻结 DemoDataClass 类,那么 a 就变成可获取和设定的公开属性。但是,b 和 c 作为类属性存在,b 存储实例属性 b 的默认值,而 c 本身就是类属性,不绑定到实例上。
下面来看 DemoDataClass 实例的情况。

from dataclasses import dataclass


@dataclass
class DemoDataClass:
    # a 是注解,也是受描述符控制的实例属性。
    a: int
    # 同样b 是注解,也是受描述符控制的实例属性,默认值为 1.1
    b: float = 1.1
    # c 是普通的类属性,没有注解
    c = 'spam'



dc = DemoDataClass(9)
print(dc.a)  # 9
print(dc.b)  # 1.1
print(dc.c)  # spam

同样,a 和 b 是实例属性,而 c 是通过实例获取的类属性。
前文说过,DemoDataClass 实例是可变的,而且运行时不检查类型。

dc.a = 10
dc.b = "china"

甚至还可以为不存在的属性赋值。

dc.c='hello'
dc.z='no error' 
# 这样都不报错

现在,dc 实例有 c 属性,这对类属性 c 没有影响。我们还可以新增一个 z 属性。这是 Python 正常的行为:常规实例自身可以有未出现在类中的属性。

五、`@dataclass` 详解

@dataclass这个装饰器接受多个关键字参数,完整签名如下

@dataclass(*, init=True, repr=True, eq=True, order=False,unsafe_hash=False, frozen=False)

第一个参数位置上的* 表示后面都是关键字参数。下面简要说明这些关键字参数。

Python深入-5-数据类
image.png


以上参数的默认值适用于多数情况。不过,你可能会更改以下参数的值,不使用默认值。


  • frozen=True 防止意外更改类的实例。

  • order=True 允许排序数据类的实例。

Python 对象是动态的,只要愿意,程序员还是可以绕过frozen=True这道防线。不过,在代码评审阶段很容易发现这种小伎俩。
如果 eqfrozen 参数的值都是True,那么 @dataclass将生成一个合适的__hash__方法,确保实例是可哈希的。生成的·hash方法使用所有字段的数据,通过字段选项(见 5.6.1 节)也不能排除。对于frozen=False(默认值),@dataclass__hash__ 设为None,覆盖从任何超类继承的__hash__ 方法,表明实例不可哈希。

5.1 字段选项

我们已经见过最基本的字段选项,即在提供类型提示的同时设定默认值。声明的字段将作为参数传给生成的__init__方法。Python 规定,带默认值的参数后面不能有不带默认值的参数。因此,为一个字段声明默认值之后,余下的字段都要有默认值。
对初级 Python 开发人员来说,可变的默认值往往导致 bug。如果在函数定义中使用可变默认值,调用函数时很容易破坏默认值,则导致后续调用的行为发生变化。类属性通常用作实例属性的默认值,数据类也是如此。@dataclass使用类型提示中的默认值生成传给__init__方法的参数默认值。为了避免 bug,@dataclass拒绝像下面代码这样定义类。

from dataclasses import dataclass


@dataclass
class ClubMember:
    name: str
    guests: list = [] # ValueError: mutable default <class 'list'> for field guests is not allowed:

ValueError 消息指出了问题所在,还提供了一个解决方案:使用default_factory。下面代码给出
了纠正 ClubMember 的方法。

from dataclasses import dataclass, field


@dataclass
class ClubMember:
    name: str
    guests: list = field(default_factory=list)

上面代码中,guests字段的默认值不是一个列表字面量,而是调用dataclasses.field函数,把参数设为default_factory=list,以此设定默认值。
default_factory参数的值可以是一个函数、一个类,或者其他可调用对象,在每次创建数据类的实例时调用(不带参数),构建默认值。这样,每个 ClubMember 实例都有自己的一个 list,而不是所有实例共用同一个 list。

浏览 dataclasses 模块文档,你会发现有一个 list 字段使用的句法比较新奇,

from dataclasses import dataclass, field


@dataclass
class ClubMember:
    name: str
    # list[str] 的意思是由字符串构成的列表
    guests: list[str] = field(default_factory=list)

新句法 list[str]是一种参数化泛型。从 Python 3.9 开始,内置类型 list 可以使用方括号表示法指定列表中项的类型。
这两种声明方式是有区别的,guests: list表示 guests 列表可以由任何类型的对象构成,而
guests: list[str]的意思是 guests 列表中的每一项都必须是字符串。因此,如果在列表中存储无效的项,或者读取到无效的项,则类型检查工具将报错。
default_factory应该是 field 函数最常使用的参数,不过除此之外还有其他参数可用

Python深入-5-数据类
image.png


Python深入-5-数据类
image.png


之所以有default参数,是因为在字段注解中设置默认值的位置被field函数调用占据了。假如我们想创建一个athlete字段,把默认值设为False,而且不提供给 __repr__ 方法使用,那么要像下面这样编写。


from dataclasses import dataclass, field

@dataclass
class ClubMember:
    name: str
    guests: list = field(default_factory=list)
    athlete: bool = field(default=False, repr=False)

5.2 初始化后处理

@dataclass生成的__init__ 方法只做一件事:把传入的参数及其默认值(如未指定值)赋值给实例属性,变成实例字段。可是,有些时候初始化实例要做的不只是这些。为此,可以提供一个__post_init__ 方法。如果存在这个方法,则@dataclass 将在生成的__init__方法最后调用__post_init__方法。
__post_init__ 经常用于执行验证,以及根据其他字段计算一个字段的值。

5.3 带类型的类属性

为了让类型检查工具和@dataclass满意,应当像下面这样声明 all_handles。

all_handles: ClassVar[set[str]] = set()

这里,类型提示的意思如下:
all_handles 是一个类属性,类型为字符串构成的集合,默认值是一个空集合。
编写这个注解之前,必须从typing模块中导入ClassVar
@dataclass装饰器不关心注解中的类型,但有两种例外情况,这是其中之一,即类型为 ClassVar 时,
不为属性生成实例字段。

5.4 初始化不作为字段的变量

有时,我们需要把不作为实例字段的参数传给__init__方法。按照dataclasses文档的说法,这种参数叫“仅作初始化的变量”(init-only variable)。为了声明这种参数,dataclasses 模块提供了伪类型InitVar,句法与 typing.ClassVar 一样。文档中给出的例子定义一个数据类,包含一个使用数据库初始化的字段,因此必须把数据库对象传给构造方法。

from dataclasses import dataclass, field, InitVar


@dataclass
class C:
    i: int
    j: int = None
    database: InitVar[DatabaseType] = None
    def __post_init__(self, database):
        if self.j is None and database is not None:
            self.j = database.lookup('j')

c = C(10, database=my_database)

注意 database 属性的声明方式。InitVar 阻止 @dataclassdatabase 视为常规的字
段。database不会被设为实例属性,也不会出现在dataclasses.fields 函数返回的列表中。然而,对于生成的__init__ 方法,database 是参数之一,同时也传给 __post_init__ 方法。如果你想自己编写__post_init__方法,那就必须像上面代码那样,在方法签名中增加相应的参数。

六、模式匹配类实例

类模式通过类型和属性(可选)匹配类实例。类模式的匹配对象可以是任何类的实例,而不仅仅是数据类的实例。
类模式有 3 种变体:简单类模式、关键字类模式和位置类模式。

6.1 简单类模式

case [str(name), _, _, (float(lat), float(lon))]:

那个模式匹配项数为 4 的序列,第一项必须是 str 实例,最后一项必须是二元组,两项均为 float 实例。 类模式的句法看起来与构造函数调用差不多。下面的类模式匹配 float 值,未绑定变量(在 case 主体中,如果需要可以直接引用 x)。

match x:
    case float():
        do_something_with(x)

但是,像下面这样做可能导致 bug。

match x:
    case float: # 危险!!!
        do_something_with(x)

这里,case float: 可以匹配任何对象,因为 Python 把 float 看作匹配对象绑定的变量。
float(x) 这种简单模式句法只适用于 9 种内置类型(在“PEP 634—Structural Pattern Matching: Specification”中“Class Patterns”一节的末尾列出)。

bytes dict float frozenset int list set str tuple

对这些类来说,看上去像构造函数的参数的那个变量,例如 float(x) 中的 x,绑定整个匹配的实例。如果是子模式,则绑定匹配对象的一部分,例如前例中序列模式内的 str(name)。

case [str(name), _, _, (float(lat), float(lon))]:

除 9 种内置类型之外,看上去像参数的那个变量表示模式匹配的类实例的属性。

6.2 关键字类模式

import typing


class City(typing.NamedTuple):
    continent: str
    name: str
    country: str


cities = [
    City('Asia''Tokyo''JP'),
    City('Asia''Delhi''IN'),
    City('North America''Mexico City''MX'),
    City('North America''New York''US'),
    City('South America''São Paulo''BR'),
]

那么,以下函数返回的列表中都是位于亚洲的城市。

def match_asian_cities():
    results = []
    for city in cities:
        match city:
            case City(continent='Asia'):
                results.append(city)
        return results

与前面一样,City(continent=’Asia’, country=cc) 也匹配位于亚洲的城市,不过现在把变量 cc绑定到了实例的 country 属性上。模式变量叫 country 也没关系。

match city:
    case City(continent='Asia', country=country):
        results.append(country)

关键字类模式的可读性非常高,适用于任何有公开的实例属性的类,不过有点烦琐。
有时候,使用位置类模式更方便,不过匹配对象所属的类要显式支持。

6.3 位置类模式

以下函数使用位置类模式获取亚洲城市列表

def match_asian_cities_pos():
    results = []
    for city in cities:
        match city:
            case City('Asia'):
                results.append(city)
    return results

City(‘Asia’) 匹配的 City 实例,第一个属性的值是 ‘Asia’,其他属性的值不考虑。如果你想收集 country 属性的值,可以像下面这样写。

def match_asian_countries_pos():
    results = []
    for city in cities:
        match city:
            case City('Asia', _, country):
                results.append(country)
    return results

与前面一样,City(‘Asia’, _, country) 也匹配位于亚洲的城市,不过现在把变量 country 绑定到了实例的第三个属性上。
可是,“第一个属性”和“第三个属性”是什么意思呢?
City 或其他类若想使用位置模式,要有一个名为__match_args__的特殊类属性。本章讲到的类构建器会自动创建这个属性。对于 City 类,__match_args__属性的值如下所示。

 City.__match_args__ # ('continent', 'name', 'country')

可以看到,位置模式中属性的顺序就是__match_args__声明的顺序。

注意:

一个模式可以同时使用关键字参数和位置参数。__match_args__ 列出的是可供匹配的实例属性,不是全部属性。因此,有时候除了位置参数之外可能还需要使用关键字参数。


原文始发于微信公众号(Python之家):Python深入-5-数据类

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/198407.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!