当前位置: 代码迷 >> 综合 >> Fluent Python - Part19 动态属性和特性
  详细解决方案

Fluent Python - Part19 动态属性和特性

热度:62   发布时间:2023-10-21 05:14:39.0

特性至关重要的地方在于,特性的存在使得开发者可以非常安全并且确定可行地将公共数据属性作为类的公共接口的一部分开放出来。

--- Alex Martelli(Python 贡献者和图书作者)

在 Python 中,数据的属性和处理数据的方法统称属性(attribute)。其实,方法只是可调用的属性。除了这二者之外,我们还可以创建特性(property),在不改变类接口的前提下,使用存取方法(即读值方法和设值方法)修改数据属性。

除了特性,Python 还提供了丰富的 API,用于控制属性的访问权限,以及实现动态属性。使用点号访问属性时(如 obj.attr),Python 解释器会调用特殊的方法(如 __getattr____setattr__) 计算属性。用户自己定义的类可以通过 __getattr__ 方法实现“虚拟属性”,当访问不存在的属性时(如 obj.no_such_attribute),即时计算属性的值。

在接下来的几个示例中,我们要使用动态属性处理一个 JSON 格式数据源。下例是哪个数据源中的4个记录。

{
    "Schedule": {
    "conferences": [{
    "serial": 115}],"events": [{
    "serial": 34505,"name": "Why Schools Don ?t Use Open Source to Teach Programming","event_type": "40-minute conference session","time_start": "2014-07-23 11:30:00","time_stop": "2014-07-23 12:10:00","venue_serial": 1462,"description": "Aside from the fact that high school programming...","website_url": "http://oscon.com/oscon2014/public/schedule/detail/34505","speakers": [157509],"categories": ["Education"]}],"speakers": [{
    "serial": 157509,"name": "Robert Lefkowitz","photo": null,"url": "http://sharewave.com/","position": "CTO","affiliation": "Sharewave","twitter": "sharewaveteam","bio": "Robert ?r0ml ? Lefkowitz is the CTO at Sharewave, a startup..."}],"venues": [{
    "serial": 1462,"name": "F151","category": "Conference Venues"}]}
}

可以看出,整个数据集是一个JSON对象,里面有一个键,名为“Schedule”;这个键对应的值也是一个映像,有4个键:“conferences”, “events”, “speakers” 和 “venues”。这4个键对应的值都是一个记录列表。在上例中,各个列表中只有一条记录。然而,在完整的数据集中,列表中有成百上千条记录。不过,“conferences” 键对应的列表只有一条记录,如上述示例所示。这4个列表中每个元素都有一个名为“serial”的字段,这是元素在各个列表中的唯一标识符。

第一个脚本只用于下载这个数据源。

from urllib.request import urlopen
import warnings
import os
import jsonURL = 'http://www.oreilly.com/pub/sc/osconfeed'
JSON = './data/osconfeed.json'def load():if not os.path.exists(JSON):msg = 'downloading {} to {}'.format(URL, JSON)warnings.warn(msg)with urlopen(URL) as remote, open(JSON, 'wb') as local:local.write(remote.read())with open(JSON) as fp:return json.load(fp)feed = load()
print(sorted(feed['Schedule'].keys()))for key, value in sorted(feed['Schedule'].items()):print('{:3} {}'.format(len(value), key))print(feed['Schedule']['speakers'][-1]['name'])
print(feed['Schedule']['speakers'][-1]['serial'])
print(feed['Schedule']['events'][40]['name'])
print(feed['Schedule']['events'][40]['speakers'])""" output: ['conferences', 'events', 'speakers', 'venues']1 conferences 494 events 357 speakers53 venues Carina C. Zona 141590 There *Will* Be Bugs [3471, 5199] """

使用动态属性访问 JSON 类数据

feed['Schedule']['events'][40]['name'] 这种句法很冗长,我们可以实现一个近似字典的类,来达到 feed.Schedule.events[40].name 这种效果。下例实现了 FrozenJSON 类,只支持读取,即只能访问数据。不过,这个类能递归,自动处理嵌套的映射和列表。

from urllib.request import urlopen
import warnings
import os
import jsonURL = 'http://www.oreilly.com/pub/sc/osconfeed'
JSON = './data/osconfeed.json'def load():if not os.path.exists(JSON):msg = 'downloading {} to {}'.format(URL, JSON)warnings.warn(msg)with urlopen(URL) as remote, open(JSON, 'wb') as local:local.write(remote.read())with open(JSON) as fp:return json.load(fp)from collections import abcclass FrozenJSON:"""一个只读接口,使用属性表示法访问JSON类对象"""def __init__(self, mapping):self.__data = dict(mapping)def __getattr__(self, name):if hasattr(self.__data, name):return getattr(self.__data, name)else:return FrozenJSON.build(self.__data[name])@classmethoddef build(cls, obj):if isinstance(obj, abc.Mapping):return cls(obj)elif isinstance(obj, abc.MutableSequence):return [cls.build(item) for item in obj]else:return objraw_feed = load()
feed = FrozenJSON(raw_feed)
print(len(feed.Schedule.speakers))
print(sorted(feed.Schedule.keys()))
""" output: 357 ['conferences', 'events', 'speakers', 'venues'] """

处理无效属性名

FrozenJSON 类有个缺陷:没有对名称为 Python 关键字的属性做特殊处理,比如像下面这样构建一个对象:

grad = FrozenJSON({
    'name': 'Jim Bo', 'class': 1982})
print (grad.class)

此时无法读取 grad.class 的值,因为在 Python 中 class 是保留字。

但是,FrozenJSON 类的目的是为了便于访问数据,因此更好的方法是检查传给 FrozenJSON.__init__ 方法的映射中是否有键的名称为关键字,如果有,那么在键名后加上 _,然后透过下述方式读取:

print(grad.class_)
""" output: 1982 """

为此,我们可以修改 __init__ 方法:

def __init__(self, mapping):self.__data = {
    }for key, value in mapping.items():if keyword.iskeyword(key):key += '_'self.__data[key] = value

对动态属性的名称做了一些处理之后,我们要分析 FrozenJSON 类的另一个重要功能 — 类方法 build 的逻辑。这个方法的嵌套结构转换成 FrozenJSON 实例或 FrozenJSON 实例列表,因此 __getattr__ 方法使用这个方法访问属性时,能为不同的值返回不同类型的对象。

除了在类方法中实现这样的逻辑之外,还可以在特殊的 __new__ 方法中实现。

使用 __new__ 方法以灵活的方式创建对象

我们通常把 __init__ 称为构造方法。其实,用于构建实例的是特殊方法 __new__: 这是个类方法(使用特殊方式处理,因此不必使用 @classmethod装饰器),必须返回一个实例。返回的实例会作为第一个参数(即 self) 传给 __init__ 方法。因为调用 __init__ 方法时要传入实例,而且禁止返回任何值,所以 __init__ 方法其实是“初始化方法”。真正的构造方法是 __new__。我们几乎不需要自己编写 __new__ 方法,因为从 object 类继承的实现已经足够了。

刚才说明的过程,即从 __new__ 方法到 __init__ 方法,是最常见的,但不是唯一的。__new__ 方法也可以返回其他类的实例,此时,解释器不会调用 __init__ 方法。

也就是说,Python构建对象的过程可以使用下述伪代码概括:

# 构建对象的伪代码
def object_maker(the_class, some_arg):new_object = the_class.__new__(some_arg)if isinstance(new_object, the_class):the_class.__init__(new_object, some_arg)return new_object

下例是 FrozenJSON 类的另一个版本,把之前在类方法 build 中的逻辑移到了 __new__方法中。

class FrozenJSON:"""一个只读接口,使用属性表示法访问JSON类对象"""def __init__(self, mapping):self.__data = {
    }for key, value in mapping.items():if keyword.iskeyword(key):key += '_'self.__data[key] = valuedef __getattr__(self, name):if hasattr(self.__data, name):return getattr(self.__data, name)else:return FrozenJSON.build(self.__data[name])def __new__(cls, arg):if isinstance(arg, abc.Mapping):return super().__new__(cls)elif isinstance(arg, abc.MutableSequence):return [cls(item) for item in arg]else:return obj

