社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

这可能是 Python 面向对象编程的最佳实践

进击的Coder • 4 年前 • 299 次点击  

阅读本文大概需要 20 分钟。


Python 是支持面向对象的,很多情况下使用面向对象编程会使得代码更加容易扩展,并且可维护性更高,但是如果你写的多了或者某一对象非常复杂了,其中的一些写法会相当相当繁琐,而且我们会经常碰到对象和 JSON 序列化及反序列化的问题,原生的 Python 转起来还是很费劲的。

可能这么说大家会觉得有点抽象,那么这里举几个例子来感受一下。

首先让我们定义一个对象吧,比如颜色。我们常用 RGB 三个原色来表示颜色,R、G、B 分别代表红、绿、蓝三个颜色的数值,范围是 0-255,也就是每个原色有 256 个取值。如 RGB(0, 0, 0) 就代表黑色,RGB(255, 255, 255) 就代表白色,RGB(255, 0, 0) 就代表红色,如果不太明白可以具体看看 RGB 颜色的定义哈。

好,那么我们现在如果想定义一个颜色对象,那么正常的写法就是这样了,创建这个对象的时候需要三个参数,就是 R、G、B 三个数值,定义如下:

class Color(object):
    """
    Color Object of RGB
    """

    def __init__(self, r, g, b):
        self.r = r
        self.g = g
        self.b = b

其实对象一般就是这么定义的,初始化方法里面传入各个参数,然后定义全局变量并赋值这些值。其实挺多常用语言比如 Java、PHP 里面都是这么定义的。但其实这种写法是比较冗余的,比如 r、g、b 这三个变量一写就写了三遍。

好,那么我们初始化一下这个对象,然后打印输出下,看看什么结果:

color = Color(255255255)
print(color)

结果是什么样的呢?或许我们也就能看懂一个 Color 吧,别的都没有什么有效信息,像这样子:

<__main__.color class="hljs-number" style="font-size: inherit;line-height: inherit;color: rgb(174, 135, 250);overflow-wrap: inherit !important;word-break: inherit !important;">0x103436f60>

我们知道,在 Python 里面想要定义某个对象本身的打印输出结果的时候,需要实现它的 __repr__ 方法,所以我们比如我们添加这么一个方法:

def __repr__(self):
    return f'{self.__class__.__name__}(r={self.r}, g={self.g}, b={self.b})'

这里使用了 Python 中的 fstring 来实现了 __repr__ 方法,在这里我们构造了一个字符串并返回,字符串中包含了这个 Color 类中的 r、g、b 属性,这个返回的结果就是 print 的打印结果,我们再重新执行一下,结果就变成这样子了:




    
Color(r=255, g=255, b=255)

改完之后,这样打印的对象就会变成这样的字符串形式了,感觉看起来清楚多了吧?

再继续,如果我们要想实现这个对象里面的 __eq____lt__ 等各种方法来实现对象之间的比较呢?照样需要继续定义成类似这样子的形式:

def __lt__(self, other):
    if not isinstance(other, self.__class__): return NotImplemented
    return (self.r, self.g, self.b) 

这里是 __lt__ 方法,有了这个方法就可以使用比较符来对两个 Color 对象进行比较了,但这里又把这几个属性写了两遍。

最后再考虑考虑,如果我要把 JSON 转成 Color 对象,难道我要读完 JSON 然后一个个属性赋值吗?如果我想把 Color 对象转化为 JSON,又得把这几个属性写几遍呢?如果我突然又加了一个属性比如透明度 a 参数,那么整个类的方法和参数都要修改,这是极其难以扩展的。不知道你能不能忍,反正我不能忍!

如果你用过 Scrapy、Django 等框架,你会发现 Scrapy 里面有一个 Item 的定义,只需要定义一些 Field 就可以了,Django 里面的 Model 也类似这样,只需要定义其中的几个字段属性就可以完成整个类的定义了,非常方便。

