Python 工匠:讓函數返回結果的技巧

序言

這是 「Python 工匠」系列的第 5 篇文章。[查看系列全部文章]html

毫無疑問,函數是 Python 語言裏最重要的概念之一。在編程時,咱們將真實世界裏的大問題分解爲小問題,而後經過一個個函數交出答案。函數便是重複代碼的剋星,也是對抗代碼複雜度的最佳武器。python

如同大部分故事都會有結局,絕大多數函數也都是以返回結果做爲結束。函數返回結果的手法,決定了調用它時的體驗。因此,瞭解如何優雅的讓函數返回結果,是編寫好函數的必備知識。git

Python 的函數返回方式

Python 函數經過調用 return 語句來返回結果。使用 return value 能夠返回單個值,用 return value1, value2 則能讓函數同時返回多個值。github

若是一個函數體內沒有任何 return 語句,那麼這個函數的返回值默認爲 None。除了經過 return 語句返回內容,在函數內還可使用拋出異常*(raise Exception)*的方式來「返回結果」。正則表達式

接下來,我將列舉一些與函數返回相關的經常使用編程建議。django

內容目錄

編程建議

1. 單個函數不要返回多種類型

Python 語言很是靈活,咱們能用它輕鬆完成一些在其餘語言裏很難作到的事情。好比:*讓一個函數同時返回不一樣類型的結果。*從而實現一種看起來很是實用的「多功能函數」。編程

就像下面這樣:緩存

def get_users(user_id=None):
    if user_id is None:
        return User.get(user_id)
    else:
        return User.filter(is_active=True)


# 返回單個用戶
get_users(user_id=1)
# 返回多個用戶
get_users()
複製代碼

當咱們須要獲取單個用戶時,就傳遞 user_id 參數,不然就不傳參數拿到全部活躍用戶列表。一切都由一個函數 get_users 來搞定。這樣的設計彷佛很合理。app

然而在函數的世界裏,以編寫具有「多功能」的瑞士軍刀型函數爲榮不是一件好事。這是由於好的函數必定是 「單一職責(Single responsibility)」 的。**單一職責意味着一個函數只作好一件事,目的明確。**這樣的函數也更不容易在將來由於需求變動而被修改。框架

而返回多種類型的函數必定是違反「單一職責」原則的,**好的函數應該老是提供穩定的返回值,把調用方的處理成本降到最低。**像上面的例子,咱們應該編寫兩個獨立的函數 get_user_by_id(user_id)get_active_users() 來替代。

2. 使用 partial 構造新函數

假設這麼一個場景,在你的代碼裏有一個參數不少的函數 A,適用性很強。而另外一個函數 B 則是徹底經過調用 A 來完成工做,是一種相似快捷方式的存在。

比方在這個例子裏, double 函數就是徹底經過 multiply 來完成計算的:

def multiply(x, y):
    return x * y


def double(value):
    # 返回另外一個函數調用結果
    return multiply(2, value)
複製代碼

對於上面這種場景,咱們可使用 functools 模塊裏的 partial() 函數來簡化它。

partial(func, *args, **kwargs) 基於傳入的函數與可變(位置/關鍵字)參數來構造一個新函數。全部對新函數的調用,都會在合併了當前調用參數與構造參數後,代理給原始函數處理。

利用 partial 函數,上面的 double 函數定義能夠被修改成單行表達式,更簡潔也更直接。

import functools

double = functools.partial(multiply, 2)
複製代碼

建議閱讀:partial 函數官方文檔

3. 拋出異常,而不是返回結果與錯誤

我在前面提過,Python 裏的函數能夠返回多個值。基於這個能力,咱們能夠編寫一類特殊的函數:同時返回結果與錯誤信息的函數。

def create_item(name):
    if len(name) > MAX_LENGTH_OF_NAME:
        return None, 'name of item is too long'
    if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
        return None, 'items is full'
    return Item(name=name), ''


def create_from_input():
    name = input()
    item, err_msg = create_item(name)
    if err_msg:
        print(f'create item failed: {err_msg}')
    else:
        print(f'item<{name}> created')
複製代碼

在示例中,create_item 函數的做用是建立新的 Item 對象。同時,爲了在出錯時給調用方提供錯誤詳情,它利用了多返回值特性,把錯誤信息做爲第二個結果返回。

乍看上去,這樣的作法很天然。尤爲是對那些有 Go 語言編程經驗的人來講更是如此。可是在 Python 世界裏,這並不是解決此類問題的最佳辦法。由於這種作法會增長調用方進行錯誤處理的成本,尤爲是當不少函數都遵循這個規範並且存在多層調用時。

Python 具有完善的*異常(Exception)*機制,而且在某種程度上鼓勵咱們使用異常(官方文檔關於 EAFP 的說明)。因此,使用異常來進行錯誤流程處理纔是更地道的作法。

引入自定義異常後,上面的代碼能夠被改寫成這樣:

class CreateItemError(Exception):
    """建立 Item 失敗時拋出的異常"""

def create_item(name):
    """建立一個新的 Item :raises: 當沒法建立時拋出 CreateItemError """
    if len(name) > MAX_LENGTH_OF_NAME:
        raise CreateItemError('name of item is too long')
    if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
        raise CreateItemError('items is full')
    return Item(name=name)


def create_for_input():
    name = input()
    try:
        item = create_item(name)
    except CreateItemError as e:
        print(f'create item failed: {err_msg}')
    else:
        print(f'item<{name}> created')

複製代碼

使用「拋出異常」替代「返回 (結果, 錯誤信息)」後,整個錯誤流程處理乍看上去變化不大,但實際上有着很是多不一樣,一些細節:

  • 新版本函數擁有更穩定的返回值類型,它永遠只會返回 Item 類型或是拋出異常
  • 雖然我在這裏鼓勵使用異常,但「異常」老是會沒法避免的讓人 感到驚訝,因此,最好在函數文檔裏說明可能拋出的異常類型
  • 異常不一樣於返回值,它在被捕獲前會不斷往調用棧上層彙報。因此 create_item 的一級調用方徹底能夠省略異常處理,交由上層處理。這個特色給了咱們更多的靈活性,但同時也帶來了更大的風險。

Hint:如何在編程語言裏處理錯誤,是一個至今仍然存在爭議的主題。好比像上面不推薦的多返回值方式,正是缺少異常的 Go 語言中最核心的錯誤處理機制。另外,即便是異常機制自己,不一樣編程語言之間也存在着差異。

異常,或是不異常,都是由語言設計者進行多方取捨後的結果,更多時候不存在絕對性的優劣之分。可是,單就 Python 語言而言,使用異常來表達錯誤無疑是更符合 Python 哲學,更應該受到推崇的。

4. 謹慎使用 None 返回值

None 值一般被用來表示**「某個應該存在可是缺失的東西」**,它在 Python 裏是獨一無二的存在。不少編程語言裏都有與 None 相似的設計,好比 JavaScript 裏的 null、Go 裏的 nil 等。由於 None 所擁有的獨特 虛無 氣質,它常常被做爲函數返回值使用。

當咱們使用 None 做爲函數返回值時,一般是下面 3 種狀況。

1. 做爲操做類函數的默認返回值

當某個操做類函數不須要任何返回值時,一般就會返回 None。同時,None 也是不帶任何 return 語句函數的默認返回值。

對於這種函數,使用 None 是沒有任何問題的,標準庫裏的 list.append()os.chdir() 均屬此類。

2. 做爲某些「意料之中」的可能沒有的值

有一些函數,它們的目的一般是去嘗試性的作某件事情。視狀況不一樣,最終可能有結果,也可能沒有結果。而對調用方來講,「沒有結果」徹底是意料之中的事情。對這類函數來講,使用 None 做爲「沒結果」時的返回值也是合理的。

在 Python 標準庫裏,正則表達式模塊 re 下的 re.searchre.match 函數均屬於此類,這兩個函數在能夠找到匹配結果時返回 re.Match 對象,找不到時則返回 None

3. 做爲調用失敗時表明「錯誤結果」的值

有時,None 也會常常被咱們用來做爲函數調用失敗時的默認返回值,好比下面這個函數:

def create_user_from_name(username):
    """經過用戶名建立一個 User 實例"""
    if validate_username(username):
        return User.from_username(username)
    else:
        return None


user = create_user_from_name(username)
if user:
    user.do_something()
複製代碼

當 username 不合法時,函數 create_user_from_name 將會返回 None。但在這個場景下,這樣作其實並很差。

不過你也許會以爲這個函數徹底合情合理,甚至你會以爲它和咱們提到的上一個「沒有結果」時的用法很是類似。那麼如何區分這兩種不一樣情形呢?關鍵在於:函數簽名(名稱與參數)與 None 返回值之間是否存在一種「意料之中」的暗示。

讓我解釋一下,每當你讓函數返回 None 值時,請仔細閱讀函數名,而後問本身一個問題:假如我是該函數的使用者,從這個名字來看,「拿不到任何結果」是不是該函數名稱含義裏的一部分?

分別用這兩個函數來舉例:

  • re.search():從函數名來看,search,表明着從目標字符串裏去搜索匹配結果,而搜索行爲,一貫是可能有也可能沒有結果的,因此該函數適合返回 None
  • create_user_from_name():從函數名來看,表明基於一個名字來構建用戶,並不能讀出一種可能返回、可能不返回的含義。因此不適合返回 None

對於那些不能從函數名裏讀出 None 值暗示的函數來講,有兩種修改方式。第一種,若是你堅持使用 None 返回值,那麼請修改函數的名稱。好比能夠將函數 create_user_from_name() 更名爲 create_user_or_none()

第二種方式則更常見的多:用拋出異常*(raise Exception)來代替 None 返回值。由於,若是返回不了正常結果並不是函數意義裏的一部分,這就表明着函數出現了「意料之外的情況」*,而這正是 Exceptions 異常 所掌管的領域。

使用異常改寫後的例子:

class UnableToCreateUser(Exception):
    """當沒法建立用戶時拋出"""


def create_user_from_name(username):
    ""經過用戶名建立一個 User 實例"
    
    :raises: 當沒法建立用戶時拋出 UnableToCreateUser
    """
    if validate_username(username):
        return User.from_username(username)
    else:
        raise UnableToCreateUser(f'unable to create user from {username}')


try:
    user = create_user_from_name(username)
except UnableToCreateUser:
    # Error handling
else:
    user.do_something()
複製代碼

與 None 返回值相比,拋出異常除了擁有咱們在上個場景提到的那些特色外,還有一個額外的優點:能夠在異常信息裏提供出現意料以外結果的緣由,這是隻返回一個 None 值作不到的。

5. 合理使用「空對象模式」

我在前面提到函數能夠用 None 值或異常來返回錯誤結果,但這兩種方式都有一個共同的缺點。那就是全部須要使用函數返回值的地方,都必須加上一個 iftry/except 防護語句,來判斷結果是否正常。

讓咱們看一個可運行的完整示例:

import decimal


class CreateAccountError(Exception):
    """Unable to create a account error"""


class Account:
    """一個虛擬的銀行帳號"""

    def __init__(self, username, balance):
        self.username = username
        self.balance = balance

 @classmethod
    def from_string(cls, s):
        """從字符串初始化一個帳號"""
        try:
            username, balance = s.split()
            balance = decimal.Decimal(float(balance))
        except ValueError:
            raise CreateAccountError('input must follow pattern "{ACCOUNT_NAME} {BALANCE}"')

        if balance < 0:
            raise CreateAccountError('balance can not be negative')
        return cls(username=username, balance=balance)


def caculate_total_balance(accounts_data):
    """計算全部帳號的總餘額 """
    result = 0
    for account_string in accounts_data:
        try:
            user = Account.from_string(account_string)
        except CreateAccountError:
            pass
        else:
            result += user.balance
    return result


accounts_data = [
    'piglei 96.5',
    'cotton 21',
    'invalid_data',
    'roland $invalid_balance',
    'alfred -3',
]

print(caculate_total_balance(accounts_data))
複製代碼

在這個例子裏,每當咱們調用 Account.from_string 時,都必須使用 try/except 來捕獲可能發生的異常。若是項目裏須要調用不少次該函數,這部分工做就變得很是繁瑣了。針對這種狀況,可使用「空對象模式(Null object pattern)」來改善這個控制流。

Martin Fowler 在他的經典著做《重構》 中用一個章節詳細說明過這個模式。簡單來講,就是使用一個符合正常結果接口的「空類型」來替代空值返回/拋出異常,以此來下降調用方處理結果的成本。

引入「空對象模式」後,上面的示例能夠被修改爲這樣:

class Account:
    # def __init__ 已省略... ...
    
 @classmethod
    def from_string(cls, s):
        """從字符串初始化一個帳號 :returns: 若是輸入合法,返回 Account object,不然返回 NullAccount """
        try:
            username, balance = s.split()
            balance = decimal.Decimal(float(balance))
        except ValueError:
            return NullAccount()

        if balance < 0:
            return NullAccount()
        return cls(username=username, balance=balance)


class NullAccount:
    username = ''
    balance = 0

 @classmethod
    def from_string(cls, s):
        raise NotImplementedError
複製代碼

在新版代碼裏,我定義了 NullAccount 這個新類型,用來做爲 from_string 失敗時的錯誤結果返回。這樣修改後的最大變化體如今 caculate_total_balance 部分:

def caculate_total_balance(accounts_data):
    """計算全部帳號的總餘額 """
    return sum(Account.from_string(s).balance for s in accounts_data)
複製代碼

調整以後,調用方沒必要再顯式使用 try 語句來處理錯誤,而是能夠假設 Account.from_string 函數老是會返回一個合法的 Account 對象,從而大大簡化整個計算邏輯。

Hint:在 Python 世界裏,「空對象模式」並很多見,好比大名鼎鼎的 Django 框架裏的 AnonymousUser 就是一個典型的 null object。

6. 使用生成器函數代替返回列表

在函數裏返回列表特別常見,一般,咱們會先初始化一個列表 results = [],而後在循環體內使用 results.append(item) 函數填充它,最後在函數的末尾返回。

對於這類模式,咱們能夠用生成器函數來簡化它。粗暴點說,就是用 yield item 替代 append 語句。使用生成器的函數一般更簡潔、也更具通用性。

def foo_func(items):
    for item in items:
        # ... 處理 item 後直接使用 yield 返回
        yield item
複製代碼

我在 系列第 4 篇文章「容器的門道」 裏詳細分析過這個模式,更多細節能夠訪問文章,搜索 「寫擴展性更好的代碼」 查看。

7. 限制遞歸的使用

當函數返回自身調用時,也就是 遞歸 發生時。遞歸是一種在特定場景下很是有用的編程技巧,但壞消息是:Python 語言對遞歸支持的很是有限。

這份「有限的支持」體如今不少方面。首先,Python 語言不支持「尾遞歸優化」。另外 Python 對最大遞歸層級數也有着嚴格的限制。

因此我建議:儘可能少寫遞歸。若是你想用遞歸解決問題,先想一想它是否是能方便的用循環來替代。若是答案是確定的,那麼就用循環來改寫吧。若是無可奈何,必定須要使用遞歸時,請考慮下面幾個點:

  • 函數輸入數據規模是否穩定,是否必定不會超過 sys.getrecursionlimit() 規定的最大層數限制
  • 是否能夠經過使用相似 functools.lru_cache 的緩存工具函數來下降遞歸層數

總結

在這篇文章中,我虛擬了一些與 Python 函數返回有關的場景,並針對每一個場景提供了個人優化建議。最後再總結一下要點:

  • 讓函數擁有穩定的返回值,一個函數只作好一件事
  • 使用 functools.partial 定義快捷函數
  • 拋出異常也是返回結果的一種方式,使用它來替代返回錯誤信息
  • 函數是否適合返回 None,由函數簽名的「含義」所決定
  • 使用「空對象模式」能夠簡化調用方的錯誤處理邏輯
  • 多使用生成器函數,儘可能用循環替代遞歸

看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。

附錄

  • 題圖來源: Dominik Scythe on Unsplash

系列其餘文章:

相關文章
相關標籤/搜索