iT邦幫忙

2023 iThome 鐵人賽

DAY 18
0
Software Development

Python十翼:與未來的自己對話系列 第 18

[Day18] 六翼 - 導讀Descriptor HowTo Guide:How dot works?

  • 分享至 

  • xImage
  •  

六翼大綱

在經過前面數翼的洗禮後,我們具備了閱讀Descriptor HowTo Guide比較深入部份的知識了。

由於attribute lookup是一個複雜的主題,我們建議大家多看看不同高手的描述方法,可能比較容易心領神會。

除了Descriptor HowTo Guide外,我們特別推薦Dr. Fred BaptistePython3:Deep Dive-Part4課程及Ionel Cristian Mărieș部落格文章

本翼筆記為嘗試交叉參考上述資料而做。由於本翼難度頗高,再加上有些自己試著修改的程式碼,如果有錯誤的話,還望諸位先進可以不吝斧正,相當感謝。

How dot works?

今天我們將分別理解四個層面的dot:

  1. How obj.attr works(obj is instance)?
  2. How obj.attr works(obj is class)?
  3. How obj.attr=value works(obj is instance)?
  4. How obj.attr=value works(obj is class)?(實驗性質,待研究)

至於super().attr,我們建議直接參考Guido的tutorial,但可能需要一些Python2的基礎,才能體會老爹於20年前建置super()的邏輯。但是即使是在有Python2及descriptormetaclasses的基本知識下,這篇tutorial還是相當難啃呀!期望未來有一天我們能靜下心來,像欣賞Descriptor HowTo Guide一樣,好好拜讀幾遍。

名詞定義

首先我們來看一段Descriptor HowTo Guide開頭的描述:

The Ten class is a descriptor whose __get__() method always returns the constant 10:...In the a.y lookup, the dot operator finds a descriptor instance, recognized by its __get__ method. Calling that method returns 10.

class Ten:
    def __get__(self, obj, objtype=None):
        return 10

class A:
    x = 5                       
    y = Ten()  

可以看出Raymond明確地說明Tendescriptor,而ydescriptor instance。雖然在大多數情況,我們會將Teny都稱作descriptor

為了方便溝通,我們於# 01定義幾個名詞。

# 01
class NonDataDescriptor:
    def __get__(self, instance, owner_cls):
        ...


class DataDescriptor:
    def __get__(self, instance, owner_cls):
        ...

    def __set__(self, instance, value):
        ...


class DummyClass:
    a = 1


class MyClass(DummyClass):
    non_data_desc = NonDataDescriptor()
    data_desc = DataDescriptor()


if __name__ == '__main__':
    print(MyClass.__mro__)  # MyClass, DummyClass, object
    my_inst = MyClass()
  • NonDataDescriptorNon-data descriptor,而non_data_desc為其生成的instance
  • DataDescriptordata descriptor,而data_desc為其生成的instance
  • 如果obj.attr在生成objclass或其MRO上任一class__dict__內,我們以obj.attr in cls_mro代稱,並以base來代稱attr所在的class。例如,# 01中的my_inst.a,因為aMyClasstype(my_inst))的MRO上的DummyClass.__dict__,我們會稱my_inst.a in cls_mro,而其base則為DummyClass
  • 如果obj.attr不在生成objclass或其MRO上任一class__dict__內,我們以obj.attr not in cls_mro代稱,
  • 如果objclass且如果obj.attrobjclass或其MRO上任一class__dict__內,我們以obj.attr in obj_cls_mro代稱,並以obj_base來代稱attr所在的class。例如,# 01中的MyClass.a,因為aMyClassMRO上的DummyClass.__dict__,我們會稱MyClass.a in obj_cls_mro,而其obj_base則為DummyClass

1. How obj.attr works(obj is instance)?

object.__getattribute__是Python取得attribute值的dunder method

object.__getattribute__

下面是object.__getattribute__於Python的實作。

find_name_in_mro

find_name_in_mro接受三個參數:

  • cls為生成objclass
  • name為想尋找的attributestr型態)。
  • default為一預設值。

針對cls.__mro__打一個迴圈,依序在MRO上每個class__dict__尋找有沒有name這個attribute,如果找到就馬上返回。當迴圈結束還是沒找到的話,則返回default

def find_name_in_mro(cls, name, default):
    "Emulate _PyType_Lookup() in Objects/typeobject.c"
    for base in cls.__mro__:
        if name in vars(base):
            return vars(base)[name]
    return default

