這是 「Python 工匠」系列的第 5 篇文章。[查看系列全部文章]html
毫無疑問,函數是 Python 語言裏最重要的概念之一。在編程時,咱們將真實世界裏的大問題分解爲小問題,而後經過一個個函數交出答案。函數便是重複代碼的剋星,也是對抗代碼複雜度的最佳武器。python
如同大部分故事都會有結局,絕大多數函數也都是以返回結果做爲結束。函數返回結果的手法,決定了調用它時的體驗。因此,瞭解如何優雅的讓函數返回結果,是編寫好函數的必備知識。git
Python 函數經過調用 return
語句來返回結果。使用 return value
能夠返回單個值,用 return value1, value2
則能讓函數同時返回多個值。github
若是一個函數體內沒有任何 return
語句,那麼這個函數的返回值默認爲 None
。除了經過 return
語句返回內容,在函數內還可使用拋出異常*(raise Exception)*的方式來「返回結果」。正則表達式
接下來,我將列舉一些與函數返回相關的經常使用編程建議。django
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()
來替代。
假設這麼一個場景,在你的代碼裏有一個參數不少的函數 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 函數官方文檔
我在前面提過,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 哲學,更應該受到推崇的。
None
值一般被用來表示**「某個應該存在可是缺失的東西」**,它在 Python 裏是獨一無二的存在。不少編程語言裏都有與 None 相似的設計,好比 JavaScript 裏的 null
、Go 裏的 nil
等。由於 None 所擁有的獨特 虛無 氣質,它常常被做爲函數返回值使用。
當咱們使用 None 做爲函數返回值時,一般是下面 3 種狀況。
當某個操做類函數不須要任何返回值時,一般就會返回 None。同時,None 也是不帶任何 return
語句函數的默認返回值。
對於這種函數,使用 None 是沒有任何問題的,標準庫裏的 list.append()
、os.chdir()
均屬此類。
有一些函數,它們的目的一般是去嘗試性的作某件事情。視狀況不一樣,最終可能有結果,也可能沒有結果。而對調用方來講,「沒有結果」徹底是意料之中的事情。對這類函數來講,使用 None 做爲「沒結果」時的返回值也是合理的。
在 Python 標準庫裏,正則表達式模塊 re
下的 re.search
、re.match
函數均屬於此類,這兩個函數在能夠找到匹配結果時返回 re.Match
對象,找不到時則返回 None
。
有時,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
,表明着從目標字符串裏去搜索匹配結果,而搜索行爲,一貫是可能有也可能沒有結果的,因此該函數適合返回 Nonecreate_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 值作不到的。
我在前面提到函數能夠用 None
值或異常來返回錯誤結果,但這兩種方式都有一個共同的缺點。那就是全部須要使用函數返回值的地方,都必須加上一個 if
或 try/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。
在函數裏返回列表特別常見,一般,咱們會先初始化一個列表 results = []
,而後在循環體內使用 results.append(item)
函數填充它,最後在函數的末尾返回。
對於這類模式,咱們能夠用生成器函數來簡化它。粗暴點說,就是用 yield item
替代 append
語句。使用生成器的函數一般更簡潔、也更具通用性。
def foo_func(items):
for item in items:
# ... 處理 item 後直接使用 yield 返回
yield item
複製代碼
我在 系列第 4 篇文章「容器的門道」 裏詳細分析過這個模式,更多細節能夠訪問文章,搜索 「寫擴展性更好的代碼」 查看。
當函數返回自身調用時,也就是 遞歸
發生時。遞歸是一種在特定場景下很是有用的編程技巧,但壞消息是:Python 語言對遞歸支持的很是有限。
這份「有限的支持」體如今不少方面。首先,Python 語言不支持「尾遞歸優化」。另外 Python 對最大遞歸層級數也有着嚴格的限制。
因此我建議:儘可能少寫遞歸。若是你想用遞歸解決問題,先想一想它是否是能方便的用循環來替代。若是答案是確定的,那麼就用循環來改寫吧。若是無可奈何,必定須要使用遞歸時,請考慮下面幾個點:
sys.getrecursionlimit()
規定的最大層數限制在這篇文章中,我虛擬了一些與 Python 函數返回有關的場景,並針對每一個場景提供了個人優化建議。最後再總結一下要點:
functools.partial
定義快捷函數看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。
系列其餘文章: