流畅的 Python笔记.
本篇主要讨论元编程中的动态创建属性.
1. 前言
平时我们一般把类中存储数据的变量称为属性, 把类中的函数称为方法. 但这两个概念其实是统一的, 它们都称为属性(Attrubute), 方法只是可调用的属性, 并且属性还是可以动态创建的. 如果我们事先不知道数据的结构, 或者在运行时需要再添加一些属性, 此时就需要动态创建属性.
本文将讲述如果通过动态创建属性来读取 JSON 中的数据. 第一个例子我们将实现一个 FrozenJSON 类, 使用__getattr__方法, 根据 JSON 文件中的数据项动态创建 FrozenJSON 实例的属性. 第二个例子, 更进一步, 实现数据的关联查找, 其中, 会用到实例的__dict__属性来动态创建属性.
不过在这两部分内容之前, 先来看一个简单粗暴地使用 JSON 数据的例子.
2. JSON 数据
首先是一个现实世界中的 JSON 数据: OSCON 大会的 JSON 数据. 为了节省篇幅, 只保留了它的数据格式中的一部分, 数据内容也有所改变, 原始数据会在用到的时候下载:
- { "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 模块就可以读取. 以下是用于读取 json 的 load()函数, 如果数据不存在, 它会自动从远端下载数据:
- # 代码 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 数据:
- # 代码 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 类
- # 代码 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
下方代码是对这个类的使用:
- # 代码 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__方法:
- # 代码 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].serial
在
feed.Schedule.speakers
中挨个查找.
为了实现这种关联访问, 需要在读取数据时调整数据的结构: 不再像之前 FrozenJSON 中那样, 将整个 JSON 原始数据存到内部的__data 中, 而是将每条数据单独存到一个 Record 对象中(这里的 "每条数据" 指每个 event, 每个 speaker, 每个 venue 以及 conferences 中的唯一一条数据). 并且, 还需要在每条数据的 serial 字段的值前面加上数据类型, 比如某个 event 的 serial 为
123
, 则将其变为 event.123.
4.1 要实现的功能
不过在给出实现方法之前, 先来看看它应该具有的功能:
- # 代码 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 和继承自 Record 的 Event, 并将这些数据放到名为 db 的映射中. Event 专门用于存 JSON 数据中 events 里的数据, 其余数据全部存为 Record 对象. 之所以这么安排, 是因为原始数据中, event 包含了 speaker 和 venue 的 serial(相当于外键约束). 现在, 我们可以通过 event 查找到与之关联的 speaker 和 venue, 而并不仅仅只是查找到这两个的 serial. 如果想根据 speaker 或 venue 查找 event, 大家可以根据后面的方法自行实现(但这么做得遍历整个 db).
4.2 Record & Event
下面是这两个类以及调整数据结构的 load_db 函数的实现:
- # 代码 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_db 和 set_db 则是设置和获取__db 的方法. fetch 方法是一个类方法, 它用于从__db 中获取数据.
Event 继承自 Record, 并添加了两个特性 venue 和 speakers, 也正是这两个特性实现了关联查找以及属性表示法. 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()函数的代码:
- # 代码 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.Shelf 是 abc.MutableMapping 的子类, 提供了处理映射类型的重要方法;
他还提供了几个管理 I/O 的方法, 比如 sync 和 close; 它也是一个上下文管理器;
键必须是字符串, 值必须是 pickle 模块能处理的对象.
本例中, 它的用法和 dict 没有太大区别, 以下是它的用法:
- # 代码 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)
这个技巧实现批量动态创建属性.
来源: https://juejin.im/post/5b2de77de51d4553156be31a