如何写出优雅的 Python 函式?

序言毫无疑问,函式是 Python 语言里最重要的概念之一。在程式设计时,我们将真实世界里的大问题分解为小问题,然后通过一个个函式交出答案。函式即是重复程式码的克星,也是对抗程式码复杂度的最佳武器。
如同大部分故事都会有结局,绝大多数函式也都是以返回结果作为结束。函式返回结果的手法,决定了呼叫它时的体验。所以,了解如何优雅的让函式返回结果,是编写好函式的必备知识。
Python 的函式返回方式Python 函式通过呼叫 return 语句来返回结果。使用 returnvalue 可以返回单个值,用 returnvalue1,value2 则能让函式同时返回多个值。
如果一个函式体内没有任何 return 语句,那么这个函式的返回值预设为 None。除了通过 return 语句返回内容,在函式内还可以使用丢掷异常(raise Exception)的方式来“返回结果”。
接下来,我将列举一些与函式返回相关的常用程式设计建议。
1. 单个函式不要返回多种型别Python 语言非常灵活,我们能用它轻松完成一些在其他语言里很难做到的事情。比如:让一个函式同时返回不同型别的结果。从而实现一种看起来非常实用的“多功能函式”。
就像下面这样:
当我们需要获取单个使用者时,就传递user_id引数,否则就不传引数拿到所有活跃使用者列表。一切都由一个函式get_users来搞定。这样的设计似乎很合理。然而在函式的世界里,以编写具备“多功能”的瑞士军刀型函式为荣不是一件好事。这是因为好的函式一定是 “单一职责(Single responsibility)” 的。单一职责意味着一个函式只做好一件事,目的明确。这样的函式也更不容易在未来因为需求变更而被修改。
而返回多种型别的函式一定是违反“单一职责”原则的,好的函式应该总是提供稳定的返回值,把呼叫方的处理成本降到最低。像上面的例子,我们应该编写两个独立的函式get_user_by_id(user_id)、get_active_users来替代。2. 使用 partial 构造新函式假设这么一个场景,在你的程式码里有一个引数很多的函式A,适用性很强。而另一个函式B则是完全通过呼叫A来完成工作,是一种类似快捷方式的存在。比方在这个例子里,函式就是完全通过对于上面这种场景,我们可以使用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:
returnNone, \'name of item is too long\'
if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
returnNone, \'items is full\'
returnItem(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 created\')
在示例中, create_item 函式的作用是建立新的 Item 物件。同时,为了在出错时给呼叫方提供错误详情,它利用了多返回值特性,把错误资讯作为第二个结果返回。
乍看上去,这样的做法很自然。尤其是对那些有 Go 语言程式设计经验的人来说更是如此。但是在 Python 世界里,这并非解决此类问题的最佳办法。因为这种做法会增加呼叫方进行错误处理的成本,尤其是当很多函式都遵循这个规范而且存在多层呼叫时。
Python 具备完善的异常(Exception)机制,并且在某种程度上鼓励我们使用异常(官方文件关于 EAFP 的说明)。所以,使用异常来进行错误流程处理才是更地道的做法。
引入自定义异常后,上面的程式码可以被改写成这样:
classCreateItemError(Exception):
"""建立 Item 失败时丢掷的异常"""
def create_item(name):
"""建立一个新的 Item
:raises: 当无法建立时丢掷 CreateItemError
"""
if len(name) > MAX_LENGTH_OF_NAME:
raiseCreateItemError(\'name of item is too long\')
if len(CURRENT_ITEMS) > MAX_ITEMS_QUOTA:
raiseCreateItemError(\'items is full\')
returnItem(name=name)
def create_for_input:
name = input
try:
item = create_item(name)
exceptCreateItemErroras e:
print(f\'create item failed: {err_msg}\')
else:
print(f\'item 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.search、re.match函式均属于此类,这两个函式在可以找到匹配结果时返回re.Match物件,找不到时则返回None。有时,None也会经常被我们用来作为函式呼叫失败时的预设返回值,比如下面这个函式:当 username 不合法时,函式create_user_from_name将会返回 None。但在这个场景下,这样做其实并不好。不过你也许会觉得这个函式完全合情合理,甚至你会觉得它和我们提到的上一个“没有结果”时的用法非常相似。那么如何区分这两种不同情形呢?关键在于:函式签名(名称与引数)与 None 返回值之间是否存在一种“意料之中”的暗示。
让我解释一下,每当你让函式返回 None 值时,请仔细阅读函式名,然后问自己一个问题:假如我是该函式的使用者,从这个名字来看,“拿不到任何结果”是否是该函式名称含义里的一部分?
分别用这两个函式来举例:
re.search:从函式名来看, search,代表着从目标字串里去搜寻匹配结果,而搜寻行为,一向是可能有也可能没有结果的,所以该函式适合返回 None
create_user_from_name:从函式名来看,代表基于一个名字来构建使用者,并不能读出一种 可能返回、可能不返回的含义。所以不适合返回 None
对于那些不能从函式名里读出 None 值暗示的函式来说,有两种修改方式。第一种,如果你坚持使用 None 返回值,那么请修改函式的名称。比如可以将函式改名为。第二种方式则更常见的多:用丢掷异常(raise Exception)来代替 None 返回值。因为,如果返回不了正常结果并非函式意义里的一部分,这就代表着函数出现了“意料以外的状况”,而这正是 Exceptions 异常 所掌管的领域。
使用异常改写后的例子:
classUnableToCreateUser(Exception):
"""当无法建立使用者时丢掷"""
def create_user_from_name(username):
""通过使用者名称建立一个 User 例项"
:raises: 当无法建立使用者时丢掷 UnableToCreateUser
"""
if validate_username(username):
returnUser.from_username(username)
else:
raiseUnableToCreateUser(f\'unable to create user from {username}\')
try:
user = create_user_from_name(username)
exceptUnableToCreateUser:
# Error handling
else:
user.do_something
与 None 返回值相比,丢掷异常除了拥有我们在上个场景提到的那些特点外,还有一个额外的优势:可以在异常资讯里提供出现意料之外结果的原因,这是只返回一个 None 值做不到的。
5. 合理使用“空物件模式”我在前面提到函式可以用None值或异常来返回错误结果,但这两种方式都有一个共同的缺点。那就是所有需要使用函式返回值的地方,都必须加上一个if或try/except防御语句,来判断结果是否正常。让我们看一个可执行的完整示例:
import decimal
classCreateAccountError(Exception):
"""Unable to create a account error"""
classAccount:
"""一个虚拟的银行账号"""
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))
exceptValueError:
raiseCreateAccountError(\'input must follow pattern "{ACCOUNT_NAME} {BALANCE}"\')
if balance
raiseCreateAccountError(\'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)
exceptCreateAccountError:
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))
try/except来捕获可能发生的异常。如果专案里需要呼叫很多次该函式,这部分工作就变得非常繁琐了。针对这种情况,可以使用“空物件模式(Null object pattern)”来改善这个控制流。Martin Fowler 在他的经典著作《重构》 中用一个章节详细说明过这个模式。简单来说,就是使用一个符合正常结果界面的“空型别”来替代空值返回/丢掷异常,以此来降低呼叫方处理结果的成本。
引入“空物件模式”后,上面的示例可以被修改成这样:
classAccount:
# def __init__ 已省略... ...
@classmethod
def from_string(cls, s):
"""从字串初始化一个账号
:returns: 如果输入合法,返回 Account object,否则返回 NullAccount
"""
try:
username, balance = s.split
balance = decimal.Decimal(float(balance))
exceptValueError:
returnNullAccount
if balance
returnNullAccount
return cls(username=username, balance=balance)
classNullAccount:
username = \'\'
balance = 0
@classmethod
raiseNotImplementedError
在新版程式码里,我定义了NullAccount这个新型别,用来作为from_string失败时的错误结果返回。这样修改后的最大变化体现在调整之后,呼叫方不必再显式使用 try 语句来处理错误,而是可以假设Account.from_string函式总是会返回一个合法的 Account 物件,从而大大简化整个计算逻辑。Hint:在 Python 世界里,“空物件模式”并不少见,比如大名鼎鼎的 Django 框架里的 AnonymousUser 就是一个典型的 null object。
6. 使用生成器函式代替返回列表在函式里返回列表特别常见,通常,我们会先初始化一个列表 results=,然后在循环体内使用 results.append(item) 函式填充它,最后在函式的末尾返回。
对于这类模式,我们可以用生成器函式来简化它。粗暴点说,就是用 yielditem 替代 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 告诉我吧。
回复下方“关键词”,获取优质资源
Python 或将超越 C、Java,成为最受欢迎的语言

Python 容器使用的 5 个技巧和 2 个误区
题图:pexels,CC0 授权。