object_getattribute

  • object_getattribute接受兩個參數,objname
  • 利用object()建立一個獨特的預設值null
  • obj_type為生成objclass
  • cls_var為呼叫find_name_in_mro回傳的結果。注意,cls_var可以不是普通的class variable。也可以是data_descnon_data_desc,因為他們也可以視為一種class variable,所以Raymond這樣命名。
  • descr_get為試圖由生成cls_varclass內去取__get__(即測試cls_varclass是否為descriptor)。如果取不到的話則返回預設值null
  • 接下來依照註解,需要判斷最多四次,來決定如何取值。
    • 第一個判斷是看看cls_var是否為data_desc。判斷方法是確定descr_get不是null且生成cls_varclass中有__set__或是__delete__。如果符合的話,代表cls_vardata_desc,呼叫它的__get__來取值。可以使用descr_get(cls_var, obj, objtype)或是cls_var.__get__(obj, objtype)兩種語法。
    • 如果cls_var不是data_desc則進行第二個判斷。看看obj有沒有__dict__name是否在__dict__中。如果是的話,代表cls_varinstance variable,使用vars(obj)['attr']取值。
    • 如果cls_var也不是instance variable則進行第三個判斷。如果descr_get不是null的話,代表cls_varnon_data_desc,呼叫它的__get__來取值。descr_get(cls_var, obj, objtype)或是cls_var.__get__(obj, objtype)兩種語法。
    • 如果cls_var也不是non_data_desc的話,則進行第四個判斷。如果cls_var不是null的話,代表其為class varaible,返回cls_var
    • 如果cls_varnull的話,代表經過前面四次確認後,仍無法順利取值,raise AttributeError
def object_getattribute(obj, name):
    "Emulate PyObject_GenericGetAttr() in Objects/object.c"
    null = object()
    objtype = type(obj)
    cls_var = find_name_in_mro(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    if descr_get is not null:
        if (hasattr(type(cls_var), '__set__')
            or hasattr(type(cls_var), '__delete__')):
            return descr_get(cls_var, obj, objtype)     # data descriptor
    if hasattr(obj, '__dict__') and name in vars(obj):
        return vars(obj)[name]                          # instance variable
    if descr_get is not null:
        return descr_get(cls_var, obj, objtype)         # non-data descriptor
    if cls_var is not null:
        return cls_var                                  # class variable
    raise AttributeError(name)

object.__getattr__

obj.attr raise AttributeError後,Python會呼叫obj.__getattr__作為最後一個補救措施(預設行為是直接raise AttributeError)。如果還是有AttributeError不能處理的話,則raise AttributeError給使用者。

由於object.__getattribute__的實作內,並未呼叫object.__getattr__,而是由Python自動偵測到AttributeError時呼叫。所以當顯性呼叫obj.__getattribute__或是super().__getattribute__時,object.__getattr__並不會被呼叫。

getattr_hook

  • getattr_hook接受兩個參數,objname
  • 嘗試顯性呼叫obj.__getattribute__,當raise AttributeError時,查看生成objclass是否有__getattr__
    • 如果沒有的話。則reraise AttributeError
    • 如果有的話,則使用type(obj).__getattr__

這邊要檢查type(obj)是否有__getattr__的原因,可能是因為一般的instance是沒有__getattr__的。如果直接return obj.__getattr__(name),會引起另一個AttributeError,那麼就需要另一個try-catch來處理,語法更為複雜。

def getattr_hook(obj, name):
    "Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
    try:
        return obj.__getattribute__(name)
    except AttributeError:
        if not hasattr(type(obj), '__getattr__'):
            raise
    return type(obj).__getattr__(obj, name)             # __getattr__

流程整理格式1

我們試著將當obj.attr(obj is instance)的lookup流程寫下來。

  • 如果obj.attr in cls_mroobj.attrdata_desc,則使用data_desc.__get__(obj, type(obj))
  • 如果obj.attr in vars(obj),則返回vars(obj)['attr']
  • 如果obj.attr in cls_mroobj.attrnon_data_desc,則使用non_data_desc.__get__(obj, type(obj))
  • 如果obj.attr in cls_mro則為class variable,返回vars(base)['attr']
  • 如果還沒成功取值,raise AttributeError(自動呼叫obj.__getattr__)。

inst_get1

流程整理格式2

流程整理格式1是根據Raymond的筆記整理的,Dr. Fred Baptiste則建議由obj是否在cls_mro來作為分支思考。

  • 如果obj.attr in cls_mro
    • 如果obj.attrdata_desc,則使用data_desc.__get__(obj, type(obj))
    • 如果obj.attr in vars(obj),則返回vars(obj)['attr']
    • 如果obj.attrnon_data_desc,則使用non_data_desc.__get__(obj, type(obj))
    • 剩下的必定是class variable,返回vars(base)['attr']
  • 如果obj.attr not in cls_mro
    • 如果obj.attr in vars(obj),則返回vars(obj)['attr']
    • 如果obj.attr not in vars(obj),則raise AttributeError(自動呼叫obj.__getattr__)。

inst_get2

2. How obj.attr works(obj is class)?

Descriptor HowTo Guide講到invocation from a class時,是這麼說的:

The logic for a dotted lookup such as A.x is in type.__getattribute__(). The steps are similar to those for object.__getattribute__() but the instance dictionary lookup is replaced by a search through the class’s method resolution order. If a descriptor is found, it is invoked with desc.__get__(None, A).

儘管文件中沒有提供相對應的Python程式碼。但是我們可以根據上面描述,試著實作看看。

# 02 Experiment code, not verified
def find_name_in_mro(cls, name, default):
    cls_mro = object.__getattribute__(cls, '__mro__')
    for base in cls_mro:
        base_dict = object.__getattribute__(base, '__dict__')
        if name in base_dict:
            return base_dict[name]
    return default

def type_getattribute(obj, name):
    ...
    _cls_var = find_name_in_mro(obj, name, null)
    if _cls_var is not null:
        if getattr(type(_cls_var), '__get__', null) is not null:
            return _cls_var.__get__(None, obj)  # descriptor(of any kind)
        return _cls_var  # class variable 

...

由於此時的objclass,所以我們需要將整個思考邏輯往上推一層。現在type_getattribute的判斷式是針對metaclassesMRO,而我們的instance lookup也因為往上推一層,而變成了class MROlookup,這也是整段程式唯一需要修改的地方。

  • 由於需要使用find_name_in_mro做兩次MRO的搜尋,一次是於metaclass中,一次是於class中。當在class中搜尋中,如果使用了obj.__mro__obj.__dict__,會造成Recursion Error。所以我們這邊改使用object.__getattribute__,這麼一來,新的find_name_in_mro就能同時適用於兩個情況。
  • _cls_varclass MRO的搜尋結果。如果_cls_var不是null的話,代表其位於class或其MRO上任一class__dict__中。我們可以利用與前面相同的技巧,看看getattr(type(_cls_var), '__get__', null)的回傳是否為null
    • 如果回傳值不是null,就代表_cls_vardescriptorinstance,依照文件說明,可以返回_cls_var.__get__(None, obj)。由於我們只需要_cls_var__get__,所以相當於要補抓任何型態的descriptor instance,包括data_descnon_data_desc
    • 如果回傳值是null,就代表_cls_varclass variable,直接返回即可。

測試

我們使用type_getattribute來作為MyType__getattribute__,並確認於不同情況下可以成功取值。

# 02 Experiment code, not verified
...

class MyType(type):
    def __getattribute__(self, name):
        # confirm only call once for each dot access
        print(f'MyType.__getattribute__ is called for {name=}')
        return type_getattribute(self, name)


class NonDataDescriptor:
    def __get__(self, instance, owner_cls):
        return 10


class DataDescriptor:
    def __get__(self, instance, owner_cls):
        return 20

    def __set__(self, instance, value):
        ...


class MyType1(MyType):
    z = DataDescriptor()


class MyType2(MyType1):
    y = NonDataDescriptor()


class MyType3(MyType2):
    x = 1


class DummyClass:
    d = 'dummy'


class MyClass(DummyClass, metaclass=MyType3):
    a = 100
    b = NonDataDescriptor()
    c = DataDescriptor()


if __name__ == '__main__':
    print(f'{MyClass.x=}')  # 1
    print(f'{MyClass.y=}')  # 10
    print(f'{MyClass.z=}')  # 20
    print(f'{MyClass.a=}')  # 100
    print(f'{MyClass.b=}')  # 10
    print(f'{MyClass.c=}')  # 20
    print(f'{MyClass.d=}')  # 'dummy'

另外,也確認了當同一個attr出現於不同地方,也會依照我們寫下的邏輯來取值。

# 02a中由於t為位於MyType中的data_desc,優先級別最高。

# 02a
...

class MyType(type):
    t = DataDescriptor()

    def __getattribute__(self, name):
        return type_getattribute(self, name)


class MyClass(metaclass=MyType):
    t = 't_in_myclass'


if __name__ == '__main__':
    print(f'{MyClass.t=}')  # 20

# 02b# 02c# 02d都因為MyType中的t不是data_desc,而優先從MyClass中取值。

# 02b
...

class MyType(type):
    t = NonDataDescriptor()

    def __getattribute__(self, name):
        return type_getattribute(self, name)


class MyClass(metaclass=MyType):
    t = 't'


if __name__ == '__main__':
    print(f'{MyClass.t=}')  # 't'
# 02c
...

class MyType(type):
    t = NonDataDescriptor()

    def __getattribute__(self, name):
        return type_getattribute(self, name)


class MyClass(metaclass=MyType):
    t = DataDescriptor()


if __name__ == '__main__':
    print(f'{MyClass.t=}')  # 20
# 02d
...

class MyType(type):
    t = 't_in_mytype'

    def __getattribute__(self, name):
        return type_getattribute(self, name)


class MyClass(metaclass=MyType):
    t = NonDataDescriptor()


if __name__ == '__main__':
    print(f'{MyClass.t=}')  # 10

流程整理格式1

我們試著將obj.attrobj is class)的lookup流程寫下來。

  • 如果obj.attr in cls_mroobj.attrdata_desc,則使用data_desc.__get__(obj, type(obj))
  • 如果obj.attr in obj_cls_mro
    • 如果obj.attrdesc_inst,則使用desc.__get__(obj, type(obj))
    • 如果obj.attr不是desc_inst,則返回vars(obj_base)['attr']
  • 如果obj.attr in cls_mroobj.attrnon_data_desc,則使用non_data_desc.__get__(obj, type(obj))
  • 如果obj.attr in cls_mro則為class variable,返回vars(base)['attr']
  • 如果還沒成功取值,raise AttributeError(自動呼叫obj.__getattr__)。