这个 JSON 数据源有一个明显的缺点:索引为 40 的事件,即名为’There Will Be Bugs‘ 的那个,有两位演讲者,3471和5199,但却不容易找到他们,因为提供的是编号,而 Schedule.speakers 列表没有使用编号建立索引。此外,每条事件记录中都有 venue_serial 字段,存储的值也是编号,但是如果想找到对应的记录,那就要线性搜索 Schedule.venues 列表。接下来的任务hi,调整数据结构,以便自动获取所链接的记录。

使用 shelve 模块调整数据源的结构

shelve.open 高阶函数返回一个 shelve.Shelf 实例,这是简单的键值对象数据库,背后由 dbm 模块支持,具有下述特点。

  • shelve.Shelfabc.MutableMapping 的子类,因此提供了处理映射类型的重要方法。
  • 此外,shelve.Shelf 类还提供了几个管理 I/O 的方法,如 syncclose;他也是一个上下文管理器。
  • 只要把新值赋予键,就会保存键和值。
  • 键必须是字符串
  • 值必须是 pickle 模块能处理的对象。

shelve 模块为识别 OSCON 的日程数据提供了一种简单有效的方式。我们将从 JSON 文件中读取所有记录,将其存在一个 shelve.Shelf 对象中,键由记录类型和编号组成(例如,event.33950speaker.3471), 而值是我们即将定义的 Record 类的实例。

import warnings
DB_NAME = './data/schedule1_db'
CONFERENCE = 'conference.115'class Record:def __init__(self, **kwargs):self.__dict__.update(kwargs)def load_db(db):raw_data = load()warnings.warn('loading ' + DB_NAME)for collection, rec_list in raw_data['Schedule'].items():record_type = collection[:-1]for record in rec_list:key = '{}.{}'.format(record_type, record['serial'])record['serial'] = keydb[key] = Record(**record)import shelvedb = shelve.open(DB_NAME)
if CONFERENCE not in db:load_db(db)speaker = db['speaker.3471']
print(type(speaker))
print(speaker.name, speaker.twitter)
db.close()

为什么之前不用 Record 类而是用更复杂的 FrozenJSON类。原因有两个。第一,FrozenJSON 类要递归转换嵌套的映射和列表;而 Record 类不需要这么做,因为转换好的数据集中没有嵌套的映射和列表,记录中只有字符串,整数,字符串列表和整数列表。第二,FrozenJSON 类要访问内嵌的 __data 属性(值是字典,用于调用 keys等方法),而现在我们也不需要这么做了。

像上面那样调整日程数据集之后,我们可以扩展 Record 类,让它提供一个有用的服务:自动获取 event 记录引用的 venuespeaker 记录。这与 Django ORM 访问 models.ForeignKey 字段时所做的事类似:得到的不是键,而是链接的模型对象。

使用特性获取链接的记录

首先将指出本节几个重要的类:

  • Record

    __init__ 方法与上面的脚本一样;为了辅助测试,增加了 __eq__ 方法。

  • DbRecord

    Record 类的子类,添加了 __db类属性,用于设置和获取__db属性的 set_dbget_db 静态方法,用于从数据库中获取记录的 fetch类方法,以及辅助调试和测试的 __repr__ 实例方法。

  • Event

    DbRecord 类的子类,添加了用于获取所链接记录的 venuespeakers 属性,以及特殊的 __repr__ 方法。

Fluent Python - Part19 动态属性和特性

DbRecord.__db 类属性的作用是存储打开的 shelve.Shelf 数据库引用,以便在需要使用数据库的 DbRecord.fetch 方法及 Event.venueEvent.speakers 属性中使用。我把 __db 设为私有类属性,然后定义了普通的读值方法和设值方法,以防不小心覆盖 __db 属性的值,基于一个重要的原因,我没有使用特性去管理 __db 属性:特性是用于管理实例属性的类属性。