说到这里,我们能不能把 Scrapy 或 Django 里面的定义模式直接拿过来呢?能是能,但是没必要,因为我们还有专门为 Python 面向对象而专门诞生的库,没错,就是 attrs 和 cattrs 这两个库。

有了 attrs 库,我们就可以非常方便地定义各个对象了,另外对于 JSON 的转化,可以进一步借助 cattrs 这个库,非常有帮助。

说了这么多,还是没有介绍这两个库的具体用法,下面我们来详细介绍下。

安装

安装这两个库非常简单,使用 pip 就好了,命令如下:

pip3 install attrs cattrs

安装好了之后我们就可以导入并使用这两个库了。

简介与特性

首先我们来介绍下 attrs 这个库,其官方的介绍如下:

attrs 是这样的一个 Python 工具包,它能将你从繁综复杂的实现上解脱出来,享受编写 Python 类的快乐。它的目标就是在不减慢你编程速度的前提下,帮助你来编写简洁而又正确的代码。

其实意思就是用了它,定义和实现 Python 类变得更加简洁和高效。

基本用法

首先明确一点,我们现在是装了 attrs 和 cattrs 这两个库,但是实际导入的时候是使用 attr 和 cattr 这两个包,是不带 s 的。

在 attr 这个库里面有两个比较常用的组件叫做 attrs 和 attr,前者是主要用来修饰一个自定义类的,后者是定义类里面的一个字段的。有了它们,我们就可以将上文中的定义改写成下面的样子:

from attr import attrs, attrib

@attrs
class Color(object):
    r = attrib(type=int, default=0)
    g = attrib(type=int, default=0)
    b = attrib(type=int, default=0)

if __name__ == '__main__':
    color = Color(255255255)
    print(color)

看我们操作的,首先我们导入了刚才所说的两个组件,然后用 attrs 里面修饰了 Color 这个自定义类,然后用 attrib 来定义一个个属性,同时可以指定属性的类型和默认值。最后打印输出,结果如下:

Color(r=255, g=255, b=255)

怎么样,达成了一样的输出效果!

观察一下有什么变化,是不是变得更简洁了?r、g、b 三个属性都只写了一次,同时还指定了各个字段的类型和默认值,另外也不需要再定义 __init__ 方法和 __repr__ 方法了,一切都显得那么简洁。一个字,爽!

实际上,主要是 attrs 这个修饰符起了作用,然后根据定义的 attrib 属性自动帮我们实现了 __init____repr____eq____ne____lt____le____gt____ge____hash__ 这几个方法。

如使用 attrs 修饰的类定义是这样子:

from attr import attrs, attrib

@attrs
class SmartClass(object):
    a = attrib()
    b = attrib()

其实就相当于已经实现了这些方法:

class RoughClass(object):
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def __repr__(self):
        return "RoughClass(a={}, b={})".format(self.a, self.b)

    def __eq__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) == (other.a, other.b)
        else:
            return NotImplemented

    def __ne__(self, other):
        result = self.__eq__(other)
        if result is NotImplemented:
            return NotImplemented
        else:
            return not result

    def __lt__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b)         else:
            return NotImplemented

    def __le__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) <= (other.a, other.b)
        else:
            return NotImplemented

    def __gt__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) > (other.a, other.b)
        else:
            return  NotImplemented

    def __ge__(self, other):
        if other.__class__ is self.__class__:
            return (self.a, self.b) >= (other.a, other.b)
        else:
            return NotImplemented

    def __hash__(self):
        return hash((self.__class__, self.a, self.b))

所以说,如果我们用了 attrs 的话,就可以不用再写这些冗余又复杂的代码了。

翻看源码可以发现,其内部新建了一个 ClassBuilder,通过一些属性操作来动态添加了上面的这些方法,如果想深入研究,建议可以看下 attrs 库的源码。

别名使用

这时候大家可能有个小小的疑问,感觉里面的定义好乱啊,库名叫做 attrs,包名叫做 attr,然后又导入了 attrs 和 attrib,这太奇怪了。为了帮大家解除疑虑,我们来梳理一下它们的名字。

