namedtuple簡易實現

weixin_34402408發表於2019-02-24

在python中,namedtuple建立一個和tuple類似的物件,可以使用名稱來訪問元素的資料物件,通常用來增強程式碼的可讀性, 在訪問一些tuple型別的資料時尤其好用。

我們可以這樣使用:

from collections import namedtuple

User = namedtuple('User', ['id', 'name'])
u = User(1, 'aa')
print(u.name) # aa

那麼,namedtuple是如何實現的呢。

見名知意,通過namedtuple的名字,我們可以推測,namedtuple繼承了tuple,並使我們定義的欄位名和tuple下標建立某種聯絡,使得通過欄位名來訪問資料成為可能。

顯然,我們無法預知使用者傳入的欄位名是什麼。比如上面的例子User = namedtuple('User', ['id', 'name'])欄位名id和name,下次有可能需要新增一個age欄位。這就要求我們要動態地建立類,在python中就需要通過元類來實現。

如何修改tuple的例項化行為呢,我們當然會首先想到繼承並重寫基類的構造方法。比如下面這樣:

class MyTuple(tuple):
    def __init__(self, iterable):
        newiter = [i for i in iterable if i != 3]
        tuple.__init__(newiter)

if __name__ == '__main__':
    mytuple = MyTuple([1,2,3,4,5])
    print(mytuple)

執行程式碼,我們將看到列印結果為(1, 2, 3, 4, 5)。這是因為,想要修改python內建不可變型別的例項化行為,需要我們實現__new__方法。__new__ 方法相當不常用,但是當繼承一個不可變的型別或使用元類時,它將派上用場。稍作修改的程式碼如下:

class MyTuple(tuple):
    def __new__(cls, id, name):
        newiter = [i for i in iterable if i != 3]
        return super(MyTuple, cls).__new__(cls, newiter)

if __name__ == '__main__':
    mytuple = MyTuple([1,2,3,4,5])
    print(mytuple)

這次,程式執行的結果就會是我們期望的(1, 2, 4, 5)

瞭解了以上知識後,我們開始著手編寫程式碼:

class User(tuple):

    def __new__(cls, id, name):
        iterable = (id, name)
        return super(User, cls).__new__(cls, iterable)

if __name__ == '__main__':
    user = User(1, 3)
    print(user)

一個基本的User類實現如上,它繼承tuple並重寫了__new__方法,根據我們傳入的引數包裝成一個可迭代物件,最後呼叫父類的__new__方法。但它還是有個嚴重的問題:不能夠動態接收引數。這裡我們傳的是id和name作為欄位名,下一次我們可能希望傳入id、name、age作欄位名。有人可能會想到用*args*args雖然能解決以上問題,但又會產生新的問題:無法對引數數量進行限制。我們最終定義的函式應該像這樣:def name_tuple(cls_name, field_names)。它接收兩個引數cls_name為生成類的類名,我們最終希望通過obj.欄位名的方式去獲取tuple中的元素,所以還需要傳入第二個引數:field_names,field_names為一系列欄位名,可以是一個可迭代物件,或是一個字串。我們希望根據field_names中欄位的數量,去動態控制__new__方法中可接受的引數數量。

那麼究竟應該怎麼做?如果我們有一個模板,並動態往裡面填充我們想要的欄位名作為引數,不就實現了這一需求了嗎。就像這樣:

class_template = """
    def __new__(_cls, {arg_list}): 
        return _tuple_new(_cls, ({arg_list}))'
"""
class_template.format(arg_list='id, name')
print(class_template)

最後生成的是個字串,並不是我們需要的__new__方法,如何將這一串字串轉成方法呢?

眾所周知,Python 是一門動態語言,在 Python 中,exec()能夠動態地執行復雜的Python程式碼,它能夠接收一個字串,並將其作為Python程式碼執行,比如:

exec('a=1')
print(globals().get('a')) # 1

目前為止,我們能實現如下程式碼:

def name_tuple(cls_name, field_names):
    if isinstance(field_names, str):
        field_names = field_names.replace(',', ' ').split()
    field_names = list(map(str, field_names))
    arg_list = repr(field_names).replace("'", "")[1:-1]
    tuple_new = tuple.__new__
    namespace = {'_tuple_new': tuple_new, '__name__': f'namedtuple_{cls_name}'}
    template = f'def __new__(_cls, {arg_list}): return _tuple_new(_cls, ({arg_list}))'
    exec(template, namespace)
    __new__ = namespace['__new__']
    
    class_namespace = {
        '__new__': __new__
    }
   
    return type(cls_name, (tuple,), class_namespace)

大概解釋一下上述程式碼。首先對傳入的field_names進行處理,若傳入的是字串,則用split將其分割為列表,否則直接通過list(map(str, field_names))將它轉為列表。之後將field_names進行處理,生成傳入模板作為引數的字串。

之後定義了namespace和template變數,並將它們作為引數傳入exec。

exec能接收三個引數:

  • object:必選引數,表示需要被指定的Python程式碼。它必須是字串或code物件。如果object是一個字串,該字串會先被解析為一組Python語句,然後在執行(除非發生語法錯誤)。如果object是一個code物件,那麼它只是被簡單的執行。
  • globals:可選引數,表示全域性名稱空間(存放全域性變數),如果被提供,則必須是一個字典物件。
  • locals:可選引數,表示當前區域性名稱空間(存放區域性變數),如果被提供,可以是任何對映物件。如果該引數被忽略,那麼它將會取與globals相同的值。
  • 如果globals與locals都被忽略,那麼它們將取exec()函式被呼叫環境下的全域性名稱空間和區域性名稱空間。

