《流暢的Python》筆記。
本篇主要讨論元程式設計中的動态建立屬性。
1. 前言
平時我們一般把類中存儲資料的變量稱為屬性,把類中的函數稱為方法。但這兩個概念其實是統一的,它們都稱為屬性(Attrubute),方法隻是可調用的屬性,并且屬性還是可以動态建立的。如果我們事先不知道資料的結構,或者在運作時需要再添加一些屬性,此時就需要動态建立屬性。
本文将講述如果通過動态建立屬性來讀取JSON中的資料。第一個例子我們将實作一個
FrozenJSON
類,使用
__getattr__
方法,根據JSON檔案中的資料項動态建立
FrozenJSON
執行個體的屬性。第二個例子,更進一步,實作資料的關聯查找,其中,會用到執行個體的
__dict__
屬性來動态建立屬性。
不過在這兩部分内容之前,先來看一個簡單粗暴地使用JSON資料的例子。
2. JSON資料
首先是一個現實世界中的JSON資料:OSCON大會的JSON資料。為了節省篇幅,隻保留了它的資料格式中的一部分,資料内容也有所改變,原始資料會在用到的時候下載下傳:
{ "Schedule": {
"conferences": [{"serial": }],
"events": [{
"serial": ,
"name": "This is a test",
"venue_serial": ,
"speakers": []
}],
"speakers": [{
"serial": ,
"name": "Speaker1",
}],
"venues": [{
"serial": ,
"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'][]['speakers']
[, ]
複制代碼
從這個例子可以看出,要通路一個資料,得輸入多少中括号和引号,為了跳出這些中括号和引号,又得浪費多少操作?如果再嵌套幾個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 >
>>> event.venue
<schedule.Record object at >
>>> event.venue.name
'Portland 251'
>>> for spkr in event.speakers:
... print("{0.serial}: {0.name}".format(spkr))
...
speaker: Anna Martelli Ravenscroft
speaker: 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[:] # 将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
- 在
中,沒有改動JSON資料的資料結構,是以,為了實作鍊式通路,需要将整個JSON資料存到内嵌的FrozenJSON
屬性中。而在__data
中,每條資料都被包裝成了單個的Record
,且對資料結構進行了重構。Record
還有一點,本例中,使用映射來實作
Record
類或許更符合Python風格,但這樣就無法展示動态屬性程式設計的技巧和陷阱。
6. 總結
我們通過兩個例子說明了如何動态建立屬性:第一個例子是在
FrozenJSON
中通過實作
__getattr__
方法動态建立屬性,這個類還可以實作鍊式通路;第二個例子是通過建立
Record
和它的子類
Event
來實作關聯查找,其中我們在
__init__
方法中通過
self.__dict__.update(**kw)
這個技巧實作批量動态建立屬性。
迎大家關注我的微信公衆号"代碼港" & 個人網站 www.vpointer.net ~