首先库的名字就叫做 attrs,这个就是装 Python 包的时候这么装就行了。但是库的名字和导入的包的名字确实是不一样的,我们用的时候就导入 attr 这个包就行了,里面包含了各种各样的模块和组件,这是完全固定的。

好,然后接下来看看 attr 包里面包含了什么,刚才我们引入了 attrs 和 attrib。

首先是 attrs,它主要是用来修饰 class 类的,而 attrib 主要是用来做属性定义的,这个就记住它们两个的用法就好了。

翻了一下源代码,发现其实它还有一些别名:

s = attributes = attrs
ib = attr = attrib

也就是说,attrs 可以用 s 或 attributes 来代替,attrib 可以用 attr 或 ib 来代替。

既然是别名,那么上面的类就可以改写成下面的样子:

from attr import s, ib

@s
class Color(object):
    r = ib(type=int, default=0)
    g = ib(type=int, default=0)
    b = ib(type=int, default=0)

if __name__ == '__main__':
    color = Color(255255255)
    print(color)

是不是更加简洁了,当然你也可以把 s 改写为 attributes,ib 改写为 attr,随你怎么用啦。

不过我觉得比较舒服的是 attrs 和 attrib 的搭配,感觉可读性更好一些,当然这个看个人喜好。

所以总结一下:

  • 库名:attrs

  • 导入包名:attr

  • 修饰类:s 或 attributes 或 attrs

  • 定义属性:ib 或 attr 或 attrib

OK,理清了这几部分内容,我们继续往下深入了解它的用法吧。

声明和比较

在这里我们再声明一个简单一点的数据结构,比如叫做 Point,包含 x、y 的坐标,定义如下:

from attr import attrs, attrib

@attrs
class Point(object):
    x = attrib()
    y = attrib()

其中 attrib 里面什么参数都没有,如果我们要使用的话,参数可以顺次指定,也可以根据名字指定,如:

p1 = Point(12)
print(p1)
p2 = Point(x=1, y=2)
print(p2)

其效果都是一样的,打印输出结果如下:

Point(x=1, y=2)
Point(x=1, y=2)

OK,接下来让我们再验证下类之间的比较方法,由于使用了 attrs,相当于我们定义的类已经有了 __eq____ne____lt____le____gt____ge__ 这几个方法,所以我们可以直接使用比较符来对类和类之间进行比较,下面我们用实例来感受一下:

print('Equal:', Point(12) == Point(12))
print('Not Equal(ne):', Point(12) != Point(34))
print('Less Than(lt):', Point(12) 3, 4))
print('Less or Equal(le):', Point(12) <= Point(1 4), Point(12) <= Point(12))
print('Greater Than(gt):', Point(42) > Point(32), Point(42) > Point(31))
print('Greater or Equal(ge):', Point(42) >= Point(41))

运行结果如下:

Same: False
Equal: True
Not Equal(ne): True
Less Than(lt): True
Less or Equal(le): True True
Greater Than(gt): True True
Greater or Equal(ge): True

可能有的朋友不知道 ne、lt、le 什么的是什么意思,不过看到这里你应该明白啦,ne 就是 Not Equal 的意思,就是不相等,le 就是 Less or Equal 的意思,就是小于或等于。

其内部怎么实现的呢,就是把类的各个属性转成元组来比较了,比如 Point(1, 2) < Point(3, 4) 实际上就是比较了 (1, 2)(3, 4) 两个元组,那么元组之间的比较逻辑又是怎样的呢,这里就不展开了,如果不明白的话可以参考官方文档:https://docs.python.org/3/library/stdtypes.html#comparisons。

属性定义

现在看来,对于这个类的定义莫过于每个属性的定义了,也就是 attrib 的定义。对于 attrib 的定义,我们可以传入各种参数,不同的参数对于这个类的定义有非常大的影响。

