Python程式碼整潔之道--使用裝飾器改進程式碼

青穗黃發表於2018-12-02

本文為英文書籍 Clean Code in Python Chapter 5 Using Decorators to Improve Our Code 學習筆記,建議直接看原書

  • 瞭解Python中裝飾器的工作原理
  • 學習如何實現應用於函式和類的裝飾器
  • 有效地實現裝飾器,避免常見的執行錯誤
  • 分析如何用裝飾器避免程式碼重複(DRY)
  • 研究裝飾器如何為關注點分離做出貢獻
  • 優秀裝飾器例項分析
  • 回顧常見情況、習慣用法或模式,瞭解何時裝飾器是正確的選擇

雖然一般見到裝飾器裝飾的是方法和函式,但實際允許裝飾任何型別的物件,因此我們將探索應用於函式、方法、生成器和類的裝飾器。

還要注意,不要將裝飾器與裝飾器設計模式(Decorator Pattern)混為一談。

函式裝飾

函式可能是可以被裝飾的Python物件中最簡單的表示形式。我們可以在函式上使用裝飾器來達成各種邏輯——可以驗證引數、檢查前提條件、完全改變行為、修改簽名、快取結果(建立原始函式的儲存版本)等等。

作為示例,我們將建立實現重試機制的基本裝飾器,控制特定的域級異常(domain-level exception)並重試一定次數:

# decorator_function_1.py
import logging
from functools import wraps

logger = logging.getLogger(__name__)


class ControlledException(Exception):
    """A generic exception on the program's domain."""
    pass


def retry(operation):
    @wraps(operation)
    def wrapped(*args, **kwargs):
        last_raised = None
        RETRIES_LIMIT = 3
        for _ in range(RETRIES_LIMIT):
            try:
                return operation(*args, **kwargs)
            except ControlledException as e:
                logger.info("retrying %s", operation.__qualname__)
                last_raised = e
        raise last_raised

    return wrapped

複製程式碼

可以暫時忽略@wraps,之後再介紹
retry裝飾器使用例子:

@retry
def run_operation(task):
   """Run a particular task, simulating some failures on its execution."""
   return task.run()
複製程式碼

因為裝飾器只是提供的一種語法糖,實際上等於run_operation = retry(run_operation)
比較常用的超時重試,便可以這樣實現。

定義一個帶引數的裝飾器

我們用一個例子詳細闡述下接受引數的處理過程。 假設你想寫一個裝飾器,給函式新增日誌功能,同時允許使用者指定日誌的級別和其他的選項。 下面是這個裝飾器的定義和使用示例:

from functools import wraps
import logging

def logged(level, name=None, message=None):
    """
    Add logging to a function. level is the logging
    level, name is the logger name, and message is the
    log message. If name and message aren't specified,
    they default to the function's module and name.
    """
    def decorate(func):
        logname = name if name else func.__module__
        log = logging.getLogger(logname)
        logmsg = message if message else func.__name__

        @wraps(func)
        def wrapper(*args, **kwargs):
            log.log(level, logmsg)
            return func(*args, **kwargs)
        return wrapper
    return decorate

# Example use
@logged(logging.DEBUG)
def add(x, y):
    return x + y

@logged(logging.CRITICAL, 'example')
def spam():
    print('Spam!')

複製程式碼

初看起來,這種實現看上去很複雜,但是核心思想很簡單。 最外層的函式 logged() 接受引數並將它們作用在內部的裝飾器函式上面。 內層的函式 decorate() 接受一個函式作為引數,然後在函式上面放置一個包裝器。 這裡的關鍵點是包裝器是可以使用傳遞給 logged() 的引數的。

定義一個接受引數的包裝器看上去比較複雜主要是因為底層的呼叫序列。特別的,如果你有下面這個程式碼:

@decorator(x, y, z)
def func(a, b):
    pass
複製程式碼

裝飾器處理過程跟下面的呼叫是等效的;

def func(a, b):
    pass
func = decorator(x, y, z)(func)
decorator(x, y, z) 的返回結果必須是一個可呼叫物件,它接受一個函式作為引數幷包裝它
複製程式碼

類裝飾

