Python学习之路38-动态创建属性

《流畅的Python》笔记。

本篇主要讨论元编程中的动态创建属性。

1. 前言

平时我们一般把类中存储数据的变量称为属性,把类中的函数称为方法。但这两个概念其实是统一的,它们都称为属性(Attrubute),方法只是可调用的属性,并且属性还是可以动态创建的。如果我们事先不知道数据的结构,或者在运行时需要再添加一些属性,此时就需要动态创建属性。

本文将讲述如果通过动态创建属性来读取JSON中的数据。第一个例子我们将实现一个FrozenJSON类,使用__getattr__方法,根据JSON文件中的数据项动态创建FrozenJSON实例的属性。第二个例子,更进一步,实现数据的关联查找,其中,会用到实例的__dict__属性来动态创建属性。

不过在这两部分内容之前,先来看一个简单粗暴地使用JSON数据的例子。

2. JSON数据

首先是一个现实世界中的JSON数据:OSCON大会的JSON数据。为了节省篇幅,只保留了它的数据格式中的一部分,数据内容也有所改变,原始数据会在用到的时候下载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{ "Schedule": {
"conferences": [{"serial": 115}],
"events": [{
"serial": 33451,
"name": "This is a test",
"venue_serial": 1449,
"speakers": [149868]
}],
"speakers": [{
"serial": 149868,
"name": "Speaker1",
}],
"venues": [{
"serial": 1448,
"name": "F151",
}]
}}

整个数据集是一个JSON对象,也是一个映射(map),(最外层)只有一个键"Schedule",它表示整个大会;"Schedule"的值也是一个map,这个map有4个键,分别是:

  • "conferences",它只记录这场大会的编号;
  • "events",它表示大会中的每场演讲;
  • "speakers",它记录每个演讲者;
  • "venues",它表示演讲的地点,比如哪个会议室,哪个场所等。

这4个键的值都是列表,而列表的元素又都是map,其中某些键的值又是列表。是不是很绕 :) ?

还需要注意一点:每条数据都有一个"serial",相当于一个标识,后面会用到

2.1 读取JSON数据

读取JSON文件很简单,用Python自带的json模块就可以读取。以下是用于读取jsonload()函数,如果数据不存在,它会自动从远端下载数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 代码2.1 osconfeed.py 注意这个模块名,后面还会用到
import json
import os
import warnings
from urllib.request import urlopen

URL = "http://www.oreilly.com/pub/sc/osconfeed"
JSON = "data/osconfeed.json"

def load():
if not os.path.exists(JSON): # 如果本地没有数据,则从远端下载
with urlopen(URL) as remote, open(JSON, "wb") as local: # 这里打开了两个上下文管理器
local.write(remote.read())
with open(JSON) as fp:
return json.load(fp)

2.2 使用JSON数据

现在我们来读取并使用上述JSON数据:

1
2
3
4
5
# 代码2.2
>>> from osconfeed import load
>>> feed = load()
>>> feed['Schedule']['events'][40]['speakers']
[3471, 5199]

从这个例子可以看出,要访问一个数据,得输入多少中括号和引号,为了跳出这些中括号和引号,又得浪费多少操作?如果再嵌套几个map……

在JavaScript中,可以通过feed.Schedule.events[40].speakers来访问数据,Python中也可以很容易实现这样的访问。这种方式,"Schedule""events""speakers"等数据项则表现的并不像map的键,而更像类的属性,因此,这种访问方式也叫做属性表示法。这在Java中有点像链式调用,但链式调用调用的是函数,而这里是数据属性。但为了方面,后面都同一叫做链式访问

下面正式进入本篇的第一个主题:动态创建属性以读取JSON数据。

3. FrozenJSON

我们通过创建一个FrozenJSON类来实现动态创建属性,其中创建属性的工作交给了__getattr__特殊方法。这个类可以实现链式访问。

3.1 初版FrozenJSON类

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
# 代码3.1 explore0.py
from collections import abc

class FrozenJSON:
def __init__(self, mapping):
self.__data = {} # 为了安全,创建副本
for key, value in mapping.items(): # 确保传入的数据能转换成字典;
if keyword.iskeyword(key): # 如果某些属性是Python的关键字,不适合做属性,
key += "_" # 则在前面加一个下划线
self.__data[key] = value

