DEV Community

Cover image for Python 物件導向背後的魔法--描述器 (descriptor)
codemee
codemee

Posted on • Edited on

4

Python 物件導向背後的魔法--描述器 (descriptor)

Python 小猜謎

請猜猜看以下程式碼的輸出結果:

>>> class A:
...     def __get__(self, instance, owner):
...         return True
>>> class B:
...     a1 = A()
>>> b1 = B()
>>> b1.a1
Enter fullscreen mode Exit fullscreen mode

它會輸出:

True
Enter fullscreen mode Exit fullscreen mode

如果改成這樣, 又會輸出什麼:

>>> B.a1
Enter fullscreen mode Exit fullscreen mode

你的答案是?其實結果還是一樣:

True
Enter fullscreen mode Exit fullscreen mode

那如果是這樣呢?

>>> B.__dict__['a1']
Enter fullscreen mode Exit fullscreen mode

這時就會直接取得 a1 屬性指向的物件了:

<__main__.A object at 0x7fe5a9ba93f0>
Enter fullscreen mode Exit fullscreen mode

為什麼同樣是讀取屬性 a1, 卻會有不同的結果呢?

描述器 (descriptor)

在 Python 中, 類別中的屬性所指向的物件, 若其所屬的類別定義有 __get__ 函式, 這個屬性就會被視為描述器 (descriptor)。以剛剛的例子來說, 定義在 B 中的 a1 屬性, 是 A 的實例, 而 A 定義有 __get__, 所以 a1 就會被視為描述器。

. 運算讀取描述器時, 不會直接取得指向的物件, 而是會轉而呼叫描述氣所屬類別中定義的 __get__ 函式, 也就是 b.a1 會被轉換成 A.__get__()

     b.a1
       
       
     A.__get__(B.__dict__['a1'], b, B)
                                  
    self ──────────────────┘       
instance ────────────────────────┘  
   owner ───────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

呼叫時會自動傳入 3 個引數, 分別是描述器本身、要讀取屬性的實例、以及實例所屬的類別, 以剛剛的例子來說, 就是 a1、b 以及 B 類別。底下修改一下範例, 讓結果更容易判讀:

>>> class A:
...     def __get__(self, instance, owner):
...         print(f'self:     {self}')
...         print(f'instance: {instance}')
...         print(f'owner:    {owner}')
...         return True
>>> class B:
...     a1 = A()
>>> b1 = B()
>>> b1.a1
Enter fullscreen mode Exit fullscreen mode

我們把傳入 __get__ 的引數都印出來, 可以得到以下結果:

self:     <__main__.A object at 0x7fe5a9a4baf0>
instance: <__main__.B object at 0x7fe5a9739510>
owner:    <class '__main__.B'>
True
Enter fullscreen mode Exit fullscreen mode

由於讀取描述器會叫用所屬類別定義的 __get__, 這就是為什麼在一開始的小謎題中會得到 True 的原因。

由於傳入的 3 個引數個別代表的意義不同, 定義 __get__ 時, 3 個參數慣例上就命名為代表描述器本身self、引發描述器機制的實例 instance, 以及定義 (擁有) 該描述器的類別 owner

透過類別讀取描述器

如果直接透過類別而非實例讀取描述器:

>>> B.a1
Enter fullscreen mode Exit fullscreen mode

則會得到以下結果:

self:     <__main__.A object at 0x7fe5a9a4baf0>
instance: None
owner:    <class '__main__.B'>
True
Enter fullscreen mode Exit fullscreen mode

透過類別讀取描述器也一樣會叫用定義在描述器所屬類別內的 __get__, 但因為不是透過實例讀取描述器, instance 引數就會是 None。

強制取得描述器

如果想要取得描述器本身, 不要引發描述器機制叫用函式, 就必須透過類別的特殊屬性 __dict__ 指向的字典:

>>> B.__dict__['a1']
<__main__.A object at 0x7fe5a9a4baf0>
Enter fullscreen mode Exit fullscreen mode

要注意 a1 是定義在 B 中的屬性, 所以必須以 B 取用 __dict__ 才能找到。

利用這種方式讀取屬性, 並不會觸發描述器機制, 只有使用 . 運算才會。

定義在類別內的屬性才會成為描述器

只有定義在類別內的屬性才會依循描述器的機制, 如果你把屬性加入類別的實例中, 即使該屬性所指向物件的類別有定義 __get__, 也不會被當成描述器。例如:

>>> b1.a2 = A()
>>> b1.a2
<__main__.A object at 0x7fe5a8b6e770>
Enter fullscreen mode Exit fullscreen mode

雖然 a2 屬性指向 A 的實例, 但是因為 a2 是在 B 的實例 b1 中定義, 所以不會被視為是描述器, 讀取 a2 屬性時就會直接取得它所指向的物件。如果將 a2 加到 B 類別中, 就會被視為描述器:

>>> del b1.a2
>>> B.a2 = A()
>>> b1.a2
self:     <__main__.A object at 0x7fe5a90094b0>
instance: <__main__.B object at 0x7fe5a9739510>
owner:    <class '__main__.B'>
True

>>> B.a2
self:     <__main__.A object at 0x7fe5a90094b0>
instance: None
owner:    <class '__main__.B'>
True
Enter fullscreen mode Exit fullscreen mode

繼承結構中的描述器

如果在衍生類別或其實例中讀取描述器, 傳入的引數會是實際引發描述器機制的物件及其類別, 例如:

>>> class C(B):
...     pass
>>> c1 = C()
>>> c1.a1
self:     <__main__.A object at 0x7fe5a9a4baf0>
instance: <__main__.C object at 0x7fe5a972b550>
owner:    <class '__main__.C'>
True
Enter fullscreen mode Exit fullscreen mode

你可以看到雖然 __get__ 的第 3 個參數慣例上命名為 owner, 但是傳入的是實際上引發描述器機制的類別。像是剛剛的例子中, 擁有 a1 屬性的是 A 類別, 但 owner 傳入的是 C 類別。

你也可以透過類別讀取定義在父類別中的描述器:

>>> C.a1
self:     <__main__.A object at 0x7fe5a9a4baf0>
instance: None
owner:    <class '__main__.C'>
True
Enter fullscreen mode Exit fullscreen mode

描述器的用途--將函式變成方法

在 Python 中描述器無所不在, 最常用到的地方就是透過物件叫用方法的時候。其實方法就是定義在類別內的函式, 本質上和定義在其它地方的函式沒有什麼不同, 不過因為函式是 function 類別的實例, 而 function 類別定義有 __get__, 這使得定義在類別內的函式變成描述器, 造成它們與其它地方定義的函式有所不同。

function 類別定義的 __get__ 運作的方式如下:

  • 如果 instance 參數是 None, 就傳回 self, 也就是傳回原本的函式。
  • 否則傳回一個 method 類別實例, 並將 selfinstance 記錄在個別屬性中。

我們可以來測試看看, 首先定義一個內含函式的類別:

>>> class D:
...     def f():
...         pass
Enter fullscreen mode Exit fullscreen mode

直接透過類別讀取屬性 f, 此時傳給 instance 的是 None

>>> D.f
<function D.f at 0x7fe5a9a4dbd0>
Enter fullscreen mode Exit fullscreen mode

你可以看到取得的是 D 中的 f 自己。但若是透過 D 的實例讀取屬性 f

>>> d1 = D()
>>> d1.f
<bound method D.f of <__main__.D object at 0x7fe5a9689c60>>

>>> type(d1.f)
<class 'method'>
Enter fullscreen mode Exit fullscreen mode

就會取得 method 類別的實例, 它會將觸發描述器機制的物件記錄在 __self__ 屬性, 並且將描述器記錄在 __func__ 屬性內:

>>> m = d1.f
>>> m.__self__
<__main__.D object at 0x7fe5a9689c60>

>>> d1
<__main__.D object at 0x7fe5a9689c60>

>>> m.__func__
<function D.f at 0x7fe5a9a4dbd0>
Enter fullscreen mode Exit fullscreen mode

method 類別的實例是可叫用的, 它的 __call__ 會轉而叫用記錄的描述器本身, 也就是原本類別中的函式, 而且還會把記錄的物件插入到引數清單的最開頭:

m()
  
 
m.__func__(m.__self__)
Enter fullscreen mode Exit fullscreen mode

也就是說:

d1.f()
  
 
D.__dict__['f'](d1)
Enter fullscreen mode Exit fullscreen mode

由於插入了一個引數, 但是剛剛範例中的 f 並沒有任何參數, 透過物件叫用就會出錯:

>>> d1.f()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: D.f() takes 0 positional arguments but 1 was given
Enter fullscreen mode Exit fullscreen mode

要讓 method 轉叫用函式的機制可以正確運作, 函式就必須至少具有一個參數, 由於傳入的是原本物件自己, 所以慣例上就把這個參數命名為 self, 以下是修正過後的類別 D

>>> class D:
...     def f(self):
...         print(self)
>>> d1 = D()
>>> d1
<__main__.D object at 0x7fe5a974a050>
>>> d1.f()
<__main__.D object at 0x7fe5a974a050>
Enter fullscreen mode Exit fullscreen mode

每次讀取描述器時, 實際上都會轉換成叫用描述器的 __get__, 比單純讀取要耗時, 這也讓 Python 效能較低, 但是卻換來了極高的彈性。

將函式變成靜態方法