class_get1

流程整理格式2

  • 如果obj.attr in cls_mro
    • 如果obj.attrdata_desc,則使用data_desc.__get__(obj, type(obj))
    • 如果obj.attr in obj_cls_mro
      • 如果obj.attrdesc_inst,則使用desc.__get__(obj, type(obj))
      • 如果obj.attr不是desc_inst,則返回vars(obj_base)['attr']
    • 如果obj.attrnon_data_desc,則使用non_data_desc.__get__(obj, type(obj))
    • 剩下的必定是class variable,返回vars(base)['attr']
  • 如果obj.attr not in cls_mro
    • 如果obj.attr in obj_cls_mro
      • 如果obj.attrdesc_inst,則使用desc.__get__(obj, type(obj))
      • 如果obj.attr不是desc_inst,則返回vars(obj_base)['attr']
    • 如果obj.attr not in obj_cls_mroraise AttributeError(自動呼叫obj.__getattr__)。

class_get2

3. How obj.attr=value works(obj is instance)?

object.__setattr__是Python設定attribute值的dunder method註1)。

流程整理格式

根據Dr. Fred Baptiste的說明,我們可以將obj.attr=value整理如下:

  • 如果obj.attr in cls_mroobj.attrdata_desc,則使用data_desc.__set__(obj, value)
  • 如果obj.attr__dict__,則使用obj.__dict__['attr']=value
  • 如果obj.attr沒有__dict__,則raise AttributeError

inst_set1

object.__setattr__

根據上面的流程整理,我們可以試著於Python中實作object.__setattr__

  • 如果descr_getdescr_set都不是null的話,代表cls_vardata_desc,使用其__set__來設定attribute值。可以使用descr_set(cls_var, obj, value)cls_var.__get__(obj, value)兩種語法。
  • 查看obj有沒有__dict__。如果有的話,使用obj.__dict__[name] = value新增或修改attribute
  • 如果上面兩點檢查都不符,則raise AttributeError
# 03 Experiment code, not verified
def object_setattr(obj, name, value):
    null = object()
    objtype = type(obj)
    cls_var = find_name_in_mro(objtype, name, null)
    descr_get = getattr(type(cls_var), '__get__', null)
    descr_set = getattr(type(cls_var), '__set__', null)
    if descr_get is not null and descr_set is not null:
        descr_set(cls_var, obj, value)  # data descriptor
        return
    if hasattr(obj, '__dict__'):
        obj.__dict__[name] = value  # instance variable
        return
    raise AttributeError(name)

...

測試