def __getattr__(self, name): # 当没有指定名称的属性时,才调用此法;name是str类型
if hasattr(self.__data, name): # 如果self.__data有这个属性,则返回这个属性
return getattr(self.__data, name)
else: # 如果self.__data没有指定的属性,创建FronzenJSON对象
return FrozenJSON.build(self.__data[name]) # 递归转换嵌套的映射和列表

@classmethod
def build(cls, obj):
# 必须要定义这个方法,因为JSON数据中有列表!如果数据中只有映射,或者在__init__中进行了
# 类型判断,则可以不定义这个方法。
if isinstance(obj, abc.Mapping): # 如果obj是映射,则直接构造
return cls(obj)
elif isinstance(obj, abc.MutableSequence):
# 如果obj是MutableSequence,则在本例中,obj则必定是列表,而列表的元素又必定是映射
return [cls.build(item) for item in obj]
else: # 如果两者都不是,则原样返回
return obj

这个类非常的简单。由于没有定义任何数据属性,所以,在访问数据时,每次都会调用__getattr__特殊方法,并在这个方法中递归创建新实例,即,通过__getattr__特殊方法实现动态创建属性,通过递归构造新实例实现链式访问

3.2 使用FrozenJSON

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 代码3.2
>>> from osconfeed import load
>>> from explore0 import FrozenJSON
>>> raw_feed = load() # 读取原始JSON数据
>>> feed = FrozenJSON(raw_feed) # 使用原始数据生成FrozenJSON实例
>>> len(feed.Schedule.speakers) # 对应于FronzenJSON.__getattr__中if为False的情况
357
>>> sorted(feed.Schedule.keys()) # 对应于FrozenJSON.__getattr__中if为True的情况
['conferences', 'events', 'speakers', 'venues']
>>> feed.Schedule.speakers[-1].name
'Carina C. Zona'
>>> talk = feed.Schedule.events[40]
>>> type(talk)
<class 'explore0.FrozenJSON'>
>>> talk.name
'There *Will* Be Bugs'
>>> talk.speakers
[3471, 5199]
>>> talk.flavor # !!!
Traceback (most recent call last):
KeyError: 'flavor'

上述代码中,通过不断从FrozenJSON对象中创建FrozenJSON对象,实现了属性表示法。为了更好的理解上述代码,我们需要分析其中实例的创建过程:

feed是一个FrozenJSON实例,当访问Schedule属性时,由于feed没有这个属性,于是调用__getattr__方法。由于Schedule也不是feed.__data的属性,所以需要再创建一个FrozenJSON对象。Schedule在JSON数据中是最外层映射的键,它的值feed.__data["Schedule"]又是一个映射,所以在build方法中,继续将feed.__data["Schedule"]包装成一个FrozenJSON对象。如果继续链接下去,还会创建FrozenJSON对象。这里之所以指出这一点,是想提醒大家注意每个FrozenJSON实例中的__data具体指的是JSON数据中的哪一部分数据(我在模拟这个递归过程的时候,多次都把__data搞混)。

上述代码中还有一处调用:feed.Schedule.keys()feed.Schedule是一个FrozenJSON对象,它并没有keys方法,于是调用__getattr__,但由于feed.Schedule.__data是个dict,它有keys方法,所以这里并没有继续创建新的FrozenJSON对象。

注意最后一处调用:talk.flavor。JSON中events里并没有flavor数据项,因此这里抛出了异常。但这个异常是KeyError,而更合理的做法应该是:只要没有这个属性,都应该抛出AttributeError。如果要抛出AttributeError__getattr__的代码长度将增加一倍,但这并不是本文的重点,所以没有处理。

3.3 特殊方法__new__