函式雖然可以透過描述器機制轉變為方法, 不過這樣一來叫用時就一定要傳入引數, 如果類別中定義的函式根本不需要讀取實例中的屬性, 而且希望即使沒有建立實例也可以叫用, 可以用內建的 staticmethod 類別來包裝:

>>> class D:
...     def f():
...         pass
...     f = staticmethod(f)
>>> type(D.__dict__['f'])
<class 'staticmethod'>
Enter fullscreen mode Exit fullscreen mode

staticmethod 也定義有 __get__, 建立實例時會把傳入的函式記錄在 __func__ 中:

>>> D.__dict__['f'].__func__
<function D.f at 0x7fe5a9a4db40>
Enter fullscreen mode Exit fullscreen mode

不論是透過類別還是實例讀取以 staticmethod 包裝的屬性而觸發描述器機制時, staticmethod__get__ 都會直接傳回記錄的函式:

>>> D.f
<function D.f at 0x7fe5a9a4db40>

>>> d1 = D()
>>> d1.f
<function D.f at 0x7fe5a9a4db40>
Enter fullscreen mode Exit fullscreen mode

因此透過類別或是實例叫用, 都像是叫用一般函式一樣, 不需要 self 參數:

>>> D.f()
>>> d1.f()
Enter fullscreen mode Exit fullscreen mode

剛剛 staticmethod 的用法其實就是裝飾器實際的功用, 所以也可以把它當成裝飾器使用:

>>> class D:
...     @staticmethod
...     def f():
...         pass
Enter fullscreen mode Exit fullscreen mode

程式碼看起來就更簡潔了。

將函式變成類別方法

如果你希望在函式中可以讀取所屬類別的屬性, 或是可以得知實際叫用函式的類別, 也可以使用內建的 classmethod 包裝函式:

>>> class D:
...     def f(cls):
...         pass
...     f = classmethod(f)
>>> type(D.__dict__['f'])
<class 'classmethod'>
Enter fullscreen mode Exit fullscreen mode

classmethod 一樣定義有 __get__, 建立實例時會把傳入的函式記錄在 __func__ 屬性中:

>>> D.__dict__['f'].__func__
<function D.f at 0x7fe5a9a4dcf0>
Enter fullscreen mode Exit fullscreen mode

不論是透過類別或是實例讀取屬性引發描述器機制時, classmethod__get__ 都會傳回一個 method 實例:

>>> D.f
<bound method D.f of <class '__main__.D'>>

>>> d1 = D()
>>> d1.f
<bound method D.f of <class '__main__.D'>>
Enter fullscreen mode Exit fullscreen mode

不過和 function 自己包裝的結果不同, 它的 __self__ 屬性不是紀錄叫用時的物件, 而是類別:

>>> m.__self__
<class '__main__.D'>

>>> m.__func__
<function D.f at 0x7fe5a9a4dcf0>
Enter fullscreen mode Exit fullscreen mode

因此, 叫用時一樣會自動將 __self__ 插入到引數清單的開頭。也就是說, 不論是透過類別或是實例, 都可以叫用以 classmethod 包裝的函式:

>>> D.f()

>>> d1.f()
Enter fullscreen mode Exit fullscreen mode

不過因為插入的引數是類別, 所以慣例上要作為類別方法的函式第一個參數會命名為 cls, 表示為 class 的意思。

如同 staticmethod, 你也可以把 classmethod 當裝飾器使用, 讓程式碼更簡潔:

>>> class D:
...     @classmethod
...     def f(cls):
...         pass
Enter fullscreen mode Exit fullscreen mode

資料描述器 (data descriptor)

描述器不只可以在讀取時發揮效用, 如果描述器所屬類別定義有 __set__, 在進行屬性的指派時就會被自動叫用, 舉例來說:

>>> class A:
...     def __get__(self, instance, owner):
...         return instance.a
...     def __set__(self, instance, value):
...         instance.a = value if value > 0 else 0
Enter fullscreen mode Exit fullscreen mode

接著在類別中建立指向 A 類別實例的屬性:

>>> class B:
...     a1 = A()
...     def __init__(self, value):
...         self.a1 = value
Enter fullscreen mode Exit fullscreen mode

a1 會因為 A 中定義有 __get____set__ 而成為描述器, 在 __init__ 中指派 a1 時就會自動叫用 __set__, 請看以下範例:

>>> b1 = B(-10)
>>> b1.a1
0
Enter fullscreen mode Exit fullscreen mode

在建立 b1 時傳入了 -10, 不過因為 a1 是描述器, 所以實際上會叫用 A 中的 __set__, 其中第 3 個參數就是指派時等號右邊的運算結果, A 中的 __set__ 會把值設定在 b1a 屬性中, 並且會確保值一定不會是負數, 所以雖然建立實例時傳入了 -10, 但是讀取時會發現值為 0。

