热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Python工匠:让函数返回结果的技巧

这是“Python工匠”系列的第5篇文章。毫无疑问,函数是Python语言里最重要的概念之一。在编程

序言

这是 “Python 工匠”系列的第 5 篇文章。 [查看系列所有文章]

Python 工匠:让函数返回结果的技巧

毫无疑问,函数是 Python 语言里最重要的概念之一。在编程时,我们将真实世界里的大问题分解为小问题,然后通过一个个函数交出答案。函数即是重复代码的克星,也是对抗代码复杂度的最佳武器。

如同大部分故事都会有结局,绝大多数函数也都是以 返回结果 作为结束。函数返回结果的手法,决定了调用它时的体验。所以,了解如何优雅的让函数返回结果,是编写好函数的必备知识。

Python 的函数返回方式

Python 函数通过调用 return 语句来返回结果。使用 return value 可以返回单个值,用 return value1, value2 则能让函数同时返回多个值。

如果一个函数体内没有任何 return 语句,那么这个函数的返回值默认为 None 。除了通过 return 语句返回内容,在函数内还可以使用抛出异常 (raise Exception) 的方式来“返回结果”。

接下来,我将列举一些与函数返回相关的常用编程建议。

内容目录

  • 1. 单个函数不要返回多种类型
  • 2. 使用 partial 构造新函数
  • 3. 抛出异常,而不是返回结果与错误
  • 4. 谨慎使用 None 返回值
    • 1. 作为操作类函数的默认返回值
    • 2. 作为某些“意料之中”的可能没有的值
    • 3. 作为调用失败时代表“错误结果”的值
  • 5. 合理使用“空对象模式”
  • 6. 使用生成器函数代替返回列表

编程建议

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 来搞定。这样的设计似乎很合理。

然而在函数的世界里,以编写具备“多功能”的瑞士军刀型函数为荣不是一件好事。这是因为好的函数一定是 “单一职责(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

看完文章的你,有没有什么想吐槽的?请留言或者在 项目 Github Issues 告诉我吧。

附录

  • 题图来源: Dominik Scythe on Unsplash

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 我们


推荐阅读
  • 技术日志:使用 Ruby 爬虫抓取拉勾网职位数据并生成词云分析报告
    技术日志:使用 Ruby 爬虫抓取拉勾网职位数据并生成词云分析报告 ... [详细]
  • 在尝试对 QQmlPropertyMap 类进行测试驱动开发时,发现其派生类中无法正常调用槽函数或 Q_INVOKABLE 方法。这可能是由于 QQmlPropertyMap 的内部实现机制导致的,需要进一步研究以找到解决方案。 ... [详细]
  • 您的数据库配置是否安全?DBSAT工具助您一臂之力!
    本文探讨了Oracle提供的免费工具DBSAT,该工具能够有效协助用户检测和优化数据库配置的安全性。通过全面的分析和报告,DBSAT帮助用户识别潜在的安全漏洞,并提供针对性的改进建议,确保数据库系统的稳定性和安全性。 ... [详细]
  • 在机器学习领域,深入探讨了概率论与数理统计的基础知识,特别是这些理论在数据挖掘中的应用。文章重点分析了偏差(Bias)与方差(Variance)之间的平衡问题,强调了方差反映了不同训练模型之间的差异,例如在K折交叉验证中,不同模型之间的性能差异显著。此外,还讨论了如何通过优化模型选择和参数调整来有效控制这一平衡,以提高模型的泛化能力。 ... [详细]
  • Python内置模块详解:正则表达式re模块的应用与解析
    正则表达式是一种强大的文本处理工具,通过特定的字符序列来定义搜索模式。本文详细介绍了Python内置的`re`模块,探讨了其在字符串匹配、验证和提取中的应用。例如,可以通过正则表达式验证电子邮件地址、电话号码、QQ号、密码、URL和IP地址等。此外,文章还深入解析了`re`模块的各种函数和方法,提供了丰富的示例代码,帮助读者更好地理解和使用这一工具。 ... [详细]
  • 利用树莓派畅享落网电台音乐体验
    最近重新拾起了闲置已久的树莓派,这台小巧的开发板已经沉寂了半年多。上个月闲暇时间较多,我决定将其重新启用。恰逢落网电台进行了改版,回忆起之前在树莓派论坛上看到有人用它来播放豆瓣音乐,便萌生了同样的想法。通过一番调试,终于实现了在树莓派上流畅播放落网电台音乐的功能,带来了全新的音乐享受体验。 ... [详细]
  • 本文介绍了如何利用 `matplotlib` 库中的 `FuncAnimation` 类将 Python 中的动态图像保存为视频文件。通过详细解释 `FuncAnimation` 类的参数和方法,文章提供了多种实用技巧,帮助用户高效地生成高质量的动态图像视频。此外,还探讨了不同视频编码器的选择及其对输出文件质量的影响,为读者提供了全面的技术指导。 ... [详细]
  • 在软件开发过程中,经常需要将多个项目或模块进行集成和调试,尤其是当项目依赖于第三方开源库(如Cordova、CocoaPods)时。本文介绍了如何在Xcode中高效地进行多项目联合调试,分享了一些实用的技巧和最佳实践,帮助开发者解决常见的调试难题,提高开发效率。 ... [详细]
  • 本文深入探讨了如何利用Maven高效管理项目中的外部依赖库。通过介绍Maven的官方依赖搜索地址(),详细讲解了依赖库的添加、版本管理和冲突解决等关键操作。此外,还提供了实用的配置示例和最佳实践,帮助开发者优化项目构建流程,提高开发效率。 ... [详细]
  • 深入解析 Android 中 EditText 的 getLayoutParams 方法及其代码应用实例 ... [详细]
  • 计算机视觉领域介绍 | 自然语言驱动的跨模态行人重识别前沿技术综述(上篇)
    本文介绍了计算机视觉领域的最新进展,特别是自然语言驱动的跨模态行人重识别技术。上篇内容详细探讨了该领域的基础理论、关键技术及当前的研究热点,为读者提供了全面的概述。 ... [详细]
  • 尽管我们尽最大努力,任何软件开发过程中都难免会出现缺陷。为了更有效地提升对支持部门的协助与支撑,本文探讨了多种策略和最佳实践,旨在通过改进沟通、增强培训和支持流程来减少这些缺陷的影响,并提高整体服务质量和客户满意度。 ... [详细]
  • 今天我开始学习Flutter,并在Android Studio 3.5.3中创建了一个新的Flutter项目。然而,在首次尝试运行时遇到了问题,Gradle任务 `assembleDebug` 执行失败,退出状态码为1。经过初步排查,发现可能是由于依赖项配置不当或Gradle版本不兼容导致的。为了解决这个问题,我计划检查项目的 `build.gradle` 文件,确保所有依赖项和插件版本都符合要求,并尝试更新Gradle版本。此外,还将验证环境变量配置是否正确,以确保开发环境的稳定性。 ... [详细]
  • 本文深入解析了Python在处理HTML过滤时的实现方法及其应用场景。通过具体实例,详细介绍了如何利用Python代码去除HTML字符串中的标签和其他无关信息,确保内容的纯净与安全。此外,文章还探讨了该技术在网页抓取、数据清洗等领域的实际应用,为开发者提供了宝贵的参考。 ... [详细]
  • 如何利用正则表达式(regexp)实现高效的模式匹配?本文探讨了正则表达式在编程中的应用,并分析了一个示例程序中存在的问题。通过具体的代码示例,指出该程序在定义和使用正则表达式时的不当之处,旨在帮助读者更好地理解和应用正则表达式技术。 ... [详细]
author-avatar
hitwill
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有