Python 进阶指南(编程轻松进阶):十、编写高效函数

2023-04-09 09:55:42 浏览数 (1)

原文:http://inventwithpython.com/beyond/chapter10.html 函数就像程序中的迷你程序,允许我们将代码分解成更小的单元。这使我们不必编写重复的代码,因为重复的代码会引入错误。但是编写有效的函数需要做出许多关于命名、大小、参数和复杂性的决定。

这一章探索了我们编写函数的不同方法以及不同权衡的优缺点。我们将深入研究如何在小函数和大函数之间进行权衡,参数的数量如何影响函数的复杂性,以及如何使用***操作符编写参数数量可变的函数。我们还将探索函数式编程范式以及根据这种范式编写函数的好处。

函数名称

函数名应该遵循我们在第 4 章中描述的标识符的惯例。但是它们通常应该包含一个动词,因为函数通常执行一些动作。你也可以用一个名词来描述正在发生的事情。例如,名字refreshConnection()setPassword()extract_version()阐明了这个函数做什么和达到什么目的。

对于作为类或模块一部分的方法,您可能不需要名词。SatelliteConnection类中的reset()方法或者webbrowser模块中的open()函数已经提供了必要的上下文。您可以看出卫星连接是正在重置的项目,而 web 浏览器是正在打开的项目。

最好使用长的描述性名称,而不是缩写或太短的名称。数学家可能会立即理解名为gcd()的函数返回两个数字的最大公分母,但其他人会发现getGreatestCommonDenominator()提供的信息更多。

切记不要使用 Python 的任何内置函数或模块名称,如allanydateemailfileformathashidinputlistminmaxobjectopenrandomsetstrsumtesttype

函数大小权衡

一些程序员说函数应该尽可能的短,不要超过一个屏幕所能容纳的长度。一个只有十几行的函数相对容易理解,至少与一个几百行的函数相比是这样。但是,通过将代码分割成多个更小的函数来缩短函数也有不利的一面。让我们来看看小函数的一些优点:

  • 该函数的代码更容易理解。
  • 该函数可能需要较少的参数。
  • 该函数不太可能有副作用,如第 172 页的“函数式编程”所述。
  • 该函数更易于测试和调试。
  • 该函数可能会引发更少的不同类型的异常。

但是短函数也有一些缺点:

  • 编写短函数通常意味着程序中有更多的函数。
  • 拥有更多的函数意味着程序更加复杂。
  • 拥有更多函数也意味着必须想出更多描述性的、准确的名称,这是一项艰巨的任务。
  • 使用更多的函数需要您编写更多的文档。
  • 函数之间的关系变得更加复杂。

有些人将“越短越好”的指导方针发挥到了极致,声称所有函数最多只有三四行代码。这太疯狂了。例如,这是第 14 章的汉诺塔游戏中的getPlayerMove()函数。这些代码如何工作的细节并不重要。看看这个函数的一般结构就知道了:

代码语言:javascript复制
def getPlayerMove(towers):
    """Asks the player for a move. Returns (fromTower, toTower)."""

    while True:  # Keep asking player until they enter a valid move.
        print('Enter the letters of "from" and "to" towers, or QUIT.')
        print("(e.g. AB to moves a disk from tower A to tower B.)")
        print()
        response = input("> ").upper().strip()

        if response == "QUIT":
            print("Thanks for playing!")
            sys.exit()

        # Make sure the user entered valid tower letters:
        if response not in ("AB", "AC", "BA", "BC", "CA", "CB"):
            print("Enter one of AB, AC, BA, BC, CA, or CB.")
            continue  # Ask player again for their move.

        # Use more descriptive variable names:
        fromTower, toTower = response[0], response[1]

        if len(towers[fromTower]) == 0:
            # The "from" tower cannot be an empty tower:
            print("You selected a tower with no disks.")
            continue  # Ask player again for their move.
        elif len(towers[toTower]) == 0:
            # Any disk can be moved onto an empty "to" tower:
            return fromTower, toTower
        elif towers[toTower][-1] < towers[fromTower][-1]:
            print("Can't put larger disks on top of smaller ones.")
            continue  # Ask player again for their move.
        else:
            # This is a valid move, so return the selected towers:
            return fromTower, toTower

这个函数有 34 行长。虽然它涵盖了多个任务,包括允许玩家进入一个移动,检查这个移动是否有效,如果移动无效,要求玩家再次进入一个移动,这些任务都属于获得玩家移动的范围。另一方面,如果我们致力于编写短函数,我们可以将getPlayerMove()中的代码分解成更小的函数,就像这样:

代码语言:javascript复制
def getPlayerMove(towers):
    """Asks the player for a move. Returns (fromTower, toTower)."""

    while True:  # Keep asking player until they enter a valid move.
        response = askForPlayerMove()
 terminateIfResponseIsQuit(response)
        if not isValidTowerLetters(response):
            continue # Ask player again for their move.

        # Use more descriptive variable names:
        fromTower, toTower = response[0], response[1]

        if towerWithNoDisksSelected(towers, fromTower):
            continue  # Ask player again for their move.
        elif len(towers[toTower]) == 0:
            # Any disk can be moved onto an empty "to" tower:
            return fromTower, toTower
        elif largerDiskIsOnSmallerDisk(towers, fromTower, toTower):
            continue  # Ask player again for their move.
        else:
            # This is a valid move, so return the selected towers:
            return fromTower, toTower

def askForPlayerMove():
    """Prompt the player, and return which towers they select."""
    print('Enter the letters of "from" and "to" towers, or QUIT.')
    print("(e.g. AB to moves a disk from tower A to tower B.)")
    print()
    return input("> ").upper().strip()

def terminateIfResponseIsQuit(response):
    """Terminate the program if response is 'QUIT'"""
    if response == "QUIT":
        print("Thanks for playing!")
        sys.exit()

def isValidTowerLetters(towerLetters):
    """Return True if `towerLetters` is valid."""
    if towerLetters not in ("AB", "AC", "BA", "BC", "CA", "CB"):
        print("Enter one of AB, AC, BA, BC, CA, or CB.")
        return False
    return True

def towerWithNoDisksSelected(towers, selectedTower):
    """Return True if `selectedTower` has no disks."""
    if len(towers[selectedTower]) == 0:
        print("You selected a tower with no disks.")
        return True
    return False

def largerDiskIsOnSmallerDisk(towers, fromTower, toTower):
    """Return True if a larger disk would move on a smaller disk."""
    if towers[toTower][-1] < towers[fromTower][-1]:
        print("Can't put larger disks on top of smaller ones.")
        return True
    return False

这六个函数有 56 行长,几乎是原始代码行数的两倍,但是它们执行相同的任务。虽然每个函数都比原来的getPlayerMove()函数更容易理解,但是它们组合在一起就意味着复杂性的增加。你的代码的读者可能很难理解它们是如何组合在一起的。getPlayerMove()函数是唯一被程序其他部分调用的函数;其他五个函数只被调用一次,来自getPlayerMove()。但是函数的质量并没有传达这个事实。

我还必须为每个新函数想出新的名字和文档字符串(每个def语句下的三重引号字符串,在第 11 章中进一步解释)。这导致一些函数的名字容易混淆,比如getPlayerMove()askForPlayerMove()。另外,getPlayerMove()仍然比三四行长,所以如果我遵循“越短越好”的原则,我需要把它分成更小的函数!

在这种情况下,只允许非常短的函数的策略可能会导致更简单的函数,但是程序的整体复杂性急剧增加。在我看来,函数最好少于 30 行,绝对不能超过 200 行。让你的函数尽可能的短,但是不要再短了。

函数形参和实参

函数的形参是函数的def语句的括号之间的变量名,而实参是函数调用的括号之间的值。一个函数的参数越多,它的代码就越容易配置和推广。但是更多的参数也意味着更大的复杂性。

一个需要遵守的好规则是零到三个参数是可以的,但是超过五个或六个可能就太多了。一旦函数变得过于复杂,最好考虑如何将它们拆分成参数更少的小函数。

默认参数

降低函数参数复杂性的一种方法是为参数提供默认参数。默认参数是一个值,如果函数调用没有指定参数的话,它就被用作参数。如果大多数函数调用使用特定的参数值,我们可以将该值作为默认参数,以避免在函数调用中重复输入。

我们在def语句中指定了一个默认参数,跟在参数名和等号后面。例如,在这个introduction()函数中,名为greeting的参数具有值'Hello',如果函数调用没有指定它:

代码语言:javascript复制
>>> def introduction(name, greeting='Hello'):
...    print(greeting   ', '   name)
...
>>> introduction('Alice')
Hello, Alice
>>> introduction('Hiro', 'Ohiyo gozaimasu')
Ohiyo gozaimasu, Hiro

当不带第二个参数调用introduction()函数时,它默认使用字符串'Hello'。请注意,带有默认参数的参数必须始终跟在不带默认参数的参数后面。

回想一下第 8 章,你应该避免使用可变对象作为默认值,比如空列表[]或者空字典{}。第 143 页的“不要使用可变值作为默认参数”解释了这种方法导致的问题及其解决方案。

使用***向函数传递参数

