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行。