確保乾淨的代碼:看一下 Python,參數化

已發表: 2022-03-11

在這篇文章中,我將討論我認為在生成乾淨的 Pythonic 代碼時最重要的技術或模式——即參數化。 這篇文章適合你,如果:

  • 您對整個設計模式的東西相對較新,可能對一長串模式名稱和類圖感到困惑。 好消息是,對於 Python,您絕對必須了解的設計模式只有一種。 更好的是,您可能已經知道它,但也許不是所有可以應用它的方式。
  • 您從另一種 OOP 語言(如 Java 或 C#)來到 Python,並想知道如何將您對該語言的設計模式知識轉化為 Python。 正如作者 Peter Norvig 所說,在 Python 和其他動態類型語言中,靜態類型 OOP 語言中常見的許多模式是“不可見的或更簡單的”。

在本文中,我們將探討“參數化”的應用以及它如何與稱為依賴注入策略模板方法抽象工廠工廠方法裝飾器的主流設計模式相關聯。 在 Python 中,由於 Python 中的參數可以是可調用的對像或類,因此其中許多變得簡單或不必要。

參數化是獲取在函數或方法中定義的值或對象,並將它們作為該函數或方法的參數的過程,以便泛化代碼。 此過程也稱為“提取參數”重構。 在某種程度上,這篇文章是關於設計模式和重構的。

Python參數化的最簡單案例

對於我們的大多數示例,我們將使用教學標準庫 turtle 模塊來製作一些圖形。

下面是一些使用turtle繪製 100x100 正方形的代碼:

 from turtle import Turtle turtle = Turtle() for i in range(0, 4): turtle.forward(100) turtle.left(90)

假設我們現在要繪製一個不同大小的正方形。 此時,非常初級的程序員會很想複製粘貼此塊並進行修改。 顯然,更好的方法是首先將正方形繪製代碼提取到一個函數中,然後將正方形的大小作為該函數的參數:

 def draw_square(size): for i in range(0, 4): turtle.forward(size) turtle.left(90) draw_square(100)

所以我們現在可以使用draw_square繪製任意大小的正方形。 這就是參數化基本技術的全部內容,我們剛剛看到了第一個主要用法——消除複製粘貼編程。

上面代碼的一個直接問題是draw_square依賴於一個全局變量。 這有很多不好的後果,有兩種簡單的方法可以解決它。 第一個是讓draw_square創建Turtle實例本身(稍後我將討論)。 如果我們想為所有繪圖使用單個Turtle ,這可能是不可取的。 所以現在,我們將再次簡單地使用參數化來使turtle成為draw_square的參數:

 from turtle import Turtle def draw_square(turtle, size): for i in range(0, 4): turtle.forward(size) turtle.left(90) turtle = Turtle() draw_square(turtle, 100)

這有一個花哨的名字——依賴注入。 它只是意味著如果一個函數需要某種對象來完成它的工作,比如draw_square需要一個Turtle ,調用者負責將該對像作為參數傳入。 不,真的,如果你曾經對 Python 依賴注入感到好奇,就是這樣。

到目前為止,我們已經處理了兩個非常基本的用法。 本文其餘部分的關鍵觀察是,在 Python 中,有很多東西可以成為參數——比在其他一些語言中更多——這使得它成為一種非常強大的技術。

任何物體

在 Python 中,您可以使用這種技術來參數化任何對象,而在 Python 中,您遇到的大多數事物實際上都是對象。 這包括:

  • 內置類型的實例,如字符串"I'm a string"和整數42或字典
  • 其他類型和類的實例,例如datetime.datetime對象
  • 函數和方法
  • 內置類型和自定義類

最後兩個是最令人驚訝的,特別是如果你來自其他語言,他們需要更多的討論。

作為參數的函數

Python 中的函數語句做了兩件事:

  1. 它創建一個函數對象。
  2. 它在指向該對象的本地範圍內創建一個名稱。

我們可以在 REPL 中使用這些對象:

 > >> def foo(): ... return "Hello from foo" > >> > >> foo() 'Hello from foo' > >> print(foo) <function foo at 0x7fc233d706a8> > >> type(foo) <class 'function'> > >> foo.name 'foo'

就像所有對像一樣,我們可以將函數分配給其他變量:

 > >> bar = foo > >> bar() 'Hello from foo'

請注意, bar是同一對象的另一個名稱,因此它具有與以前相同的內部__name__屬性:

 > >> bar.name 'foo' > >> bar <function foo at 0x7fc233d706a8>

但關鍵是,因為函數只是對象,所以在任何你看到函數被使用的地方,它都可能是一個參數。

因此,假設我們擴展了上面的正方形繪製函數,現在有時當我們繪製正方形時,我們希望在每個角處暫停——調用time.sleep()

但是假設有時我們不想暫停。 實現這一點的最簡單方法是添加一個pause參數,可能默認為零,這樣默認情況下我們不會暫停。

然而,我們後來發現,有時我們實際上想要在角落做一些完全不同的事情。 也許我們想在每個角落繪製另一個形狀,改變筆的顏色,等等。我們可能會想添加更多的參數,一個用於我們需要做的每一件事。 但是,更好的解決方案是允許傳入任何函數作為要採取的操作。 默認情況下,我們將創建一個什麼都不做的函數。 如果需要,我們還將使此函數接受本地turtlesize參數:

 def do_nothing(turtle, size): pass def draw_square(turtle, size, at_corner=do_nothing): for i in range(0, 4): turtle.forward(size) at_corner(turtle, size) turtle.left(90) def pause(turtle, size): time.sleep(5) turtle = Turtle() draw_square(turtle, 100, at_corner=pause)

或者,我們可以做一些更酷的事情,比如在每個角落遞歸繪製更小的正方形:

 def smaller_square(turtle, size): if size < 10: return draw_square(turtle, size / 2, at_corner=smaller_square) draw_square(turtle, 128, at_corner=smaller_square) 

如上面的 python 參數化代碼所示,遞歸繪製的小方塊的插圖

當然,這有一些變化。 在許多示例中,將使用函數的返回值。 在這裡,我們有一種更加命令式的編程風格,調用函數只是為了它的副作用。

在其他語言中……

在 Python 中擁有一流的函數使這變得非常容易。 在缺少它們的語言中,或者某些需要參數類型簽名的靜態類型語言中,這可能會更難。 如果我們沒有一流的功能,我們將如何做到這一點?

一種解決方案是將draw_square變成一個類SquareDrawer

 class SquareDrawer: def __init__(self, size): self.size = size def draw(self, t): for i in range(0, 4): t.forward(self.size) self.at_corner(t, size) t.left(90) def at_corner(self, t, size): pass

現在我們可以SquareDrawer並添加一個at_corner方法來滿足我們的需要。 這種python模式被稱為模板方法模式——一個基類定義了整個操作或算法的形狀,操作的變體部分被放入需要由子類實現的方法中。

雖然這有時在 Python 中可能會有所幫助,但將變體代碼提取到一個簡單地作為參數傳遞的函數中通常會簡單得多。

在沒有第一類函數的語言中,我們可以解決這個問題的第二種方法是將我們的函數包裝為類中的方法,如下所示:

 class DoNothing: def run(self, turtle, size): pass def draw_square(turtle, size, at_corner=DoNothing()): for i in range(0, 4): turtle.forward(size) at_corner.run(turtle, size) t.left(90) class Pauser: def run(self, turtle, size): time.sleep(5) draw_square(turtle, 100, at_corner=Pauser())

這被稱為策略模式。 同樣,這肯定是在 Python 中使用的有效模式,特別是如果策略類實際上包含一組相關函數,而不僅僅是一個。 然而,通常我們真正需要的只是一個函數,我們可以停止編寫類。

其他可調用對象

在上面的例子中,我談到了將函數作為參數傳遞給其他函數。 然而,事實上,我寫的所有東西都適用於任何可調用對象。 函數是最簡單的例子,但我們也可以考慮方法。

假設我們有一個列表foo

 foo = [1, 2, 3]

foo現在附加了一大堆方法,例如.append().count() 。 這些“綁定方法”可以像函數一樣傳遞和使用:

 > >> appendtofoo = foo.append > >> appendtofoo(4) > >> foo [1, 2, 3, 4]

除了這些實例方法之外,還有其他類型的可調用對象—— staticmethodsclassmethods ,實現__call__的類的實例,以及類/類型本身。

類作為參數

在 Python 中,類是“第一類”——它們是運行時對象,就像字典、字符串等一樣。這可能看起來比作為對象的函數更奇怪,但幸運的是,證明這一事實實際上比函數更容易。

您熟悉的 class 語句是創建類的好方法,但它不是唯一的方法——我們還可以使用類型的三參數版本。 以下兩個語句的作用完全相同:

 class Foo: pass Foo = type('Foo', (), {})

在第二個版本中,注意我們剛剛做的兩件事(使用 class 語句更方便地完成):

  1. 在等號的右側,我們創建了一個內部名稱為Foo的新類。 如果您執行Foo.__name__ ,這是您將獲得的名稱。
  2. 通過賦值,我們在當前範圍內創建了一個名稱,Foo,它引用了我們剛剛創建的那個類對象。

我們對函數語句的作用做了同樣的觀察。

這裡的關鍵見解是類是可以指定名稱的對象(即,可以放入變量中)。 在任何地方你看到一個正在使用的類,你實際上只是看到一個正在使用的變量。 如果它是一個變量,它可以是一個參數。

我們可以將其分解為多種用法:

作為工廠的類

類是創建自身實例的可調用對象:

 > >> class Foo: ... pass > >> Foo() <__main__.Foo at 0x7f73e0c96780>

並且作為一個對象,它可以分配給其他變量:

 > >> myclass = Foo > >> myclass() <__main__.Foo at 0x7f73e0ca93c8>

回到我們上面的海龜示例,使用海龜進行繪圖的一個問題是,繪圖的位置和方向取決於海龜的當前位置和方向,它也可以讓它處於不同的狀態,這可能對你沒有幫助。呼叫者,召集者。 為了解決這個問題,我們的draw_square函數可以創建自己的海龜,將其移動到所需的位置,然後繪製一個正方形:

 def draw_square(x, y, size): turtle = Turtle() turtle.penup() # Don't draw while moving to the start position turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

但是,我們現在有一個自定義問題。 假設調用者想要設置烏龜的一些屬性或使用具有相同界面但具有一些特殊行為的不同種類的烏龜?

我們可以像以前一樣通過依賴注入來解決這個問題——調用者將負責設置Turtle對象。 但是如果我們的函數有時需要為不同的繪圖目的製作許多海龜,或者如果它想啟動四個線程,每個線程都有自己的海龜來繪製正方形的一側? 答案很簡單,就是讓 Turtle 類成為函數的參數。 我們可以使用帶有默認值的關鍵字參數,以使不關心的調用者保持簡單:

 def draw_square(x, y, size, make_turtle=Turtle): turtle = make_turtle() turtle.penup() turtle.goto(x, y) turtle.pendown() for i in range(0, 4): turtle.forward(size) turtle.left(90)

要使用它,我們可以編寫一個make_turtle函數來創建一個海龜並對其進行修改。 假設我們想在繪製正方形時隱藏海龜:

 def make_hidden_turtle(): turtle = Turtle() turtle.hideturtle() return turtle draw_square(5, 10, 20, make_turtle=make_hidden_turtle)

或者我們可以繼承Turtle以內置該行為並將子類作為參數傳遞:

 class HiddenTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.hideturtle() draw_square(5, 10, 20, make_turtle=HiddenTurtle)

在其他語言中……

其他幾種 OOP 語言,如 Java 和 C#,缺少一流的類。 要實例化一個類,您必須使用new關鍵字後跟一個實際的類名。

這種限制是抽象工廠(需要創建一組類,其唯一工作是實例化其他類)和工廠方法模式等模式的原因。 如您所見,在 Python 中,只需將類作為參數提取出來,因為類是它自己的工廠。

作為基類的類

假設我們發現自己創建子類以將相同的功能添加到不同的類。 例如,我們想要一個Turtle子類,它會在創建時寫入日誌:

 import logging logger = logging.getLogger() class LoggingTurtle(Turtle): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Turtle got created")

但隨後,我們發現自己對另一個類做了完全相同的事情:

 class LoggingHippo(Hippo): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("Hippo got created")

這兩者之間唯一不同的是:

  1. 基類
  2. 子類的名稱——但我們並不真正關心它,可以從基類的__name__屬性中自動生成它。
  3. debug調用中使用的名稱——但同樣,我們可以從基類名稱生成它。

面對只有一個變體的兩個非常相似的代碼,我們能做什麼? 就像在我們的第一個示例中一樣,我們創建一個函數並將變量部分作為參數提取出來:

 def make_logging_class(cls): class LoggingThing(cls): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) logger.debug("{0} got created".format(cls.__name__)) LoggingThing.__name__ = "Logging{0}".format(cls.__name__) return LoggingThing LoggingTurtle = make_logging_class(Turtle) LoggingHippo = make_logging_class(Hippo)

在這裡,我們有一個頭等艙的演示:

  • 我們將一個類傳遞給一個函數,給參數一個常規名稱cls以避免與關鍵字class衝突(您還將看到用於此目的的class_klass )。
  • 在函數內部,我們創建了一個類——注意每次調用這個函數都會創建一個類。
  • 我們將該類作為函數的返回值返回。

我們還設置LoggingThing.__name__這完全是可選的,但可以幫助調試。

這種技術的另一個應用是當我們有時想要添加到一個類中的一大堆特性時,我們可能想要添加這些特性的各種組合。 手動創建我們需要的所有不同組合可能會變得非常笨拙。

在編譯時而不是運行時創建類的語言中,這是不可能的。 相反,您必須使用裝飾器模式。 這種模式有時在 Python 中可能很有用,但大多數情況下你可以使用上面的技術。

通常,我實際上避免創建大量用於自定義的子類。 通常,有更簡單、更 Pythonic 的方法,它們根本不涉及類。 但是,如果您需要,可以使用此技術。 另請參閱 Brandon Rhodes 對 Python 中裝飾器模式的完整處理。

作為例外的類

您看到正在使用的類的另一個地方是在 try/except/finally 語句的except子句中。 猜測我們也可以參數化這些類也就不足為奇了。

例如,以下代碼實現了一個非常通用的策略,即嘗試可能失敗的操作並使用指數退避重試,直到達到最大嘗試次數:

 import time def retry_with_backoff(action, exceptions_to_catch, max_attempts=10, attempts_so_far=0): try: return action() except exceptions_to_catch: attempts_so_far += 1 if attempts_so_far >= max_attempts: raise else: time.sleep(attempts_so_far ** 2) return retry_with_backoff(action, exceptions_to_catch, attempts_so_far=attempts_so_far, max_attempts=max_attempts)

