Python學習之路38-動態建立屬性

VPointer發表於2019-02-27

《流暢的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模組就可以讀取。以下是用於讀取jsonload()函式,如果資料不存在,它會自動從遠端下載資料:

# 程式碼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].serialfeed.Schedule.speakers中挨個查詢。

為了實現這種關聯訪問,需要在讀取資料時調整資料的結構:不再像之前FrozenJSON中那樣,將整個JSON原始資料存到內部的__data中,而是將每條資料單獨存到一個Record物件中(這裡的“每條資料”指每個event,每個speaker,每個venue以及conferences中的唯一一條資料)。並且,還需要在每條資料的serial欄位的值前面加上資料型別,比如某個eventserial123,則將其變為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和繼承自RecordEvent,並將這些資料放到名為db的對映中。Event專門用於存JSON資料中events裡的資料,其餘資料全部存為Record物件。之所以這麼安排,是因為原始資料中,event包含了speakervenueserial(相當於外來鍵約束)。現在,我們可以通過event查詢到與之關聯的speakervenue,而並不僅僅只是查詢到這兩個的serial。如果想根據speakervenue查詢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_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()函式的程式碼:

# 程式碼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沒有太大區別,以下是它的用法:

# 程式碼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)這個技巧實現批量動態建立屬性。


迎大家關注我的微信公眾號"程式碼港" & 個人網站 www.vpointer.net ~

Python學習之路38-動態建立屬性

相關文章