有些人認為,裝飾類是比較複雜的事情,而且這樣的方案可能危及可讀性。因為我們在類中宣告一些屬性和方法,但是裝飾器可能會改變它們的行為,呈現出完全不同的類。

在這種技術被嚴重濫用的情況下,這種評價是正確的。客觀地說,這與裝飾函式沒有什麼不同;畢竟,類只是Python生態系統中的另一種型別的物件,就像函式一樣。我們將在標題為“裝飾器和關注點分離”的章節中一起回顧這個問題的利弊,但是現在,我們將探討類的裝飾器的好處:

  • 程式碼重用和DRY。一個恰當的例子是,類裝飾器強制多個類符合某個特定的介面或標準(通過在裝飾器中僅檢查一次,而能應用於多個類)
  • 可以建立更小或更簡單的類,而通過裝飾器增強這些類
  • 類的轉換邏輯將更容易維護,而不是使用更復雜(通常是理所當然不被鼓勵的)的方法,比如元類

回顧監視平臺的事件系統,我們現在需要轉換每個事件的資料並將其傳送到外部系統。 但是,在選擇如何傳送資料時,每種型別的事件可能都有自己的特殊性。

特別是,登入的事件可能包含敏感資訊,如登入資訊需要隱藏, 時間戳等其他欄位也可能需要特定的格式顯示。

class LoginEventSerializer:
    def __init__(self, event):
        self.event = event

    def serialize(self) -> dict:
        return {
            "username": self.event.username,
            "password": "**redacted**",
            "ip": self.event.ip,
            "timestamp": self.event.timestamp.strftime("%Y-%m-%d% H: % M"),}


class LoginEvent:
    SERIALIZER = LoginEventSerializer

    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp

    def serialize(self) -> dict:
        return self.SERIALIZER(self).serialize()
複製程式碼

在這裡,我們宣告一個類,該類將直接對映到登入事件,包含其邏輯——隱藏密碼欄位,並根據需要格式化時間戳。

雖然這種方法可行,而且看起來是個不錯的選擇,但是隨著時間的推移,想要擴充套件我們的系統,就會發現一些問題:

  • 類太多:隨著事件數量的增加,序列化類的數量將以相同的數量級增長,因為它們是一一對映的。
  • 解決方案不夠靈活:如果需要重用元件的一部分(例如,我們需要在另一種事件中隱藏密碼),則必須將其提取到一個函式中,還要從多個類中重複呼叫它,這意味著我們沒有做到程式碼重用。
  • Boilerplate:serialize()方法必須出現在所有事件類中,呼叫相同的程式碼。雖然我們可以將其提取到另一個類中(建立mixin),但它似乎不是繼承利用的好方式( Although we can extract this into another class (creating a mixin), it does not seem like a good use of inheritance.)。

另一種解決方案是,給定一組過濾器(轉換函式)和一個事件例項,能夠動態構造物件,該物件能夠通過濾器對其欄位序列化。然後,我們只需要定義轉換每種型別的欄位的函式,並且通過組合這些函式中的許多函式來建立序列化程式。

一旦有了這個物件,我們就可以裝飾類,以便新增serialize()方法,該方法將只呼叫這些Serialization物件本身:

def hide_field(field) -> str:
    return "**redacted**"


def format_time(field_timestamp: datetime) -> str:
    return field_timestamp.strftime("%Y-%m-%d %H:%M")


def show_original(event_field):
    return event_field


class EventSerializer:
    def __init__(self, serialization_fields: dict) -> None:
        self.serialization_fields = serialization_fields

    def serialize(self, event) -> dict:
        return {
            field: transformation(getattr(event, field))
            for field, transformation in self.serialization_fields.items()
        }


class Serialization:
    def __init__(self, **transformations):
        self.serializer = EventSerializer(transformations)

    def __call__(self, event_class):
        def serialize_method(event_instance):
            return self.serializer.serialize(event_instance)

        event_class.serialize = serialize_method
        return event_class


@Serialization(
    username=show_original,
    password=hide_field,
    ip=show_original,
    timestamp=format_time,
)
class LoginEvent:
    def __init__(self, username, password, ip, timestamp):
        self.username = username
        self.password = password
        self.ip = ip
        self.timestamp = timestamp
複製程式碼

待續。。。

相關文章