Python 物件導向背後的魔法--描述器 (descriptor)
Tags: python
Published: 2024-02-13 08:00:55

## Python 小猜謎

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

```python
>>> class A:
...     def __get__(self, instance, owner):
...         return True
>>> class B:
...     a1 = A()
>>> b1 = B()
>>> b1.a1
```

它會輸出：

```python
True
```

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

```python
>>> B.a1
```

你的答案是？其實結果還是一樣：

```python
True
```

那如果是這樣呢？

```python
>>> B.__dict__['a1']
```

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

```
<__main__.A object at 0x7fe5a9ba93f0>
```

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

## 描述器 (descriptor)

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

以 `.` 運算讀取描述器時, 不會直接取得指向的物件, 而是會轉而呼叫 `__get__` 函式, 也就是 `b.a1` 會被轉換成 `A.__get__()`：

```
b.a1
 │ 
 ↓ 
A.__dict__[__get__](b.__dict__['a1'], b, B)
                                │     │  │
         self ──────────────────┘     │  │
     instance ────────────────────────┘  │
        owner ───────────────────────────┘
```

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

```python
>>> 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
```

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

```
self:     <__main__.A object at 0x7fe5a9a4baf0>
instance: <__main__.B object at 0x7fe5a9739510>
owner:    <class '__main__.B'>
True
```

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

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


### 透過類別讀取描述器

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

```python
>>> B.a1
```

則會得到以下結果：

```
self:     <__main__.A object at 0x7fe5a9a4baf0>
instance: None
owner:    <class '__main__.B'>
True
```

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

### 強制取得描述器

如果想要取得描述器本身, 不要引發描述器機制叫用函式, 就必須透過類別的特殊名稱 `__dict__`：

```python
>>> B.__dict__['a1']
<__main__.A object at 0x7fe5a9a4baf0>
```

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

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

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

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

```python
>>> b1.a2 = A()
>>> b1.a2
<__main__.A object at 0x7fe5a8b6e770>
```

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

```python
>>> 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
```

### 繼承結構中的描述器

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

```python
>>> 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
```

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

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

```python
>>> C.a1
self:     <__main__.A object at 0x7fe5a9a4baf0>
instance: None
owner:    <class '__main__.C'>
True
```

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

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

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

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

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

```python
>>> class D:
...     def f():
...         pass
```

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

```python
>>> D.f
<function D.f at 0x7fe5a9a4dbd0>
```

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

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

>>> type(d1.f)
<class 'method'>
```

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

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

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

>>> m.__func__
<function D.f at 0x7fe5a9a4dbd0>
```

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

```python
m()
 │ 
 ↓
m.__func__(m.__self__)
```

也就是說：

```python
d1.f()
 │ 
 ↓
D.__dict__['f'](d1)
```

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

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

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

```python
>>> class D:
...     def f(self):
...         print(self)
>>> d1 = D()
>>> d1
<__main__.D object at 0x7fe5a974a050>
>>> d1.f()
<__main__.D object at 0x7fe5a974a050>
```

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

## 將函式變成靜態方法

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

```python
>>> class D:
...     def f():
...         pass
...     f = staticmethod(f)
>>> type(D.__dict__['f'])
<class 'staticmethod'>
```

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

```python
>>> D.__dict__['f'].__func__
<function D.f at 0x7fe5a9a4db40>
```

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

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

>>> d1 = D()
>>> d1.f
<function D.f at 0x7fe5a9a4db40>
```

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

```python
>>> D.f()
>>> d1.f()
```

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

```python
>>> class D:
...     @staticmethod
...     def f():
...         pass
```

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

## 將函式變成類別方法

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

```python
>>> class D:
...     def f(cls):
...         pass
...     f = classmethod(f)
>>> type(D.__dict__['f'])
<class 'classmethod'>
```

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

```python
>>> D.__dict__['f'].__func__
<function D.f at 0x7fe5a9a4dcf0>
```

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

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

>>> d1 = D()
>>> d1.f
<bound method D.f of <class '__main__.D'>>
```

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

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

>>> m.__func__
<function D.f at 0x7fe5a9a4dcf0>
```

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

```python
>>> D.f()

>>> d1.f()
```

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

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


```python
>>> class D:
...     @classmethod
...     def f(cls):
...         pass
```

## 資料描述器 (data descriptor)

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

```python
>>> class A:
...     def __get__(self, instance, owner):
...         return instance.a
...     def __set__(self, instance, value):
...         instance.a = value if value > 0 else 0
```

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

```python
>>> class B:
...     a1 = A()
...     def __init__(self, value):
...         self.a1 = value
```

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

```python
>>> b1 = B(-10)
>>> b1.a1
0
```

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

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

## 把屬性包裝成受管理屬性

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

```python
>>> 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>
```

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

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

>>> B.__dict__['a1'].fset
<function B.set_a1 at 0x7fe5aadfd510>
```

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

```python
>>> b1 = B(-10)
>>> b1.a1
0
```

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

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

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

### 分段建立受管理的屬性

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

```python
>>> 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
```

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

```python
>>> 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
```

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

### 把 property 當裝飾器用

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

```python
>>> 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
```

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

```python
>>> 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
```

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

## 小結

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