更 Python 的 Pythonic Coding – Dunder Method 篇

Introduction

Dunder Method 就是 Python 中 Class 內建用兩個底線包夾名稱的方法,例如 __init__(唸 Dunder Init)。除了初始化之外,還能夠讓你自行發揮創意,客製化各種 Python 內建的語法,使得程式碼看起來更像Python。

來看看如何使用吧~

__dunder__ Methods

__repr__ 與 __str__

字串雙兄弟,前者用字串來描述我是誰,後者單純將自己輸出成字串格式。一個常見的 __repr__ 寫法是直接輸出與自己等價的創建呼叫(直接複製貼上就可以再 New 一個跟自己一樣的複製物件的懶人作法)。

class Person:
    def __init__(self, name):
        self._n = name
    def __repr__(self):
        return f'{self.__class__.__name__}({self._n!r})'
寫成 self.__class__.__name__ 的話,會顯示最後一個繼承自己的 Class 名稱,相當方便。

另一個例子是輸出 str 的時候,如果需要引號以表明型態,__repr__ 此時就能派上用場。

>>> f'A function name {method}' # call __str__
A function name __init__
>>> f'A function name {method!r}' # call __repr__
A function name '__init__'
See §7.1.1. Formatted String Literals for more about string format.

__getitem__ 與 __setitem__

切片語法(Slice Notation)就靠它們了。

## __getitem__
>>> foo[2]
'n'
>>> foo[:1]
'p'
>>> foo[1:]
'en'

當中括號裡面出現冒號的時候,輸入的 index 會變成時 slice type(包含 startstopstep Attributes),並且再透過 __setitem__ 就可以實作一個神奇的 Mutable(內容可變型)自製 str

class MutableStr:
    def __init__(self, txt):
        self._txt = txt
    def __repr__(self):
        return self._txt.__repr__()
    def __str__(self):
        return self._txt
    def __getitem__(self, index):
        return self._txt.__getitem__(index)
    def __setitem__(self, index, txt):
        if type(index) == int:
            start, end = index, index + 1
        else:
            start, end = index.start or 0, index.stop or None
        tail = self._txt[end:] if end is not None else ''
        self._txt = self._txt[:start] + txt + tail

來玩一下 MutableStr。

## __setitem__
>>> m = MutableStr('00000')
>>> m
'00000'
>>> m[3:] = 'zzz'
>>> m
'000zzz'
>>> m[:2] = '22'
>>> m
'220zzz'
如果想要使用 + 來串接 MutableStr,需要實作 __add__ Method。

__getattr__ 與 __setattr__

如果覺得 dict 每次都要寫中括號很麻煩,可以用 SimpleNamespacenamedtuple 來簡單改寫,改成以 Attribute 的方式(Dot Notation)來存取變數。而若想要進一步客製化屬性的行為,就需要用到這 2 個 Dunder Method。

例如以下 Class。

class DefaultZero:
    def __getattr__(self, key):
        return 0
    def __setattr__(self, key, value):
        self.__dict__[key] = value
    def print_1(self):
        print(1)
在 __setattr__ 中如果使用到 self.xxx 依然會呼叫 __getattr__ method,反之亦然將陷入無窮迴圈。用 self.__dict__ 更新屬性可以避免此狀況。

來看一下行為:

>>> z = DefaultZero()
>>> z.Apple
0
>>> z.banana = 777
>>> z.banana
777
>>> z.print_1()
1
>>> z.print_2() # z.print_2 fallback to 0, so not callable
TypeError: 'int' object is not callable

注意:被設值之後的屬性(Attribute)或有實作的方法(Method)就不會再走 __getattr__。

__enter__ 與 __exit__

有一些操作需要前置作業以及收拾殘局,例如 open 一個檔案之後需要 close 檔案。Python 提供了一個方便的 with 語法(Context Management)可以輕鬆解決這個問題,而我們可以輕鬆的客製化這個行為。

例如一個簡單的 Class 將 stdout 暫時關閉。

import sys
import io

class MuteStdout:
    def __init__(self):
        self._backup = sys.stdout
        self.buffer = io.StringIO()
    def __enter__(self):
        sys.stdout = self.buffer
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        sys.stdout = self._backup

然後試玩一下:

>>> with MuteStdout() as m:
>>>     print(123)
>>>     print(456)
>>> ## nothing print out here
>>> m.buffer.getvalue()
'123\n456\n'

__enter__ 之前會先走 __init__,這樣就可以簡單的暫時把 stdout 重新導向至 buffer 而不用使用者麻煩的切換及備份。如果中途有發生任何的 Exception 也都會由 __exit__ 提供的參數傳入。

順便介紹一個漂亮的 with 用法:

from contextlib import suppress

with suppress(ValueError):
    raise ValueError # some codes may raise error that we don't care

如此即可免去如下的 try…catch… 的寫法:

try:
    raise ValueError # some codes may raise error that we don't care
except ValueError:
    pass

延伸閱讀:處理 Python 拋出的 Exception 例外異常並使用 Assert 確保程式正常執行

各種算數符(四則運算、比較符號等)

根據參考官方文件 Basic customization,可以進一步客製化:

Abstract Base Classes (ABC)

只要實作 Collections Abstract Base Classes 中的 Abstract Methods,就能立即享有所有 Mixin Methods 中的內建方法。

例如我想要自製一種 Sequece 可以走訪某個資料夾底下的檔案們。

from abc import abstractmethod
from collections.abc import Sequence
import os, glob

class LS(Sequence):
    def __init__(self, directory):
        self.file_list = self.list_files(directory)
    def cd(self, directory):
        self.file_list = self.list_files(directory)        
    def __getitem__(self, index):
        return self.file_list[index]        
    def __len__(self):
        return len(self.file_list)
    @abstractmethod
    def list_files(self, directory):
        return []

class ListDir(LS):
    # 歷遍當層資料夾的所有檔案
    def list_files(self, directory):
        return os.listdir(directory)

class ListExe(LS): 
    # 歷遍當層資料夾的所有.exe檔,遞迴要用**並且加recursive=True參數
    def list_files(self, directory):
        directory += '/' # add tailing slash to directory
        flist = glob.glob(directory + '*.exe')
        return [f.replace(directory, '') for f in flist]
有標記 @abstractmethod 的 Class 講無法獨立被創建,只能用來被繼承;而繼承的子類別一定要實作那些標為 Abstract 的方法

來實驗看看:

>>> path = 'path/to/a/dir'
>>> ls1 = ListDir(path)
>>> ls2 = ListExe(path)
>>> list(ls1)
['index.html', 'test.exe', 'canvas.cpp', 'makefile', 'a.exe', 'input.txt']
>>> list(ls2)
['test.exe', 'a.exe']

如果想要建立一個完全空白的 Interface,可以繼承 abc.ABC、並且標記所必須的 abc.abstractmethod 就好。

 


Pythonic 精神就在於用更簡潔易懂的程式來實現同樣的功能,而善用這些 Dunder Method 就能過使得我們自製的Python API 更加平易近人、並且寫出更 Python 的程式。

 

References

好像是第一次放影片的連結上來,最近很愛這名講者:)