執行後產生的__new__方法可以通過namespace['__new__']獲取。

最後一句return type(cls_name, (tuple,), class_namespace)非常關鍵,它表示生成一個名為cls_name的類,且繼承自tuple。第三個引數class_namespace是一個包含屬性的字典,我們在其中新增了之前生成的__new__方法。

讓我們測試一下:

User = name_tuple('User', ['id', 'name'])
print(User)    # <class '__main__.User'>
u = User(1,'aa') 
print(u)       # (1, 'aa')
print(u.name)  # AttributeError: 'User' object has no attribute 'name'

可以發現最後一句報錯了,因為我們並沒有在class_namespace字典中新增名為name的屬性。

現在要考慮的是如何新增這些鍵值對,屬性名我們很容易拿到,接下來要做的就是獲取值;此外,不僅要獲取,而且還要和tuple一致,保證這些屬性是隻讀,不可變的(immutable)。

通過property可以實現上述操作。通常,我們會這麼使用property:

class User():
    __name = 'private'

    @property
    def name(self):
        return self.__name
    
if __name__ == '__main__':
    u = User()
    print(u.name)      # private
    u.name = 'public'  # AttributeError: can't set attribute

把一個方法變成屬性,只需要加上@property裝飾器就可以了,此時,@property本身又建立了另一個裝飾器@name.setter,負責把一個setter方法變成屬性賦值,若不定義這一方法,則表示name屬性是隻讀的。

property還有另一種寫法:

class User():
    __name = 'private'

    def name(self):
        return self.__name

    name = property(fget=name)

以上兩種property的用法是等價的。理解了這些之後,我們繼續實現程式碼:

for i, v in enumerate(field_names):
    rv = itemgetter(i)
    class_namespace[v] = property(rv)

itemgetter函式如下:

def itemgetter(item):
    def func(obj):
        return obj[item]

    return func

完整程式碼:

def itemgetter(item):
    def func(obj):
        return obj[item]

    return func


def name_tuple(cls_name, field_names):
    if isinstance(field_names, str):
        field_names = field_names.replace(',', ' ').split()
    field_names = list(map(str, field_names))
    "a simple implementation of python's namedtuple"
    arg_list = repr(field_names).replace("'", "")[1:-1]
    tuple_new = tuple.__new__
    namespace = {'_tuple_new': tuple_new, '__name__': f'namedtuple_{cls_name}'}
    template = f'def __new__(_cls, {arg_list}): return _tuple_new(_cls, ({arg_list}))'
    exec(template, namespace)
    __new__ = namespace['__new__']

    class_namespace = {
        '__new__': __new__
    }

    for i, v in enumerate(field_names):
        rv = itemgetter(i)
        class_namespace[v] = property(rv)

    return type(cls_name, (tuple,), class_namespace)

至此一個簡易版本的namedtuple已經實現。關於namedtuple的官方完整實現可以參考它的原始碼。

擴充套件

1.元類:

陌生的 metaclass

2.exec:

官方文件

3.描述符:

描述符是一種特殊的物件,這種物件實現了 __get____set____delete__ 這三個特殊方法中任意的一個

其中,實現了 __get__ 以及 __set__ / __delete__ 的是 Data descriptors ,而只實現了 __get__ 的是Non-Data descriptor 。這兩者有什麼區別呢?

我們呼叫一個屬性,順序如下:

  1. 如果attr出現在類的__dict__中,且attr是一個Data descriptor,那麼呼叫__get__
  2. 如果attr出現在例項的__dict__中, 那麼直接返回
  3. 如果attr出現在類的__dict__中:
    3.1 如果是Non-Data descriptor, 那麼呼叫其__get__方法
    3.2 返回cls.__dict__['attr']
  4. 若有__getattr__方法則呼叫
  5. 否則丟擲AttributeError

更多與描述符相關的內容可以參考官方文件

4.property

一種property的模擬實現:

class Property(object):
    def __init__(self, fget=None, fset=None, fdel=None, doc=None):
        self.fget = fget
        self.fset = fset
        self.fdel = fdel
        if doc is None and fget is not None:
            doc = fget.__doc__
        self.__doc__ = doc

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def __delete__(self, obj):
        if self.fdel is None:
            raise AttributeError("can't delete attribute")
        self.fdel(obj)

    def getter(self, fget):
        self.fget = fget

    def setter(self, fset):
        self.fset = fset

    def deleter(self, fdel):
        self.fdel = fdel

在之前的例子中,我們用@property裝飾器裝飾了name方法,我們的 name就變成了一個 property 物件的例項,它也是一個描述符,當一個變數成為一個描述符後,它將改變正常的呼叫邏輯,現在當我們 u.name='public' 的時候,因為我們的name是一個 Data descriptors ,那麼不管我們的例項字典中是否有 name 的存在,我們都會觸發其 __set__ 方法,由於在我們初始化該變數時,沒有為其傳入 fset 的方法,因此,我們 __set__ 方法在執行過程中將會丟擲 AttributeError("can't set attribute") 的異常。我們在簡易實現namedtuple時使用了property,這保證了它將遵循了 tuple不可變 (immutable) 特性。

相關文章