下面我们就来详细了解一下每个属性的具体参数和用法吧。

首先让我们概览一下总共可能有多少可以控制一个属性的参数,我们用 attrs 里面的 fields 方法可以查看一下:

from attr import attrs, attrib, fields

@attrs
class Point(object):
    x = attrib()
    y = attrib()

print(fields(Point))

这就可以输出 Point 的所有属性和对应的参数,结果如下:




    
(Attribute(name='x', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False), Attribute(name='y', default=NOTHING, validator=None, repr=True, cmp=True, hash=None, init=True, metadata=mappingproxy({}), type=None, converter=None, kw_only=False))

输出出来了,可以看到结果是一个元组,元组每一个元素都其实是一个 Attribute 对象,包含了各个参数,下面详细解释下几个参数的含义:

  • name:属性的名字,是一个字符串类型。

  • default:属性的默认值,如果没有传入初始化数据,那么就会使用默认值。如果没有默认值定义,那么就是 NOTHING,即没有默认值。

  • validator:验证器,检查传入的参数是否合法。

  • init:是否参与初始化,如果为 False,那么这个参数不能当做类的初始化参数,默认是 True。

  • metadata:元数据,只读性的附加数据。

  • type:类型,比如 int、str 等各种类型,默认为 None。

  • converter:转换器,进行一些值的处理和转换器,增加容错性。

  • kw_only:是否为强制关键字参数,默认为 False。

属性名

对于属性名,非常清楚了,我们定义什么属性,属性名就是什么,例如上面的例子,定义了:

x = attrib()

那么其属性名就是 x。

默认值

对于默认值,如果在初始化的时候没有指定,那么就会默认使用默认值进行初始化,我们看下面的一个实例:

from attr import attrs, attrib, fields

@attrs
class Point(object):
    x = attrib()
    y = attrib(default=100)

if __name__ == '__main__':
    print(Point(x=1, y=3))
    print(Point(x=1))

在这里我们将 y 属性的默认值设置为了 100,在初始化的时候,第一次都传入了 x、y 两个参数,第二次只传入了 x 这个参数,看下运行结果:




    
Point(x=1, y=3)
Point(x=1, y=100)

可以看到结果,当设置了默认参数的属性没有被传入值时,他就会使用设置的默认值进行初始化。

那假如没有设置默认值但是也没有初始化呢?比如执行下:

Point()

那么就会报错了,错误如下:

TypeError: __init__() missing 1 required positional argument: 'x'

所以说,如果一个属性,我们一旦没有设置默认值同时没有传入的话,就会引起错误。所以,一般来说,为了稳妥起见,设置一个默认值比较好,即使是 None 也可以的。

初始化

如果一个类的某些属性不想参与初始化,比如想直接设置一个初始值,一直固定不变,我们可以将属性的 init 参数设置为 False,看一个实例:

from attr import attrs, attrib

@attrs
class Point(object):
    x = attrib(init=False, default=10)
    y = attrib()

if __name__ == '__main__':
    print(Point(3))

比如 x 我们只想在初始化的时候设置固定值,不想初始化的时候被改变和设定,我们将其设置了 init 参数为 False,同时设置了一个默认值,如果不设置默认值,默认为 NOTHING。然后初始化的时候我们只传入了一个值,其实也就是为 y 这个属性赋值。

这样的话,看下运行结果:

Point(x=10, y=3)

没什么问题,y 被赋值为了我们设置的值 3。

那假如我们非要设置 x 呢?会发生什么,比如改写成这样子:

Point(12)

报错了,错误如下:

TypeError: __init__() takes 2 positional arguments but 3 were given

参数过多,也就是说,已经将 init 设置为 False 的属性就不再被算作可以被初始化的属性了。

强制关键字

强制关键字是 Python 里面的一个特性,在传入的时候必须使用关键字的名字来传入,如果不太理解可以再了解下 Python 的基础。