我們已經提取了要採取的操作和要捕獲的異常作為參數。 參數exceptions_to_catch可以是單個類,例如IOErrorhttplib.client.HTTPConnectionError ,也可以是此類類的元組。 (我們要避免使用“bare except”子句,甚至是except Exception ,因為眾所周知這會隱藏其他編程錯誤)。

警告和結論

參數化是重用代碼和減少代碼重複的強大技術。 它並非沒有一些缺點。 在追求代碼復用的過程中,經常會出現幾個問題:

  • 變得非常難以理解的過於通用或抽象的代碼。
  • 帶有大量參數的代碼會掩蓋全局或引入錯誤,因為實際上,只有某些參數組合得到了正確測試。
  • 代碼庫不同部分的無用耦合,因為它們的“公共代碼”已被分解到一個地方。 有時兩個地方的代碼只是偶然相似,兩個地方應該相互獨立,因為它們可能需要獨立更改。

有時,一些“重複”的代碼比這些問題要好得多,所以要小心使用這種技術。

在這篇文章中,我們介紹了稱為依賴注入策略模板方法抽象工廠工廠方法裝飾器的設計模式。 在 Python 中,其中許多確實是參數化的簡單應用,或者由於 Python 中的參數可以是可調用的對像或類這一事實而變得不必要。 希望這有助於減輕“作為真正的 Python 開發人員應該知道的事情”的概念負擔,並使您能夠編寫簡潔的 Python 代碼!

進一步閱讀:

  • Python 設計模式:用於時尚時尚的代碼
  • Python 模式:對於 Python 設計模式
  • Python 日誌記錄:深度教程