一直想写一篇介绍 attrs 的文章,但是最近几个月忙于做爱湃森课程实在抽不出空来做,最近感觉找到节奏了,还是稳步向前走了,这个周末就硬挤了一下午写写,要不感觉对不起订阅专栏的同学们。

在国内我没见过有同学说这2个东西,它们是什么,又有什么关联呢?别着急,先铺垫一下他俩出现的背景。

痛点

写多了Python,尤其是开发和微信的项目比较大的时候,你可能和我一样感觉写Python的类很累。怎么累呢?举个例子,现在有个商品类,__init__是这么写的:

1
2
3
4
5
6
7
8
class Product(object):
def __init__(self, id, author_id, category_id, brand_id, spu_id,
title, item_id, n_comments, creation_time, update_time,
source='', parent_id=0, ancestor_id=0):
self.id = id
self.author_id = author_id
self.category_id = category_id
...

问题1:特点是初始化参数很多,每一个都需要self.xx = xx 这样往实例上赋值。我印象见过一个类有30多个参数,这个init方法下光是赋值就占了一屏多…

再说问题2,如果不定义__repr__方法,打印类对象的方式很不友好,大概是这样的:

1
2
In : p
Out: <test.Product at 0x10ba6a320>

定义时参数太多,一般按需要挑几个露出来:

1
2
3
4
def __repr__(self):
return '{}(id={}, author_id={}, category_id={}, brand_id={})'.format(
self.__class__.__name__, self.id, self.author_id, self.category_id,
self.brand_id)

这样再看类对象就友好了:

1
2
In : p
Out: Product(id=1, author_id=100001, category_id=2003, brand_id=20)

但是每个类都需要手动的去写__repr__!

接着说问题3,对象比较,有时候需要判断2个对象是否相等甚至大小(例如用于展示顺序):

1
2
3
4
5
6
7
8
9
10
def __eq__(self, other):
if not isinstance(other, self.__class__):
return NotImplemented
return (self.id, self.author_id, self.category_id, self.brand_id) == (
other.id, other.author_id, other.category_id, other.brand_id)
def __lt__(self, other):
if not isinstance(other, self.__class__):
return NotImplemented
return (self.id, self.author_id, self.category_id, self.brand_id) < (
other.id, other.author_id, other.category_id, other.brand_id)

如果对比的更全面不用所有gt、gte、lte都写出来,用functools.total_ordering这样就够了:

1
2
3
4
5
6
from functools import total_ordering


@total_ordering
class Product(object):
...

然后是问题4。有些场景下希望对对象去重,可以添加__hash__:

1
2
def __hash__(self):
return hash((self.id, self.author_id, self.category_id, self.brand_id))

这样就不需要对一大堆的对象挨个比较去重了,直接用集合就可以了:

1
2
3
4
5
6
In : p1 = Product(1, 100001, 2003, 20, 1002393002, '这是一个测试商品1', 2000001, 100, None, 1)

In : p2 = Product(1, 100001, 2003, 20, 1002393002, '这是一个测试商品2', 2000001, 100, None, 2)

In : {p1, p2}
Out: {Product(id=1, author_id=100001, category_id=2003, brand_id=20)}

可以看到集合只返回了一个对象,另外一个被去掉了。

最后是问题5。我很喜欢给类写一个to_dict、to_json或者as_dict这样的方法,把类里面的属性打包成一个字典返回。基本都是每个类都要写一遍它:

1
2
3
4
5
6
7
def to_dict(self):
return {
'id': self.id,
'author_id': self.author_id,
'category_id': self.category_id,
...
}

当然没有特殊的理由,可以直接使用vars(self)获得, 上面这种键值对指定的方式会更精准,只导出想导出的部分,举个特殊的理由吧:

1
2
3
def to_dict(self):
self._a = 1
return vars(self)

会把_a也包含在返回的结果中,然而它并不应该被导出,所以不适合vars函数。

到这里,我们停下来想想,self.id、self.author_id、self.category_id 这些分别写了几次?

那有没有一种方法,可以在创建类的时候自动给类加上这些东西,把开发者解脱出来呢?这就是我们今天介绍的attrs和Python 3.7标准库里面将要加的dataclasses模块做的事情,而且它们能做的会更多。

attrs

attrs是Python核心开发Hynek Schlawack设计并实现的一个项目,它就是解决上述痛点而生的,上述类,使用attrs这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import attr


@attr.s(hash=True)
class Product(object):
id = attr.ib()
author_id = attr.ib()
brand_id = attr.ib()
spu_id = attr.ib()
title = attr.ib(repr=False, cmp=False, hash=False)
item_id = attr.ib(repr=False, cmp=False, hash=False)
n_comments = attr.ib(repr=False, cmp=False, hash=False)
creation_time = attr.ib(repr=False, cmp=False, hash=False)
update_time = attr.ib(repr=False, cmp=False, hash=False)
source = attr.ib(default='', repr=False, cmp=False, hash=False)
parent_id = attr.ib(default=0, repr=False, cmp=False, hash=False)
ancestor_id = attr.ib(default=0, repr=False, cmp=False, hash=False)

这就可以了,上面说的那些dunder方法(双下划线开头和结尾的方法)都不用写了:

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
In : p1 = Product(1, 100001, 2003, 20, 1002393002, '这是一个测试商品1', 2000001, 100, None, 1)

In : p2 = Product(1, 100001, 2003, 20, 1002393002, '这是一个测试商品2', 2000001, 100, None, 2)

In : p3 = Product(3, 100001, 2003, 20, 1002393002, '这是一个测试商品3', 2000001, 100, None, 3)

In : p1
Out: Product(id=1, author_id=100001, brand_id=2003, spu_id=20)

In : p1 == p2
Out: True

In : p1 > p3
Out: False

In : {p1, p2, p3}
Out:
{Product(id=1, author_id=100001, brand_id=2003, spu_id=20),
Product(id=3, author_id=100001, brand_id=2003, spu_id=20)}

In : attr.asdict(p1)
Out:
{'ancestor_id': 0,
'author_id': 100001,
'brand_id': 2003,
'creation_time': 100,
'id': 1,
'item_id': '这是一个测试商品1',
'n_comments': 2000001,
'parent_id': 0,
'source': 1,
'spu_id': 20,
'title': 1002393002,
'update_time': None}

In : attr.asdict(p1, filter=lambda a, v: a.name in ('id', 'title', 'author_id'))
Out: {'author_id': 100001, 'id': 1, 'title': 1002393002}

是不是清爽直观?

当然, 我这个例子中对属性的要求比较多,所以不同属性的参数比较长。看这个类的定义的方式是不是有点像ORM?对象和属性的关系直观,不参与类中代码逻辑。