我們使用object_setattr來作為Character中的__setattr__,並確認可以成功存取instance variable及使用descriptor instance

# 03 Experiment code, not verified
...

class DataDescriptor:
    def __get__(self, instance, owner_cls):
        print('__get__ called')
        if instance is None:
            return self
        return instance.__dict__.get(self._name)

    def __set__(self, instance, value):
        print('__set__ called')
        instance.__dict__[self._name] = value

    def __set_name__(self, owner_cls, name):
        self._name = name


class Character:
    dog = DataDescriptor()

    def __init__(self, name):
        self.name = name

    def __setattr__(self, name, value):
        print(f'Character.__setattr__ is called for {name=}. {value=}')
        object_setattr(self, name, value)


if __name__ == '__main__':
    john_wick = Character('John Wick')
    print(john_wick.name)  # John Wick
    john_wick.dog = 'Daisy'  # __set__ called
    print(john_wick.dog)  # __get__ called, Daisy
    print(john_wick.__dict__)  # {'name': 'John Wick' 'dog': 'Daisy'}

4. How obj.attr=value works(obj is class)?

本小節的程式碼純屬實驗性質,強烈不建議這麼寫。但通過這個實作,我們學習思考了很多,所以想要做個筆記,留下記錄。

至於obj.attr=value(obj is class)需要mutate obj.__dict__(mappingproxy),所以需要overwrite type.__setattr__。但是這麼做會於呼叫setattr時,造成Recursion Error,除非直接使用type.__setattr__,我們沒有想到適合的解決方法。

在看了這篇stackoverflow的討論及C code之後,了解自己暫時沒有能力於Python實作type.__setattr__

既然無法mutate obj.__dict__ ,我們嘗試每次需要mutate時都重新生成一個class,於是有了# 04。於每次MyType.__setattr__被呼叫時,複製class的資訊並加上新設定的attribute,接著利用exec重新產生新的class來取代原先的class

# 04 Experiment code, not verified
def find_name_in_mro(cls, name, default):
    "Emulate _PyType_Lookup() in Objects/typeobject.c"
    for base in cls.__mro__:
        if name in vars(base):
            return vars(base)[name]
    return default


def type_setattr(obj, name, value):
    """Just exploring the magic of Python, don't do this..."""
    if hasattr(obj, '__dict__'):
        cls_name = obj.__name__
        cls_bases = obj.__mro__
        cls_dict = dict(vars(obj)) | {name: value}
        _globals = globals() | {'cls_name':  cls_name,
                                'cls_bases': cls_bases,
                                'cls_dict': cls_dict}
        meta = type(obj).__name__
        cls_body = f'{cls_name}={meta}(cls_name, cls_bases, cls_dict)'
        exec(cls_body, _globals, globals())
    else:
        raise AttributeError(name)

        
class MyType(type):
    def __setattr__(self, name, value):
        print(f'MyType.__setattr__ is called for {name=}. {value=}')
        type_setattr(self, name, value)

...

測試

這樣的寫法的確可以設定class variabledescriptor。從id比較中可以看出,於每次設定attribute時,都會新生成一個MyClass

# 04 Experiment code, not verified
...

class DataDescriptor:
    def __get__(self, instance, owner_cls):
        if instance is None:
            return self
        return instance.__dict__.get(self._name)

    def __set__(self, instance, value):
        instance.__dict__[self._name] = value

    def __set_name__(self, owner_cls, name):
        self._name = name


class MyClass(metaclass=MyType):
    a = 'a'


if __name__ == '__main__':
    orig_cls_id = id(MyClass)
    print(MyClass.a)  # a
    MyClass.a = 'b'
    print(MyClass.a)  # b
    assert orig_cls_id != id(MyClass)

    MyClass.c = DataDescriptor()
    my_inst = MyClass()
    my_inst.c = 'c'
    print(my_inst.c)  # c
    print(MyClass.c)  # <__main__.DataDescriptor object at 0x0000014667AE6690>

備註

註1:Python有object.__getattribute__object.__getattr__ ,但只有object.__setattr__而沒有object__setattribute__

註2:本日流程圖為ChatGPTPlantUML協作繪製而成。為方便排版,所有的double underscore都使用single underscore代替。

Code

本日程式碼傳送門


上一篇
[Day17] 五翼 - Metaclasses:Metaclasses相關整理
下一篇
[Day19] 六翼 - 導讀Descriptor HowTo Guide:Pure Python Equivalents
系列文
Python十翼:與未來的自己對話30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言