import warnings
DB_NAME = './data/schedule2_db'
CONFERENCE = 'conference.115'class Record:def __init__(self, **kwargs):self.__dict__.update(kwargs)def __eq__(self, other):if isinstance(other, Record):return self.__dict__ == other.__dict__else:return NotImplemented
class MissingDatabaseError(RuntimeError):"""需要数据库但没指定数据库时抛出 """class DbRecord(Record):__db = None@staticmethoddef set_db(db):DbRecord.__db = db@staticmethoddef get_db():return DbRecord.__db@classmethoddef fetch(cls, ident):db = cls.get_db()try:return db[ident]except TypeError:if db is None:msg = "database not set; call '{}.set_db(my_db)'"raise MissingDatabaseError(msg.format(cls.__name__))else:raisedef __repr__(self):if hasattr(self, 'serial'):cls_name = self.__class__.__name__return '<{} serial={!r}>'.format(cls_name, self.serial)else:return super().__repr__()
class MissingDatabaseError(RuntimeError):"""需要数据库但没指定数据库时抛出 """class DbRecord(Record):__db = None@staticmethoddef set_db(db):DbRecord.__db = db@staticmethoddef get_db():return DbRecord.__db@classmethoddef fetch(cls, ident):db = cls.get_db()try:return db[ident]except TypeError:if db is None:msg = "database not set; call '{}.set_db(my_db)'"raise MissingDatabaseError(msg.format(cls.__name__))else:raisedef __repr__(self):if hasattr(self, 'serial'):cls_name = self.__class__.__name__return '<{} serial={!r}>'.format(cls_name, self.serial)else:return super().__repr__()
def load_db(db):raw_data = load()warnings.warn('loading ' + DB_NAME)for collection, rec_list in raw_data['Schedule'].items():record_type = collection[:-1]cls_name = record_type.capitailze()cls = globals().get(cls_name, DbRecord)if inspect.isclass(cls) and issubclass(cls, DbRecord):factory = clselse:factory = DbRecordfor record in rec_list:key = '{}.{}'.format(record_type, record['serial'])record['serial'] = keydb[key] = factory(**record)
import shelve
db = shelve.open(DB_NAME)
if CONFERENCE not in db:load_db(db)DbRecord.set_db(db)
event = DbRecord.fetch('event.33950')
print(event)
print(event.venue)
print(event.venue.name)
for spkr in event.speakers:print('{0.serial}: {0.name}'.format(spkr))""" output: <Event 'There *Will* Be Bugs'> <DbRecord serial='venue.1449'> Portland 251 speaker.3471: Anna Ravenscroft speaker.5199: Alex Martelli """

特性经常用于把公开的属性变成使用读值方法和设值方法管理的属性,且不在影响客户端代码的前提下实施业务规则。

使用特性验证属性

目前,我们只介绍了如何使用 @property 装饰器实现只读特性。本节要创建一个可读写的特性。

LineItem 类第1版:表示订单中商品的类

class LineItem:def __init__(self, description, weight, price):self.description = descriptionself.weight = weightself.price = pricedef subtotal(self):return self.weight * self.price

这个类很精简,不过或许太简单了,用户能把 weight 设置成负数,以致计算后的subtotal 也是负数。

LineItem 类第2版:能验证值的特性

实现特性之后,我们可以使用读值方法和设值方法,但是 LineItem 类的接口保持不变(即,设值 LineItem 对象的 weight 属性依然写成 raisins.weight = 12)

class LineItem:def __init__(self, description, weight, price):self.description = descriptionself.weight = weightself.price = pricedef subtotal(self):return self.weight * self.price@propertydef weight(self):return self.__weight@weight.setterdef weight(self, value):if value > 0:self.__weight = valueelse:raise ValueError('value must be > 0')

现在,我们禁止用户为 weight 属性提供负值或零。但工作人员可能犯错,导致 LineItem 对象的 price 属性为负值。为了防止出现这种情况,我们也可以把 price 属性变成特性,但这样我们的代码中就存在一些重复。

这里,我们要实现一个特性工厂函数。但是,在之前,我们要深入理解特性。

特性全解析

虽然内置的 property 经常用作装饰器,但是它其实是一个类,在 Python 中,函数和类通常可以互换,因为二者都是可调用的对象,而且没有实例化对象的 new 运算符,所以调用构造方法与调用工厂函数没有区别。所以调用构造方法与调用工厂函数没有区别,此外,只要能返回新的可调用对象,代替被装饰的函数,二者都可以用作装饰器。

property 构造方法的完整签名如下:

property(fget=None, fset=None, fdel=None, doc=None)

不适用装饰器定义特性的“经典”句法如下:

class LineItem:def __init__(self, description, weight, price):self.description = descriptionself.weight = weightself.price = pricedef subtotal(self):return self.weight * self.pricedef get_weight(self):return self.__weightdef set_weight(self, value):if value > 0:self.__weight = valueelse:raise ValueError('value must be > 0')weight = property(get_weight, set_weight)