您可以使用***语法(通常读作双星)将参数组分别传递给函数。*语法允许你在一个可迭代对象(比如一个列表或者元组)中传递条目。**语法允许您将映射对象(比如字典)中的键值对作为单独的参数传入。

例如,print()函数可以接受多个参数。默认情况下,它会在它们之间放置一个空格,如下面的代码所示:

代码语言:javascript复制
>>> print('cat', 'dog', 'moose')
cat dog moose

这些参数被称为位置参数,因为它们在函数调用中的位置决定了哪个参数分配给哪个参数。但是,如果您将这些字符串存储在一个列表中,并试图传递该列表,print()函数会认为您试图将该列表作为单个值打印出来:

代码语言:javascript复制
>>> args = ['cat', 'dog', 'moose']
>>> print(args)
['cat', 'dog', 'moose']

将列表传递给print()会显示列表,包括括号、引号和逗号字符。

打印列表中单个项目的一种方法是,通过将每个项目的索引分别传递给函数,将列表拆分为多个参数,这样会产生难以阅读的代码:

代码语言:javascript复制
>>> # An example of less readable code:
>>> args = ['cat', 'dog', 'moose']
>>> print(args[0], args[1], args[2])
cat dog moose

有一种更简单的方法将这些项目传递给print()。您可以使用*语法将列表中的项目(或任何其他可迭代的数据类型)解释为单独的位置参数。在交互式 Shell 中输入以下示例。

代码语言:javascript复制
>>> args = ['cat', 'dog', 'moose']
>>> print(*args)
cat dog moose

*语法允许您将列表项单独传递给一个函数,不管列表中有多少项。

您可以使用**语法将映射数据类型(比如字典)作为单独的关键字参数来传递。关键字参数前面有参数名和等号。例如,print()函数有一个sep关键字参数,它指定一个字符串放在它显示的参数之间。默认情况下,它被设置为一个空格字符串' '。您可以使用赋值语句或**语法将关键字参数赋给不同的值。要了解这是如何工作的,请在交互式 Shell 中输入以下内容:

代码语言:javascript复制
>>> print('cat', 'dog', 'moose', sep='-')
cat-dog-moose
>>> kwargsForPrint = {'sep': '-'}
>>> print('cat', 'dog', 'moose', **kwargsForPrint)
cat-dog-moose

请注意,这些指令产生相同的输出。在这个例子中,我们只用了一行代码来设置kwargsForPrint字典。但是对于更复杂的情况,您可能需要更多的代码来建立一个关键字参数的字典。**语法允许您创建配置设置的自定义字典,以传递给函数调用。这对于接受大量关键字参数的函数和方法尤其有用。

通过在运行时修改列表或字典,您可以使用***语法为函数调用提供可变数量的参数。

使用*创建变参函数

您还可以在def语句中使用*语法来创建接收不同数量的位置参数的可变参数变参函数。例如,print()是一个变参函数,因为您可以向它传递任意数量的字符串:例如,print('Hello!')print('My name is', name)。注意,尽管我们在上一节的函数调用中使用了*语法,但在本节的函数定义中我们使用了*语法。

让我们通过创建一个product()函数来看一个例子,该函数接受任意数量的参数并将它们相乘:

代码语言:javascript复制
>>> def product(*args):
...    result = 1
...    for num in args:
...        result *= num
...    return result
...
>>> product(3, 3)
9
>>> product(2, 1, 2, 3)
12

在函数内部,args只是一个包含所有位置参数的常规 Python 元组。从技术上讲,您可以给这个参数起任何名字,只要它以星号(*)开头,但是按照惯例,它通常被命名为args

知道何时使用*需要一些思考。毕竟,生成可变函数的替代方法是使用单个参数接受一个列表(或其他可迭代的数据类型),其中包含不同数量的项。这就是内置的sum()函数的作用:

代码语言:javascript复制
>>> sum([2, 1, 2, 3])
8

sum()函数需要一个可迭代的参数,因此向它传递多个参数会导致一个异常:

代码语言:javascript复制
>>> sum(2, 1, 2, 3)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: sum() takes at most 2 arguments (4 given)

同时,内置的min()max()函数可以找到几个值的最小值或最大值,接受单个可迭代的参数或多个单独的参数:

代码语言:javascript复制
>>> min([2, 1, 3, 5, 8])
1
>>> min(2, 1, 3, 5, 8)
1
>>> max([2, 1, 3, 5, 8])
8
>>> max(2, 1, 3, 5, 8)
8

所有这些函数都采用不同数量的参数,那么为什么它们的参数设计不同呢?什么时候我们应该使用*语法设计函数来接受一个可迭代的参数或者多个独立的参数?