在初版FrozenJSON中,我们定义了一个类方法build来创建新实例,但更方便也更符合Python风格的做法是定义__new__方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 代码3.3 frozenjson.py  新增__new__,去掉build,修改__getattr__
class FrozenJSON:
def __getattr__(self, name):
-- snip --
else: # 直接创建FrozenJSON对象
return FrozenJSON(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 arg

不知道大家第一次看到“构造方法__init__”这个说法时有没有疑惑:这明明是初始化Initialize这个单词的缩写,将其称为“构造(create, build)”似乎不太准确呀?其实这个称呼是从其他语言借鉴过来的,它更应该叫做“初始化方法”,因为它确实执行的是初始化的工作,真正执行“构造”的是__new__方法。

一般情况下,当创建类的实例时,首先调用的是__new__方法,它必须创建并返回一个实例,然后将这个实例作为第一个参数(即self)传入__init__方法,再由__init__执行初始化操作。但也有不常见的情况:__new__也可以返回其他类的实例,此时,解释器不会继续调用__init__方法。

__new__方法是一个类方法,由于使用了特殊方法方式处理,所以它不用加@classmethod装饰器。

我们几乎不需要自行编写__new__方法,因为从object类继承的这个方法已经足够了。

使用FrozenJSON读取JSON数据的例子到此结束。

4. Record

上述FrozenJSON有个明显的缺点:查找有关联的数据很麻烦,必须从头遍历Schedule的相关数据项。比如feed.Schedule.events[40].speakers是一个含有两个元素的列表,它是这场演讲的演讲者们的编号。如果想访问演讲者的具体信息,比如姓名,我们不能直接调用feed.Schedule.events[40].speakers[0].name,这样会报AttributeError,只能根据feed.Schedule.events[40].serialfeed.Schedule.speakers中挨个查找。

为了实现这种关联访问,需要在读取数据时调整数据的结构:不再像之前FrozenJSON中那样,将整个JSON原始数据存到内部的__data中,而是将每条数据单独存到一个Record对象中(这里的“每条数据”指每个event,每个speaker,每个venue以及conferences中的唯一一条数据)。并且,还需要在每条数据的serial字段的值前面加上数据类型,比如某个eventserial123,则将其变为event.123

4.1 要实现的功能

不过在给出实现方法之前,先来看看它应该具有的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 代码4.1
>>> from schedule import Record, Event, load_db
>>> db = {}
>>> load_db(db)
>>> Record.set_db(db)
>>> event = Record.fetch("event.33950")
>>> event
<schedule.Event object at 0x000001DBC71E9CF8>
>>> event.venue
<schedule.Record object at 0x000001DBC7714198>
>>> event.venue.name
'Portland 251'
>>> for spkr in event.speakers:
... print("{0.serial}: {0.name}".format(spkr))
...
speaker.3471: Anna Martelli Ravenscroft
speaker.5199: Alex Martelli

这其中包含了两个类,Record和继承自RecordEvent,并将这些数据放到名为db的映射中。Event专门用于存JSON数据中events里的数据,其余数据全部存为Record对象。之所以这么安排,是因为原始数据中,event包含了speakervenueserial(相当于外键约束)。现在,我们可以通过event查找到与之关联的speakervenue,而并不仅仅只是查找到这两个的serial。如果想根据speakervenue查找event,大家可以根据后面的方法自行实现(但这么做得遍历整个db)。

4.2 Record & Event

下面是这两个类以及调整数据结构的load_db函数的实现:

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
# 代码4.2 schedule.py
import inspect
import osconfeed

class Record:
__db = None
def __init__(self, **kwargs):
self.__dict__.update(**kwargs) # 在这里动态创建属性!

@staticmethod
def set_db(db):
Record.__db = db

@staticmethod
def get_db():
return Record.__db

@classmethod
def fetch(cls, ident): # 获取数据
db = cls.get_db()
return db[ident]

class Event(Record):
@property
def venue(self):
key = "venue.{}".format(self.venue_serial)
return self.__class__.fetch(key) # 并不是self.fetch(key)

@property
def speakers(self): # event对应的speaker的数据项保存在_speaker_objs属性中
if not hasattr(self, "_speaker_objs"): # 如果没有speakers数据,则从数据集中获取
spkr_serials = self.__dict__["speakers"] # 首先获取speaker的serial
fetch = self.__class__.fetch
self._speaker_objs = [fetch("speaker.{}".format(key)) for key in spkr_serials]
return self._speaker_objs

可以看到,Record类中一个数据属性都没有,真正实现动态创建属性的是__init__方法中的self.__dict__.update(**kwargs),其中kwargs是一个映射,在本例中,它就是每一个条JSON数据。

如果类中没有声明__slots__,实例的属性都会存到实例的__dict__中,Record.__init__方法展示的是一个流行的Python技巧,这种方法能快速地为实例添加大量属性。

Record中还有一个类属性__db,它是数据集的引用,并不是数据集的副本。本例中,我们将数据放到了一个dict中,__db指向这个dict。其实也可以放到数据库中,然后__db存放数据库的引用。静态方法get_dbset_db则是设置和获取__db的方法。fetch方法是一个类方法,它用于从__db中获取数据。

Event继承自Record,并添加了两个特性venuespeakers,也正是这两个特性实现了关联查找以及属性表示法。venue的实现很简单,因为一个event只对于一个venue,给event中的venue_serial添加一个前缀,然后查找数据集即可。

Event.speakers的实现则稍微有点复杂:首先得清楚,这里查找的不是speaker的标识serial,而是查找speaker的具体数据项。查找到的数据项保存在Event实例的_speaker_objs中。一般在第一访问event.speakers时会进入到if中。还有情况就是event._speakers_objs被删除了。

Event中还有一个值得注意的地方:调用fetch方法时,并不是直接self.fetch,而是self.__class__.fetch。这样做是为了避免一些很隐秘的错误:如果数据中有名为fetch的字段,这就会和fetch方法冲突,此时获取的就不是fetch方法,而是一个数据项。这种错误不易发觉,尤其是在动态创建属性的时候,如果数据不完全规则,几百几千条数据中突然有一条数据的某个属性名和实例的方法重名了,这个时候调试起来简直是噩梦。所以,除非能确保数据中一定不会有重名字段,否则建议按照本例中的写法。

4.3 load_db()

下面是加载和调整数据的load_db()函数的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 代码4.3,依然在schedule.py文件中
def load_db(db):
raw_data = a.load() # 首先加载原始JSON数据
for collection, rec_list in raw_data["Schedule"].items(): # 遍历Schedule中的数据
record_type = collection[:-1] # 将Schedule中4个键名作为类型标识,去掉键名后面的's'
cls_name = record_type.capitalize() # 将类型名首字母大写作为可能的类名
# 从全局作用域中获取对象;如果找不到所要的对象,则用Record代替
cls = globals().get(cls_name, Record)
# 如果获取的对象是个类,且是Record的子类,则稍后用其创建实例;否则用Record创建实例
if inspect.isclass(cls) and issubclass(cls, Record):
factory = cls
else:
factory = Record
for record in rec_list: # 遍历Schedule中每个键对应的数据列表
key = "{}.{}".format(record_type, record["serial"]) # 生成新的serial
record["serial"] = key # 这里是替换原有数据,而不是添加新数据!
db[key] = factory(**record) # 生成实例,并存入数据集中

该函数是一个嵌套循环,最外层循环只迭代4次。每条数据都被包装为一个Record,且serial字段的值中添加了数据类型,这个新的serial也作为键和Record实例组成键值对存入db中。

4.4 shelve

前面说过,db可以从dict换成数据库的引用。Python标准库中则提供了一个现成的数据库类型shelve.Shelf。它是一个简单的键值对数据库,背后由dbm模块支持,具有如下特点:

  • shelve.Shelfabc.MutableMapping的子类,提供了处理映射类型的重要方法;
  • 他还提供了几个管理I/O的方法,比如syncclose;它也是一个上下文管理器;
  • 键必须是字符串,值必须是pickle模块能处理的对象。

本例中,它的用法和dict没有太大区别,以下是它的用法:

1
2
3
4
5
6
7
# 代码4.4
>>> import shelve
>>> db = shelve.open("data/schedule_db") # shelve.open方法返回一个shelve.Shelf对象
>>> if "conference.115" not in db: # 这是一个简单的检测数据库是否加载的技巧,仅限本例
... load_db(db) # 如果是个空数据库,则向数据库中填充数据
... # 中间的用法就和之前的dict没有区别了,不过最后需要记住调用close()关闭数据库连接
>>> db.close() # 建议在with块中访问db

5. Record vs FrozenJSON

如果不需要关联查询,那么Record只需要一个__init__方法,而且也不用定义Event类。这样的话,Record的代码将比FrozenJSON简单很多,那为什么之前FrozenJSON不这么定义呢?原因有两点:

  • FrozenJSON要递归转换嵌套的映射和列表,而Record类不需要这么做,因为所有的映射都被转换成了对应的Record,转换好的数据集中没有嵌套的映射和列表。
  • FrozenJSON中,没有改动JSON数据的数据结构,因此,为了实现链式访问,需要将整个JSON数据存到内嵌的__data属性中。而在Record中,每条数据都被包装成了单个的Record,且对数据结构进行了重构。

还有一点,本例中,使用映射来实现Record类或许更符合Python风格,但这样就无法展示动态属性编程的技巧和陷阱。

6. 总结

我们通过两个例子说明了如何动态创建属性:第一个例子是在FrozenJSON中通过实现__getattr__方法动态创建属性,这个类还可以实现链式访问;第二个例子是通过创建Record和它的子类Event来实现关联查找,其中我们在__init__方法中通过self.__dict__.update(**kw)这个技巧实现批量动态创建属性。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~