毫无疑问,函数是 Python 语言里最重要的概念之一。在编程时,我们将真实世界里的大问题分解为小问题,然后通过一个个函数交出答案。函数既是重复代码的克星,也是对抗代码复杂度的最佳武器。如同大部分故事都会有结局,绝大多数函数也都是以返回结果作为结束。函数返回结果的手法,决定了调用它时的体验。所以,了解如何优雅的让函数返回结果,是编写好函数的必备知识。
单个函数不要返回多种类型
让一个函数同时返回不同类型的结果。 从而实现一种看起来非常实用的“多功能函数”
def get_users(user_id=None):
if user_id is not None:
return User.get(user_id)
else:
return User.filter(is_active=True)
# 返回单个用户
get_users(user_id=1)
# 返回多个用户
get_users()
当我们需要获取单个用户时,就传递 user_id
参数,否则就不传参数拿到所有活跃用户列表。一切都由一个函数 get_users
来搞定。这样的设计似乎很合理。
然而在函数的世界里,以编写具备“多功能”的瑞士军刀型函数为荣不是一件好事。这是因为好的函数一定是 “单一职责(Single responsibility)” 的。单一职责意味着一个函数只做好一件事,目的明确。 这样的函数也更不容易在未来因为需求变更而被修改。
而返回多种类型的函数一定是违反“单一职责”原则的,好的函数应该总是提供稳定的返回值,把调用方的处理成本降到最低。 像上面的例子,我们应该编写两个独立的函数 get_user_by_id(user_id)
、get_active_users()
来替代。
使用 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)
抛出异常,而不是返回结果与错误
我在前面提过,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 对象。同时,为了在出错时给调用方提供错误详情,它利用了多返回值特性,把错误信息作为第二个结果返回。
乍看上去,这样的做法很自然。在 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: {e}')
else:
print(f'item<{name}> created')
使用“抛出异常”替代“返回 (结果, 错误信息)”后,整个错误流程处理乍看上去变化不大,但实际上有着非常多不同,一些细节:
- 新版本函数拥有更稳定的返回值类型,它永远只会返回
Item
类型或是抛出异常 - 虽然我在这里鼓励使用异常,但“异常”总是会无法避免的让人 感到惊讶,所以,最好在函数文档里说明可能抛出的异常类型
- 异常不同于返回值,它在被捕获前会不断往调用栈上层汇报。所以
create_item
的一级调用方完全可以省略异常处理,交由上层处理。这个特点给了我们更多的灵活性,但同时也带来了更大的风险。
Hint:如何在编程语言里处理错误,是一个至今仍然存在争议的主题。比如像上面不推荐的多返回值方式,正是缺乏异常的 Go 语言中最核心的错误处理机制。另外,即使是异常机制本身,不同编程语言之间也存在着差别。
异常,或是不异常,都是由语言设计者进行多方取舍后的结果,更多时候不存在绝对性的优劣之分。但是,单就 Python 语言而言,使用异常来表达错误无疑是更符合 Python 哲学,更应该受到推崇的。
使用生成器函数代替返回列表
在函数里返回列表特别常见,通常,我们会先初始化一个列表 results = []
,然后在循环体内使用 results.append(item)
函数填充它,最后在函数的末尾返回。
对于这类模式,我们可以用生成器函数来简化它。粗暴点说,就是用 yield item
替代 append
语句。使用生成器的函数通常更简洁、也更具通用性。
def foo_func(items):s
for item in items:
# ... 处理 item 后直接使用 yield 返回
yield item