某些情况下,这种经典形式比装饰器句法好;稍后讨论的特性工厂函数就是一例。但是,在方法众多的类定义体重使用装饰器的话,一眼就能看出哪些是读值方法,哪些是设值方法,而不用按照惯例,在方法名的前面加上 getset

特性会覆盖实例属性

特性都是类属性, 但是特性惯例的其实是实例属性的存取。
如果实例和所属的类有同名数据属性,那么实例属性会覆盖(或称遮盖)类属性。

>>> class Class:
...     data = 'the class data attr'
...     @property
...     def prop(self):
...             return 'the prop value'
...
>>> obj = Class()
>>> vars(obj)
{
    }
>>> obj.data
'the class data attr'
>>> obj.data = 'bar'
>>> vars(obj)
{
    'data': 'bar'}
>>> obj.data
'bar'
>>> Class.data
'the class data attr'

下面尝试覆盖 obj 实例的 prop 特性。

>>> Class.prop
<property object at 0x10626d860>
>>> obj.prop
'the prop value'
>>> obj.prop = 'foo'
Traceback (most recent call last):File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> obj.__dict__['prop'] = 'foo'
>>> vars(obj)
{
    'data': 'bar', 'prop': 'foo'}
>>> obj.prop
'the prop value'
>>> Class.prop = 'baz'
>>> obj.prop
'foo'

最好再举一个例子,为 Class 类新添一个特性,覆盖实例属性。

>>> obj.data
'bar'
>>> Class.data
'the class data attr'
>>> Class.data = property(lambda self: 'the "data" pro value')
>>> obj.data
'the "data" pro value'
>>> del Class.data
>>> obj.data
'bar'

定义一个特性工厂函数

我们将定义一个名为 quantity 的特性工厂函数。

class LineItem:weight = quantity('weight')price = quantity('price')def __init__(self, description, weight, price):self.description = descriptionself.weight = weightself.price = pricedef subtotal(self):return self.weight * self.price

前文说过,特性是类属性。构建各个 quantity 特性对象时,要传入 LineItem 实例属性的名称,让特性管理。可惜,这一行要两次输入单词 weight:

weight = quantity('weight')

这里很难避免重复输入,因为特性根本不知道要绑定哪个类属性名。赋值语句的右边先计算,因此调用 quantity() 时,weight 类属性还不存在。

def quantity(storage_name):def qty_getter(instance):return instance.__dict__(storage_name)def qty_setter(instance, value):if value > 0:instance.__dict__[storage_name] = valueelse:raise ValueError('value must be > 0')return property(qty_getter, qty_setter)

处理属性删除操作

对象的属性可以使用 del 语句删除:

del my_object.an_attribute

定义特性时,可以使用 @my_propety.deleter 装饰器包装一个方法,负责删除特性管理的属性。


class BlackKnight:def __init__(self):self.members = ['an arm', 'another arm','a leg', 'another leg']self.phrases = ['Tis but a scratch.',"It's just a flesh wound.","I'm invincible!","All right, we'll call it a draw."]@propertydef member(self):print('next member is:')return self.members[0]@member.deleterdef member(self):text = 'BLACK KNIGHT (lose {})\n-- {}'print(text.format(self.members.pop(0), self.phrases.pop(0)))knight = BlackKnight()
print(knight.member)
del knight.member
del knight.member
del knight.member
del knight.member
""" output next member is: an arm BLACK KNIGHT (lose an arm) -- Tis but a scratch. BLACK KNIGHT (lose another arm) -- It's just a flesh wound. BLACK KNIGHT (lose a leg) -- I'm invincible! BLACK KNIGHT (lose another leg) -- All right, we'll call it a draw. """

特性是个强大的功能,不过有时更适合使用简单或底层的替代方案。在本章的最后一节,我们将回顾 Python 为动态属性编程提供的部分核心 API。

处理属性的重要属性和函数

