從Python 3.5起,正式納入了型態提示(Type Hints)特性,後續各個版本,也不斷增強改進,相關工具至今的支援,已有很高的成熟度,除了最初Python開發者都熟悉的mypy檢查工具(也是typing模組主要的貢獻來源),PyCharm之類的整合開發環境必然是支援的,VSCode也可以安裝新的語言伺服器Pylance,透過設定,令整個專案全面獲得靜態時期的型態檢查能力。

對開發中型或大型程式而言,若全面性啟用型態檢查,並在公開介面上標註型態,對程式的維護性有非常大的助益,而且是多個面向的,像是:撰寫程式碼時的自動提示、增加程式碼的可讀性、相關重構工具的進一步支援,或是變動函式簽署就能檢查出錯誤等,在嘗試過型態檢查的便利與彈性之後,在參數、引數、變數等各方面,自然而然地,都會開始考慮起型態的問題,以及相關設計原則。

參數列上的Optional?

大部份情況下,靜態定型語言中怎麼宣告型態,拿來套用在型態提示上都不會有什麼問題,不過,我最近看到某個函式的參數預設值為None,這表示該參數可能或沒有值,而呼叫函式時的預設值是後者,對於有或沒有,在Python中,通常建議使用typing模組的Optional來標註,但我在標註param: Optional[Type] = None後,心裡卻出現違和感。

因為Java中也有Optional類別,而它有個使用原則「應該只作為傳回值,不應該用在參數或類別欄位」,事實上,支持此一原則的理由有好幾個,首先,客戶端在呼叫方法時,doSome(Optional.of(value))或是doSome(Optional.empty()),在撰寫或可讀性上,不但沒有幫助,甚至還有點妨礙。

另外,參數若是Optional型態,該參數接受到null也是合法的,這麼一來,原本希望參數值彰顯出有或沒有,現在卻有三個可能性,分別是:「null、包裏值的Optional、沒有值的Optional.empty()」,為了避免NullPointException,在方法實作中,勢必要針對null進行檢查,才能進一步確認是否可呼叫Optional的相關方法,但這又使得實作流程變複雜。

我們在網路上搜尋「java Optional in parameter」,可以找到更多的文件與案例,以及改善的方式,簡而言之,就Java而言,若是參數可能接受null,此時使用Optional取代並不適合,應該改用重載(overload)。畢竟當參數接受null時,既有的方法實作中,必然是有一道替代流程,這時可以重載出另一個方法,避免使用者誤用,或者是避免他們寫出doSome(null)這種可讀性不佳的呼叫。

參數列上的None?

只不過,Java的設計原則可以套用在Python嗎?Python的Optional只是個型態標示,並非真的有個Optional實例會用來包裹值,接受None的函式在實作時,檢查流程並不會因此而增加,基本上並不會有Java中被指定null之類的問題。

然而,要進一步思考的是,Python中函式的參數預設為None的必要性。對於動態定型語言來說,參數指定預設值,除了方便使用者只需修改自訂值之外,還可以用來模擬重載,若參數可能接受None,與其讓使用者以doSome(None)方式呼叫,不如將參數預設值設為None,如此一來呼叫時就可以使用doSome()的方式。

因此,在Python中,經常可見參數預設為None的情況,在型態提示時,標註param: Optional[Type] = None合適嗎?可以先想想看param: Optional[Type]表示什麼?這表示只能用doSome(None)或doSome(value),不能用doSome(),為了可以用doSome()呼叫,才設定預設值為None,對吧!

此時何不直接標註為param: Type = None?就語義來說,型態標註表明param接受Type型態的值,而預設值為None,表示param可能有或沒有值,這跟標註為param: Optional[Type] = None,效果也是相同的(若標註為param: Type,預設不能接受None)。

那麼,Optional出現在參數的標註時,就只剩下一個作用了,也就是標註為param: Optional[Type]時,若真的沒有值,呼叫時就必須明確指定None,這就要看客戶端呼叫時,要求明確指定None是否有助於可讀性了,但這種情況不多,我想得到的,大概會是某個寫入資料表欄位的方法,想明確表明該欄位被寫為None的這類時機吧!

Python中的重載?

想要求客戶端呼叫函式,在參數沒有值時必須明確指定None的情況不多,因此若想將參數列標註為Optional時,可以思考一下必要性,例如,參數有預設值None時,可以直接標註param: Type = None,而不是param: Optional[Type] = None,另一個思考的方向是,有辦法像Java的做法,透過重載來避免設定None為預設值嗎?

Python的typing模組提供overload裝飾器,被裝飾的函式可以僅定義函式簽署,但無法在其中進行實作,它們主要是作為型態檢查之用,參數的型態與順序必須對應一個真正的實作函式,例如,可參考我的_plugin.pyhull2D方法的原始碼,它們是底下實作的重載版本:

def hull2D(self: T, points: Iterable[VectorLike] = None,
forConstruction: bool = False) -> T:

那麼,你會喜歡哪個版本呢?就閱讀原始碼來說,重載的寫法,列出了方法套用時可行的幾個案例,我個人認為易讀易懂一些,只不過原始碼是否易讀易懂,有時很主觀,然而實際上,重載的版本與非重載版本在型態檢查上,還有一點極大的不同,那就是型態檢查時,函式呼叫指定的引數,必須符合重載時宣告的函式簽署。

也就是說,_plugin.py中重載的兩個函式簽署是hull2D(self: T)與hull2D(self: T, points: Iterable[VectorLike] = ..., forConstruction: bool = ...),這表示,在型態檢查時,只能用hull2D()或者是hull2D(points),雖然hull2D真正的函式實作中,points預設值是None,然而使用hull2D(None)呼叫的話,型態檢查時會過不了。

因此,若參數預設值會是None的情況,非重載的版本會允許hull2D(None)的呼叫方式,若你不希望客戶端這麼呼叫,可以改以重載方式來設計,執行型態檢查時,就會出現錯誤來提醒客戶端,獲得原始碼易讀之外的實質效益。

當然,若是客戶端沒有執行型態檢查,完全以動態定型觀點使用Python,就仍然能夠進行hull2D(None)呼叫,如果真的不想客戶端這麼做,建議就別模擬重載了,直接定義名稱不同的函式!例如,hull2DFrom(points,forConstruction=True),以及hull2DInternal(forConstruction=True),不要使用None作為參數預設值了。

Optional只用在傳回型態

如果你查詢Python官方文件有關於typing.Optional的說明,會發現其中的範例,也是標註了arg: Optional[int] = None,然而,這不能說是錯,只不過作用上與arg: int = None沒兩樣就是了。

綜上所述,在Java領域中「Optional只用在傳回型態」的建議,其實也適用於Python,如果打算在參數列上標註Optional,可以多思考一下其他的可能性,例如重載,或者是不模擬重載,不使用None作為參數預設值等其他的設計方式。

專欄作者

熱門新聞

Advertisement