本文介绍Python函数相关的进阶知识。
目录
函数是Python的一等公民,除了像其它语言那样封装一组代码逻辑被调用。它还和变量一样可以作为另外一个函数的参数或者返回值,并且随时在运行时修改它的代码。在Python里,用一种特殊的对象来表示函数,这个对象和普通的对象一样可以被复制,它的特殊之处在于可以用()来调用这个对象(函数),比如下面的代码:
def example():
print("func called")
print(type(example))
print(example)
example()
输出类似:
<class 'function'>
<function example at 0x7fa62a8bae18>
func called
example是类function的一个实例,函数对象有个”特殊”的能力(其实也不特殊,没有这个功能还能叫函数吗?),那就是可以用()语法调用它。
参数
Parameter和Argument的区别
Parameter和Argument都被翻译成中文的”参数”,但是它们的准确含义是不同的。根据SO问题,它们的区别为:在函数原型或者内部定义使用的是parameter;而调用时传入的是argument。比如:
void Foo(int i, float f)
{
// Do things
}
void Bar()
{
int anInt = 1;
Foo(anInt, 2.0);
}
则i和f是parameter;而anInt和2.0是argument。
默认参数
在C/Java等语言中,函数是没有默认参数的。如果一个函数的参数很多,那么传递参数会很麻烦。因为设计函数的时候通常要考虑灵活性,这个时候很多可以由调用者决定的变量会作为参数。大部分用户不需要这种灵活性,但是也需要为这种灵活性付出代价——需要记住不用的参数的默认值并且传入。如果默认值发生变化,那就更是灾难性的后果。当然Java提供了函数重载,比较好的设计是有一个包含所有参数的函数负责实现真正的逻辑,而那些重载的版本只是调用这个函数并且提供默认值。比如:
void fun_with_many_params(int a, int b, int c, int d, int e){
// Do things
}
void fun_with_many_params(int a, int b){
fun_with_many_params(a, b, 100, 200, 300);
}
void fun_with_many_params(int a, int b, int c){
fun_with_many_params(a, b, c, 200, 300);
}
void fun_with_many_params(int a, int b, int c, int d){
fun_with_many_params(a, b, c, d, 300);
}
但是如果有默认值的参数很多,那么它们的组合方式也会非常多,写起来非常啰嗦。而且由于函数前面的限制,有些组合是无法实现的。比如上面的例子,我们如果只想传入a、b和d,那么也许我们想这样:
void fun_with_many_params(int a, int b, int d){
fun_with_many_params(a, b, 100, d, 300);
}
但这是不行的!因为函数重载不能通过参数名字来区别,在执行fun_with_many_params(1,2,3)时不可能知道3是传给c还是d,所以为了避免这种情况就会在编译时就不允许出现这样的代码,从而给出编译错误。
而C++和Python提供了默认参数,也就是说如果调用者不传入,则使用默认值:
def fun_with_many_params(a, b, c=100, d=200, e=300):
pass
注意有默认值的参数后面不能出现必选参数(没有默认值的参数),比如下面的定义会出现编译错误:
def fun_with_many_params(a, b, c=100, d=200, e=300, f):
pass
为什么呢?因为假设允许这样,则fun_with_many_params(1,2,3)的参数3是传给c还是f呢?也许读者会说传给c,那么fun_with_many_params(1,2,3,c=4)中的3传给谁呢?另外如果想传如f,那么就必须得传入c、d和e,这也让默认参数失去的意义。因此Python规定默认参数后面不能出现必选参数。
注意:Python是没有函数重载的概念的,一个函数名只能对于一个函数的定义。如果用同一个名称定义多次,即使参数个数(Python不是静态语言,所以也就不可能根据参数类型区分函数)不同也是会覆盖原来的定义的,比如:
def func(a, b):
print("a={}, b={}".format(a, b))
def func(e, f, g):
print("e={}, f={}".format(e, f))
func(1, 3) # 缺少参数g
可变长的位置参数
这里出现了位置(Positional)参数的概念,对于C/C++/Java等语言来说,所有的参数都是位置参数,因此不用特意区别。而Python有在调用的时候可以使用变量名进行调用。比如:
def func(a, b, c):
pass
func(1, 2, 3)
func(b=3, a=1, c=2)
其它语言通常只能按照函数定义的顺序一个一个传入参数,但是Python可以使用参数(parameter)的名称进行传递,而且可以乱序,这就是keyword传递参数的方式。同时也可以混合这两种方式,比如:
func(1, b=2, c=3)
但是不能反过来:
func(b=2, c=3, 1)
因为和默认参数类似,keyword参数后面不运行出现位置参数。编译错误为:”SyntaxError: positional argument follows keyword argument”。
可变位置参数是为了解决变长参数的问题,比如假设有个ShoppingCart类,有个方法是往购物车增加商品:
class ShoppingCart:
def add_to_cart(items):
self.items.extend(items)
如果只有一个商品,我们调用时也得构造一个list:
cart.add_to_cart([item])
这样就很不方便。其实相对其它语言来说还好了,设想一下Java语言:
List<Item> items = new ArrayList<Item>(1);
items.add(item);
cart.add_to_cart(items);
这时可以使用可变的位置参数来定义函数,它的语法是在参数前面加一个*:
def add_to_cart(*items):
self.items.extend(items)
调用方法为:
cart.add_to_cart(item)
cart.add_to_cart(item1, item2)
cart.add_to_cart(item1, item2, item3, item4, item5)
在函数里拿到的items是一个tuple。读者可能会问,如果我有一个item的list呢?那我总不能展开成item[0],item[1], ….传入吧。这时我们可以在调用的时候在list前加一个*,Python会把它展开。
items = ["item"+str(i) for in range(10)]
cart.add_to_cart(*items)
不过注意:上面的代码Python会把items转换成一个tuple然后传入,因此不适合用这种方式传入大量的数据。
可变长keyword参数(parameter)
有的时候某个函数的参数个数是不确定的,比如这个函数只是把这些参数传递给另外一个函数。如果显式的定义这些参数,那么当另一个函数修改参数时这个函数也得跟着改。一种解决办法是把这些参数放到一个dict里,比如:
class ShoppingCart:
def __init__(self, options):
self.options = options
如果要不这些参数传给另外某个真正要使用的函数呢,则可以使用**展开成keyword参数调用:
def call_another_func(self):
another_func(**self.options)
假设options={“a”:1, “b”:2},则相当于调用another_func(a=1, b=2)。
但是ShoppingCart的调用者还得手动构造一个dict:
options = {'currency': 'USD'}
cart = ShoppingCart(options)
或者把两个语句合并成一个:
cart = ShoppingCart({'currency': 'USD'})
但是这样还是不像传递参数。我们这是可以使用与变长位置参数类似的方法——变长keyword参数:
def __init__(self, **options):
self.options = options
也就是在参数前面加两个*,这个时候就可以这样调用:
cart = ShoppingCart(currency='USD', usertype='VIP')
而__init__方法得到的options则是一个dict。
注意:变长位置参数拿到的是一个不能修改的tuple;而变长keyword参数拿到的是一个dict,它是可以修改的。
四种类型的参数
通过前文的介绍,函数总共有4种类型的参数:
- 必选(位置)参数
- 可选参数
- 变长位置参数
- 变长keyword参数
那怎么把这4种类型的参数放到一个函数的定义中呢,最自然常见的做法是:
def create_element(name, editable=True, *children, **attributes):
首先是必选参数,然后是可选参数,变长位置参数,最后是变长keyword参数。但是这样定义有一个问题——我们如果想要传入变长位置参数时一定得传入可选参数。这样一来可选参数就失去了实际意义。读者可能会问能不能这样传入参数:
create_element("name", child1, child2)
但是程序并不知道child1代表什么含义,因此它只能按照顺序把child1传给editable。为了解决这个问题,我们需要把变长位置参数放到可选参数之前:
def create_element(name, *children, editable=True, **attributes):
不过这个时候如果需要传递可选参数就只能通过keyword的方法传入了,比如:
create_element("name", "c1", "c2", False, attr1="value", attr2="value2")
读者是不是期望children是(“c1”, “c2”)而editable是False呢?很可惜,实际的children是(“c1”, “c2”, False),而editable是默认的True。为什么呢?因为Python是动态语言,它不能通过类型来匹配,比如:
create_element("name", "c1", "c2", "c3", attr1="value", attr2="value2")
这个调用和前面的参数个数一模一样,因此传递的参数方法也是一样的。但是我们可能期望”c3”是属于children而不是editable的。这种”语义”的判断是无法实现的,所以Python规定可变位置参数会”贪婪”的匹配尽可能多的参数(keyword参数它匹配不了)。所以如果我们要传入可选参数editable的话就只能通过keyword的方法:
create_element("name", "c1", "c2", editable=False, attr1="value", attr2="value2")
因为放到可变位置参数之后的位置参数(有没有默认值都有一样)只能通过keyword的方式传入,所以这类参数叫做keyword only参数,在inspect模块中通常简称kwonlyargs。注意:kwonly的参数可以有默认值从而是可选的,也可以没有默认值从而是必须的。比如:
def func(required1, *args, option1=0, required2):
pass
参数required2是必须的,但是只能通过keyword的方式传入。我们甚至可以不命名变长位置参数,从而目的只是让某些参数变成keyword only,比如:
def func(a, *, b, **kwargs):
pass
func(1, b=4)
Partial函数
对于一个函数,我们可以提前设置(preload)它的某些参数,从而得到一个参数更少的函数。比如:
def func(a, b, c, d):
print(f"a={a}, b={b}, c={c}, d={d}")
import functools
f2 = functools.partial(func, 1, 2)
f2(3, 4)
# 输出:a=1, b=2, c=3, d=4
partial函数也支持keyword参数的传递方式:
f3 = functools.partial(func, c=1, d=2)
f3(3, 4)
# 输出:a=3, b=4, c=1, d=2
注意:Partial函数只是提前设置参数得到一个新的函数,它和currying是不同的,它们的区别参考这里。
函数的自省(Introspection)
函数在Python内部也是表示成一个对象,我们可以通过inspect在模块运行时检查函数的各个方面。我们可以通过getfullargspec()函数获得函数参数的详细信息,其中包括:
- args
- 固定位置参数,不包括keyword参数、可变位置参数和可变keyword参数
- varargs
- 可变位置参数,最多一个
- defaults
- 固定位置参数的默认值,可能小于args的个数,它的第一个值对应args的第一个默认参数
- varkw
- 可变keyword参数,最多一个
- kwonlyargs
- keyword参数
- kwonlydefaults
- keyword参数的默认值,和defautls不同,它是个dict
- annotations:
- 后面再讲
这些概念有些抽象,我们看一个例子:
import inspect
def func(a, b=2, *c, d, e=3, **f)-> str:
print(f"a={a}, b={b}, c={c}, d={d}, e={e}, f={f}")
func(1, 2, 3, 4, 5, d=6, attr1="v1", attr2="v2")
spec = inspect.getfullargspec(func)
print(spec)
输出为:
FullArgSpec(args=['a', 'b'], varargs='c', varkw='f', defaults=(2,), kwonlyargs=['d', 'e'], kwonlydefaults={'e': 3}, annotations={'return': <class 'str'>})
对于上面定义的func,各个参数的含义为:
- args
- 固定位置参数list,包括a和b
- defaults
- 这是个tuple,说明args中默认参数b的默认值为2。这里需要注意:2为什么是b的默认值而不是a的呢?因为根据语法,默认值参数总是出现在最后面,所以defaults的最后一个值对应args的最后一个参数;defaults的倒数第二个对应args的倒数第二个;……。
- varargs
- 可变位置参数c
- varkw
- 可变keyword参数f
- kwonlyargs
- keyword参数list,这里包括d和e
- kwonlydefaults
- keyword参数的默认值,这里是个dict,key就是keyword参数名,value就是默认值
注意:args和defaults的对应关系是比较复杂的,是需要倒过来对应;而kwonlydefaults是个dict,所以比较简单。
例子:确定参数(Argument)的值
比如我们想在log里记录函数调用的参数,我们当然可以在函数的开头手工的打印,就像前面的代码:
def func(a, b=2, *c, d, e=3, **f)-> str:
print(f"a={a}, b={b}, c={c}, d={d}, e={e}, f={f}")
但是这有一个问题,首先是很麻烦,每个函数开头都得加上一段代码,而且增加或者删除参数后都得改这行代码。我们可以通过inspect模块在运行时获取这些信息,从而实现一个通用的函数来打印所有的参数。
我们下面一步一步来实现一个get_arguments函数,它的原型为:
def get_arguments(func, args, kwargs):
pass
其中func就是函数,而args和kwargs是传给它的位置参数和keyword参数。可能有读者会问:我调用的是”func(1,2,3,4,d=5,attr1=”v1”)”,这么多参数怎么变成两个参数args和kwargs的呢?后面我们介绍Decorator时会解释,总之现在我们知道这样的调用最终args=(1,2,3,4)而kwargs={“d”:5, “attr1”:”v1”}就行了。
现在的问题就是,我们知道func对象,从而可以通过inspect得到的FullArgSpec信息结合传入的args和kwargs推出真正的参数,并且需要小心处理参数值和名称的对应关系以及默认值。我们采取迭代的方法逐步完善这个函数。
keyword参数
我们首先处理传入的kwargs,这是一个dict,因此参数名和值的对应关系是一目了然的:
def example(a, b=2, *c, d, e=3, **f)-> str:
print(f"a={a}, b={b}, c={c}, d={d}, e={e}, f={f}")
def get_arguments(func, args, kwargs):
arguments = kwargs.copy()
return arguments
args = (1, )
varargs = {'attr1': "v1", "attr2": "v2", 'd': 5}
print(get_arguments(example, args, varargs))
example(*args, **varargs)
输出:
{'attr1': 'v1', 'attr2': 'v2', 'd': 5}
a=1, b=2, c=(), d=5, e=3, f={'attr1': 'v1', 'attr2': 'v2'}
我们发现可变的keyword参数包括attr1和attr2,而固定的keyword参数d为5,但是get_argument的输出还缺了e,因为e是有默认值的,调用这并没有传入,所以我们需要从默认值里获取它。不过在处理默认值之前,我们先处理固定的位置参数。
固定的位置参数
这部分也非常简单,spec.args里就是所有的位置参数(包括有默认值的),而传入的args就是位置参数。
def get_arguments(func, args, kwargs):
arguments = kwargs.copy()
spec = inspect.getfullargspec(func)
arguments.update(zip(spec.args, args))
return arguments
修改后执行的结果如下:
{'attr1': 'v1', 'attr2': 'v2', 'd': 5, 'a': 1}
a=1, b=2, c=(), d=5, e=3, f={'attr1': 'v1', 'attr2': 'v2'}
注意:和keyword参数一样,由于有的参数有默认值,spec.args=[“a”, “b”],而args=(1, ),所以b取的是默认值,这也需要后面处理。
处理默认值
对于上面的函数,spec.args=[“a”, “b”],spec.defaults=[2],所以参数b的默认值都是1。
if spec.defaults:
for i, name in enumerate(spec.args[-len(spec.defaults):]):
if name not in arguments:
arguments[name] = spec.defaults[i]
这段代码有点复杂,我们首先看spec.args[-len(spec.defaults):],结合前面的具体例子,spec.args[-len(spec.defaults):]就是spec.args[-1:],所以就是[“b”]。用自然语言描述就是:默认参数的长度为l=len(spec.defaults),然后spec.arg[-l:]就是与之对应的有默认值的参数名。
这里还要判断这个变量命是否已经通过args或者varargs传入了,如果没有传入则使用默认值,否则使用传入的值,这就是”if name not in arguments”语句的作用。
这是执行get_arguments返回的就是:
{'attr1': 'v1', 'attr2': 'v2', 'd': 5, 'a': 1, 'b': 2}
我们看到b是默认值2,而a是传入的1。我们也可以测试一下传入b:
args = (1, 10)
varargs = {'attr1': "v1", "attr2": "v2", 'd': 5}
print(get_arguments(example, args, varargs))
这个时候输出的是:
{'attr1': 'v1', 'attr2': 'v2', 'd': 5, 'a': 1, 'b': 10}
当然我们也可以通过keyword传入b:
args = (1, )
varargs = {'attr1': "v1", "attr2": "v2", 'd': 5, 'b': 10}
print(get_arguments(example, args, varargs))
结果和上面是一样的!
处理keyword only的参数的默认值
处理keyword参数比较简单,因为spec.kwonlydefaults是个dict:
if spec.kwonlydefaults:
for name, value in spec.kwonlydefaults.items():
if name not in arguments:
arguments[name] = value
执行测试代码:
args = (1, )
varargs = {'attr1': "v1", "attr2": "v2", 'd': 5}
print(get_arguments(example, args, varargs))
输出为:
{'attr1': 'v1', 'attr2': 'v2', 'd': 5, 'a': 1, 'b': 2, 'e': 3}
处理变长位置参数
if spec.varargs:
arguments[spec.varargs] = args[len(spec.args):]
执行测试代码:
args = (1, 2, 3, 4)
varargs = {'attr1': "v1", "attr2": "v2", 'd': 5}
print(get_arguments(example, args, varargs))
example(*args, **varargs)
结果为:
{'attr1': 'v1', 'attr2': 'v2', 'd': 5, 'a': 1, 'b': 2, 'e': 3, 'c': (3, 4)}
变成参数c是(3, 4)。
重构
对于默认参数,我们的理解是这样的:默认参数的值初始化为默认值,如果传入时有这个参数,则更新为传入的值。基于这个理解,我们可以简化上面的代码:
arguments = {}
spec = inspect.getfullargspec(func)
if spec.defaults:
arguments.update(zip(reversed(spec.args), reversed(spec.defaults)))
我们首先构造一个空的arguments的dict,然后对必选位置参数设置默认值。这里比前面简单,因为spec.defaults是和spec.args倒过来对齐的,因此使用reversed倒置后zip就行。
接着处理kwonlydefaults,这个更简单:
if spec.kwonlydefaults:
arguments.update(spec.kwonlydefaults)
接着设置传入的位置参数和keyword参数:
arguments.update(zip(spec.args, args))
arguments.update(kwargs)
完整代码如下:
def get_arguments(func, args, kwargs):
arguments = {}
spec = inspect.getfullargspec(func)
if spec.defaults:
arguments.update(zip(reversed(spec.args), reversed(spec.defaults)))
if spec.kwonlydefaults:
arguments.update(spec.kwonlydefaults)
arguments.update(zip(spec.args, args))
arguments.update(kwargs)
return arguments
和前面比简单了很多!
- 显示Disqus评论(需要科学上网)