我们如何设计参数取决于我们预测程序员将如何使用我们的代码。print()函数接受多个参数,因为程序员更经常向它传递一系列字符串或包含字符串的变量,如在print('My name is', name)中。通过几个步骤将这些字符串收集到一个列表中,然后将列表传递给print(),这种情况并不常见。此外,如果您向print()传递了一个列表,该函数将打印完整的列表值,因此您不能用它来打印列表中的单个值。

没有理由用单独的参数调用sum(),因为 Python 已经为此使用了 操作符。因为你可以像2 4 8一样写代码,你不需要能够像sum(2, 4, 8)一样写代码。有意义的是,您必须将不同数量的参数作为一个列表传递给sum()

min()max()函数允许两种风格。如果程序员传递一个参数,该函数会假设它是一个要检查的值的列表或元组。如果程序员传递多个参数,它会假设这些是要检查的值。这两个函数通常在程序运行时处理值列表,如函数调用min(allExpenses)。它们还处理程序员在编写代码时选择的独立参数,比如在max(0, someNumber)中。因此,这些函数被设计为接受这两种类型的参数。下面的myMinFunction(),是我自己对min()函数的实现,演示了这一点:

代码语言:javascript复制
def myMinFunction(*args):
    if len(args) == 1:
    1 values = args[0]
    else:
    2 values = args

    if len(values) == 0:
    3 raise ValueError('myMinFunction() args is an empty sequence')

 4 for i, value in enumerate(values):
        if i == 0 or value < smallestValue:
            smallestValue = value
    return smallestValue

myMinFunction()使用*语法接受不同数量的参数作为元组。如果这个元组只包含一个值,我们假设它是一个要检查的值序列 1 。否则,我们假设args是一个值的元组来检查 2 。无论哪种方式,values变量都将包含一个值序列,供其余代码检查。像实际的min()函数一样,如果调用者没有传递任何参数或者传递了一个空序列 3 ,我们就会引发ValueError。其余代码循环遍历值,并返回找到的最小值 4 。为了保持这个例子的简单性,myMinFunction()只接受列表或元组这样的序列,而不接受任何可迭代的值。

您可能想知道为什么我们不总是编写函数来接受传递不同数量参数的两种方式。答案是最好让你的函数尽可能简单。除非调用函数的两种方式都很常见,否则选择其中一种。如果一个函数通常处理程序运行时创建的数据结构,最好让它接受单个参数。如果一个函数通常处理程序员在编写代码时指定的参数,那么最好使用*语法来接受不同数量的参数。

使用**创建变参函数

可变函数也可以使用**语法。尽管在def语句中的*语法表示不同数量的位置参数,但是**语法表示不同数量的可选关键字参数。

如果你定义了一个函数,它可以在不使用**语法的情况下接受许多可选的关键字参数,那么你的def语句可能会变得难以使用。考虑一个假设的formMolecule()函数,它有所有 118 个已知元素的参数:

代码语言:javascript复制
>>> def formMolecule(hydrogen, helium, lithium, beryllium, boron, `--snip--`

hydrogen参数传递2,为oxygen参数传递1,以返回'water',这也很麻烦且难以理解,因为您必须将所有不相关的元素设置为零:

代码语言:javascript复制
>>> formMolecule(2, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 `--snip--`
'water'

通过使用每个都有一个默认参数的命名关键字参数,可以使函数更易于管理,从而使您不必在函数调用中向该参数传递参数。


尽管术语形参实参被很好地定义,程序员倾向于互换使用关键字形参关键字实参(中文种一般统称关键字参数)。


例如,这个def语句对于每个关键字参数都有默认参数0

代码语言:javascript复制
>>> def formMolecule(hydrogen=0, helium=0, lithium=0, beryllium=0, `--snip--`

这使得调用formMolecule()变得更加容易,因为您只需要为参数指定不同于默认参数的参数值。您还可以按任意顺序指定关键字参数:

代码语言:javascript复制
>>> formMolecule(hydrogen=2, oxygen=1)
'water'
>>> formMolecule(oxygen=1, hydrogen=2)
'water'
>>> formMolecule(carbon=8, hydrogen=10, nitrogen=4, oxygen=2)
'caffeine'

但是仍然有一个有 118 个参数名的笨拙的def语句。如果发现了新元素呢?您必须更新函数的def语句以及函数参数的任何文档。

相反,您可以使用关键字参数的**语法将所有参数及其参数作为键值对收集到一个字典中。从技术上来说,您可以给**参数起任何名字,但是按照惯例,它通常被命名为kwargs

代码语言:javascript复制
>>> def formMolecules(**kwargs):
...    if len(kwargs) == 2 and kwargs['hydrogen'] == 2 and 

kwargs['oxygen'] == 1:

代码语言:javascript复制
 ...        return 'water'
...    # (rest of code for the function goes here)
...
>>> formMolecules(hydrogen=2, oxygen=1)
'water'

**语法表示kwargs参数可以处理函数调用中传递的所有关键字参数。它们将作为键值对存储在分配给kwargs参数的字典中。随着新的化学元素的发现,您需要更新函数的代码,而不是它的def语句,因为所有的关键字参数都被放入kwargs

代码语言:javascript复制
>>> def formMolecules(**kwargs): # 1
...    if len(kwargs) == 1 and kwargs.get('unobtanium') == 12: # 2
...        return 'aether'
...    # (rest of code for the function goes here)
...
>>> formMolecules(unobtanium=12)
'aether'

如你所见,def语句 1 和以前一样,只有函数的代码 2 需要更新。当您使用**语法时,def语句和函数调用变得更容易编写,并且仍然产生可读的代码。

使用***创建包装函数

def语句中的***语法的一个常见用例是创建包装函数,该函数将参数传递给另一个函数并返回该函数的返回值。您可以使用***语法将任何和所有参数转发给包装的函数。例如,我们可以创建一个printLowercase()函数来包装内置的print()函数。它依靠print()来完成实际的工作,但是首先将字符串参数转换成小写:

代码语言:javascript复制
>>> def printLower(*args, **kwargs): # 1
...    args = list(args) # 2
...    for i, value in enumerate(args):
...        args[i] = str(value).lower()
...    return print(*args, **kwargs) # 3
...
>>> name = 'Albert'
>>> printLower('Hello,', name)
hello, albert
>>> printLower('DOG', 'CAT', 'MOOSE', sep=', ')
dog, cat, moose

printLower()函数 1 使用*语法接受分配给args参数的元组中不同数量的位置参数,而**语法将任何关键字参数分配给kwargs参数中的字典。如果函数同时使用*args**kwargs,则*args参数必须在**kwargs参数之前。我们将这些传递给包装的print()函数,但是首先我们的函数修改了一些参数,所以我们创建了一个列表形式的args元组 2 。

在将args中的字符串改为小写后,我们使用***语法 3 将args中的项目和kwargs中的键值对作为单独的参数传递给print()print()的返回值也作为printLower()的返回值返回。这些步骤有效地包装了print()函数。

函数式编程

函数式编程是一种编程范式,强调编写执行计算而不修改全局变量或任何外部状态(如硬盘上的文件、互联网连接或数据库)的函数。一些编程语言,比如 Erlang、Lisp 和 Haskell,在很大程度上是围绕函数式编程概念设计的。尽管没有被范式所束缚,Python 还是有一些函数式编程特性。Python 程序可以使用的主要函数有无副作用函数、高阶函数和 Lambda 函数。

副作用

副作用是一个函数对存在于它自己的代码和局部变量之外的程序部分所做的任何改变。为了说明这一点,让我们创建一个实现 Python 的减法运算符(-)的subtract()函数:

代码语言:javascript复制
>>> def subtract(number1, number2):
...    return number1 - number2
...
>>> subtract(123, 987)
-864

这个subtract()函数没有副作用。也就是说,它不会影响程序中不属于其代码的任何内容。没有办法从程序或计算机的状态来判断subtract()函数以前是否被调用过一次、两次或一百万次。一个函数可能会修改函数内部的局部变量,但是这些修改与程序的其他部分是隔离的。

现在考虑一个addToTotal()函数,它将数值参数添加到名为TOTAL的全局变量中:

代码语言:javascript复制
>>> TOTAL = 0
>>> def addToTotal(amount):
...    global TOTAL
...    TOTAL  = amount
...    return TOTAL
...
>>> addToTotal(10)
10
>>> addToTotal(10)
20
>>> addToTotal(9999)
10019
>>> TOTAL
10019

addToTotal()函数确实有副作用,因为它修改了一个存在于函数之外的元素:全局变量TOTAL。副作用不仅仅是对全局变量的改变。它们包括更新或删除文件、在屏幕上打印文本、打开数据库连接、向服务器进行认证或在函数之外进行任何其他更改。函数调用返回后留下的任何痕迹都是副作用。

副作用还包括对函数外部引用的可变对象进行原地更改。例如,下面的removeLastCatFromList()函数原地修改列表参数:

代码语言:javascript复制
>>> def removeLastCatFromList(petSpecies):
...    if len(petSpecies) > 0 and petSpecies[-1] == 'cat':
...        petSpecies.pop()
...
>>> myPets = ['dog', 'cat', 'bird', 'cat']
>>> removeLastCatFromList(myPets)
>>> myPets
['dog', 'cat', 'bird']

在这个例子中,myPets变量和petSpecies参数保存对同一个列表的引用。在函数内部对列表对象进行的任何原地修改也会存在于函数外部,这使得这种修改成为副作用。

一个相关的概念,一个确定性函数,总是在给定相同参数的情况下返回相同的返回值。subtract(123, 987)函数调用总是返回−864。Python 内置的round()函数在传递3.14作为参数时总是返回3。当传递相同的参数时,非确定性函数不会总是返回相同的值。比如调用random.randint(1, 10)会返回一个在110之间的随机整数。time.time()函数没有参数,但是它根据调用该函数时计算机时钟的设置返回不同的值。在time.time()的例子中,时钟是一个外部资源,它实际上是函数的输入,就像参数一样。依赖于函数外部资源(包括全局变量、硬盘上的文件、数据库和互联网连接)的函数不被认为是确定性的。

确定性函数的一个好处是可以缓存它们的值。如果subtract()能记住第一次用这些参数调用它时的返回值,它就不需要多次计算123987的差。因此,确定性函数允许我们进行时空权衡,通过使用内存空间缓存先前的结果来加快函数的运行时间。

一个确定性的、没有副作用的函数叫做纯函数。函数式程序员努力在他们的程序中只创建纯函数。除了已经提到的那些,纯函数还提供了几个好处:

  • 它们非常适合单元测试,因为它们不需要你设置任何外部资源。
  • 通过使用相同的参数调用函数,很容易在纯函数中重现 bug。
  • 纯函数可以调用其他纯函数并保持纯粹。
  • 在多线程程序中,纯函数是线程安全的,可以安全地并发运行。(多线程超出了本书的范围。)
  • 对纯函数的多次调用可以在并行 CPU 内核或多线程程序中运行,因为它们不需要依赖任何要求它们以任何特定顺序运行的外部资源。

只要有可能,您就可以并且应该用 Python 编写纯函数。Python 函数是纯约定的;没有任何设置会导致 Python 解释器保证纯粹性。最常见的方法是避免在函数中使用全局变量,并确保它们不会与文件、互联网、系统时钟、随机数或其他外部资源交互。

高阶函数

高阶函数可以接受其他函数作为参数或者返回函数作为返回值。例如,让我们定义一个名为callItTwice()的函数,它将调用给定的函数两次:

代码语言:javascript复制
>>> def callItTwice(func, *args, **kwargs):
...     func(*args, **kwargs)
...     func(*args, **kwargs)
...
>>> callItTwice(print, 'Hello, world!')
Hello, world!
Hello, world!

callItTwice()函数与它传递的任何函数一起工作。在 Python 中,函数是一级对象,这意味着它们就像任何其他对象一样:你可以将函数存储在变量中,将它们作为参数传递,或者将它们用作返回值。

Lambda 函数

Lambda 函数,也称为匿名函数无名函数,是没有名字的简化函数,其代码仅由一条return语句组成。当将函数作为参数传递给其他函数时,我们经常使用 Lambda 函数。

例如,我们可以创建一个普通的函数,它接受一个包含 4 乘 10 的矩形的宽度和高度的列表,如下所示:

代码语言:javascript复制
>>> def rectanglePerimeter(rect):
...    return (rect[0] * 2)   (rect[1] * 2)
...
>>> myRectangle = [4, 10]
>>> rectanglePerimeter(myRectangle)
28

等效的 Lambda 函数如下所示:

代码语言:javascript复制
lambda rect: (rect[0] * 2)   (rect[1] * 2)

要定义一个 Python Lambda 函数,使用lambda关键字,后跟一个逗号分隔的参数列表(如果有的话),一个冒号,然后是一个充当返回值的表达式。因为函数是一级对象,所以可以将 Lambda 函数赋给变量,有效地复制了def语句的功能:

代码语言:javascript复制
>>> rectanglePerimeter = lambda rect: (rect[0] * 2)   (rect[1] * 2)
>>> rectanglePerimeter([4, 10])
28

我们将这个 Lambda 函数赋给一个名为rectanglePerimeter的变量,实际上给了我们一个rectanglePerimeter()函数。如您所见,由lambda语句创建的函数与由def语句创建的函数是一样的。


在真实世界的代码中,使用def语句,而不是将 Lambda 函数赋给常量变量。Lambda 函数是专门为函数不需要名字的情况而设计的。


Lambda 函数语法有助于指定小函数作为其他函数调用的参数。例如,sorted()函数有一个名为key的关键字参数,它允许您指定一个函数。它不是根据项的值对列表中的项进行排序,而是根据函数的返回值进行排序。在下面的例子中,我们向sorted()传递一个 Lambda 函数,该函数返回给定矩形的周长。这使得sorted()函数基于其[width, height]列表的计算周长进行排序,而不是直接基于[width, height]列表:

代码语言:javascript复制
>>> rects = [[10, 2], [3, 6], [2, 4], [3, 9], [10, 7], [9, 9]]
>>> sorted(rects, key=lambda rect: (rect[0] * 2)   (rect[1] * 2))
[[2, 4], [3, 6], [10, 2], [3, 9], [10, 7], [9, 9]]

例如,该函数现在根据返回的周长整数2418进行排序,而不是对值[10, 2][3, 6]进行排序。Lambda 函数是一种方便的语法捷径:您可以指定一个小的单行 Lambda 函数,而不是用一个def语句定义一个新的命名函数。

将列表推导式用于映射和过滤

在早期的 Python 版本中,map()filter()函数是常见的高阶函数,可以转换和过滤列表,通常借助于 Lambda 函数的。映射可以基于另一个列表的值创建一个值列表。筛选可以创建一个列表,其中只包含另一个列表中符合某些条件的值。

例如,如果你想创建一个新的列表,它包含字符串而不是整数[8, 16, 18, 19, 12, 1, 6, 7],你可以将这个列表和lambda n: str(n)传递给map()函数:

代码语言:javascript复制
>>> mapObj = map(lambda n: str(n), [8, 16, 18, 19, 12, 1, 6, 7])
>>> list(mapObj)
['8', '16', '18', '19', '12', '1', '6', '7']

map()函数返回一个map对象,我们可以通过将它传递给list()函数以列表形式获得它。映射列表现在包含基于原始列表的整数值的字符串值。filter()函数与此类似,但在这里,Lambda 函数参数决定列表中的哪些项目保留(如果 Lambda 函数返回True)或被过滤掉(如果它返回False)。例如,我们可以通过lambda n: n % 2 == 0过滤掉任何奇数:

代码语言:javascript复制
>>> filterObj = filter(lambda n: n % 2 == 0, [8, 16, 18, 19, 12, 1, 6, 7])
>>> list(filterObj)
[8, 16, 18, 12, 6]

filter()函数返回一个过滤器对象,我们可以再次将它传递给list()函数。只有偶数整数保留在过滤列表中。

但是map()filter()函数是在 Python 中创建映射或过滤列表的过时方法。相反,你现在可以用列表推导式来创建它们。列表推导式不仅让你不用写 Lambda 函数,而且比map()filter()更快。

在这里,我们使用列表推导式来复制map()函数示例:

代码语言:javascript复制
>>> [str(n) for n in [8, 16, 18, 19, 12, 1, 6, 7]]
['8', '16', '18', '19', '12', '1', '6', '7']

注意,列表推导式的str(n)部分与lambda n: str(n)相似。

这里我们使用列表推导式复制了filter()函数示例:

代码语言:javascript复制
>>> [n for n in [8, 16, 18, 19, 12, 1, 6, 7] if n % 2 == 0]
[8, 16, 18, 12, 6]

注意,列表推导式的if n % 2 == 0部分与lambda n: n % 2 == 0相似。

许多语言都有函数作为一级对象的概念,允许存在更高阶的函数,包括映射和过滤函数。

返回值应该总是具有相同的数据类型

Python 是一种动态类型语言,这意味着 Python 函数和方法可以自由地返回任何数据类型的值。但是为了使您的函数更加可预测,您应该努力使它们只返回单一数据类型的值。

例如,下面的函数根据随机数返回整数值或字符串值:

代码语言:javascript复制
>>> import random
>>> def returnsTwoTypes():
...    if random.randint(1, 2) == 1:
...        return 42
...    else:
...        return 'forty two'

当您编写调用该函数的代码时,很容易忘记必须处理几种可能的数据类型。继续这个例子,假设我们调用returnsTwoTypes()并想把它返回的数字转换成十六进制:

代码语言:javascript复制
>>> hexNum = hex(returnsTwoTypes())
>>> hexNum
'0x2a'

Python 的内置hex()函数返回它所传递的整数值的一个十六进制数的字符串。只要returnsTwoTypes()返回一个整数,这段代码就能正常工作,给我们的印象是这段代码没有错误。但是当returnsTwoTypes()返回一个字符串时,它会引发一个异常:

代码语言:javascript复制
>>> hexNum = hex(returnsTwoTypes())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'str' object cannot be interpreted as an integer

当然,我们应该始终记住处理返回值可能具有的每一种可能的数据类型。但在现实世界中,很容易忘记这一点。为了防止这些错误,我们应该总是尝试让函数返回单一数据类型的值。这不是一个严格的要求,有时无法让函数返回不同数据类型的值。但是你越接近只返回一种类型,你的函数就越简单,越不容易出错。

有一种情况需要特别注意:不要从函数中返回None,除非你的函数总是返回NoneNone值是NoneType数据类型中唯一的值。让函数返回None来表示发生了错误(我将在下一节“引发异常与返回错误代码”中讨论这种做法),这很有吸引力,但是您应该为没有有意义的返回值的函数保留返回None

原因是返回None指示错误是未捕获'NoneType' object has no attribute异常的常见来源:

代码语言:javascript复制
>>> import random
>>> def sometimesReturnsNone():
...    if random.randint(1, 2) == 1:
...        return 'Hello!'
...    else:
...        return None
...
>>> returnVal = sometimesReturnsNone()
>>> returnVal.upper()
'HELLO!'
>>> returnVal = sometimesReturnsNone()
>>> returnVal.upper()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'NoneType' object has no attribute 'upper'

这个错误消息相当模糊,可能需要花费一些努力来追溯到一个函数,该函数通常返回预期的结果,但也可能在错误发生时返回None。出现这个问题是因为sometimesReturnsNone()返回了None,然后我们将它赋给了returnVal变量。但是错误消息会让您认为问题发生在对upper()方法的调用中。

在 2009 年的一次会议上,计算机科学家东尼·霍尔为 1965 年发明的空引用(与 Python 的None值类似的值)道歉,他说“我称之为我的十亿美元错误。[……]我无法抗拒放入空引用的诱惑,仅仅是因为它太容易实现了。这导致了数不清的错误、漏洞和系统崩溃,在过去的 40 年里,这可能造成了数十亿美元的痛苦和损失。”你可以在autbor.com/billiondollarmistake在线观看他的完整演讲。

引发异常与返回错误代码

在 Python 中,术语异常错误的含义大致相同:程序中的异常情况,通常表明存在问题。在 20 世纪 80 年代和 90 年代,随着 C 和 Java 的出现,异常作为一种编程语言特性开始流行起来。它们取代了使用错误码,错误码是从函数返回的指示问题的值。异常的好处是返回值只与函数的用途有关,而不是表明存在错误。

错误代码也会导致程序出现问题。例如,Python 的find() 字符串方法通常返回找到子串的索引,如果找不到子串,则返回-1作为错误代码。但是因为我们也可以使用-1来指定从字符串末尾开始的索引,无意中使用-1作为错误代码可能会引入一个 bug。在交互式 Shell 中输入以下内容,看看这是如何工作的。

代码语言:javascript复制
>>> print('Letters after b in "Albert":', 'Albert'['Albert'.find('b')   1:])
Letters after b in "Albert": ert
>>> print('Letters after x in "Albert":', 'Albert'['Albert'.find('x')   1:])
Letters after x in "Albert": Albert

代码的'Albert'.find('x')部分求值为错误代码-1。这使得表达式'Albert'['Albert'.find('x') 1:]求值为'Albert'[-1 1:],它进一步求值为'Albert'[0:],然后求值为'Albert'。显然,这不是代码的预期行为。调用index()而不是find(),就像在'Albert'['Albert'.index('x') 1:]中一样,会引发一个异常,使问题变得明显而不可忽略。

另一方面,index() 字符串方法在找不到子串时会引发一个ValueError异常。如果您不处理这个异常,它将使程序崩溃——这种行为通常比没有注意到错误要好。

当异常指示实际错误时,异常类的名称通常以“错误”结尾,如ValueErrorNameErrorSyntaxError。代表不一定是错误的异常情况的异常类包括StopIterationKeyboardInterruptSystemExit

总结

函数是将我们的程序代码组合在一起的一种常见方式,它们需要您做出某些决定:给它们起什么名字,它们有多大,它们应该有多少个参数,以及您应该为这些参数传递多少个参数。def语句中的***语法允许函数接收不同数量的参数,使它们成为可变函数。

虽然 Python 不是函数式编程语言,但它有许多函数式编程语言使用的特性。函数是一级对象,这意味着您可以将它们存储在变量中,并将它们作为参数传递给其他函数(在此上下文中称为高阶函数)。Lambda 函数提供了一个简短的语法,用于指定无名、匿名函数作为高阶函数的参数。Python 中最常见的高阶函数是map()filter(),尽管使用列表推导式可以更快地执行它们提供的功能。

函数的返回值应该总是相同的数据类型。你不应该使用返回值作为错误代码:异常是用来指示错误的。特别是None值经常被错误地用作错误代码。

0 人点赞