5. 数据类构建器
数据类就像小孩子 , 作为一个起点很好 , 但若要让它们像成熟的对象那样参与整个系统的工作 ,
它们就必须承担一定责任 .
--Martin Fowler 和Kent Beck ①
( 注 1 : < < 重构 : 改善即有代码的设置 > > ( 后面改名 < < 重构 > > ) , 第 3 章 , 3.20 节 . )
Python提供了几种构建简单类的方法 , 这些类只是字段的容器 , 几乎没有额外功能 .
这种模式称为 '数据类' ( data class ) , dataclass包就支持该模式 .
本章介绍以下 3 个可简化数据类构建过程的类构建器 .
collections . namedtuple :
最简单的构建方式 , 从Python2 . 6 开始提供 .
typing . NameTuple :
另一个构建方式 , 需要为字段添加类型提示 , 从Python 3.5 开始提供 .
class句法在Python 3.6 中新增 .
@ dataclass . dataclass :
一个类装饰器 , 与前两种方式相比 , 可定义的内容更多 , 增加了大量选项 ,
可实现更复杂的功能 , 从Python 3.7 开始提供 .
介绍这些类构建器之后 , 接下来将讨论为什么数据类模式也是一种代码异味 ,
它的出现可能意味着面向对象设计欠佳 .
* * ------------------------------------------------------------------------------------------ * *
typing . TypedDict与typing . NamedTuple句法相似 ,
而且在Python 3.9 的typing模块文档中 , 二者紧挨在一起 .
这让人感觉typing . TypedDict也是一种数据类构建器 .
然而 , TypedDict不能改建科颜实例化的具体类 .
TypedDict只提供编写类型提示的句法 , 为把映射用作记录 ( 键时字典名称 ) 的函数和变量注解类型 . 详见 15.3 节 .
* * ------------------------------------------------------------------------------------------ * *
5.1 本章新增内容
本章是第二版新增的 .
5.3 节原来在第 1 版第 2 章中 , 除此之外 , 本章都是新内容 .
首先概述 3 个类构建器 .
5.2 数据类构建器概述
示例 5 - 1 是一个简单的类 , 表示地理位置的经纬度 .
class Coordiate :
def __init__ ( self, lat, lon) :
self. lat = lat
self. lon = lon
Coordinate类的作用是保存经纬度属性 .
为__init__方法编写样板代码容易让人感觉到枯燥 , 尤其是属性较多的时候 .
想想看 , 每一个属性都要写三次 .
更糟的是 , 样板代码并设置没有给我们提供Python对象都有的基本功能 .
>> > from coordinates import Coordinate
>> > moscow = Coordinate( 55.76 , 37.62 )
>> > moscow
< coordinates. Coordinate object at 0x107142f10 >
>> > location = Coordinate( 55.76 , 37.62 )
>> > location == moscow
False
>> > ( location. lat, location. lon) == ( moscow. lat, moscow. lon)
True
本章要讲的数据类构建器自定提供必要的__init__ , __repr__和__eq__等方法 , 此外还有其他有用的功能 .
* * ------------------------------------------------------------------------------------------ * *
本章讨论的类构造器都不依赖继承 .
collections . namedtuple和typing . NamedTuple构建的类都是tuple的子类 .
@ dataclass是类装饰器 , 不影响类层次结构 .
这 3 个类构建器使用不同的元编程技术把方法和数据属性注入要构建的类 .
* * ------------------------------------------------------------------------------------------ * *
下面使用namedtuple构建Coordinate类 .
nameetuple是一个工厂方法 , 使用指定的名称和字段构建tuple的子类 .
namedtuple : 允许创建具有命名字段的元组 , 并且这些字段可以像对象属性一样访问 .
参数 :
typename : 第一个参数为新创建的元组类的名称 .
field_names : 第二个参数字段名称的字符串 , 以空格或逗号分隔或作为由字符串组成的可迭代对象 .
>> > from collections import namedtuple
>> > Coordinate = namedtuple( 'Coordinate' , 'lat lon' )
>> > issubclass ( Coordinate, tuple )
True
>> > moscow = Coordinate( 55.756 , 37.617 )
>> > moscow
moscow == Coordinate( lat= 55.756 , lon= 37.617 )
>> > moscow == Coordinate( lat= 55.756 , lon= 37.617 )
True
新出现的typing . NameTuple具有一样的功能 , 不过可为各个字段添加类型注解 .
>> > import typing
>> > Coordinate = typing. NamedTuple( 'Coordinate' ,
. . . [ ( 'lat' , float ) , ( 'lon' , float ) ] )
. . .
>> > issubclass ( Coordinate, tuple )
True
>> > typing. get_type_hints( Coordinate)
{ 'lat' : < class 'float' > , 'lon' : < class 'float' > }
* * ------------------------------------------------------------------------------------------ * *
构建带类型的具名元组 , 也可以通过关键字参数指定字段 , 如下所示 .
Coordinate = typing . NamedTuple ( 'Coordinate' , lat = float , lon = float )
这种方式可读性高 , 而且可以通过映射指定字段及其类型 , 再使用 * * fielda_and_types拆包 , 如下所示 :
fields = {
'lat' : float ,
'lon' : float ,
'name' : str
}
Coordinate = NamedTuple ( 'Coordinate' , * * fields )
* * ------------------------------------------------------------------------------------------ * *
从Python3 . 6 开始 , typing . NamedTuple也可以在class语句中使用 .
类型注解按 'PEP 526--Syntax for Variable Annotations' 标准编写 .
这样写出的代码可读性更高 , 而且方便覆盖方法或添加新方法 .
示例 5 - 2 再次定义Coordiante类 , 经纬度属性均为float类型 , 同时自定义了__str__方法 ,
以 55.8 °N , 37.6 °E的格式显示坐标经纬度 .
from typing import NamedTuple
class Coordinate ( NamedTuple) :
lat: float
lon: float
def __str__ ( self) :
ns = 'N' if self. lat >= 0 else 'S'
we = 'E' if self. lon >= 0 else 'W'
return f' { abs ( self. lat) : .1f } ° { ns} , { abs ( self. lon) } ° { we} '
* * * ---------------------------------------------------------------------------------------- * * *
在class语句中 , 虽然NamedTuple出现在超类的位置上 , 但其实它不是超类 .
typing . NamedTuple使用元类②这一高级功能创建用户类 .
( 注 2 : 元类将在第 24 章探讨 . )
不信 , 请看下面的代码片段 .
# typing . namedTuple是一个函数 , 这行代码运行还报错
> > > issubclass ( Coordinate , typing . namedTuple )
False
> > > issubclass ( Coordinate , tuple )
True
* * * ---------------------------------------------------------------------------------------- * * *
在typing . NamedTuple生成的__init__方法中 , 字段参数的顺序与在class语句中出现的顺序相同 .
与typing . NamedTuple一样 , dataclass装饰器也支持使用PEP 526 句法来声明示例属性 .
dataclass装饰器读取变量注解 , 自动为构件的类生成方法 .
示例 5 - 3 使用dataclass装饰器再次定义Coordinate类 , 你可以比较一下 .
from dataclasses import dataclass
@dataclass ( frozen= True )
class Coordinate :
lat: float
lon: float
def __str__ ( self) :
ns = 'N' if self. lat >= 0 else 'S'
we = 'E' if self. lon >= 0 else 'W'
return f' { abs ( self. lat) : .1f } ° { ns} , { abs ( self. lon) } ° { we} '
注意 , 示例 5 - 2 和示例 5 - 3 中的类的主体完全一样 , 区别在class语句上 .
@ dataclass装饰器不依赖继承或元类 , 如果你想使用这些机制 , 则不受影响 .
③ ( 注 3 : 类装饰器和元类都将在第 24 章探讨 . 这两种机制提供了超越继承的功能 , 方便你定制类的行为 . )
示例 5 - 3 中的Coordinate类是object的子类 .
5.2.1 主要功能
3 个数据类构建有许多共同点 , 如表 5 - 1 所示 .
表 5 - 1 : 比较 3 个数据类构建器的部分功能 ( x表示此数据类的实例 )
namedtuple NamedTuple dataclass
可变实例 否 否 是
calss语句句法 否 是 是
构造字典 x . _asdict ( ) x . _asdict ( ) dataclasses . asdict ( x )
获取字段名称 x . _fields x . _fields [ f.name for f in dataclasses.fields(x) ]
获取默认值 x . _field_default x . _field_defaults [ f.default for f in dataclasses.fields(x) ]
获取字段类型 N / A x . __annotations__ x . __annotations__
更改之后创建新实例 x . _replace ( . . . ) x . _replace ( . . . ) dataclasses . replace ( x , . . . )
运行时定义新类 namedtuple ( . . . ) NamedTuple ( . . . ) dataclasses . make_dataclass ( . . . )
* * ------------------------------------------------------------------------------------------ * *
typinf . NamedTuple和 @ dataclass构建的类有一个__annotations__属性 , 存放字段的类型提示 .
然而 , 不建议直接读取__annotations__属性 .
推荐使用inspect . get_annotations ( MyClass ) ( Python3 . 10 新增 ) 或
typing . get_type_hints ( MyClass ) ( Python 3.5 ~ 3.9 ) 获取类型信息 ,
因此这两个函数提供了额外的服务 , 例如可以解析类型提示中的向前应用 .
15.5 .1 节将详谈这个问题 .
* * ------------------------------------------------------------------------------------------ * *
下面分别讨论这些主要功能 .
* 1. 可变实例
3 个数据诶构建器之间只要的区别在于 , collections . namedtuple和
typing . NamedTuple构建的类是tuple的子类 , 因此实例是不可变的 .
@ dataclass默认构建可变的类 .
不过 @ dataclass装饰器接受一个关键字参数frozen , 如示例 5 - 3 所示 .
指定frozen = True , 初始化实例之后 , 如果未字段复制 , 则抛出异常 .
* 2. class语句句法
只有typing . NamedTuple和dataclass支持常规的class语句句法 , 方便为构件的类添加方法和文档字符串 .
( 这个意思是说 : typing . NamedTuple和dataclass都是通过class语句定义的 ,
class Coordinate ( typing . NamedTuple ) :
pass
@ dataclass ( frozen = True )
class Coordinate :
pass
在class内部方便添加方法等 .
)
* 3. 构建字典
两种具名元组都提供了构造dict对象的实例方法 ( . _asdict ) , 可根据数据类示例的字段构造字典 .
dataclasses模块也提供了构造字典的函数 , 即dataclasses . asdict .
* 4. 获取字段名称和默认值
3 个类构建都支持获取字段名称和可能配置的默认值 .
对于具名元组类 , 这些元数据在类属性 . _fields和 . _fields_defaults中 .
对于使用dataclass装饰器构建的类 , 这些元数据使用dataclasses模块中的fields函数获取 .
fields函数返回一个由Field对象构成的原则 , Field对象有几个属性 , 包括name和default .
* 5. 获取字段类型
typing . NamedTuple和 @ dataclass定义的类有一个__annotations__类属性 , 值为字段名称到类型的映射 .
前面说过 , 不要直接读取__annotations__属性 , 而要使用typing . get_type_hints函数 .
* 6. 更改之后关键新实例
对于具名元组实例x , x . _replace ( * kwargs ) 根据指定的关键字参数替换某些属性的值 , 返回一个新实例 .
模块级函数dataclasses . replacr ( x , * * kwargs ) 与dataclass装饰的类具有相同的作用 .
* 7. 运行时定义新类
class句法虽然可读性更高 , 但毕竟还是硬编码的 . 框架可能需要在运行时动态构建数据类 .
为此 , 可以使用默认的函数调用句法 , collections . namedtuple和typing . NamedTuple都支持 .
dataclasses模块提供的make_dataclaass函数也是出于这个目的 ,
大致介绍数据类构造器的主要功能之后 , 下面逐一讨论这 3 个类构建器 , 先从最简单的开始 .
5.3 典型的具名元组
collections . namedtuple是一个工厂函数 , 用于构建增强的tuple子类 ,
具有字段名称 , 类名和提供有用信息的__repr__ .
namedtuple构建的类可以在任何需要元组的地方使用 .
其实 , 为了方便 , 以前Python标准库中返回元组的函数 , 现在都返回具名元组 , 这对用户的代码没有任何影响 .
( Python标准库中有很多返回元组的函数 , 这里列举一些常见的 :
divmod ( x , y ) : 返回一个由商和余数组成的元组 ( x / / y , x % y ) ;
os . path . split ( path ) : 将路径名拆分成目录和文件名 ,
返回一个元组 ( path , last_part ) , 其中last_part是最后一个斜杠后面的部分 .
如果路径不包含斜杠 , last_part就是空字符串 ;
os . path . splitext ( path ) : 将路径名拆分成文件名和扩展名 , 返回一个元组 ( root , ext ) ,
其中ext包含了最后一个点及之后的所有字符,root是路径名的其余部分;
datetime . datetime . now ( tz = None ) : 返回一个元组 ( year , month , day , hour , minute , second ,
microsecond ) , 包含当前日期和时间 ;
random . random ( ) : 返回一个元组 ( 0 < = x < 1 ) 的随机实数 .
这些函数返回的元组在以前可能是不具名的 , 但从 Python 2.6 开始 , 标准库中开始使用具名元组来返回值 ,
这有助于提高代码的可读性和可维护性 . )
* -------------------------------------------------------------------------------------------- *
namedtuple构建的类 , 其实例占用的内存量与元组相同 , 因为字段名称存储在类中 .
* -------------------------------------------------------------------------------------------- *
示例 5 - 4 定义一个具名元组 , 存储一个城市的信息 .
>> > from collections import namedtuple
>> > City = namedtuple( 'City' , 'name county population coordinates' )
>> > tokyo = City( 'Tokyo' , 'JP' , 36.933 , ( 35.689722 , 139.691667 ) )
>> > tokyo
City( name= 'Tokyo' , county= 'JP' , population= 36.933 , coordinates= ( 35.689722 , 139.691667 ) )
>> > tokyo. population
36.933
>> > tokyo. coordinates
( 35.689722 , 139.691667 )
>> > tokyo[ 1 ]
'JP'
作为tuple的子类 , City继承了一些有用的方法 , 例如__eq__ , 以及比较运算符背后的特殊方法__lt__等 .
可以用于排序City实例构成的列表 , 例如 :
from collections import namedtuple
Person = namedtuple( 'Person' , [ 'name' , 'age' , 'gender' ] )
people = [
Person( 'Alice' , 25 , 'F' ) ,
Person( 'Bob' , 30 , 'M' ) ,
Person( 'Charlie' , 20 , 'M' ) ,
Person( 'Diana' , 24 , 'F' )
]
people. sort( key= lambda x: x. age)
for i in people:
print ( i)
除了从tuple继承 , 具名元组还有几个额外的属性和方法 .
示例 5 - 5 演示了几个最有用的属性和方法 : 类属性_fields , 类方法_make ( itreable ) 和实例方法_asdict ( ) .
>> > City. _fields
( 'name' , 'county' , 'population' , 'coordinates' )
>> > Coordinate = namedtuple( 'Cooedinate' , 'lat lon' )
>> > delhi_data = ( 'Delhi NCR' , 'IN' , 21.935 , Coordinate( 28.613889 , 77.208889 ) )
>> > delhi = City. _make( delhi_data)
>> > delhi. _asdict( )
{ 'name' : 'Delhi NCR' , 'county' : 'IN' , 'population' : 21.935 ,
'coordinates' : Cooedinate( lat= 28.613889 , lon= 77.208889 ) }
>> > import json
>> > json. dumps( delhi. _asdict( ) )
'{ "name" : "Delhi NCR" , "county" : "IN" , "population" : 21.935 ,
"coordinates" : [ 28.613889 , 77.208889 ] } '
* * * ---------------------------------------------------------------------------------------- * * *
在Python 3.7 之前 , . _asdict方法返回一个OrderedDict对象 .
从Python 3.8 开始 , 它返回一个简单的dict对象 , 应为现在键的插入顺序得以保留 , 所以影响不大 ,
如果你还想等到OrderedDict对象 , _asdict文档建议根据返回的结果自行构建 , 即OrderedDict ( x . _asdict ( ) ) .
* * * ---------------------------------------------------------------------------------------- * * *
从Python 3.7 开始 , namedtuple接受default关键字参数 ,
值为一个产生N项的可迭代对象 , 为从右数的N个字典指定默认值 .
示例 5 - 6 定义具名元组Coordinate , 为reference字典指定默认值 .
>> > Coordinate = namedtuple( 'Coordinate' , 'lat lon reference' , default= [ 'WGS84' ] )
>> > Coordinate( 0 , 0 )
Coordinate( lat= 0 , lon= 0 , reference= 'WGS84' )
>> > Coordinate. _field_default
{ 'reference' : 'WGS84' }
5.2 节的 'class语句句法' 中提到 , 使用typing . NamedTuple和 @ dataclass支持的class句法方便我们增加方法 .
具名元组也能用于增加方法 , 只是过程有点曲折 .
如果你不想这么麻烦 , 请跳过下面的附注栏 .
* -----------------------------------------为具名元组注入方法----------------------------------- *
回顾一下第一章中示例 1 - 1 是如何构建Card类的 .
Card = collections . namedtuple ( 'Card' , [ 'rank' , 'suit' ] )
随后定义了spades_high函数 , 用来给扑克牌排序 .
如果把这部分逻辑封装成Card的方法就好了 .
然而 , Card不是使用class语句定义的 , 添加spades_high方法的过程有点曲折 , 要
先定义函数 , 再把函数赋值给一个类属性 , 如示例 5 - 7 所示 .
>> > Card. suit_values = dict ( spades= 3 , hearts= 2 , diamonds= 1 , clubs= 0 )
>> > def spades_high ( card) :
. . . rank_value = FrenchDeck. ranks. index( card. rank)
. . . suit_value = card. suit_values[ card. suit]
. . . return rank_calue * len ( card. suit_values) + suit_value
. . .
>> > Card. overall_rank = spades_high
>> > lowest_card = Card( '2' , 'clubs' )
>> > highest_card = Card( 'A' , 'spades' )
>> > lowest_card. overall_rank( )
0
>> > highest_card. overall_rank( )
51
在class语句中定义方法可读性好 , 也方便后期维护 .
但你也要知道 , 刚刚介绍的这种方法是可行的 , 或许有时用得到 . ④
( 注 4 : 不知道你是否了解Ruby , Ruby程序员经常注入方法 , 不过这种技术也有一些争议 .
在Python中 , 这种做法不常见 , 因为str , list等内置类型不支持 . 我觉得这种限制是一件好事 . )
我们稍微偏离了主线 , 目的是展示动态语言的强大 .
* -------------------------------------------------------------------------------------------- *
接下来讲讲具名元组的变体--typing . NamedtUPLE .
5.4 带类型的具名元组
示例 5 - 6 中有一个默认字段的Coordinate类hiahia可以使用typing . NamedTuple定义 , 如示例 5 - 8 所示 .
from typing import NamedTuple
class Coordinate ( NamedTuple) :
lat: float
lon: float
reference: str = 'WGS84'
使用typinf . NamedTuple构建的类 , 拥有的方法并不比collections . namedtuple生成的更多 ,
而且同样从tuple继承方法 .
唯一的区别是多了类属性__annotations__ , 而在运行时 , Python完全忽略该属性 .
鉴于typing . NameTuple的主要功能是类型注解 , 下面就花点儿时间介绍一下 , 然后再继续讨论数据类构建器 .
5.5 类型提示入门
类型提示 ( 也叫类型注解 ) 声明函数参数 , 返回值 , 变量和属性的预期类型 .
关于类型提示 , 首先你要知道 , Python字节码编码器器和解释器根本不强制你提供类型信息 .
* * ------------------------------------------------------------------------------------------ * *
本节只简略介绍类型提示 , 让你对typing . NamedtTuple和 @ dataclass声明使用的句法和注解的意义有一点感性认识 .
函数签名的类型注解将在第 8 章讲解 , 更高级的注解将在第 15 章探讨 .
本节主要涉及str , int和float等简单内置类型的注解 .
数据类中的字段最常使用这些类型 .
* * ------------------------------------------------------------------------------------------ * *
5.5.1 运行时没有作用
Python类型提示可以看作 '供IDE和类型检查工具验证类型的文档' .
这是因为 , 类型提示对Python程序的运行时行为没有影响 . 请看示例 5 - 9.
>> > import typing
>> > class Coordinate ( typing. NamedTuple) :
. . . lat: float
. . . lon: float
. . .
>> > trash = Coordinaete( 'Ni!' , None )
>> > print ( trash)
Coordinate( lat= 'Ni!' , lon= None )
在一个Python模块中输入示例 5 - 9 中代码 , 运行后 , 你会看到一个没什么意义的Coordinate , 不报错也不发出警告 .
$ python3 nocheck_demo. py
Coordinate( lat= 'Ni!' , lon= None )
类型提示主要为第三方类型检查工具提供支持 , 例如Mypy和Pycharm IDE内置的类型检查器 .
在 '静止' 状态下检测Python源码 , 不允许代码 .
为了看到类型提示的效果 , 必须使用相关工具 ( 例如linter ) 检查代码 .
使用Mypy检查之前的示例 , 看到的输出如下所示 .
$ mypy nocheck_demo. py
nocheck_demo. py: 8 : error: Argument 1 to 'Coordnate' has
incompatible type "str" : expected "float"
nocheck_demo. py: 8 : error: Argument 2 to 'Coordnate' has
incompatible type "None" : expected "float"
可以看到 , 根据Coordinate , Mypy知道创建实例时传入的两个参数必须都是float类型 ,
但是创建trash时传入的是str对象和None .
( 注 5 : 在类型提示上下文中 None不是NoneType单例 , 而是NoneType的别名 .
仔细想一想 , 这样做有点奇怪 , 但也符合直觉 , 而且对于返回None的函数 , 使用None注解返回值更容易理解 . )
接下来讲一讲类型提示的句法和意义 .
5.5.2 变量注解句法
typinf . NamedTuple和 @ dataclass使用PEP 526 定义的句法注解变量 .
本节简要介绍在class语句中定义属性的注解句法 .
变量只有的基本句法如下所示 .
var_name : some_type
允许使用的类型在PEP 484 中的 'Acceptable type hintd' 一节规定 , 不过定义数据类时 , 最常使用以下类型 .
? 一个具体类 , 例如str或FrenchDeck .
? 一个参数化容器类型 , 例如list [ int ] , tuple [ str, float ] 等 .
? typing . Optional , 例如Optional [ str ] , 声明一个字段的类型可以使str或None .
( Optional [ str ] 等价于 Union [ str, None ] 意味着 : 既可以传指定的类型 str , 也可以传 None )
另外 , 还可以为变量指定初始值 .
在typing . NamedTuple和 @ dataclass声明中 , 指定的初始化值作为属性的默认值 ,
防止调用构造函数时没有提供对应的参数 .
cvar_name : some_type = a_value
5.5.3 变量注解的意义
5.5 .1 节说过 , 类型提示在运行时没有作用 ,
然而 , 构建__annotations__字典 , 供typing . NamedTuple和 @ dataclass使用 , 增强类的功能 .
接下来先分析示例 5 - 10 定义的一个简单类 , 然后再讨论typing . NamedTuple和 @ dataclass增加的额外功能 .
class DemoPlainClass :
a: int
b: float = 1.1
c = 'spam'
我们可以在控制台中验证一下 , 首先读取DemoPlainClass的__annotations__ ,
然后尝试获取属性a , b和c .
>> > from demo_plain import DemoPlainClass
>> > DemoPlainClass. __annotations__
{ 'a' : < class 'int' > , 'b' : < class 'float' > }
>> > DemoPlainClass. a
Traceback ( most recent call last) :
File "<stdin>" , line 1 , in < module>
AttributeError: type object 'DemoPlainClass' has no attribute 'a'
>> > DemoPlainClass. b
1.1
>> > DemoPlainClass. c
'spam'
注意 , 特殊属性__annotations__由解释器创建 , 记录源码中出现的类型提示 , 即使用时普通的类 .
a只作为注解存在 , 不是类属性 , 因为没有绑定值 . ⑥ b和c存储为类属性 , 因为他们绑定了值 .
( 注 6 : Python没有 '未定义' ( undefined ) 概念 . '未定义' 是JavaScript设计最大的败笔之一 . 感谢Guido ! )
这 3 个属性都不出现在DemoPlainClass的实例中 .
使用o = DemoPlainClass ( ) 创建一个对象 , o . a抛出AttributeError ,
而o . b和o . c检索类属性 , 值分别为 1.1 和 'spam' , 行为与常规的Python对象相同 .
1. 研究一个typing.NamedTuple类
现在来研究一个使用typing . NamedTuple构建的类 ( 见示例 5 - 11 ) .
这个类的属性和注解与示例 5 - 10 中的DemoPlainClass类一样 .
import typing
class DemoNTClass ( typing. NamedTuple) :
a: int
b: float = 1.1
c = 'spam'
研究一下DemoNtClass , 结果如下 .
>> > from demo_nt import DemoNTClass
>> > DemoNTClass. __annotations__
{ 'a' : < class 'int' > , 'b' : < class 'float' > }
>> > DemoNTClass. a
< _collections. _tuplegetter object at 0x101f0f940 >
>> > DemoNTClass. b
< _collections. _tuplegetter object at 0x101f0f8b0 >
>> > DemoNTClass. c
'spam'
可以看到 , a和b的注解与示例 5 - 10 一样 .
但是 , typing . NamedTuple创建了类属性a和b . c是普通的类属性 , 值为spam .
a和b是 '描述符' . 这是高级功能 , 将在第 23 章讨论 .
现在可以把描述符理解为特性 ( priperty ) 读值 ( getter ) 方法 , 即不带调用运算符 ( ) 的方法 , 用于读取实例属性 .
实际上 , 这意味着a和b是只读实例属性 .
这一点不难理解 , 因为DemoNTClass实例是某种高级的元组 , 而元组是不可变的 .
DemoNTClass还有定制的文档字符串 .
>> > DemoNTClass. __doc__
'DemoNTClass(a, b)'
下面研究一个DemoNTClass实例 .
>> > nt = DemoNTClass( 8 )
>> > nt. a
8
>> > nt. b
1.1
>> > nt. c
'spam'
构造nt对象时 , 至少要为DemoNTClass提供a参数 .
b也是构造函数的参数 , 不过它有默认值 1.1 , 因此可以不提供 . nt对象有a和b两个属性 , 这在预期之中 .
但是c属性 , 像往常一样Python从类中检索该属性 .
为nt . a , nt . b , nt . c甚至nt . z赋值 , 抛出AttributeError异常 , 这几个错误消息稍有区别 .
请你自己试一下 , 分析错误信息的内容 .
>> > nt. a = 1
Traceback ( most recent call last) :
File "<stdin>" , line 1 , in < module>
AttributeError: can't set attribute
2. 研究一个使用daataclass装饰类
现在研究一下示例 5 - 12.
from dataclasses import dataclass
@dataclass
class DemoDataClass :
a: int
b: float = 1.1
c = 'spam'
现在查看DemoDataClass类的__annotations__ , __doc__ , 以及a , b , c和属性 .
>> > from demo_dc import DemoDataClass
>> > DemoDataClass. __annotations__
{ 'a' : < class 'int' > , 'b' : < class 'float' > }
>> > DemoDataClass. __doc__
'DemoDataClass(a: int, b: float = 1.1)'
>> > DemoDataClass. a
Traceback ( most recent call last) :
File "<stdin>" , line 1 , in < module>
AttributeError: type object 'DemoDataClass' has no attribute 'a'
>> > DemoDataClass. b
1.1
>> > DemoDataClass. c
'spam'
__annotations__和__doc__没什么让人意外地 . 然而 , DemoDataClass没有名为a的属性 .
相比之下 , 示例 5 - 11 中的DemoNTClass有可从实例中获取只读属性a的描述符
( 那个神秘的 < _collections . __tuplegetter > ) . 这是因为a属性只在DemoDataClass实例中存在 .
如果冻结DemoDateClass类 , 那么a就变成可获取和设定的公开属性 .
( 可以使用__slots__属性来冻结类 , 后面再说 . . . )
但是 , b和c作为类属性存在 , b存储实例属性b的默认值 , 而c本身就是类属性 , 不绑定到实例上 .
下面来看DemoDataClass实例的情况 .
>> > dc = DemoDataClass( 9 )
>> > dc. a
9
>> > dc. b
1.1
>> > dc. c
'spam'
同样 , a和b是实例属性 , 而c是通过实例获取的类属性 .
前文说过 , DemoDataClass实例是可变的 , 而且运行时不检查类型 .
>> > dc. a = 10
>> > dc. b = 'oops'
甚至还可以为不存在的属性赋值 .
>> > dc. c = 'whatever'
>> > dc. z = 'secret stash'
现在 , dc实例有c属性 , 这对类属性c没有影响 . 我们还可以增增一个z属性 .
这是Python正常的行为 : 常规示例自身可以有哦未出现在类中的属性 . ⑦
( 注 7 : 执行__init__之后设置的属性 , 有悖于 3.9 讲过的字典键共享内存优化措施 . )
5.6 @dataclass详解
我们目前见过的 @ dataclass示例都比较简单 .
这个装饰器接受多个关键字参数 , 完整签名如下 .
@dataclass ( * , init= True , repr = True , eq= True , order= False ,
unsafe_hash= False , frozen= False )
第一个参数位置上的 * 表示后面构思关键字参数 . 表 5 - 2 简要说明这些关键字参数 .
表 5 - 2 : @ dataclass装饰器接受的关键字参数
参数 作用 默认值 备注
init 生成__init__ True 如果用户自己实现了__init__ , 则忽略该参数
repr 生成__repr__ True 如果用户自定实现了__repr__ , 则忽略该参数
eq 生成__eq__ True 如果用户自定实现了__eq__ , 则忽略该参数
order 生成__lt__ , _ge__ , False 设置为True时 , 如果eq = Flase , 或者自行定义或继承
__gt__ , __ge__ 其他用于比较的方法 , 则抛出异常
unsafe_hash 生成__hash__ False 语义复杂 , 有多个问题需要注意 , 详见dataclass函数的文档
frozen 让实例不可变 False 防止意外更改实例 , 相对安全 , 但不是绝对不可变 ( a )
------------------------------------------------------------------------------------------
a : @ dataclass生成__setattr__和__delattr__ , 在用户尝试设置或删除字段时抛出
dataclass . FrozenInstanceError ( AttributeError 的子类 ) , 以此模拟不可变性 .
以上参数的默认值适用于多数情况 .
不过 , 你可能会更改一下参数的值 , 不使用默认值 .
frozen = True : 防止意外更改来的实例 .
order = True : 允许排序数据类的实例 .
Python对象是动态的 , 只要愿意 , 程序员还是可以绕过frozen = True这道防线 .
不过 , 在代码评审阶段很容易发现这种小伎俩 .
如果eq和frozen参数的值都是True , 那么 @ dataclass将生成一个合适的__hash__方法 , 确保实例是可哈希的 .
生成的__hash__方法使用所有字段的数据 , 通过字段选项 ( 见 5.6 .1 节 ) 也不能排除 .
对应frozen = False ( 默认值 ) , dataclass把__hash__设置为None ,
覆盖从任何超类继承的__hash__方法 , 表明实例不可哈希 .
关于unsafe_hash : 'PEP 557 -Data Class' 是这样说的 :
可以设置unsafe_hash = True , 强制数据类创建__hash__方法 , 但是不建议这么做 .
如果一个类再逻辑上是不可变的 , 但实际上是可变的 , 则可以这么做 .
然而 , 这是特殊情况 , 务必小心 .
这也是我对unsafe_hash = True的观点 .
如果你认为必须使用这个选项 , 请阅读dataclasses . dataclass文档 .
生成的数据类还可以在字段层面进一步定制 .
5.6.1 字段选项
我们已经见过最基本的字段选项 , 即在提供类型提示的同时设定默认值 .
声明的字段间作为参数传给生成的__init__方法 .
Python规定 , 带默认值的参数后面不能有不带默认值的参数 .
因此 , 为一个字段声明默认值之后 , 余下的字段都要有默认值 .
对初级Python开发人员来说 , 可变的默认值往往导致bug ( 例如 , 使用 [ ] 作为默认值 ) .
如果在函数定义中使用可变默认值 , 调用函数时很容易破坏默认值 ,
则导致后续调用的行为发生变化 ( 这个问题将在 5.6 .1 节详谈 ) .
类属性通常用作实例属性的默认值 , 数据类也是如此 ( 对应下一句理解 ) .
@ dataclass使用类型提示中的默认值生成传给__init__方法的参数默认值 .
为了避免bug , @ dataclass拒绝像示例 5 - 13 那样定义类 .
from dataclasses import dataclass
@dataclass
class ClubMember :
name: str
guests: list = [ ]
加载CluMember所在的模块 , 看到的结果如下所示 .
$ python3 club_wrong. py
Traceback ( most recent call last) :
File "club_woring.py" , line 4 , in < module>
class ClubMember :
. . . 省略多行. . .
ValueError: mutable default < class 'list' > for field guests is not allowed: use default_factory
ValueError消息指出了问题所在 , 还提供了一个解决方案 : 使用default_factory .
示例 , 5 - 14 给出了纠正ClubMember的方法 .
from dataclasses import dataclass, field
@dataclass
class ClubMember :
name: str
guests: list = field( default_factory= list )
在示例 5 - 14 中 , guests字段的默认值不是一个列表的字面量 , 而是调用dataclasses . field函数 ,
把参数设为default_factory = list , 以此设定默认值 .
default_factory参数的值可以是一个函数 , 一个类 , 或者其他可调用对象 ,
在每次创建数据类的实例时调用 ( 不带参数 ) , 构建默认值 .
这样 , 每个ClubMember实例都有自己的一个list , 而不是所有实例共用一个list .
共用往往导致bug , 而且我们很少希望共用 .
* -------------------------------------------------------------------------------------------- *
如果类中有默认值为list的字段 , 则 @ dataclass拒绝定义 , 这一点很好 .
然而你要知道 , 这种方案只适合用于部分情况 , 只对list , dict和set有效 .
除此之外 , 其他可变的值不会引起 @ dataclass的主要 .
遇到这样的问题 , 你要自己处理 , 为可变的默认值设置默认工厂 .
* -------------------------------------------------------------------------------------------- *
浏览dataclasses模块文件 , 你会发现有一个list字段使用的句法比较新奇 , 如示例 5 - 15 所示 .
from dataclass import dataclass, field
@dataclass
class CluMember :
name: str
guests: list [ str ] = field( defualt_factory= list )
新句法list [ str ] 是一种参数化泛型 .
从Python3 . 9 开始 , 内置类型list可以使用方括号表示法指定列表中项的类型 .
* * * ---------------------------------------------------------------------------------------- * * *
在Python 3.9 之前 , 内置容器类型不支持泛型表示法 .
为了临时解决这一问题 , typing模块提供了对应的容器类型 .
在Python 3.8 或之前的版本中 , 如果需要参数化list类型提示 ,
则必须使用从typing模块中导入的List类型 , 写作List [ str ] .
这个问题详见 8.5 .4 节中的附注栏 .
* * * ---------------------------------------------------------------------------------------- * * *
泛型将在第 8 章探讨 .
现在只需要知道 , 示例 5 - 14 和示例 5 - 15 中的做法都是对的 , 而且Mypy在检查这两种定义的类型时均不报错 .
这两种声明方式是有区别的 ,
suests : list表示guests列表可以由任何类型的对象构成 ,
而guests : list [ str ] 的意思是guests列表中的每一项都必须是字符串 .
因此 , 如果在列表中存储无效的项 , 或读取到无效的项 , 则类型检查工具将报错 .
defualt_factory应该是dield函数最常使用的参数 , 不过除此之外还有其他参可用 , 如表 5 - 3 :
表 5 - 3 : field含接收的关键字参数
参数 作用 默认值
default 字段的默认值 _MISSING_TYPY ( a )
default_factory 不接受参数的函数 , 用于产生默认值 _MISSING_TYPE
init 把字典作为参数传给__init__方法 True
repr 在__repr__方法中使用的字段 True
compare 在__eq__ , __lt__等比较方法中使用字段 True
hash 在__hash__方法中使用字段计算哈希值 None ( b )
metadata 用户定义的数据映射 ; @ dataclass忽略该参数 None
-------------------------------------------------------------------------------
a : dataclass . _MISSING_TYPE是一个哨符号 , 表示未提供该参数 .
这样就可以把默认值设置为经常需要使用的None .
b : hash = None表示仅当compare = True时才在__hash__方法中使用字段 .
之所以有default参数 , 是因为在字段注解中设置默认值的位置被field函数调用占据了 .
假如我们想创建一个athlete字段 , 把默认值设置为False , 而且不提供给__repr__方法使用 ,
那么要像下面这样编写 .
( default_factory和default都是设置默认值 , 一个是提供构造参数 , 一个是提供类型的值 . )
@dataclass
class ClubMember :
name: str
guests: list = field( default_factory= list )
athlete: bool = field( default= False , repr = False )
5.6.2 初始化后处理
@ detaclass生成的__init__方法只做一件事 : 把传入的参数及其默认值 ( 如未指定值 ) 赋值给实例属性 , 变成实例字段 .
可是 , 有些时候初始化实例要做的不只是这些 . 因此 , 可以提供一个__post_init__方法 .
如果存在这个方法 , 则 @ dataclass将在生成的__init__方法最后调用__post__init__方法 .
__post_init__经常用于执行验证 , 以及根据其他字段计算一个字段的值 .
下面举个例子说明这两种用途 .
我们将定义一个ClubMember子类 , 名为HackerClubMember .
首先doctest说明HackerClubMember的预期行为 , 如示例 5 - 16 所示 .
"""
``HackerClubMember``构造函数接受一个可选的``handle``参数::
>>> anna = HackerClubMember('Anna Ravenscroft', handle='AnnaRaven')
>>> anna
HackerClubMember(name='Anna Ravenscroft', guets=[], handle='AnnarRaven')
如果没有指定``handle``, 则设为会员姓名的第一部分::
>>> leo = HackerClubMember('Leo Rochael')
>>> leo
HackerClubMember(name='Leo Rochael', guets=[], handle='Leo')
会员的昵称必须是唯一的. 下面的``le02``无法创建, 因此``handle``的值是'Leo'.
而这个昵称已经被``leo``占用了::
>>> leo2 = HackerClubMember('Leo DaVinci')
Traceback (most recent call last):
...
ValueError: handle 'Leo' already exists.
因此, 创建``leo2``时必须明确指定``handle``::
>>> leo2 = HackerClubMember('Leo Davinci', handle='Neo')
>>> leo2
HackerClubMember(name='Leo Davinci', guets=[], handle='Neo')
"""
注意 , handle必须是关键字参数 , 因为HackerClubMember从ClubMember继承了name和guets ,
handle字段是额外增加的 .
从为HackerClubMember生成的文档字符串可以看出构造函数调用中各字段的顺序 .
>> > HackerClubMember. __doc__
"HackerClubMember(name: str, guests: list = <factory>, handle: str = '')"
这里 , < factory > 是一种简略表示 , 意思是guests字段的默认值由某个可调用对象产生 ( 这里使用的工厂是list类 ) .
上诉文档字符串的终端是 , 如果想提供handle , 但不提供guests , 那么必须利用关键字参数传入handle .
dataclasses模块文档中的 'Inheritance' 一节详细说明涉及多层继承时如何给字段排序 .
* * ------------------------------------------------------------------------------------------- * *
第 14 章将讨论继承的错误用法 , 尤其是超类类的情况 .
创建具有层次结构的数据类往往不是个好主意 , 示例 5 - 17 这么做是为了减少HackerClubMember类代码 ,
把精力集中在handle字段声明和__post_init__方法中的验证逻辑上 ,
* * ------------------------------------------------------------------------------------------- * *
HackerClubMember类的实现如示例 5 - 17 所示 .
from dataclasses import dataclass
from club import ClubMember
@dataclass
class HackerClubMember ( ClubMember) :
all_handles = set ( )
handle: str = ''
def __post_init__ ( self) :
cls = self. __class__
if self. handle == '' :
self. handle = self. name. split( ) [ 0 ]
if self. handle in cls. all_handles:
msg = f'handle { self. handle!r } already exites.'
raise ValueError( msg)
cls. all_handles. add( self. handle)
anna = HackerClubMember( 'Anna Ravenscroft' , handle= 'AnnaRaven' )
print ( anna)
leo = HackerClubMember( 'Leo Rochael' )
print ( leo)
"""
leo2 = HackerClubMember('Leo DaVinci')
print(leo2)
# ValueError: handle 'Leo' already exites.
"""
leo2 = HackerClubMember( 'Leo Davinci' , handle= 'Neo' )
print ( leo2)
示例 5 - 17 能实现我们的需求 , 但不能让静态类检查工具满意 .
5.6 .3 节会说明原因 , 并给出修正方法 .
5.6.3 给类型的类属性
使用Mymy检查示例 5 - 17 , 报错信息如下 .
$ mypy hackerclub. py
hankerclub. py: 37 : error: Need type annotation for "all_handles"
( hint: "all_handles: Set[<type>] = ..." )
Found 1 error in 1 file ( checked 1 source file )
window中如下使用 : ( Mymy官网 : https : / / mypy . readthedocs . io / )
* 1. linux中安装 : pip install mypy
cmd中安装 : python3 -m pip install mypy ( 必须是Python 3.7 版本及以上 )
* 2. 检查安装是否成功 : where mymy
* 3. 使用
可惜 , Mypy ( 我用的是 0.910 版 ) 提供的提示信息对使用 @ dataclass构建的类没有什么用处 .
Mypy建议使用Set , 而我用的是Python 3.9 , 可以使用set--免得再从typing模块中导入Set .
更重要的是 , 如果未all_handles添加类型提示 , 例如set [ ... ] ,
那么 @ dataclass将把all_handles变成实例字段 .
5.5 .3 节中 '研究一个使用dataclass装饰器的类' 中就出现过这种情况 .
'PEP 526--Synax for Variable Annotations' 中定义的变通方法不太优雅 .
若想为类变量添加类型提示 , 则要使用一个名为typing . ClassVar的伪类型 ,
借助泛型表示法 [ ] 设定变量的类型 , 同时声明为类属性 .
为了让类型检查工具和 @ dataclass满意 , 在示例 5 - 17 中应当像下面这样声明all_handles .
all_handles : ClassVar [ set [ ste ] ] = set ( )
这里 , 类型提示的信息如下 :
all_handles是一个类属性 , 类型为字符串构成的集合 , 默认值是一个空集合 .
编写这个注解之前 , 必须从typing模块中导入ClassVar .
from dataclasses import dataclass
from club import ClubMember
from typing import ClassVar
@dataclass
class HackerClubMember ( ClubMember) :
all_handles: ClassVar[ set [ set ] ] = set ( )
handle: str = ''
def __post_init__ ( self) :
cls = self. __class__
if self. handle == '' :
self. handle = self. name. split( ) [ 0 ]
if self. handle in cls. all_handles:
msg = f'handle { self. handle!r } already exites.'
raise ValueError( msg)
cls. all_handles. add( self. handle)
@ dataclass装饰器不关心注解中的类型 , 但有两种例外情况 , 这是其中之一 ,
即类型为ClassVar时 , 不为属性生成实例字段 .
另一种情况是声明 '仅作初始化的变量' , 详见 5.6 .4 节 .
5.6.4 初始化不作为字段的变量
有时 , 我们需要把不作为实例字段的参数传给__init__方法 .
按照dataclasses文档的说法 , 这种参数叫作 '仅作初始化的变量' ( init-only variable ) .
为声明这种参数 , dataclasses模块还提供了伪类型InitVar , 句法与typing . ClassvAR一样 .
文档中给出的例子定义一个数据类 , 包含一个使用数据库初始化的字段 , 因此必须把数据库对象传递构造方法 .
下面重现文档中的那个例子 , 如示例 5 - 18 所示 .
@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属性的声明方式 .
InitVat阻止 @ dataclass把database视为常规的字段 .
database不被设为实例属性 , 也不会出在dataclass . fields函数返回的列表中 .
然而 , 对于生成的init方法 , databse是参数之一 , 同时也传给__post_init__方法 .
如果你想自己编写__post__init__方法 , 那就必须像示例 5 - 18 那样 , 在方法签名中增加相应的参数 .
本书对 @ dataclass的讲解占据了很长篇幅 , 涵盖了多数有用的功能 .
有些功能在前面提到过 , 比如 , 表 5 - 1 把 3 个数据类构建器放在一起比较 .
如果想深入了解 , 请阅读dataclasses文档和 'PEP 526-Syntax for Variable Annotations' .
5.6 .5 节会再举一个示例 , 使用 @ dataclass构建一个内容比较长的类 .
5.6.5@dataclass示例: 都柏林核心模式
目前所见的示例中 , 字段数量不对 , 实际使用中通常需要更多的字段 .
本节根据都柏林核心 ( DUbion Core ) 模式 , 使用 @ daataclass构建一个更为复杂的类 .
都柏林核心模式时一小组术语 , 可用于描述数字资源 ( 视频 , 图像 , 网页等 ) .
也可用于描述物理资源 , 例如图书 , CD个艺术品等对象 .
该模式定义了 15 个可选字段 , 示例 5 - 19 中的Resource类用到了其中 8 个 .
from dataclasses import dataclass, field
from typing import Optional
from enum import Enum, auto
from datetime import date
class ResourceType ( Enum) :
BOOK = auto( )
EBOOK = auto( )
VIDEO = auto( )
@dataclass
class Resource :
"""描述媒体资源."""
identifier: str
title: str = '<untitled>'
creators: list [ str ] = field( default_factory= list )
date: Optional[ date] = None
type : ResourceType = ResourceType. BOOK
description: str = ''
language: str = ''
subjects: list [ str ] = field( default_factory= list )
示例 5 - 20 中doctest演示了如何在代码中使用Resource记录 .
>> > description = 'Improving the design of existing code'
>> > book = Resource( '978-0-13-475759-9' , 'Refactoring, 2ed Edition' ,
. . . [ 'Martin Fowler' , 'Kent Beck' ] , date( 2018 , 11 , 19 ) ,
. . . ResourceType. BOOK, description, 'EN' ,
. . . [ 'computer programming' , 'OOP' ] )
. . .
>> > book
Resource( identifier= '978-0-13-475759-9' , title= 'Refactoring, 2ed Edition' ,
creators= [ 'Martin Fowler' , 'Kent Beck' ] , date= datetime. date( 2018 , 11 , 19 ) ,
type = < ResourceType. BOOK: 1 > , description= 'Improving the design of existing code' , language= 'EN' , subjects= [ 'computer programming' , 'OOP' ] )
@ dataclass生成的__repr__方法效果还行 , 不过还可以进一步定制 , 以提高其可读性 .
我们希望repr ( book ) 返回以下格式 .
>> > book
Resource(
identifier= '978-0-13-475759-9' ,
title= 'Refactoring, 2ed Edition' ,
creators= [ 'Martin Fowler' , 'Kent Beck' ] ,
date= datetime. date( 2018 , 11 , 19 ) ,
type = < ResourceType. BOOK: 1 > ,
description= 'Improving the design of existing code' ,
language= 'EN' ,
subjects= [ 'computer programming' , 'OOP' ]
)
示例 5 - 21 给出__repr__方法的代码 , 输出这种格式 .
这个示例使用dataclass . fields获取数据类字段的名称 .
from typing import fields
def __repr__ ( self) :
cls = self. __class__
cls_name = cls. __name__
indent = ' ' * 4
res = [ f' { cls_name} (' ]
for f in fields( cls) :
value = getattr ( self, f. name)
res. append( f' { indent} { f. name} = { value!r } ,' )
res. append( ')' )
return '\n' . join( res)
from dataclasses import dataclass, field, fields
from typing import Optional
from enum import Enum, auto
from datetime import date
class ResourceType ( Enum) :
BOOK = auto( )
EBOOK = auto( )
VIDEO = auto( )
@dataclass
class Resource :
"""描述媒体资源."""
identifier: str
title: str = '<untitled>'
creators: list [ str ] = field( default_factory= list )
date: Optional[ date] = None
type : ResourceType = ResourceType. BOOK
description: str = ''
language: str = ''
subjects: list [ str ] = field( default_factory= list )
def __repr__ ( self) :
cls = self. __class__
cls_name = cls. __name__
indent = ' ' * 4
res = [ f' { cls_name} (' ]
for f in fields( cls) :
value = getattr ( self, f. name)
res. append( f' { indent} { f. name} = { value!r } ,' )
res. append( ')' )
return '\n' . join( res)
description = 'Improving the design of existing code'
book = Resource( '978-0-13-475759-9' , 'Refactoring, 2ed Edition' ,
[ 'Martin Fowler' , 'Kent Beck' ] , date( 2018 , 11 , 19 ) ,
ResourceType. BOOK, description, 'EN' ,
[ 'computer programming' , 'OOP' ] )
print ( book)
"""
Resource(
identifier = '978-0-13-475759-9',
title = 'Refactoring, 2ed Edition',
creators = ['Martin Fowler', 'Kent Beck'],
date = datetime.date(2018, 11, 19),
type = <ResourceType.BOOK: 1>,
description = 'Improving the design of existing code',
language = 'EN',
subjects = ['computer programming', 'OOP'],
)
"""
这个示例的灵感来自在美国俄亥俄州都柏林举办的一次会议 .
我们对Python数据类构建器的讨论到此结束 .
数量类的确很方便 , 但是如果过度使用 , 也会为你的项目带来不好的影响 .
5.7 节将详谈 .
5.7 数据类导致代理异味
无论是自己编写所有代码实现的数据类 , 还是利用本章介绍的某个类构建器实现实际类 , 都要注意一点 :
这可能表示你的设计存在问题 .
在 < < 重构 ( 第二版 ) > > 中 , Martin Fowler和Kent Beck提出来 '代码异味' 这一概念 .
一旦代码出现意味 , 可能就意味着需要重构 .
讲数据类那一节 , 开始是这样说的 :
所谓数据类是指 , 它们拥有一些字段 , 以及用于访问 ( 读写 ) 这些字段的函数 , 除此之外一无长物 .
这样的类只是一种不会说话的数据容器 , 它们几乎一定被其他类过分烦琐地操控着 .
在Martion Fowler的个人网站中 , 有一篇很有启发性的文章 , 题为 'Code smell' .
这篇文章以本章讨论的数据类为例说明代码意味 , 并给出解决建议 .
下面完整转载了该文 . ⑧
( 注 8 : 很荣幸 , 我与Martin Fowler是Thoughtworks同事 , 只用了 20 分钟就得到了授权 . )
* -----------------------------------------------代码异味--------------------------------------- *
Martion Fowler
代码异味是一种迹象 , 通常表明系统存在深层问题 .
这个说法最初由Kent Beck提出 , 但是我们在商讨 < < 重构 > > 一书的写作 .
上面的定义比较简单 , 但是隐含了几层意思 .
首先 , 异味能迅速引起我们的注意 . 我最近喜欢说 , 异味能被 '嗅到' .
内容较长的方法就有异味 , 只要看到十几行Java代码 , 我的鼻子就会不自觉地抽动 .
其次 , 有异味并不一定代码有问题 . 有些地方的内容就是长 . 你必须深入观察 , 判断有没有潜在的问题 .
不是说有异味就不好 , 异味通常是问题的表征 , 但不是说一定有问题 .
我们都喜欢容易察觉的异味 , 多数时候这能让我们找到问题的根源 .
数据类 ( 只包含数据而没有行为的类 ) 就是很好的例子 .
遇到数据类 , 请问自己一个问题 : 这个类需要什么行为? 然后 , 开始重构 , 加入需要的行为 .
通常 , 简单的思考和基本的重构就可以把空洞的对象抽象为真正的类 .
异味的好处之一是 , 没有经验的人也很容易发现 ,
即使他们没有足够的知识来评判是否真的有问题 , 也不知道如何修正问题 .
我听说 , 有一些首席来发人员会提出 '一周异味之星' ,
让团队成员寻找代码意味问题 , 找到问题后交给高级开发人员解决 .
一次解决一个异味问题是不错的做法 , 可以让团队循环渐进 , 引导他们成为更好的程序员 .
* --------------------------------------------------------------------------------------------- *
面向对象编程的主要思想是把行为和数据放在同一个代码单元 ( 一个类 ) 中 .
如果一个类使用广泛 , 但是自身没有什么重要的行为 ,
那么整个系统中可能遍布处理实例的代码 , 并出现在很多方法和函数中 ( 有些甚至是重复的 ) .
这样的系统对维护来说简单就是噩梦 .
鉴于此 , Martin Fowler提出的重构方案才建议把职责放回数据类中 .
尽管如此 , 仍然有几种情况适合使用没什么行为或者没有任何行为的数据类 .
5.7.1 把数据类用作脚手架
这种情况是指 , 刚一开始创建一个项目或者编写一个模块时 , 先用数据类简单实现一个类 .
随着时间的推移 , 类应该拥有自己的方法 , 而不是依赖其他类的方法操作该类的实例 .
脚手架是临时的 , 最终 , 你自定义的类或许应当完全独立 , 不依赖一开始使用的类构建器 .
Python也可用于快速解决问题和实验 , 用完之后把脚手架留在原地完全没有问题 .
5.7.2 把数据类用作中间表述
数据类可以用于构建将要导出为JSON或其他交换格式的记录 , 也可用于存储刚刚从其他系统导入的数据 .
Python中的数据类构建器都提供了把实例转换为普通字典的方法或函数 ,
而且构造函数全部支持通过关键字参数提供一个字段 ( 非常接近JSON记录 ) , 再使用 * * 展开 .
在这种情况下 , 应把数据类实例当作不可变对象处理 , 即便字段是可变的 , 也不应该在处于中间形式时更改 .
倘若更改 , 把数据和行为结合在一起的巨大优势就没有了 .
假如导入或导出时需要更改值 , 应该自己实现构造器方法 ,
而不是使用数据类构建器提供的 '用作字段' 方法或常规的构造函数 .
如果确实有必要更改数据类实例的内部数据 , 最好的方式是创建一个新实例 , 而不是直接修改现有实例 .
例如 , 假设我们有一个Person数据类 :
from dataclasses import dataclass
@dataclass
class Person :
name: str
age: int
occupation: str
如果我们需要更改一个Person实例的年龄 , 最好的方式是创建一个新实例 , 而不是直接修改现有实例 , 例如 :
old_person = Person( 'Alice' , 35 , 'engineer' )
old_person. age = 31
new_person = Person( old_person. name, 36 , old_person. occupation)
在这个例子中 , 我们创建一个new_person实例 , 它将原始实例的name和occupation保持不变 , 但将age更改为 36 岁 . 这种方式可以确保数据类实例被视为不可变对象 , 并且保留了数据类的优点 , 例如可读性 , 可维护性和可扩展性 .
2.6 节和 3.3 节讲过如何通过模式匹配序列和映射 , 现在换个话题 , 说明如何使用模式匹配任意类的实例 .
5.8 模式匹配类实例
类模式通过类型和属性 ( 可选 ) 匹配类实例 .
类模式的匹配对象可以使任何类的实例 , 而不仅仅是数据类的实例 . ⑨
( 注 9 : 我之所以把这部分内容放在这里 , 是因为这一章在本书中最早讲到用户定义的类 ,
而我认为类模式匹配非常重要 , 等到第二部分再讲就晚了 . 我的理念是 , 知道如何使用类比知道如何定义类更重要 . )
类模式有 3 种变体 : 简单类模式 , 关键字模式和位置类模式 .
下面按顺序依次研究 .
5.8.1 简单类模式
其实 , 2.6 节有一个示例已经用到了类模式 , 那是是作为子模式使用的 .
case [ str ( name) , _, _m ( float ( lat) , float ( lon) ) ] :
那个模式匹配项数为 4 的序列 , 第一项必须是str示例 , 最后一项必须是而元组 , 两项均为float实例 .
类模式的句法看起来与构建函数调用差不多 .
下面的类模式匹配float值 , 未绑定变量 ( 在case主体中 , 如果需要可以直接引用x ) .
match x:
case float ( ) :
do_something_with( x)
但是 , 像下面这样做可能导致bug .
match x:
case flaot:
do_something_with( x)
这里 , case float : 可以匹配任何对象 , 因为Python把float看着匹配对象绑定的变量 .
( 等效于 case _ : 区别在于 _不绑定值 , float是一个匹配对象绑定的变量的名称 . )
float ( x ) 这种简单模式句法只适用于 9 种内置类型 ( 在 'PEP 634--Structural Patterm Matching: Specification' 中 'Class Patterns' 一节的末尾列出 ) .
? bytes
? dict
? float
? frozenset
? int
? list
? str
? tuple
( 上面九种内置类型适用于简单模式句法 . )
对这些类来说 , 看上去像构造函数的参数的那个变量 , 例如float ( x ) 中的x , 绑定整个匹配的实例 .
( 注意这里的float不是构造函数 , 而是类型注解 . x是一个变量 , 匹配的实例会绑定给x . )
如果是子模式 , 则绑定匹配对象的一部分 , 例如前例中序列模式内的str ( name ) .
case [ str ( name) , _, _, ( flaot( lat) , float ( lon) ) ] :
除 9 种内置类型之外 , 看上去像参数的那个变量表示模式匹配的类实例的属性 .
( 如果不是上面的 9 中内置类型 , 那么变量x则表示实例的属性名称 , 那么使用方法如下 . )
class Person :
def __init__ ( self, name, age) :
self. name = name
self. age = age
person = Person( "Alice" , 25 )
case person:
case Person( name= "Alice" , age= _) :
print ( "匹配成功!姓名为 Alice" )
case Person( name= _, age= 25 ) :
print ( "匹配成功!年龄为 25" )
case Person( name= "Alice" , age= 25 ) :
print ( "匹配成功!姓名为 Alice,年龄为 25" )
case _ :
print ( "未匹配到任何条件" )
5.8.2 关键字类模式
为了说明如何使用关键字类模式 , 下面定义一个City类 , 在创建 5 个实例 , 如示例 5 - 22 所示 .
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' ) 匹配的City实例 , continent属性的值等于 'Asia' , 其他属性的值不考虑 .
如果你想收集country属性的值 , 可以像下面这样写 .
def match_asian_conutries ( ) :
results = [ ]
for city in cities:
match city:
case City( continent= 'Asia' , country= cc) :
results. append( cc)
return results
与前面一样 , City ( continent = 'Asia' , country = cc ) 也匹配位于亚洲的城市 ,
不过现在把变量cc绑定到了实例的country属性上 .
模式变量叫作country也没关系 .
match city:
case City( continent= 'Asia' , country= country) :
results. append( country)
关键字模式的可读性非常高 , 适用于任何公开的实例属性的类 , 不过有点烦琐 .
有时候 , 使用位置类模式更方便 , 不过匹配对象所属的类要显示支持 , 详见 5.8 .3 节 .
5.8.3 位置类模式
对于示例 5 - 22 中的定义 , 以下函数使用位置类模式获取亚洲城市列表 .
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__声明的顺序 .
11.8 节将说明如何为没有使用类构建器创建的类定义__match__args__属性 .
* --------------------------------------------------------------------------------------------- *
一个模式可以同时使用关键字参数和位置参数 .
__match_args__列出的是可供匹配的实例属性 , 不是全部属性 .
因此 , 有时候除了位置参数之外可能还需要使用关键字参数 .
* --------------------------------------------------------------------------------------------- *
小节时间到 .
5.9 本章小结
本章主要讲解了 3 个数据类构建器 :
collentions . namedtuple , typing . NamedTuple和dataclasses . dataclass .
我们知道 , 每个构建器都可以根据传给工厂函数的参数生成数据类 ,
后两个构建器还可以通过class语句提供类型提示 .
两种具名元组生成的是tuple子类 , 与普通的元素相比 , 增加了通过名称访问字段的功能 ,
另外还提供一个类属性_fields , 以字符串元组的形式列出字段的名称 .
我们把 3 个类构建器排放在一起 , 研究了它们的主要功能 , 包括如何提取实例数据 , 返回一个dict ,
如何获取字段的名称和默认值 , 以及如何根据现有实例创建新实例 .
借此机会 , 我们第一次讲到类型提示 , 尤其是如何使用Python3 . 6 引入的表示法
( 'PEP 526--SYntac for Variable Annotations' ) 在class语句中注解变量 .
类型提示最让人惊讶的一方面应该是 , 它在运行时根本没有作用 .
毕竟 , Python是动态语言 .
如果想利用类型信息检测错误 , 则需要使用外部工具 , 例如Mypy , 对源码做静态分析 .
基本了解PEP 526 引入的句法之后 ,
我们研究了注解在普通类和通过typing . NamedTuple和 @ dataclass构建类中起到什么效果 .
接下来 , 我们探讨了 @ dataclass提供的常用功能 , 以及dataclasses . field函数的default_factory选项 .
我们还介绍了对数据类很重要的两个特殊的伪类型提示--typing . Class和dataclasses . InitVar .
随后 , 根据都柏林核心模式举例一个例子 ,
说明如何在自定义的__repr__方法中使用datacalss . fields迭代Resource实例的属性 .
然后 , 告诫大家不要滥用数量类 , 以免违背面向编程的一个基本原则 , 即数据和处理数据的函数因放在同一个类中 .
不含逻辑的类可能表明你把逻辑放错位置了 .
5.10 节将讲解如何使用模式匹配任意类的实例 , 而且不限于只匹配本章涵盖的类构建器构建的类 .
( 5.10 搞错了的具体是哪个小节哪还不知道 . . . )
5.10 延伸阅读
Python标准库文档对数据类构建器的讲解很全面 , 还有许多小例子 .
提议增加 @ dataclass的 'PEP 557--Data Class' , 多数内容复制到了dataclasses模块的文档中 .
不过 , PEP 557 中有几节信息丰富 , 却没有复制到文档中 , 包括 'Why not just use namedtuple?'
'Why not just use typing.NamedTuple?' 和 'Rationale' .
'Rationale' 一节最后提出一个问题 , 并给出了解答 .
什么时候不使用使用数据类?
需要兼容元组或字典的API . 需要的类型验证超出PEP 484 和PEP 526 定义的范围 , 或者需要验证值或做转换 .
--Eric V . Smith
PEP 557 , 'Rationale'
Geir Arne Hjelle写了一篇非常全面的文章 , 题为 'Data Classes in Python 3.7+(Guide)' .
Pycon US 2018 , Raymond Hettinger所做的演讲 ,
'Dataclasses: The code generator to end add code generators' ( 视频 ) .
如果你需要更多高级的功能 , 例如验证 , 则可以研究一下Hyeck Schlawack创建的attrs项目 .
这个项目比dataclasses早几年 , 功能更多 ,
承诺 '把你从显示对象协议(即双下划线方法)的苦差事中解放出来, 让你重拾编写类的乐趣' .
Eric V . Smith在PEP 557 中特别感谢了sttrs对 @ dataclass的影响 .
Smith所指的影响可能包括最重要的API决策 : 使用类装饰器实现目的 , 而使用基类和 ( 或 ) 元类 .
Twisted项目的创始人Glyph写的一篇文章对attrs做了精彩的介绍 ,
题为 'The One Python Library Evaryone Needs' .
attrs的文档对替代方案也做了讨论 .
图书作者 , 讲师和狂热的计算机科学家Dave Beazley编写的cluegen也是一个数据类生成器 .
如果你通过Dave的演讲 , 不难发现它是一位Python元编程专家 .
从cluegen项目的README . md文件给出的具体用例可以看出 , 尽管Python已经有 @ dataclass了 ,
但他还要实现一种替代方案 , 以及他为涉密提供一种解决方案 , 而不是一个工具 .
工具一开始时用着方便 , 但是方案更灵活 , 并适用于各种情况 .
至于 '数据类' 是一种代码异味 , 我能找到的最好的佐证资料是Martin Fowler写的 < < 重构 ( 第 2 版 ) > > 一书 .
这一版去掉了本章开头引用的那句话 , 即 '数据类将像小孩子......' , 但仍不失为该书最好的版本 .
对Python程序员来说说尤其如此 ,
因为本书的示例用的是现代的JavaScript , 与Python接近 , 而不想第一版用的是Java .
* --------------------------------------------杂谈---------------------------------------------- *
'The Jargon File' 中的 'Guido' 词条讲的是Guido van Rossum . 其中有一部分是这样说的 :
你可能想象不到 , 除了Python之外 , Guido还有一个代表性作品--一台时间机器 .
人们声称Guido有这样一台设备 , 因为急躁的用户经常请求增加新功能 ,
而得到的答复往往是 '我昨晚刚刚实现了.....'
很长一段时间以来 , Python缺少为类声明实例属性的标准句法 , 显得不便 . 而这在很多面对对象语言中有 .
下面是使用Smalltalk定义的Point类的一部分 .
Object subclass : # Point
instanceVariableNames : 'x y'
classVaeiableNames : ''
package : 'Kernel-BasicObjects'
第二行列出实例属性的名称 , 即x和y . 如果是类属性的话 , 则放在第三行 .
Python一直都有声明类属性的简便方式 , 如果类属性有初始值的话 .
然而 , 实例属性更常用 , Python程序员不得不在__init__方法中寻找有没有实例属性 ,
而且总是担心类中的其他地方 , 甚至外部函数或其他类的方法也创建了实例属性 .
现在好了 , 我们有了 @ daataclass .
但是 , 问题也随之而来 .
首先 , 使用 @ dataclass是不能省略类型提示 .
过去 7 年间 . 'PEP 484-Type Hints' 给我们的承诺是 , 类型提示始终是可选的 .
而现在 , 这个重要的语言功能却要求必须提供类型提示 .
如果你不喜欢静态类型趋势 , 可以选择使用attrs .
其次 , PEP 526 提出的实例属性和类属性注解句法与我们在class语法中养成的习惯相反 .
以前 , calss块顶层声明的全是类属性 ( 方法也是类属性 ) .
而对于PEP 526 和 @ dataclass , 在顶层声明的带有类型提示的属性变成了实例属性 .
@ dataclass
class Spam :
repeat : int # 实例属性
下面的repeat也是实例属性 .
@ dataclass
class Spam :
repeat : int = 99 # 实例属性
但是 , 如果没有类型提示 , 我们一下子就回到了从前 , 在顶层声明的属性只属于类自身 .
@ dataclass
calss Spam :
repeat = 99 # 类属性 !
最后 , 如果你想为类属性注解类型 , 则不能使用常规的类型 , 否则就变成实例属性了 .
正确的做法是使用伪类型ClassVar注解 .
@ dataclass
calss Spam :
repeat : ClassVar [ int ] = 99 # 真乱 !
这是例外中的例外 , 我觉得这不太符合Python风格 .
我没有参与PEP 526 和 'PEP 557-Data Classes' 的讨论 , 我希望实现的是下面这样句法 .
@ dataclass
class HackerCluMember :
# 声明实例属性时必须在前面加上 .
. name : str
. guests : list = field ( dedault_factory = list )
. handle : str = ''
# 前面没有 . 的属性时类属性 ( 像以前一样 ) .
all_handle = set ( )
以此 , 语法必须做出改变 . 我觉得这种句法的可读性非常高 , 而且没有例外中的例外 .
征希望我能把Guido的时间机器借来医用 , 回到 2017 年 , 让核心团队采纳我的想法 .
* --------------------------------------------------------------------------------------------- *