影响属性处理方式的特殊属性

  • __class__: 对象所属类的引用(即 obj.__class__type(obj) 的作用相同)。Python 的某些特殊方法,例如 __getattr__,只在对象的类中寻找,而不在实例中寻找。

  • __dict__: 一个映射,存储对象或类的可写属性。有 __dict__ 属性的对象,任何时候都能随意设置新属性。如果类有 __slots__ 属性,它的实例可能没有 __dict__ 属性。

  • __slots__: 类可以定义这个属性,限制实例能有哪些属性。__slots__ 属性的值是一个字符串组成的元组,指明允许有的属性。如果 __slots__ 中没有 __dict__, 那么该类的实例没有 __dict__ 属性,实例只允许有指定名称的属性。

处理属性的内置函数

  • dir([object]): 列出对象的大多数属性。官方文档说,dir 函数的目的是交互式使用,因此没有提供完整的属性列表,只列出一组“重要的”属性名。dir 函数能审查有或没有 __dict__ 属性的对象。dir 函数不会列出 __dict__ 属性本身,但会列出其中的键。dir 函数也不会列出类的几个特殊属性。例如 __mro__, __bases____name__。如果没有指定可选的 object 参数,dir 函数会列出当前作用域中的名称。

  • getattr(object, name[, default]): 从 object 对象中获取 name 字符串对应的属性。获取的属性可能来自对象所属的类或超类。如果没有指定的属性,getattr 函数抛出 AttributeError 异常,或者返回 default 参数的值(如果设定了这个参数的话)。

  • hasattr(object, name):如果 object 对象中存在指定的属性,或者能以某种方式(例如继承)通过 object 对象获取指定的属性,返回 True。文档 (https://docs.python.org/3/library/functions.html#hasattr)说道:“这个函数的实现方法是调用 getattr(object, name) 函数,看看是否抛出 AttributeError 异常。”

  • setattr(object, name, value): 把 object 对象指定属性的值设为 value,前提是 object 对象能接受那个值。这个函数可能会创建一个新属性,或者覆盖现有的属性。

  • vars([object]): 返回 object 对象的 __dict__ 属性;如果实例所属的类定义了 __slots__ 属性,实例没有 __dict__ 属性,那么 vars 函数不能处理那个实例(相反,dir 函数能处理这样的实例)。如果没有指定参数, 那么 vars() 函数的作用与 locals() 函数一样:返回表示本地作用域的字典。

处理属性的特殊方法

在用户自己定义的类中,下述特殊方法用于获取、设置、删除和列出属性。

使用点号或内置的 getattrhasattrsetattr 函数存取属性都会 触发下述列表中相应的特殊方法。但是,直接通过实例的 __dict__ 属性读写属性不会触发这些特殊方法——如果需要,通常会使用这种方式 跳过特殊方法。

对用户自己定义的类来说,如果隐式调用特殊方法,仅当特殊方法在对象所属的类型上定义,而不是在对象的实例字典中定义时,才 能确保调用成功。

也就是说,要假定特殊方法从类上获取,即便操作目标是实例也是如此。因此,特殊方法不会被同名实例属性遮盖。

  • __delattr__(self, name): 只要使用 del 语句删除属性,就会调用这个方法。例如,del obj.attr 语句触发 Class.__delattr__(obj, 'attr') 方法。

  • __dir__(self): 把对象传给 dir 函数时调用,列出属性。例如,dir(obj) 触发 Class.__dir__(obj) 方法。

  • __getattr__(self, name): 仅当获取指定的属性失败,搜索过 obj、Class 和超类之后调用。 表达式 obj.no_such_attrgetattr(obj, 'no_such_attr')hasattr(obj, 'no_such_attr') 可能会触发 Class.__getattr__(obj, 'no_such_attr') 方法,但是,仅当在 obj、Class 和超类中找不到指定的属性时才会触发。

  • __getattribute__(self, name): 尝试获取指定的属性时总会调用这个方法,不过,寻找的属性是特殊属性或特殊方法时除外。点号与 getattrhasattr 内置函数会触 发这个方法。调用 __getattribute__ 方法且抛出 AttributeError 异常时,才会调用 __getattr__ 方法。为了在获取 obj 实例的属性时 不导致无限递归,__getattribute__ 方法的实现要使用 super().__getattribute__(obj, name)

  • __setattr__(self, name, value): 尝试设置指定的属性时总会调用这个方法。点号和 setattr 内置函数会触发这个方法。例如,obj.attr = 42setattr(obj, 'attr', 42) 都会触发 Class.__setattr__(obj, ‘attr’, 42) 方法。