天天看點

Python學習之路38-動态建立屬性1. 前言2. JSON資料3. FrozenJSON4. Record5. Record vs FrozenJSON6. 總結

《流暢的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

    ,轉換好的資料集中沒有嵌套的映射和清單。
  • FrozenJSON

    中,沒有改動JSON資料的資料結構,是以,為了實作鍊式通路,需要将整個JSON資料存到内嵌的

    __data

    屬性中。而在

    Record

    中,每條資料都被包裝成了單個的

    Record

    ,且對資料結構進行了重構。

還有一點,本例中,使用映射來實作

Record

類或許更符合Python風格,但這樣就無法展示動态屬性程式設計的技巧和陷阱。

6. 總結

我們通過兩個例子說明了如何動态建立屬性:第一個例子是在

FrozenJSON

中通過實作

__getattr__

方法動态建立屬性,這個類還可以實作鍊式通路;第二個例子是通過建立

Record

和它的子類

Event

來實作關聯查找,其中我們在

__init__

方法中通過

self.__dict__.update(**kw)

這個技巧實作批量動态建立屬性。

迎大家關注我的微信公衆号"代碼港" & 個人網站 www.vpointer.net ~