"To create architecture is to put in order. Put what in order? Functions and objects." – Le Corbusier
在前面的章节中,我们已经看到 Python 中的一切都是对象,函数也不例外。但是,函数到底是什么?函数是执行任务的指令序列,捆绑为一个单元。然后,该装置可以导入并在需要时使用。在代码中使用函数有很多优点,我们将很快看到。
在本章中,我们将介绍以下内容:
- 函数是什么以及我们为什么要使用它们
- 作用域和名称解析
- 函数签名输入参数和返回值
- 递归和匿名函数
- 导入对象以进行代码重用
我相信,一张图片胜过千言万语这句话在向不熟悉这个概念的人解释功能时尤其正确,所以请看下图:
如您所见,函数是一个指令块,作为一个整体打包,就像一个盒子。函数可以接受输入参数并生成输出值。这两个选项都是可选的,我们将在本章的示例中看到。
Python 中的函数是通过使用def关键字来定义的,之后是函数名,结尾是一对括号(可能包含也可能不包含输入参数),冒号(:表示函数定义行的结尾。紧接着,用四个空格缩进,我们找到函数体,这是函数调用时将执行的指令集。
Note that the indentation by four spaces is not mandatory, but it is the amount of spaces suggested by PEP 8, and, in practice, it is the most widely used spacing measure.
函数可以返回输出,也可以不返回输出。如果一个函数想要返回一个输出,它可以使用return关键字,然后是所需的输出。如果您有鹰眼,您可能已经注意到上图输出部分中可选之后的小*****。这是因为函数总是返回 Python 中的某些内容,即使您没有显式使用return子句。如果函数体中没有return语句,或者return语句本身没有赋值,则函数返回None。这种设计选择背后的原因超出了介绍性章节的范围,因此您需要知道的是,这种行为将使您的生活更轻松。一如既往,谢谢你,Python。
函数是任何语言中最重要的概念和结构之一,因此,让我给你一些我们需要它们的理由:
- 它们减少了程序中的代码重复。通过让一个特定的任务由一个很好的打包代码块处理,我们可以随时导入和调用它,我们不需要重复它的实现。
- 它们有助于将复杂的任务或过程拆分为更小的块,每个块都成为一个函数。
- 它们对用户隐藏实现细节。
- 它们提高了可追溯性。
- 它们提高了可读性。
让我们看几个例子来更好地理解每一点。
假设你正在编写一个科学软件,你需要计算一个极限的素数,就像我们在上一章所做的那样。你有一个很好的算法来计算它们,所以你可以复制并粘贴到任何你需要的地方。然而,有一天,你的朋友B.Riemann给了你一个更好的算法来计算素数,这将节省你很多时间。此时,您需要检查整个代码库,并用新代码替换旧代码。
这实际上是一个糟糕的方式。它很容易出错,当您将代码剪切并粘贴到其他代码中时,您永远不知道错误地剪切或留下了哪些行,而且您还可能会丢失一个进行基本计算的位置,从而使软件处于不一致的状态,即同一操作在不同的位置以不同的方式执行。如果您需要修复一个 bug,而不是用更好的版本替换代码,而错过了其中一个位置,该怎么办?那就更糟了。
那么,你应该怎么做?易于理解的你编写一个函数,get_prime_numbers(upto),并在需要素数列表的任何地方使用它。当B.Riemann向您提供新代码时,您所要做的就是用新的实现替换该函数的主体,您就完成了!软件的其余部分将自动适应,因为它只是调用函数。
您的代码将更短,不会因为执行任务的新旧方法之间的不一致,或者由于复制和粘贴失败或疏忽而未被发现的错误而受到影响。使用函数,你只会从中获益,我保证。
函数对于将长任务或复杂任务拆分为小任务也非常有用。最终的结果是代码从几个方面受益,例如可读性、可测试性和重用性。举个简单的例子,假设你正在准备一份报告。您的代码需要从数据源中获取数据,对其进行解析、过滤、润色,然后需要对其运行一系列算法,以生成将提供给Report类的结果。阅读这样的过程并不罕见,它们只是一个大的do_report(data_source)函数。有数十行或数百行代码以return report结尾。
这些情况在科学代码中更为常见,从算法的角度来看,科学代码往往非常出色,但有时在编写风格方面缺乏经验丰富的程序员的参与。现在,想象一下几百行代码。很难坚持到底,很难找到事情发生变化的地方(比如完成一项任务,开始下一项任务)。你脑子里有这幅画吗?好的别这样!相反,请查看以下代码:
# data.science.example.py
def do_report(data_source):
# fetch and prepare data
data = fetch_data(data_source)
parsed_data = parse_data(data)
filtered_data = filter_data(parsed_data)
polished_data = polish_data(filtered_data)
# run algorithms on data
final_data = analyse(polished_data)
# create and return report
report = Report(final_data)
return report当然,前面的示例是虚构的,但是您能看到遍历代码有多容易吗?如果最终结果看起来是错误的,那么调试do_report函数中的每个单个数据输出将非常容易。此外,从整个过程中临时排除部分过程更容易(您只需要注释掉需要挂起的部分)。这样的代码更容易处理。
让我们继续前面的例子来讨论这一点。您可以看到,通过阅读do_report函数的代码,您可以在不阅读任何一行实现的情况下获得相当好的理解。这是因为函数隐藏了实现细节。这个特性意味着,如果你不需要深入研究细节,你就不会像do_report只是一个大而胖的函数那样被迫去做。为了理解发生了什么,您必须阅读每一行代码。对于函数,您不需要这样做。这减少了您阅读代码的时间,因为在专业环境中,阅读代码比实际编写代码花费的时间要多得多,所以尽可能减少代码非常重要。
程序员有时看不到用一行或两行代码编写函数的意义,所以让我们看一个例子,说明为什么应该这样做。
假设需要将两个矩阵相乘:
您是否希望阅读以下代码:
# matrix.multiplication.nofunc.py
a = [[1, 2], [3, 4]]
b = [[5, 1], [2, 1]]
c = [[sum(i * j for i, j in zip(r, c)) for c in zip(*b)]
for r in a]还是您更喜欢这个:
# matrix.multiplication.func.py
# this function could also be defined in another module
def matrix_mul(a, b):
return [[sum(i * j for i, j in zip(r, c)) for c in zip(*b)]
for r in a]
a = [[1, 2], [3, 4]]
b = [[5, 1], [2, 1]]
c = matrix_mul(a, b)更容易理解的是,c是第二个例子中a和b相乘的结果。通读代码要容易得多,如果不需要修改乘法逻辑,甚至不需要进入实现细节。因此,这里的可读性得到了提高,而在第一个代码片段中,您将不得不花时间试图理解复杂的列表理解在做什么。
Don't worry if you don't understand list comprehensions, we'll study them in Chapter 5, Saving Time and Memory.
假设您已经编写了一个电子商务网站。您已经在所有页面上显示了产品价格。假设数据库中的价格存储时没有增值税(增值税),但您希望在网站上显示它们,增值税为 20%。以下是从增值税专用价格计算增值税专用价格的几种方法:
# vat.py
price = 100 # GBP, no VAT
final_price1 = price * 1.2
final_price2 = price + price / 5.0
final_price3 = price * (100 + 20) / 100.0
final_price4 = price + price * 0.2所有这四种不同的计算增值税包含价格的方法都是完全可以接受的,我向你们保证,多年来,我在我同事的代码中都找到了它们。现在,假设您已经开始在不同的国家销售您的产品,其中一些国家的增值税税率不同,因此您需要重构代码(在整个网站中),以使增值税计算动态化。
您如何追踪您进行增值税计算的所有地点?今天的编码是一项协作任务,您无法确定增值税是否仅使用其中一种形式计算。这将是地狱,相信我。
那么,让我们编写一个函数,它接受输入值vat和price(增值税除外),并返回增值税包含的价格:
# vat.function.py
def calculate_price_with_vat(price, vat):
return price * (100 + vat) / 100现在,您可以导入该功能,并在网站中需要计算增值税包含价格的任何地方使用它,当您需要跟踪这些呼叫时,您可以搜索calculate_price_with_vat。
Note that, in the preceding example, price is assumed to be VAT-exclusive, and vat is a percentage value (for example, 19, 20, or 23).
你还记得我们在第 1 章中谈到作用域和名称空间时,是对 Python的温和介绍吗?我们现在要扩展这个概念。最后,我们可以讨论函数,这将使一切更容易理解。让我们从一个非常简单的例子开始:
# scoping.level.1.py
def my_function():
test = 1 # this is defined in the local scope of the function
print('my_function:', test)
test = 0 # this is defined in the global scope
my_function()
print('global:', test)在前面的示例中,我在两个不同的地方定义了test名称。它实际上在两个不同的范围内。一个是全局范围(test = 0),另一个是my_function函数(test = 1的局部范围。如果执行代码,您将看到:
$ python scoping.level.1.py
my_function: 1
global: 0很明显,test = 1在my_function中隐藏了test = 0赋值。在全局上下文中,test仍然是0,您可以从程序的输出中看到,但我们在函数体中再次定义了test名称,并将其设置为指向值1的整数。因此,两个test名称都存在,一个在全局范围内,指向值为0的int对象;另一个在my_function范围内,指向值为1的int对象。让我们用test = 1注释掉这行。Python 在下一个封闭名称空间中搜索test名称(回忆LEGB规则:本地、封闭、全局、第 1 章中描述的内置、对 Python 的温和介绍以及,在本例中,我们将看到值0打印两次。在代码中尝试一下。
现在,让我们在这里提高赌注并提高水平:
# scoping.level.2.py
def outer():
test = 1 # outer scope
def inner():
test = 2 # inner scope
print('inner:', test)
inner()
print('outer:', test)
test = 0 # global scope
outer()
print('global:', test)在前面的代码中,我们有两个级别的阴影。一级在功能outer中,另一级在功能inner中。这与火箭科学相去甚远,但也可能很棘手。如果我们运行代码,我们会得到:
$ python scoping.level.2.py
inner: 2
outer: 1
global: 0试着对test = 1行进行注释。你能算出结果是什么吗?那么,当到达print('outer:', test)行时,Python 将不得不在下一个封闭范围中查找test,因此它将查找并打印0,而不是1。在继续之前,确保您也对test = 2进行了注释,以查看您是否了解发生了什么,以及 LEGB 规则是否明确。
另一件需要注意的事情是,Python 使您能够在另一个函数中定义函数。内部函数的名称是在外部函数的名称空间中定义的,与任何其他名称一样。
回到前面的示例,我们可以通过使用以下两个特殊语句之一来改变测试名称的阴影情况:global和nonlocal。从前面的示例中可以看到,当我们在inner函数中定义test = 2时,我们既不在outer函数中也不在全局范围中覆盖test。如果我们在没有定义这些名称的嵌套作用域中使用它们,我们可以获得对这些名称的读取权限,但是我们不能修改它们,因为当我们编写赋值指令时,我们实际上是在当前作用域中定义一个新名称。
我们如何改变这种行为?好吧,我们可以使用nonlocal语句。根据官方文件:
"The nonlocal statement causes the listed identifiers to refer to previously bound variables in the nearest enclosing scope excluding globals."
让我们在inner函数中介绍一下,看看会发生什么:
# scoping.level.2.nonlocal.py
def outer():
test = 1 # outer scope
def inner():
nonlocal test
test = 2 # nearest enclosing scope (which is 'outer')
print('inner:', test)
inner()
print('outer:', test)
test = 0 # global scope
outer()
print('global:', test)注意在inner函数的主体中,我如何将test名称声明为nonlocal。运行此代码会产生以下结果:
$ python scoping.level.2.nonlocal.py
inner: 2
outer: 2
global: 0哇,看那个结果!这意味着,通过在inner函数中声明test为nonlocal,我们实际上可以将test名称绑定到在outer函数中声明的名称。如果我们从inner函数中删除nonlocal test行,并在outer函数中尝试相同的技巧,我们将得到一个SyntaxError,因为nonlocal语句处理的是封闭范围,而不是全局范围。
那么,有没有办法在全局名称空间中实现这一点?当然,我们只需要使用global语句:
# scoping.level.2.global.py
def outer():
test = 1 # outer scope
def inner():
global test
test = 2 # global scope
print('inner:', test)
inner()
print('outer:', test)
test = 0 # global scope
outer()
print('global:', test)请注意,我们现在已经将test名称声明为global,这将基本上将它绑定到我们在全局名称空间(test = 0中定义的名称)。运行代码,您将获得以下信息:
$ python scoping.level.2.global.py
inner: 2
outer: 1
global: 2这表明受test = 2赋值影响的名称现在是global名称。这个技巧也适用于outer函数,因为在本例中,我们指的是全局范围。自己尝试一下,看看有什么变化,熟悉范围和名称解析,这非常重要。另外,如果您在前面的示例中定义了outer之外的inner,您能告诉我们会发生什么吗?
在本章的开头,我们看到函数可以接受输入参数。在深入研究所有可能的参数类型之前,让我们确保您清楚地了解将参数传递给函数意味着什么。要记住三个关键点:
- 参数传递只不过是将一个对象赋给一个局部变量名
- 将对象指定给函数中的参数名不会影响调用方
- 更改函数中的可变对象参数会影响调用方
让我们看一下每一点的示例。
看看下面的代码。我们在全局范围内声明一个名称x,然后声明一个函数func(y),最后调用它,传递x:
# key.points.argument.passing.py
x = 3
def func(y):
print(y)
func(x) # prints: 3当用x调用func时,在其局部范围内,创建一个名称y,并指向x所指向的同一对象。下图更好地说明了这一点(不要担心 Python 3.3,这是一个没有改变的特性):
上图右侧描述了在func返回后(None执行结束时程序的状态。看看 Frames 列,注意我们在全局名称空间(global frame)中有两个名称,x和func,分别指向int(值为3)和function对象。在它的正下方,在标题为func的矩形中,我们可以看到函数的本地名称空间,其中只定义了一个名称:y。因为我们已经用x调用了func(图左半部分的5行),y指向的是x指向的同一个对象。当一个参数被传递给一个函数时,这就是在幕后发生的事情。如果我们在函数定义中使用名称x而不是y,事情会完全一样(只是一开始可能有点混乱),函数中会有一个局部x,外部会有一个全局x,正如我们在范围和名称解析中看到的那样本章前面的章节。
因此,简而言之,真正发生的是函数在其局部范围内创建定义为参数的名称,当我们调用它时,我们基本上告诉 Python 这些名称必须指向哪些对象。
这是一个很难理解的问题,让我们看一个例子:
# key.points.assignment.py
x = 3
def func(x):
x = 7 # defining a local x, not changing the global one
func(x)
print(x) # prints: 3在前面的代码中,当执行x = 7行时,在func函数的本地范围内,名称x指向一个值为7的整数,全局x保持不变。
这是最后一点,这一点非常重要,因为 Python 显然对可变项的行为有所不同(尽管很明显)。让我们看一个例子:
# key.points.mutable.py
x = [1, 2, 3]
def func(x):
x[1] = 42 # this affects the caller!
func(x)
print(x) # prints: [1, 42, 3]哇,我们实际上改变了原来的对象!仔细想想,这种行为没有什么奇怪的。函数中的x名称通过函数调用设置为指向调用者对象,在函数体中,我们没有更改x,因为我们没有更改其引用,或者换句话说,我们没有更改x指向的对象。我们在位置 1 访问该对象的元素,并更改其值。
记住输入参数部分的第 2 点:将对象分配给函数中的参数名不会影响调用者。如果您清楚这一点,那么以下代码应该不会令人惊讶:
# key.points.mutable.assignment.py
x = [1, 2, 3]
def func(x):
x[1] = 42 # this changes the caller!
x = 'something else' # this points x to a new string object
func(x)
print(x) # still prints: [1, 42, 3]看看我突出显示的两行。首先,像前面一样,我们只需再次访问调用者对象,在位置 1 处,并将其值更改为数字42。然后,我们重新分配x以指向'something else'字符串。这使调用者保持不变,事实上,输出与前面代码段的输出相同。
花点时间玩转这个概念,并尝试打印和调用id函数,直到一切都在你的脑海中清晰可见。这是 Python 的关键方面之一,必须非常清楚,否则可能会在代码中引入微妙的 bug。再一次,Python 导师网站(http://www.pythontutor.com/ 将为您提供这些概念的可视化表示,这对您有很大帮助。
现在我们已经很好地理解了输入参数及其行为,让我们看看如何指定它们。
指定输入参数有五种不同的方法:
- 位置参数
- 关键字参数
- 可变位置参数
- 变量关键字参数
- 仅关键字参数
让我们逐一看一看。
位置参数从左到右读取,它们是最常见的参数类型:
# arguments.positional.py
def func(a, b, c):
print(a, b, c)
func(1, 2, 3) # prints: 1 2 3没什么好说的了。它们可以是您想要的任意数量,并按位置分配。在函数调用中,1排在第一位,2排在第二位,3排在第三位,因此它们分别被分配到a、b和c。
关键字参数由关键字使用name=value语法分配:
# arguments.keyword.py
def func(a, b, c):
print(a, b, c)
func(a=1, c=2, b=3) # prints: 1 3 2关键字参数按名称匹配,即使它们不尊重定义的原始位置(稍后我们将看到,当我们混合和匹配不同类型的参数时,这种行为有一个限制)。
在定义端,关键字参数的对应项是默认值。语法相同,name=value,如果我们对给定的默认值感到满意,则不必提供参数:
# arguments.default.py
def func(a, b=4, c=88):
print(a, b, c)
func(1) # prints: 1 4 88
func(b=5, a=7, c=9) # prints: 7 5 9
func(42, c=9) # prints: 42 4 9
func(42, 43, 44) # prints: 42, 43, 44有两件事需要注意,这是非常重要的。首先,不能在位置参数的左侧指定默认参数。其次,请注意,在示例中,当一个参数在未使用argument_name=value语法的情况下被传递时,它必须是列表中的第一个参数,并且始终被分配给a。还要注意,以位置方式传递值仍然有效,并且遵循函数签名顺序(示例的最后一行)。
试着把这些论点打乱,看看会发生什么。Python 错误消息非常善于告诉您出了什么问题。例如,如果您尝试了以下方法:
# arguments.default.error.py
def func(a, b=4, c=88):
print(a, b, c)
func(b=1, c=2, 42) # positional argument after keyword one您将得到以下错误:
$ python arguments.default.error.py
File "arguments.default.error.py", line 4
func(b=1, c=2, 42) # positional argument after keyword one
^
SyntaxError: positional argument follows keyword argument这会通知您调用函数的方式不正确。
有时,您可能希望向函数传递数量可变的位置参数,而 Python 为您提供了这样做的能力。让我们来看一个非常常见的用例,minimum函数。这是一个计算其最小输入值的函数:
# arguments.variable.positional.py
def minimum(*n):
# print(type(n)) # n is a tuple
if n: # explained after the code
mn = n[0]
for value in n[1:]:
if value < mn:
mn = value
print(mn)
minimum(1, 3, -7, 9) # n = (1, 3, -7, 9) - prints: -7
minimum() # n = () - prints: nothing如您所见,当我们在其名称前面指定一个参数*时,我们告诉 Python 该参数将根据函数的调用方式收集数量可变的位置参数。在函数中,n是一个元组。取消注释print(type(n))以便自己查看并玩一会儿。
Have you noticed how we checked whether n wasn't empty with a simple if n:? This is because collection objects evaluate to True when non-empty, and otherwise False in Python. This is true for tuples, sets, lists, dictionaries, and so on.
One other thing to note is that we may want to throw an error when we call the function with no arguments, instead of silently doing nothing. In this context, we're not concerned about making this function robust, but in understanding variable positional arguments.
根据我的经验,让我们再举一个例子来说明两件事,这两件事让新手感到困惑:
# arguments.variable.positional.unpacking.py
def func(*args):
print(args)
values = (1, 3, -7, 9)
func(values) # equivalent to: func((1, 3, -7, 9))
func(*values) # equivalent to: func(1, 3, -7, 9)请仔细查看前面示例的最后两行。在第一个例子中,我们用一个参数调用func,一个四元素元组。在第二个示例中,通过使用*语法,我们正在执行一个名为解包的操作,这意味着四元素元组被解包,函数被四个参数调用:1, 3, -7, 9。
这种行为是 Python 神奇的一部分,它允许您在动态调用函数时做出惊人的事情。
变量关键字参数与变量位置参数非常相似。唯一的区别是语法(**而不是*),它们被收集在字典中。收集和解包的工作方式相同,因此让我们看一个示例:
# arguments.variable.keyword.py
def func(**kwargs):
print(kwargs)
# All calls equivalent. They print: {'a': 1, 'b': 42}
func(a=1, b=42)
func(**{'a': 1, 'b': 42})
func(**dict(a=1, b=42))在前面的示例中,所有调用都是等效的。您可以看到,在函数定义中的参数名称前面添加一个**告诉 Python 使用该名称来收集数量可变的关键字参数。另一方面,当我们调用函数时,我们可以显式地传递name=value参数,或者使用相同的**语法解包字典。
为什么能够传递一个可变数量的关键字参数是如此重要的原因目前可能并不明显,那么,举一个更现实的例子怎么样?让我们定义一个连接到数据库的函数。我们希望通过不带参数的简单调用此函数来连接到默认数据库。我们还希望通过向函数传递适当的参数来连接到任何其他数据库。在继续阅读之前,试着花几分钟自己想出一个解决方案:
# arguments.variable.db.py
def connect(**options):
conn_params = {
'host': options.get('host', '127.0.0.1'),
'port': options.get('port', 5432),
'user': options.get('user', ''),
'pwd': options.get('pwd', ''),
}
print(conn_params)
# we then connect to the db (commented out)
# db.connect(**conn_params)
connect()
connect(host='127.0.0.42', port=5433)
connect(port=5431, user='fab', pwd='gandalf')请注意,在函数中,我们可以使用默认值作为回退准备一个连接参数字典(conn_params),如果函数调用中提供了这些参数,则允许覆盖它们。有更好的方法可以用更少的代码行来实现这一点,但我们现在不关心这一点。运行上述代码将产生以下结果:
$ python arguments.variable.db.py
{'host': '127.0.0.1', 'port': 5432, 'user': '', 'pwd': ''}
{'host': '127.0.0.42', 'port': 5433, 'user': '', 'pwd': ''}
{'host': '127.0.0.1', 'port': 5431, 'user': 'fab', 'pwd': 'gandalf'}注意函数调用和输出之间的对应关系。注意如何根据传递给函数的内容覆盖默认值。
Python3 允许一种新类型的参数:仅限关键字的参数。由于它们的用例并不那么频繁,我们将只对它们进行简单的研究。有两种方法可以指定它们,要么在变量位置参数之后,要么在裸*之后。让我们看一个两者的例子:
# arguments.keyword.only.py
def kwo(*a, c):
print(a, c)
kwo(1, 2, 3, c=7) # prints: (1, 2, 3) 7
kwo(c=4) # prints: () 4
# kwo(1, 2) # breaks, invalid syntax, with the following error
# TypeError: kwo() missing 1 required keyword-only argument: 'c'
def kwo2(a, b=42, *, c):
print(a, b, c)
kwo2(3, b=7, c=99) # prints: 3 7 99
kwo2(3, c=13) # prints: 3 42 13
# kwo2(3, 23) # breaks, invalid syntax, with the following error
# TypeError: kwo2() missing 1 required keyword-only argument: 'c'正如预期的那样,函数kwo接受数量可变的位置参数(a),并且只接受一个关键字c。调用的结果很简单,您可以取消对第三个调用的注释以查看 Python 返回的错误。
这同样适用于函数kwo2,它与kwo的不同之处在于,它接受一个位置参数a、一个关键字参数b,然后只接受一个关键字参数c。您可以取消对第三个调用的注释以查看错误。
既然您知道了如何指定不同类型的输入参数,那么让我们看看如何在函数定义中组合它们。
只要遵循以下顺序规则,就可以组合输入参数:
-
定义函数时,首先是普通位置参数(
name),然后是任何默认参数(name=value),然后是变量位置参数(*name或简单的*),然后是任何仅关键字参数(name或name=value形式良好),然后是任何变量关键字参数(**name。 -
另一方面,调用函数时,参数必须按以下顺序给出:首先是位置参数(
value),然后是关键字参数(name=value)、变量位置参数(*name)的任意组合,然后是变量关键字参数(**name)。
由于这在理论世界中可能有点棘手,让我们看几个快速示例:
# arguments.all.py
def func(a, b, c=7, *args, **kwargs):
print('a, b, c:', a, b, c)
print('args:', args)
print('kwargs:', kwargs)
func(1, 2, 3, *(5, 7, 9), **{'A': 'a', 'B': 'b'})
func(1, 2, 3, 5, 7, 9, A='a', B='b') # same as previous one请注意函数定义中参数的顺序,并且这两个调用是等效的。在第一个例子中,我们对 iterables 和 dictionary 使用解包操作符,而在第二个例子中,我们使用更显式的语法。执行此操作将产生以下结果(我只打印了一个调用的结果,另一个调用的结果相同):
$ python arguments.all.py
a, b, c: 1 2 3
args: (5, 7, 9)
kwargs: {'A': 'a', 'B': 'b'}现在让我们看一个仅包含关键字参数的示例:
# arguments.all.kwonly.py
def func_with_kwonly(a, b=42, *args, c, d=256, **kwargs):
print('a, b:', a, b)
print('c, d:', c, d)
print('args:', args)
print('kwargs:', kwargs)
# both calls equivalent
func_with_kwonly(3, 42, c=0, d=1, *(7, 9, 11), e='E', f='F')
func_with_kwonly(3, 42, *(7, 9, 11), c=0, d=1, e='E', f='F')注意,我在函数声明中突出显示了仅关键字参数。它们位于*args变量位置参数之后,如果它们正好位于单个*之后,则情况相同(在这种情况下,不会有变量位置参数)。执行此操作将产生以下结果(我只打印了一次调用的结果):
$ python arguments.all.kwonly.py
a, b: 3 42
c, d: 0 1
args: (7, 9, 11)
kwargs: {'e': 'E', 'f': 'F'}另一件需要注意的事情是我给变量位置参数和关键字参数起的名字。您可以自由选择不同的参数,但请注意,args和kwargs是这些参数的常规名称,至少在一般情况下是这样。
Python 3.5 中引入的最新功能之一是扩展 iterable(*和 dictionary(**解包运算符的能力,以允许在更多位置、任意次数和其他情况下解包。我将向您介绍一个有关函数调用的示例:
# additional.unpacking.py
def additional(*args, **kwargs):
print(args)
print(kwargs)
args1 = (1, 2, 3)
args2 = [4, 5]
kwargs1 = dict(option1=10, option2=20)
kwargs2 = {'option3': 30}
additional(*args1, *args2, **kwargs1, **kwargs2)在上一个示例中,我们定义了一个简单的函数,用于打印其输入参数args和kwargs。新特性在于我们调用此函数的方式。注意我们如何解包多个 iterable 和 dictionary,它们在args和kwargs下正确合并。此功能之所以重要,是因为它允许我们不必将代码中的args1与args2和kwargs1与kwargs2合并。运行代码会产生:
$ python additional.unpacking.py
(1, 2, 3, 4, 5)
{'option1': 10, 'option2': 20, 'option3': 30}请参考 PEP 448(https://www.python.org/dev/peps/pep-0448/ )了解此新功能的完整范围,并查看更多示例。
Python 需要特别注意的一点是,默认值是在def时间创建的,因此,对同一函数的后续调用可能会根据其默认值的可变性而表现出不同的行为。让我们看一个例子:
# arguments.defaults.mutable.py
def func(a=[], b={}):
print(a)
print(b)
print('#' * 12)
a.append(len(a)) # this will affect a's default value
b[len(a)] = len(a) # and this will affect b's one
func()
func()
func()这两个参数都有可变的默认值。这意味着,如果您影响这些对象,任何修改都将在后续函数调用中保留。看看您是否能理解这些调用的输出:
$ python arguments.defaults.mutable.py
[]
{}
############
[0]
{1: 1}
############
[0, 1]
{1: 1, 2: 2}
############很有趣,不是吗?虽然这种行为一开始看起来很奇怪,但实际上是有道理的,而且非常方便,例如,在使用记忆技术时(如果你感兴趣,谷歌就是一个例子)。更有趣的是,在调用之间,我们引入了一个不使用默认值的调用,例如:
# arguments.defaults.mutable.intermediate.call.py
func()
func(a=[1, 2, 3], b={'B': 1})
func()当我们运行此代码时,这是输出:
$ python arguments.defaults.mutable.intermediate.call.py
[]
{}
############
[1, 2, 3]
{'B': 1}
############
[0]
{1: 1}
############这个输出告诉我们,即使我们使用其他值调用函数,默认值也会保留。我想到的一个问题是,如何每次都得到一个新的空值?那么公约是这样的:
# arguments.defaults.mutable.no.trap.py
def func(a=None):
if a is None:
a = []
# do whatever you want with `a` ...请注意,通过使用前面的技术,如果调用函数时未传递a,则始终会得到一个全新的空列表。
好了,输入够了,让我们看看硬币的另一面,输出。
函数的返回值是 Python 领先于大多数其他语言的地方之一。函数通常被允许返回一个对象(一个值),但在 Python 中,您可以返回一个元组,这意味着您可以返回您想要的任何内容。这个特性允许程序员编写用其他语言编写的软件要困难得多,当然也要繁琐得多。我们已经说过,要从函数中返回某些内容,我们需要使用return语句,后跟我们想要返回的内容。函数体中可以根据需要有任意多个返回语句。
另一方面,如果在函数体中我们不返回任何内容,或者我们调用一个空的return语句,那么函数将返回None。这种行为是无害的,尽管我没有足够的空间来详细解释为什么 Python 是这样设计的,但让我告诉您,这个特性允许几种有趣的模式,并确认 Python 是一种非常一致的语言。
我说它是无害的,因为您从未被迫收集函数调用的结果。我将用一个例子来说明我的意思:
# return.none.py
def func():
pass
func() # the return of this call won't be collected. It's lost.
a = func() # the return of this one instead is collected into `a`
print(a) # prints: None请注意,整个函数体仅由pass语句组成。正如官方文件告诉我们的,pass是一个空操作。当它被执行时,什么也不会发生。当语法上需要语句,但不需要执行代码时,它可以用作占位符。在其他语言中,我们可能只会指出,使用一对花括号({})定义空范围,但在 Python 中,范围是通过缩进代码定义的,因此需要使用pass之类的语句。
还要注意,func函数的第一次调用返回一个我们不收集的值(None。如前所述,收集函数调用的返回值不是强制性的。
这很好,但不是很有趣,那么,我们写一个有趣的函数怎么样?请记住,在第 1 章对 Python的温和介绍中,我们讨论了函数的阶乘。让我们在这里编写我们自己的(为简单起见,我将假设函数始终使用适当的值正确调用,因此我不会检查输入参数的合理性):
# return.single.value.py
def factorial(n):
if n in (0, 1):
return 1
result = n
for k in range(2, n):
result *= k
return result
f5 = factorial(5) # f5 = 120注意,我们有两个返回点。如果n是0或1(在 Python 中,通常使用in类型的检查,正如我所做的,而不是更详细的if n == 0 or n == 1:,我们返回1。否则,我们将执行所需的计算并返回result。让我们试着更简洁地编写这个函数:
# return.single.value.2.py
from functools import reduce
from operator import mul
def factorial(n):
return reduce(mul, range(1, n + 1), 1)
f5 = factorial(5) # f5 = 120我知道你在想什么:一句话?Python 优雅、简洁!我认为这个函数是可读的,即使您从未见过reduce或mul,但如果您无法阅读或理解它,请留出几分钟时间,对 Python 文档进行一些研究,直到您清楚它的行为。能够在文档中查找函数并理解其他人编写的代码是每个开发人员都需要能够执行的任务,因此将此视为一项挑战。
To this end, make sure you look up the help function, which proves quite helpful when exploring with the console.
与大多数其他语言不同,在 Python 中,从一个函数返回多个对象非常容易。这个特性打开了一个充满可能性的世界,允许您以一种很难用其他语言复制的风格编写代码。我们的思维受到我们使用的工具的限制,因此当 Python 给你比其他语言更多的自由时,它实际上也在提升你自己的创造力。要返回多个值非常容易,只需使用元组(显式或隐式)。让我们看一个模拟divmod内置函数的简单示例:
# return.multiple.py
def moddiv(a, b):
return a // b, a % b
print(moddiv(20, 7)) # prints (2, 6)我本可以将前面代码中突出显示的部分用括号括起来,使其成为显式元组,但不需要这样做。前面的函数同时返回除法的结果和余数。
In the source code for this example, I have left a simple example of a test function to make sure my code is doing the correct calculation.
在编写函数时,遵循指导原则是非常有用的,这样可以很好地编写它们。我将很快指出其中的一些:
- 函数应该做一件事:做一件事的函数很容易用一句话来描述。可以将执行多项任务的函数拆分为执行一项任务的较小函数。这些较小的函数通常更容易阅读和理解。还记得我们几页前看到的数据科学示例吗。
- 函数应该很小:函数越小,测试和编写它们就越容易,这样它们就可以做一件事。
- 输入参数越少越好:包含大量参数的函数很快变得更难管理(除其他问题外)。
- 函数的返回值应该一致:返回
False或None不是一回事,即使在布尔上下文中,它们的计算结果都是False。False表示我们有信息(False,而None表示没有信息。尝试编写以一致的方式返回的函数,不管它们的身体中发生了什么。 - 函数不应该有副作用:换句话说,函数不应该影响调用它们的值。这可能是目前最难理解的语句,因此我将使用列表为您提供一个示例。在下面的代码中,请注意
numbers是如何不按sorted函数排序的,它实际上返回一个排序后的numbers副本。相反,list.sort()方法作用于numbers对象本身,这很好,因为它是一种方法(属于对象的函数,因此有权修改它):
>>> numbers = [4, 1, 7, 5]
>>> sorted(numbers) # won't sort the original `numbers` list
[1, 4, 5, 7]
>>> numbers # let's verify
[4, 1, 7, 5] # good, untouched
>>> numbers.sort() # this will act on the list
>>> numbers
[1, 4, 5, 7]遵循这些准则,您将编写更好的函数,这将很好地为您服务。
Chapter 3, Functions in Clean Code by Robert C. Martin, Prentice Hall is dedicated to functions and it's probably the best set of guidelines I've ever read on the subject.
当一个函数调用自身来产生一个结果时,它被称为递归。有时递归函数非常有用,因为它们使编写代码更容易。一些算法很容易使用递归范式编写,而其他算法则不然。没有不能以迭代方式重写的递归函数,因此通常由程序员为手头的情况选择最佳方法。
递归函数体通常有两个部分:一个部分返回值取决于对自身的后续调用,另一个部分返回值不取决于对自身的后续调用(称为基本情况)。
作为一个例子,我们可以考虑(希望现在熟悉)函数 T0。。基本情况是当N为0或1时。函数返回1,无需进一步计算。另一方面,在一般情况下,N!退回产品12*…*(N-1)N。如果你仔细想想,*N!*可以这样重写:*N!=(N-1)!N。作为一个实际的例子,考虑 ART16(5)!1 * 2 * 3 * 4 * 5 = (1 * 2 * 3 * 4) * 5 = 4! * 5。
让我们用代码写下来:
# recursive.factorial.py
def factorial(n):
if n in (0, 1): # base case
return 1
return factorial(n - 1) * n # recursive caseWhen writing recursive functions, always consider how many nested calls you make, since there is a limit. For further information on this, check out sys.getrecursionlimit() and sys.setrecursionlimit().
在编写算法时经常使用递归函数,编写递归函数非常有趣。作为练习,尝试使用递归和迭代方法解决两个简单问题。
我想谈的最后一类函数是匿名函数。这些函数在 Python 中被称为lambdas,通常在一个具有自己名字的完全成熟的函数被过度使用时使用,而我们所需要的只是一个快速、简单的一行程序来完成这项工作。
想象一下,您想要一个列表,其中列出了N之前的所有数字,这些数字是 5 的倍数。假设您想使用filter函数过滤掉这些元素,该函数接受一个函数和一个 iterable,并从函数返回True的 iterable 元素中构造一个可以迭代的过滤对象。如果不使用匿名函数,您可以执行以下操作:
# filter.regular.py
def is_multiple_of_five(n):
return not n % 5
def get_multiples_of_five(n):
return list(filter(is_multiple_of_five, range(n)))注意我们如何使用is_multiple_of_five过滤第一个n自然数。这似乎有点过分,任务很简单,我们不需要为其他任何事情保留is_multiple_of_five函数。让我们使用 lambda 函数重写它:
# filter.lambda.py
def get_multiples_of_five(n):
return list(filter(lambda k: not k % 5, range(n)))逻辑完全相同,但过滤函数现在是 lambda。定义 lambda 非常简单,并遵循以下形式:func_name = lambda [parameter_list]: expression。返回一个函数对象,相当于:def func_name([parameter_list]): return expression。
Note that optional parameters are indicated following the common syntax of wrapping them in square brackets.
让我们看另外两个以两种形式定义的等效函数示例:
# lambda.explained.py
# example 1: adder
def adder(a, b):
return a + b
# is equivalent to:
adder_lambda = lambda a, b: a + b
# example 2: to uppercase
def to_upper(s):
return s.upper()# is equivalent to:
to_upper_lambda = lambda s: s.upper()前面的例子非常简单。第一个将两个数字相加,第二个生成字符串的大写版本。请注意,我将lambda表达式返回的内容分配给了一个名称(adder_lambda、to_upper_lambda),但当您以filter示例中的方式使用 lambdas 时,不需要这样做。
每个函数都是一个成熟的对象,因此,它们具有许多属性。其中一些是特殊的,可以以内省的方式在运行时检查函数对象。以下脚本是一个示例,它显示了它们的一部分以及如何为示例函数显示它们的值:
# func.attributes.py
def multiplication(a, b=1):
"""Return a multiplied by b. """
return a * b
special_attributes = [
"__doc__", "__name__", "__qualname__", "__module__",
"__defaults__", "__code__", "__globals__", "__dict__",
"__closure__", "__annotations__", "__kwdefaults__",
]
for attribute in special_attributes:
print(attribute, '->', getattr(multiplication, attribute))我使用内置的getattr函数来获取这些属性的值。getattr(obj, attribute)相当于obj.attribute,当我们需要在运行时使用属性的字符串名称获取属性时,它非常方便。运行此脚本将产生:
$ python func.attributes.py
__doc__ -> Return a multiplied by b.
__name__ -> multiplication
__qualname__ -> multiplication
__module__ -> __main__
__defaults__ -> (1,)
__code__ -> <code object multiplication at 0x10caf7660, file "func.attributes.py", line 1>
__globals__ -> {...omitted...}
__dict__ -> {}__closure__ -> None
__annotations__ -> {}
__kwdefaults__ -> None我省略了__globals__属性的值,因为它太大了。有关此属性含义的解释,请参见Python 数据模型文档页(中的可调用**类型部分 https://docs.python.org/3/reference/datamodel.html#the-标准类型层次结构。如果您想查看一个对象的所有属性,只需调用dir(object_name)即可获得该对象所有属性的列表。
Python 附带了许多内置函数。它们在任何地方都可以使用,您可以通过使用dir(__builtins__)检查builtins模块,或者访问官方 Python 文档来获得它们的列表。不幸的是,我没有足够的空间把它们全部看一遍。我们已经看到了其中的一些,例如any、bin、bool、divmod、filter、float、getattr、id、int、len、list、min、print、set、tuple、type、zip,但还有很多,您至少应该阅读一次。熟悉它们,进行实验,为每一个编写一小段代码,并确保它们就在您的指尖上,以便您可以在需要时使用它们。
在我们结束本章之前,最后一个例子怎么样?我在想我们可以写一个函数来生成一个素数列表,达到一个极限。我们已经看到了这方面的代码,所以让我们把它变成一个函数,为了保持它的趣味性,让我们对它进行一些优化。
事实证明,你不需要将它除以从2到N-1 的所有数字来决定一个数字N是否为素数。您可以在停车√N。此外,您不需要对从2到的所有数字进行除法测试√N,您可以使用该范围内的素数。如果你感兴趣的话,我会让你去弄清楚为什么这是可行的。让我们看看代码是如何变化的:
# primes.py
from math import sqrt, ceil
def get_primes(n):
"""Calculate a list of primes up to n (included). """
primelist = []
for candidate in range(2, n + 1):
is_prime = True
root = ceil(sqrt(candidate)) # division limit
for prime in primelist: # we try only the primes
if prime > root: # no need to check any further
break
if candidate % prime == 0:
is_prime = False
break
if is_prime:
primelist.append(candidate)
return primelist代码与上一章中的代码相同。我们已经改变了除法算法,因此我们只使用先前计算的素数测试可除性,并且一旦测试除数大于候选素数的根,我们就停止测试。我们使用primelist结果列表获取除法的素数。我们使用一个奇特的公式计算根值,即候选根上限的整数值。虽然一个简单的int(k ** 0.5) + 1也可以达到我们的目的,但我选择的配方更干净,需要我使用一些进口产品,我想展示给大家。查看math模块中的功能,它们非常有趣!
我非常喜欢不需要文档的代码。当您正确地编程,选择正确的名称并注意细节时,您的代码应该是自解释的,不需要文档。不过,有时注释非常有用,一些文档也是如此。您可以在PEP257-文档字符串约定(中找到 Python 文档编制指南 https://www.python.org/dev/peps/pep-0257/ ),但我将在这里向您展示基本知识。
Python 是用字符串记录的,字符串被恰当地称为docstrings。可以记录任何对象,并且可以使用单行或多行 docstring。一行程序非常简单。他们不应为功能提供另一个签名,但应明确说明其目的:
# docstrings.py
def square(n):
"""Return the square of a number n. """
return n ** 2
def get_username(userid):
"""Return the username of a user given their id. """
return db.get(user_id=userid).username使用三重双引号字符串允许您以后轻松展开。使用句号结尾的句子,不要在前后留下空行。
多行注释的结构与此类似。应该有一行简单地告诉你对象的要点,然后是更详细的描述。例如,我在以下示例中使用 Sphinx 符号记录了一个虚构的connect函数:
def connect(host, port, user, password):
"""Connect to a database.
Connect to a PostgreSQL database directly, using the given
parameters.
:param host: The host IP.
:param port: The desired port.
:param user: The connection username.
:param password: The connection password.
:return: The connection object.
"""
# body of the function here...
return connectionSphinx is probably the most widely used tool for creating Python documentation. In fact, the official Python documentation was written with it. It's definitely worth spending some time checking it out.
现在您已经了解了很多函数,让我们看看如何使用它们。编写函数的全部目的是能够在以后重用它们,在 Python 中,这转化为将它们导入到需要它们的名称空间中。将对象导入命名空间的方法有很多种,但最常见的方法是import module_name和from module_name import function_name。当然,这些都是非常简单的例子,但请暂时容忍我。
import module_name表单查找module_name模块,并在执行import语句的本地名称空间中为其定义名称。from module_name import identifier形式比这稍微复杂一点,但基本上做相同的事情。它查找module_name并搜索属性(或子模块),并在本地名称空间中存储对identifier的引用。
两种表单都可以使用as子句更改导入对象的名称:
from mymodule import myfunc as better_named_func 为了让您了解导入的样子,下面是我的一个项目的测试模块的一个例子(注意到,导入块之间的空行遵循 PoP 8 在 Stutt0 中的指导原则。https://www.python.org/dev/peps/pep-0008/#imports :标准库、第三方和本地代码):
from datetime import datetime, timezone # two imports on the same line
from unittest.mock import patch # single import
import pytest # third party library
from core.models import ( # multiline import
Exam,
Exercise,
Solution,
)当您有一个从项目根目录开始的文件结构时,您可以使用点表示法来获取要导入到当前名称空间的对象,无论是包、模块、类、函数还是其他任何对象。from module import语法还允许使用一个 catch all 子句from module import *,它有时用于将模块中的所有名称一次放入当前名称空间,但由于性能和隐藏其他名称的风险等原因,不赞成使用它。您可以阅读官方 Python 文档中关于导入的所有知识,但是在我们离开主题之前,让我给您一个更好的示例。
假设您在模块funcdef.py中定义了两个函数:square(n)和cube(n),模块位于lib文件夹中。您希望在两个模块中使用它们,这些模块与lib文件夹处于同一级别,称为func_import.py和func_from.py。显示该项目的树结构会产生如下结果:
├── func_from.py
├── func_import.py
├── lib
├── funcdef.py
└── __init__.py在我向您展示每个模块的代码之前,请记住,为了告诉 Python 它实际上是一个包,我们需要在其中放入一个__init__.py模块。
There are two things to note about the __init__.py file. First of all, it is a fully-fledged Python module so you can put code into it as you would with any other module. Second, as of Python 3.3, its presence is no longer required to make a folder be interpreted as a Python package.
代码如下:
# funcdef.py
def square(n):
return n ** 2
def cube(n):
return n ** 3
# func_import.py
import lib.funcdef
print(lib.funcdef.square(10))
print(lib.funcdef.cube(10))
# func_from.py
from lib.funcdef import square, cube
print(square(10))
print(cube(10)) 这两个文件在执行时都会打印100和1000。根据我们在当前范围中导入的方式和内容,您可以看到我们访问square和cube函数的方式有多不同。
到目前为止,我们看到的导入被称为绝对,也就是说,它们定义了我们想要导入的模块的整个路径,或者我们想要从中导入对象。还有另一种将对象导入 Python 的方法,称为相对导入。当我们想在不必编辑子包的情况下重新排列大型包的结构时,或者当我们想使包中的模块能够导入自身时,它会很有帮助。相对导入是通过在模块前面添加尽可能多的前导点来完成的,前导点的数量取决于我们需要回溯的文件夹的数量,以便找到我们正在搜索的内容。简而言之,它是这样的:
from .mymodule import myfunc 有关相对进口的完整说明,请参阅 PEP 328(https://www.python.org/dev/peps/pep-0328/ )。在后面的章节中,我们将使用不同的库创建项目,并使用几种不同类型的导入,包括相关的导入,因此请确保您在官方 Python 文档中花一点时间阅读相关内容。
在本章中,我们探讨了函数的世界。它们非常重要,从现在起,我们将在任何地方使用它们。我们讨论了使用它们的主要原因,其中最重要的是代码重用和实现隐藏。
我们看到函数对象就像一个接受可选输入并产生输出的盒子。我们可以通过许多不同的方式向函数提供输入值,使用位置参数和关键字参数,并对这两种类型使用变量语法。
现在,您应该知道如何编写函数、编写文档、将其导入代码并调用它。
下一章将迫使我更进一步,因此我建议您抓住任何机会,通过深入 Python 官方文档来巩固和丰富您迄今为止收集的知识。