這種定義有 __set__ 的描述器稱為資料描述器 (data descriptor), 用它來包裝類別中的屬性時, 就可以在讀取或是指派時進行額外的轉換或是檢查工作, 確保儲存的屬性值合乎正確範圍。

把屬性包裝成受管理屬性

使用描述器包裝屬性雖然很好用, 不過因為還要自行建立描述器的類別, 讀取與設定屬性的函式也都與實際應用描述器的類別不在同一個地方, 很容易造成兩邊不一致, 因此 Python 提供有 property 類別可以用來簡化程式碼, 它也定義有 __get____set__, 所以若是在類別中以該類別實例建立屬性, 也會變成描述器。實際使用方式如下:

>>> class B:
...     def get_a1(self):
...         return self.__a
...     def set_a1(self, value):
...         self.__a = value if value > 0 else 0
...     a1 = property(get_a1, set_a1)
...     def __init__(self, value):
...         self.a1 = value
>>> B.__dict__['a1']
<property object at 0x7fe5abf8e2a0>
Enter fullscreen mode Exit fullscreen mode

只要定義好讀取與指派屬性的函式, 然後傳入 property 建立實例, 它會將傳入的函式記錄在個別屬性中:

>>> B.__dict__['a1'].fget
<function B.get_a1 at 0x7fe5aadfca60>

>>> B.__dict__['a1'].fset
<function B.set_a1 at 0x7fe5aadfd510>
Enter fullscreen mode Exit fullscreen mode

之後透過物件讀取它時就會引發描述器機制, 它的 __get__/__set__ 會自動幫你叫用對應的 fget/fset 函式:

>>> b1 = B(-10)
>>> b1.a1
0
Enter fullscreen mode Exit fullscreen mode

實際結果跟前面的範例一樣, 但有以下好處:

  • 讀取與指派的函式都直接定義在類別中, 並不需要自己額外為描述器定義類別。
  • 可以在讀取與設定屬性的函式中使用雙底線開頭的名稱, 便於隱藏實際儲存資料的屬性。像是本例中, 就把值儲存在 __a 中。

這種實際上是透過叫用函式來取得或是設定值的屬性, 因為可以在函式中加入轉換或是檢查的程式碼, 所以稱為受管理的屬性 (managed attributes)

分段建立受管理的屬性

建立 property 實例的過程也可以分段進行, 例如先傳入讀取屬性的函式建立實例後, 再指定用來設定屬性的函式, 像是這樣:

>>> class B:
...     def a1(self):
...         return self.__a
...     _a1 = property(fget=a1)
...     def a1(self, value):
...         self.__a = value if value > 0 else 0
...     a1 = _a1.setter(a1)
...     def __init__(self, value):
...         self.a1 = value
Enter fullscreen mode Exit fullscreen mode

或是也可以反過來, 先以指派屬性的函式建立 property 實例, 再設定讀取屬性的函式:

>>> class B:
...     def a1(self, value):
...         self.__a = value if value > 0 else 0
...     _a1 = property(fset=a1)
...     def a1(self):
...         return self.__a
...     a1 = _a1.getter(a1)
...     def __init__(self, value):
...         self.a1 = value
Enter fullscreen mode Exit fullscreen mode

由於分段進行, 甚至可以把讀取、設定屬性的函式都和最後的屬性同名。

把 property 當裝飾器用

由於 property 建立實例時的第 1 個參數是 fget, 所以也可以不用指名把剛剛的範例改寫成這樣:

>>> class B:
...     def a1(self):
...         return self.__a
...     _a1 = property(a1)
...     def a1(self, value):
...         self.__a = value if value > 0 else 0
...     a1 = _a1.setter(a1)
...     def __init__(self, value):
...         self.a1 = value
Enter fullscreen mode Exit fullscreen mode

剛剛的範例中又出現了裝飾器的程式碼結構, 所以我們也可以把 property 當裝飾器用, 改寫成以下這樣:

>>> class B:
...     @property
...     def a1(self):
...         return self.__a
...     @a1.setter
...     def a1(self, value):
...         self.__a = value if value > 0 else 0
...     def __init__(self, value):
...         self.a1 =  value
Enter fullscreen mode Exit fullscreen mode

就可以讓程式碼更簡潔, 表達出 a1 是一個受管理屬性的意圖。

小結

本文一路從描述器的簡介, 到把函式轉成方法、靜態方法、類別方法, 以及定義受管理屬性, 可以看到 Python 中巧妙的利用描述器的機制, 完成物件導向中各種概念的實作, 非常幽雅。

Reinvent your career. Join DEV.

It takes one minute and is worth it for your career.

Get started

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs