讓人眼花繚亂的無限繼承

Python語言的一個優勢是簡潔易用。是否簡潔易用僅僅是Python語言本身的一個話題,但“好消息”是如果你想創造那種一大堆繼承、混亂的內部關係的代碼,也是可以的!

今天煩人的代碼來自於驗證某些math-y數學分析代碼。一開始,他們是發現文檔和代碼對應不上,只得去閱讀代碼看看代碼到底做了什麼事情。在一個文件中,找到了一個業務關注的核心類,定義如下所示。

class WeibullFitter(KnownModelParametricUnivariateFitter):
    # snip: some math

表面上看,這個數學方法看上去多少是對的,但有個問題是:父類是怎麼調用的?沒辦法,只能往上查看其父類,父類的代碼如下:

class KnownModelParametricUnivariateFitter(ParametricUnivariateFitter):

    _KNOWN_MODEL = True

這個所謂的基類壓根就算不上基類,“雞肋”還差不多!僅僅是設置了一個屬性爲True,沿着繼承樹再往上走,找到基類代碼如下:

class ParametricUnivariateFitter(UnivariateFitter):
    
    # ...snip...

呃,雖然這還不是最終的基類,但至少這個類還實現了某些方法。這樣的寫法肯定讓你懷疑代碼結構的問題了,目前爲止還沒找到真正煩人的代碼。但目前我們可以討論一下爲什麼說繼承在某種程度上是有害的。繼承會自動產生依賴,這意味着如果要理解子類的行爲必須同時瞭解父類的行爲。當然,好的繼承的實現會劃定這些邊界,但顯然我們看到的是一個反面例子。

除此之外,由於Python是一個弱類型語言,因此繼承的優點之一的多態在Python裏都算不上優點了。沒錯,我們可以通過Python的類型註解去做類型檢查,這會讓多態派上用場,但這沒法判斷整個繼承樹。

撇開這一切不談,使用繼承的主要原因是我們可以將公共的業務邏輯部分抽離開,讓子類系統無需處理這些公共的業務邏輯。因此,即便存在多種可能的類型,我們仍然可以調用具體實例的某個方法,並且能夠保證如期望那樣運行,且可以呈現不同的行爲。接着來看ParametricUnivariateFitter這個類,類中定義瞭如下方法:

def _fit_model(self, Ts, E, entry, weights, show_progress=True):

        if utils.CensoringType.is_left_censoring(self): # Oh no.            
           negative_log_likelihood = self._negative_log_likelihood_left_censoring
        elif utils.CensoringType.is_interval_censoring(self): # Oh no no no. 
           negative_log_likelihood = self._negative_log_likelihood_interval_censoring
        elif utils.CensoringType.is_right_censoring(self): # This is exactly what I think it is isn't it.               
           negative_log_likelihood = self._negative_log_likelihood_right_censoring
        
        # ...snip...

註釋是問題發現人提供的。爲了滿足整個子類樹,每個子類都使用了類型檢查,因此子類不同的行爲是通過類型檢查來實現的。這可以說是100%的臭代碼!當我們去閱讀CensoringType代碼的時候,讓我們再次確信了這一點。

class CensoringType(Enum): # enum.Enum from the standard library
    LEFT = "left"
    INTERVAL = "interval"
    RIGHT = "right"
    
    @classmethod
    def right_censoring(cls, function: Callable) -> Callable:
        @wraps(function) # functools.wraps from the standard library        def f(model, *args, **kwargs):
            cls.set_censoring_type(model, cls.RIGHT)
            return function(model, *args, **kwargs)

        return f

    @classmethod
    def left_censoring(cls, function: Callable) -> Callable:
        @wraps(function)
        def f(model, *args, **kwargs):
            cls.set_censoring_type(model, cls.LEFT)
            return function(model, *args, **kwargs)

        return f

    @classmethod
    def interval_censoring(cls, function: Callable) -> Callable:
        @wraps(function)
        def f(model, *args, **kwargs):
            cls.set_censoring_type(model, cls.INTERVAL)
            return function(model, *args, **kwargs)

        return f

    @classmethod
    def is_right_censoring(cls, model) -> bool:
        return cls.get_censoring_type(model) == cls.RIGHT

    @classmethod
    def is_left_censoring(cls, model) -> bool:
        return cls.get_censoring_type(model) == cls.LEFT

    @classmethod
    def is_interval_censoring(cls, model) -> bool:
        return cls.get_censoring_type(model) == cls.INTERVAL

    @classmethod
    def get_censoring_type(cls, model) -> str:
        return model._censoring_type

    @classmethod
    def str_censoring_type(cls, model) -> str:
        return model._censoring_type.value

    @classmethod
    def set_censoring_type(cls, model, censoring_type) -> None:
        model._censoring_type = censoring_type

即便是你不懂Python代碼,你也會想到這是一個枚舉。@classmethod是Python註解靜態方法的修飾符,就像類成員方法中使用self作爲第一個參數一樣,靜態方法使用cls作爲方法的第一個參數,以代表類本身。

去看一下right_censoring方法也很重要,因爲這些方法看起來是“裝飾器”。他們使用了@wraps來修飾定義的局部方法。這個right_censoring方法需要接收一個可調用的函數(也許是一個構造函數),然後將該方法的實現用內部以“f”方法替換。並且在這裏面,在調用構造函數之前,修改了構造方法的參數值。

如果你不經常使用Python編程的話,你可能會覺得十分困惑,因此我們來看看這個方法如何使用的:

@CensoringType.right_censoringclass SomeCurveFitterType(SomeHorribleTreeOfBaseClasses):
    def __init__(self, model, *args, **kwargs):
        # snip
instance = SomeCurveFitterType(model, *args, **kwargs)

在最後一行代碼,並沒有調用```init``構造函數,而是首先通過內部的f函數,這個函數最重要的一件事就是在調用構造函數前,調用靜態方法cls.set_censoring_type(model, cls.RIGHT)

如果你對此完全不理解,也不用感到糟糕。裝飾器是Python的一種獨特的方式去修改類和方法的實現。這個特性允許你在傳統的方式中混合使用聲明式編程。

最終,爲了理解WeibullFilter這個類的行爲,你必須閱讀半大祖先類代碼才能看到BaseFitter類型,然後你必須注意應用了什麼裝飾器以及裝飾器對應的祖先類,只有這樣才真正知道這個業務的功能是什麼。

如果你是寫這些代碼的人,也許會覺得這種混入裝飾器和繼承的寫法擴展性很高。可以快速輕鬆地在框架裏插入一個曲線擬合方法。你甚至會以爲你的大腦創造了這個星球上最具工程化的框架。而餘下的我們,必須像盲人那樣使用棍子去探路。
最後我們的問題提交人總結到:

我對此感到很糟糕。這個庫看着不錯,數學方法本身也不錯,並且這個庫非常有用,減輕了我很多工作……
這一系列的行爲導致了性能問題,我不得不重新做不同的實現。因爲這一系列的嵌套調用將簡單的處理過程的性能給破壞了——執行時間可能超過10分鐘。而新寫的代碼幾乎是瞬間完成,包含註釋也就不到20行。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章