设置了强制关键字参数的属性必须要放在后面,其后面不能再有非强制关键字参数的属性,否则会报这样的错误:




    
ValueError: Non keyword-only attributes are not allowed after a keyword-only attribute (unless they are init=False)

好,我们来看一个例子,我们将最后一个属性设置 kw_only 参数为 True:

from attr import attrs, attrib, fields

@attrs
class Point(object):
    x = attrib(default=0)
    y = attrib(kw_only=True)

if __name__ == '__main__':
    print(Point(1, y=3))

如果设置了 kw_only 参数为 True,那么在初始化的时候必须传入关键字的名字,这里就必须指定 y 这个名字,运行结果如下:

Point(x=1, y=3)

如果没有指定 y 这个名字,像这样调用:

Point(13)

那么就会报错:

TypeError: __init__() takes from 1 to 2 positional arguments but 3 were given

所以,这个参数就是设置初始化传参必须要用名字来传,否则会出现错误。

注意,如果我们将一个属性设置了 init 为 False,那么 kw_only 这个参数会被忽略。

验证器

有时候在设置一个属性的时候必须要满足某个条件,比如性别必须要是男或者女,否则就不合法。对于这种情况,我们就需要有条件来控制某些属性不能为非法值。

下面我们看一个实例:

from attr import attrs, attrib

def is_valid_gender(instance, attribute, value):
    if value not  in ['male''female']:
        raise ValueError(f'gender {value} is not valid')

@attrs
class Person(object):
    name = attrib()
    gender = attrib(validator=is_valid_gender)

if __name__ == '__main__':
    print(Person(name='Mike', gender='male'))
    print(Person(name='Mike', gender='mlae'))

在这里我们定义了一个验证器 Validator 方法,叫做 is_valid_gender。然后定义了一个类 Person 还有它的两个属性 name 和 gender,其中 gender 定义的时候传入了一个参数 validator,其值就是我们定义的 Validator 方法。

这个 Validator 定义的时候有几个固定的参数:

  • instance:类对象

  • attribute:属性名

  • value:属性值

这是三个参数是固定的,在类初始化的时候,其内部会将这三个参数传递给这个 Validator,因此 Validator 里面就可以接受到这三个值,然后进行判断即可。在 Validator 里面,我们判断如果不是男性或女性,那么就直接抛出错误。

下面做了两个实验,一个就是正常传入 male,另一个写错了,写的是 mlae,观察下运行结果:

Person(name='Mike', gender='male')
TypeError: __init__() missing 1 required positional argument: 'gender'

OK,结果显而易见了,第二个报错了,因为其值不是正常的性别,所以程序直接报错终止。

注意在 Validator 里面返回 True 或 False 是没用的,错误的值还会被照常复制。所以,一定要在 Validator 里面 raise 某个错误。

另外 attrs 库里面还给我们内置了好多 Validator,比如判断类型,这里我们再增加一个属性 age,必须为 int 类型:

age = attrib(validator=validators.instance_of(int))

这时候初始化的时候就必须传入 int 类型,如果为其他类型,则直接抛错:

TypeError: ("'age' must be  (got 'x' that is a ).

另外还有其他的一些 Validator,比如与或运算、可执行判断、可迭代判断等等,可以参考官方文档:https://www.attrs.org/en/stable/api.html#validators。

另外 validator 参数还支持多个 Validator,比如我们要设置既要是数字,又要小于 100,那么可以把几个 Validator 放到一个列表里面并传入:

from attr import attrs, attrib, validators

def is_less_than_100(instance, attribute, value):
    if value > 100:
        raise ValueError(f'age {value} must less than 100')

@attrs
class Person(object):
    name = attrib()
    gender = attrib(validator=is_valid_gender)
    age = attrib(validator=[validators.instance_of(int), is_less_than_100])

if __name__ == '__main__':
    print(Person(name='Mike', gender='male', age=500))

这样就会将所有的 Validator 都执行一遍,必须每个 Validator 都满足才可以。这里 age 传入了 500,那么不符合第二个 Validator,直接抛错:

ValueError: age 500 must less than 100

转换器

其实很多时候我们会不小心传入一些形式不太标准的结果,比如本来是 int 类型的 100,我们传入了字符串类型的 100,那这时候直接抛错应该不好吧,所以我们可以设置一些转换器来增强容错机制,比如将字符串自动转为数字等等,看一个实例:

from attr import attrs, attrib

def to_int(value):
    try:
        return int(value)
    except:
        return None

@attrs
class Point(object):
    x = attrib(converter=to_int)
    y = attrib()

if __name__ == '__main__':
    print(Point('100'3))

看这里,我们定义了一个方法,可以将值转化为数字类型,如果不能转,那么就返回 None,这样保证了任何可以被转数字的值都被转为数字,否则就留空,容错性非常高。

运行结果如下:

Point(x=100, y=3)

类型

为什么把这个放到最后来讲呢,因为 Python 中的类型是非常复杂的,有原生类型,有 typing 类型,有自定义类的类型。

首先我们来看看原生类型是怎样的,这个很容易理解了,就是普通的 int、float、str 等类型,其定义如下:




    
from attr import attrs, attrib

@attrs
class Point(object):
    x = attrib(type=int)
    y = attrib()

if __name__ == '__main__':
    print(Point(1003))
    print(Point('100'3))

这里我们将 x 属性定义为 int 类型了,初始化的时候传入了数值型 100 和字符串型 100,结果如下:

Point(x=100, y=3)
Point(x='100', y=3)

但我们发现,虽然定义了,但是不会被自动转类型的。

另外我们还可以自定义 typing 里面的类型,比如 List,另外 attrs 里面也提供了类型的定义:

from attr import attrs, attrib, Factory
import typing

@attrs
class Point(object):
    x = attrib(type=int)
    y = attrib(type=typing.List[int])
    z = attrib(type=Factory(list))

这里我们引入了 typing 这个包,定义了 y 为 int 数字组成的列表,z 使用了 attrs 里面定义的 Factory 定义了同样为列表类型。

另外我们也可以进行类型的嵌套,比如像这样子:

from attr import attrs, attrib, Factory
import typing

@attrs
class Point(object):
    x = attrib(type=int, default=0)
    y = attrib(type=int, default=0)

@attrs
class Line (object):
    name = attrib()
    points = attrib(type=typing.List[Point])

if __name__ == '__main__':
    points = [Point(i, i) for i in range(5)]
    print(points)
    line = Line(name='line1', points=points)
    print(line)

在这里我们定义了 Point 类代表离散点,随后定义了线,其拥有 points 属性是 Point 组成的列表。在初始化的时候我们声明了五个点,然后用这五个点组成的列表声明了一条线,逻辑没什么问题。

运行结果:

[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)]
Line(name='line1', points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])

可以看到这里我们得到了一个嵌套类型的 Line 对象,其值是 Point 类型组成的列表。

以上便是一些属性的定义,把握好这些属性的定义,我们就可以非常方便地定义一个类了。

序列转换

在很多情况下,我们经常会遇到 JSON 等字符串序列和对象互相转换的需求,尤其是在写 REST API、数据库交互的时候。

attrs 库的存在让我们可以非常方便地定义 Python 类,但是它对于序列字符串的转换功能还是比较薄弱的,cattrs 这个库就是用来弥补这个缺陷的,下面我们再来看看 cattrs 这个库。

cattrs 导入的时候名字也不太一样,叫做 cattr,它里面提供了两个主要的方法,叫做 structure 和 unstructure,两个方法是相反的,对于类的序列化和反序列化支持非常好。

基本转换

首先我们来看看基本的转换方法的用法,看一个基本的转换实例:

from attr import attrs, attrib
from cattr import unstructure, structure

@attrs
class Point(object):
    x = attrib(type=int, default=0)
    y = attrib(type=int, default=0)

if __name__ == '__main__':
    point = Point(x=1, y=2)
    json = unstructure(point)
    print('json:', json)
    obj = structure(json, Point)
    print('obj:', obj)

在这里我们定义了一个 Point 对象,然后调用 unstructure 方法即可直接转换为 JSON 字符串。如果我们再想把它转回来,那就需要调用 structure 方法,这样就成功转回了一个 Point 对象。

看下运行结果:

json: {'x'1'y'2}
obj: Point(x=1, y=2)

当然这种基本的来回转用的多了就轻车熟路了。

多类型转换

另外 structure 也支持一些其他的类型转换,看下实例:

>>> cattr.structure(1, str)
'1'
>>> cattr.structure("1", float)
1.0
>>> cattr.structure([1.02"3"], Tuple[int, int, int])
(123)
>>> cattr.structure((123), MutableSequence[int])
[123]
>>> cattr.structure((1None3), List[Optional[str]])
['1'None'3']
>>> cattr.structure([1234], Set)
{1234}
>>> cattr.structure([[1 2], [34]], Set[FrozenSet[str]])
{frozenset({'4''3'}), frozenset({'1''2'})}
>>> cattr.structure(OrderedDict([(12), (34)]), Dict)
{1234}
>>> cattr.structure([123], Tuple[int, str, float])
(1'2'3.0)

这里面用到了 Tuple、MutableSequence、Optional、Set 等类,都属于 typing 这个模块,后面我会写内容详细介绍这个库的用法。

不过总的来说,大部分情况下,JSON 和对象的互转是用的最多的。

属性处理

上面的例子都是理想情况下使用的,但在实际情况下,很容易遇到 JSON 和对象不对应的情况,比如 JSON 多个字段,或者对象多个字段。

我们先看看下面的例子:

from attr import attrs, attrib
from cattr import structure

@attrs
class Point(object):
    x = attrib(type=int, default=0)
    y = attrib(type=int, default=0)

json = {'x'1'y'2'z'3}
print(structure(json, Point))

在这里,JSON 多了一个字段 z,而 Point 类只有 x、y 两个字段,那么直接执行 structure 会出现什么情况呢?

TypeError: __init__() got an unexpected keyword argument 'z'

不出所料,报错了。意思是多了一个参数,这个参数并没有被定义。

这时候一般的解决方法的直接忽略这个参数,可以重写一下 structure 方法,定义如下:




    
def drop_nonattrs(d, type):
    if not isinstance(d, dict): return d
    attrs_attrs = getattr(type, '__attrs_attrs__'None)
    if attrs_attrs is None:
        raise ValueError(f'type {type} is not an attrs class')
    attrs: Set[str] = {attr.name for attr in attrs_attrs}
    return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
    return cattr.structure(drop_nonattrs(d, type), type)

这里定义了一个 drop_nonattrs 方法,用于从 JSON 里面删除对象里面不存在的属性,然后调用新的 structure 方法即可,写法如下:

from typing import Set
from attr import attrs, attrib
import cattr

@attrs
class Point(object):
    x = attrib(type=int, default=0)
    y = attrib(type=int, default=0)

def drop_nonattrs(d, type):
    if not isinstance(d, dict): return d
    attrs_attrs = getattr(type, '__attrs_attrs__'None)
    if attrs_attrs is  None:
        raise ValueError(f'type {type} is not an attrs class')
    attrs: Set[str] = {attr.name for attr in attrs_attrs}
    return {key: val for key, val in d.items() if key in attrs}

def structure(d, type):
    return cattr.structure(drop_nonattrs(d, type), type)

json = {'x'1'y'2'z'3}
print(structure(json, Point))

这样我们就可以避免 JSON 字段冗余导致的转换问题了。

另外还有一个常见的问题,那就是数据对象转换,比如对于时间来说,在对象里面声明我们一般会声明为 datetime 类型,但在序列化的时候却需要序列化为字符串。

所以,对于一些特殊类型的属性,我们往往需要进行特殊处理,这时候就需要我们针对某种特定的类型定义特定的 hook 处理方法,这里就需要用到 register_unstructure_hook 和 register_structure_hook 方法了。

下面这个例子是时间 datetime 转换的时候进行的处理:

import datetime
from attr import attrs, attrib
import cattr

TIME_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'

@attrs
class Event(object):
    happened_at = attrib(type=datetime.datetime)

cattr.register_unstructure_hook(datetime.datetime, lambda dt: dt.strftime(TIME_FORMAT))
cattr.register_structure_hook(datetime.datetime,
                              lambda string, _: datetime.datetime.strptime(string, TIME_FORMAT))

event = Event(happened_at=datetime.datetime(201961))
print('event:', event)
json = cattr.unstructure(event)
print('json:', json)
event = cattr.structure(json, Event)
print('Event:', event)

在这里我们对 datetime 这个类型注册了两个 hook,当序列化的时候,就调用 strftime 方法转回字符串,当反序列化的时候,就调用 strptime 将其转回 datetime 类型。

看下运行结果:




    
event: Event(happened_at=datetime.datetime(20196100))
json: {'happened_at''2019-06-01T00:00:00.000000Z'}
Event: Event(happened_at=datetime.datetime(20196100))

这样对于一些特殊类型的属性处理也得心应手了。

嵌套处理

最后我们再来看看嵌套类型的处理,比如类里面有个属性是另一个类的类型,如果遇到这种嵌套类的话,怎样类转转换呢?我们用一个实例感受下:

from attr import attrs, attrib
from typing import List
from cattr import structure, unstructure

@attrs
class Point(object):
    x = attrib(type=int, default=0)
    y = attrib(type=int, default=0)

@attrs
class Color(object):
    r = attrib(default=0)
    g = attrib(default=0)
    b = attrib(default=0)

@attrs
class Line(object):
    color = attrib(type=Color)
    points = attrib(type=List[Point])

if __name__ == '__main__':
    line = Line(color=Color(), points=[Point(i, i) for i in range(5)])
    print('Object:', line)
    json = unstructure(line)
    print('JSON:', json)
    line = structure(json, Line)
    print('Object:', line)

这里我们定义了两个 Class,一个是 Point,一个是 Color,然后定义了 Line 对象,其属性类型一个是 Color 类型,一个是 Point 类型组成的列表,下面我们进行序列化和反序列化操作,转成 JSON 然后再由 JSON 转回来,运行结果如下:




    
Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4, y=4)])
JSON: {'color': {'r'0'g'0'b'0}, 'points': [{'x'0'y'0}, {'x'1'y'1}, {'x'2'y'2}, {'x'3'y'3}, {'x'4'y'4}]}
Object: Line(color=Color(r=0, g=0, b=0), points=[Point(x=0, y=0), Point(x=1, y=1), Point(x=2, y=2), Point(x=3, y=3), Point(x=4 , y=4)])

可以看到,我们非常方便地将对象转化为了 JSON 对象,然后也非常方便地转回了对象。

这样我们就成功实现了嵌套对象的序列化和反序列化,所有问题成功解决!

结语

本节介绍了利用 attrs 和 cattrs 两个库实现 Python 面向对象编程的实践,有了它们两个的加持,Python 面向对象编程不再是难事。

获取本节代码,公众号”进击的Coder“回复”面向对象“即可。


谢谢!


推荐阅读

1

跟繁琐的命令行说拜拜!Gerapy分布式爬虫管理框架来袭!

2

跟繁琐的模型说拜拜!深度学习脚手架 ModelZoo 来袭!

3

只会用Selenium爬网页?Appium爬App了解一下

4

妈妈再也不用担心爬虫被封号了!手把手教你搭建Cookies池

崔庆才

静觅博客博主,《Python3网络爬虫开发实战》作者

隐形字

个人公众号:进击的Coder

长按识别二维码关注


Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/34170
 
299 次点击