有兴趣的可以看[Kenneth Reitz、Łukasz Langa、Glyph Lefkowitz等人对项目的评价] (http://www.attrs.org/en/stable/index.html#testimonials)。

除此之外,attrs 还支持多种高级用法,如字段类型验证、自动类型转化、属性值不可变(Immutability)、类型注解等等 ,我列3个我觉得非常有用的吧

字段类型验证

业务代码中经验会对对象属性的类型和内容验证,attrs也提供了验证支持。验证有2种方案:

  1. 装饰器
1
2
3
4
5
6
7
8
9
10
11
12
13
>>> @attr.s
... class C(object):
... x = attr.ib()
... @x.validator
... def check(self, attribute, value):
... if value > 42:
... raise ValueError("x must be smaller or equal to 42")
>>> C(42)
C(x=42)
>>> C(43)
Traceback (most recent call last):
...
ValueError: x must be smaller or equal to 42
  1. 属性参数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> def x_smaller_than_y(instance, attribute, value):
... if value >= instance.y:
... raise ValueError("'x' has to be smaller than 'y'!")
>>> @attr.s
... class C(object):
... x = attr.ib(validator=[attr.validators.instance_of(int),
... x_smaller_than_y])
... y = attr.ib()
>>> C(x=3, y=4)
C(x=3, y=4)
>>> C(x=4, y=3)
Traceback (most recent call last):
...
ValueError: 'x' has to be smaller than 'y'!

属性类型转化

Python不会检查传入的值的类型,类型错误很容易发生,attrs支持自动的类型转化:

1
2
3
4
5
6
>>> @attr.s
... class C(object):
... x = attr.ib(converter=int)
>>> o = C("1")
>>> o.x
1

包含元数据

属性还可以包含元数据!这个真的非常有用,这个属性的值就不仅仅是一个值了,带上元数据的值非常灵活也更有意义,这样就不需要额外的把属性需要的元数据独立存储起来了。

1
2
3
4
5
6
7
>>> @attr.s
... class C(object):
... x = attr.ib(metadata={'my_metadata': 1})
>>> attr.fields(C).x.metadata
mappingproxy({'my_metadata': 1})
>>> attr.fields(C).x.metadata['my_metadata']
1

通过上面支持的几个功能可以看出作者做项目就是基于实际工作痛点出发的。

dataclasses模块

在Python 3.7里面会添加一个新的模块 dataclasses ,它基于PEP 557,Python 3.6可以通过pip下载安装使用:

1
pip install dataclasses

解决如上痛点,把Product类改成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from datetime import datetime
from dataclasses import dataclass, field


@dataclass(hash=True, order=True)
class Product(object):
id: int
author_id: int
brand_id: int
spu_id: int
title: str = field(hash=False, repr=False, compare=False)
item_id = int = field(hash=False, repr=False, compare=False)
n_comments = int = field(hash=False, repr=False, compare=False)
creation_time: datetime = field(default=None, repr=False, compare=False,hash=False)
update_time: datetime = field(default=None, repr=False, compare=False, hash=False)
source: str = field(default='', repr=False, compare=False, hash=False)
parent_id: int = field(default=0, repr=False, compare=False, hash=False)
ancestor_id: int = field(default=0, repr=False, compare=False, hash=False)

先验证一下:

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
In : p1 = Product(1, 100001, 2003, 20, 1002393002, '这是一个测试商品1', 2000001, 100, None, 1)

In : p2 = Product(1, 100001, 2003, 20, 1002393002, '这是一个测试商品2', 2000001, 100, None, 2)

In : p3 = Product(3, 100001, 2003, 20, 1002393002, '这是一个测试商品3', 2000001, 100, None, 3)

In : p1
Out: Product(id=1, author_id=100001, brand_id=2003, spu_id=20)

In : p1 == p2
Out: True

In : p1 > p3
Out: False

In : {p1, p2, p3}
Out:
{Product(id=1, author_id=100001, brand_id=2003, spu_id=20),
Product(id=3, author_id=100001, brand_id=2003, spu_id=20)}

In : from dataclasses import asdict

In : asdict(p1)
Out:
{'ancestor_id': 1,
'author_id': 100001,
'brand_id': 2003,
'creation_time': '这是一个测试商品1',
'id': 1,
'parent_id': None,
'source': 100,
'spu_id': 20,
'title': 1002393002,
'update_time': 2000001}

dataclasses.asdict不能过滤返回属性。但是总体满足需求。但是,你有没有发现什么不对?

attrs 和 dataclasses

虽然2种方案写的代码确实有些差别,但有木有觉得它俩很像?其实attrs 的诞生远早于 dataclasses, dataclasses更像是在借鉴。dataclasses可以看做是一个 强制类型注解,功能是attrs的子集。那么为什么不把 attrs 放入标准库,而是Python 3.7加入一个阉割版的 attrs 呢?

Glyph Lefkowitz犀利的写了标题为why not just attrs?的issue,我打开这个issue没往下看的时候,猜测是「由于attrs兼容Python3.6,包含Python2.7的版本,进入标准库必然是一次卸掉包袱的重构,attrs作者不同意往这个方向发展?」,翻了下讨论发现不是这样的。

这个issue很有意思,多个Python开发都参与进来了,最后Gvanrossum结束了讨论,明确表达不同意 attrs 进入标准库,Donald Stufft也直接问了为啥?Gvanrossum虽然解释了下,但是我还是觉得这算是「仁慈的独裁者」中的「独裁」部分的体现吧,Python社区的态度一直是不太开放。包含在 PEP 557解释为什么不用 attrs,也完全说服不了我。

我站 attrs,向大家推荐! 不仅是由于attrs兼容之前的Python版本,而是attrs是真的站在开发者的角度上添加功能支持,最后相信 attrs 会走的更远。

参考文章

  1. http://www.attrs.org/en/stable/index.html
  2. https://glyph.twistedmatrix.com/2016/08/attrs.html
  3. https://www.python.org/dev/peps/pep-0557/