这次学习 python 中函数的基础知识

  • 函数定义
  • 函数参数
  • 匿名函数
  • 生产函数
  • 变量作用域 等

在上一次最后留给思考:给定三条线段的长度,判断是否能组成三角形,如果能输出三角形:普通三角形、等腰三角形、等边三角形或直角三角形。不知道大家有没有写出求解程序,如果没有写出来也没有关系,今天会给出一种解决方案。如果大家去写了就会发现,我们在求解三角形类型的时候,每次需要判断新给定的三条边时,都需要重复输入一遍求解代码,这样就会出现很多代码的重复输入,有没有什么办法能让我们不必重复输入相同逻辑代码呢?这个是肯定有的,其中一种解决方案就是今天讲解的 函数。在介绍函数之前,先来整体了解一下为什么需要使用函数?

  1. 就像上面介绍的:最大化代码重用,最小化代码冗余。冗余就是指代码重复
  2. 程序分解。在代码量越来越大后,就需要我们将大的程序进行分解,且有效的组织代码结构,函数就是分解和组织代码的最基础手段之一。

函数定义

def fun_name(arg1, arg2,... argN):
    statements

函数常常包含有返回值,这个时候函数至少包含一个 return语句。包含有返回语句的函数

def fun_name(arg1, arg2,... argN):
    ...
    return value

简单的规则:

  • 函数代码块以 def 关键词开头,后接函数标识符名称和圆括号 ()。
  • 任何传入参数和自变量必须放在圆括号中间,圆括号之间可以用于定义参数。
  • 函数的第一行语句可以选择性地使用文档字符串—用于存放函数说明。
  • 函数内容以冒号起始,并且缩进。
  • return [表达式] 结束函数,选择性地返回一个值给调用方。不带表达式的return相当于返回 None。

下面是一个输出 “你好” 的函数定义

In[2]:  
def hello():
    print("你好!")
再看一个复杂点的函数定义
In[18]:  
# 打印一个加法口诀表
def tab(x):
    for i in range(x+1):
        for j in range(1, i+1):
            print('%d+%d=%d' %(j,i,i+j), end='\t')
        print()

函数调用

函数定义完成后,使用如下格式调用

  • 无参数函数调用:func_name()
  • 有参数函数调用:func_name(arg1,arg2,…)
In[6]:  
hello()
执行结果:
In[19]:  
tab(9)
执行结果:
In[21]:  
tab(7)
执行结果:
再看一个有返回值的函数定义和调用。求两个数的和。
In[23]:  
# 定义 add 函数,并返回 2 个数的和
def add(x,y):
    return x+y

# 调用 add 函数
print("直接使用调用结果:", add(3,4))

# 保存函数调用结果
result = add(5,6)
print("保存使用调用结果:", add(3,4))
执行结果:

函数参数

由于 python 动态类型域名,在使用到变量的时候才会动态计算类型, 每一个函数都有自己处理和使用参数的方式,这也决定了调用时传入的参数必须符合函数处理时对变量使用的规范。 所以在函数调用的时候需要注意,传入的参数类型。

比如上线定义的 add 函数,虽然本意是计算两个数的和。但是它的实际语义是:将 + 运算符作用到变量 x 引用的对象和变量 y 引用的对象上,所以只要 x 引用对象和 y 引用对象符合 + 运算符定义,都可以正常调用 add 函数。比如下面的调用:

In[26]:  
print(add('aa', 'b'))
print(add([1,2],[3,4,5]))
执行结果:
但是下面的调用将会出现错误,因为 + 运算符不能同时应用到 2 个不同类型的对象
In[34]:  
# error
add('aa',4)
执行结果:

参数传递

python 中一切数据都是对象。在定义时定义的参数只是一个变量,在函数调用时传入的参数才是对象,并将响应的参数变量引用到传入的对象。 比如上面的 add 方法定义和调用,在定义时定义了 x 和 y 两个参数变量,调用 add(3,4) 时,传入了 3 和 4 两个对象,这时变量 x 引用到 3 这个对象,变量 y 引用到 4 这个对象。请看下面的例子:

In[8]:  
a="99"
b=[1,2,3]
def f(x, y):
    print("x 参数传入:")
    print("    ", x==a)
    print("    ", id(x), id(a)) # 返回对象的唯一标识符
    print("    ", x is a)
    print("y 参数传入:")
    print("    ", y==b)
    print("    ", id(y), id(b)) # 返回对象的唯一标识符
    print("    ", y is b)

f(a,b)
print("=================")
a=1
b=(1,2,2)
f(a,b)
执行结果:
从上面的实例可以看到不论传入的何种参数类型(简单对象或复杂对象、可变对象或不可变对象),函数的参数变量引用的对象都是原始对象,这里和其他语言里面种传值调用和传引用调用是不一样的。此时变量 a 和 参数变量 x 引用的是同一个对象,变量 b 和参数变量 y引用的同一个对象,所以在参数里面对 x 或 y 进行修改会影响 a 或 b 的访问结果;但是对 a 和 b 进行赋值不会影响 a 或 b 的访问结果,因为赋值只是将 a 或 b 引用到新的对象,并没有改变原有对象。请看下面例子:
In[14]:  
a = [1]
b = [1]
print("a=",a," b=",b)
def change(x,y):
    x.append([1,2])
    y = y+[1,2]
    print("x=",x," y=",y)

    
change(a,b)
print("a=",a," b=",b)
执行结果:

参数修改影响传入对象,只发生在 可变对象 上,因为试着修改不可变对象将会报错, 所以对于传入不可变对象只能对参数变量做赋值或取值操作,不能修改操作。

见下面例子:

In[17]:  
a = (1,1)
b = [1]
change(a,b)
执行结果:

参数

调用函数时可使用的正式参数类型:

  • 必需参数
  • 关键字参数
  • 默认参数
  • 不定长参数
  • 命名参数
  • 不定长命名参数

他们的区别主要主要体现在 声明方式、调用解析 的不同。

必需参数

必需参数须以正确的顺序传入函数。调用时的数量必须和声明时的一样。

  • 声明方式:正常的参数变量
  • 调用解析:严格位置前后顺序解析

比如上面的 change 函数的参数都是必要参数。

In[21]:  
def change(x,y):
    x.append([1,2])
    y = y+[1,2]
    print("x=",x," y=",y)

    
a = [1]
b = [2]
change(a,b)
print("a=",a," b=",b)
执行结果:

关键字参数

关键字参数和函数调用关系紧密,函数调用使用关键字参数来确定传入的参数值。

  • 声明方式:正常的参数变量,和必需参数相同
  • 调用解析:在调用是具体指定传入的参数变量,不受前后顺序限制,关键字必须是声明的参数变量

由于声明方式和必须参数相同,还是以上面的 change 函数为例(不需重新声明)看调用方式

In[27]:  
a = [1]
b = [2]
change(y=a,x=b)        # 指定传入特定的参数变量关键字
print("a=",a," b=",b)
执行结果:

默认参数

在函数定义声明参数时,可以给参数一个默认值,在函数调用时如果没有传入这个参数,就会使用默认值。

  • 声明方式:正常的参数变量,在声明是赋值一个默认值。默认参数的声明一定要在必要参数之后。
  • 调用解析:没有传入时解析到默认值,传入参数时解析到传入值。传入值调用方式可以是 必要参数 或 关键字参数 形式

注意:不是默认参数,在调用时必须参数值

In[32]:  
def add(a,b=2):
    return a+b

print("正常参数调用:",add(1))
print("默认参数调用:",add(1,4))
执行结果:
以下声明方式是错误的:
In[33]:  
def add(a=1,b):
    return a+b
执行结果:

不定长参数

上面 add 实现了求 2 个数的和,如果我们需要同时求多个数的和呢? 并且数的数量还不是确定的,总不能每次调用都修改一遍函数定义吧! 这个时候就需要使用不定长参数。

不定长参数接受在函数调用时传入任意多个参数。

  • 声明方式:前面带有 * 的参数就是不定长参数。不定长参数的声明一定要在必要参数或默认参数之后。
  • 调用解析:没有传入时解析到默认值,传入参数时解析到传入值。传入值调用方式可以是 必要参数 或 关键字参数 形式

注意:

  • 一个函数里面只能有一个不定长参数
  • 不定长参数类型是 tuple,就意味着不定长参数本身是不可改变的,但是传入的可变数据是可以改变的
In[43]:  
def add(a,b=2, *other):
    print("[other] 参数类型:", type(other),other)
    return a+b+sum(other)

print(add(1))
print(add(1,2))
print(add(1,2,3,4))
执行结果:

命名参数

在不定长参数后面声明的参数就是命名参数。命名参数的调用必须使用关键字参数的调用方式调用。

  • 声明方式:参数名字和必须参数一样,只是位置在不定参数之后
  • 调用解析:调用时必须使用使用关键字参数调用方式

注意: * 命名参数尽量为其声明默认值 * 不定长参数的名字可以省略,只保留 *。这个时候只是表示后面的参数是命名参数,不再接受不定长参数

In[53]:  
def add(a,b=2, *other, c):
    print("[other] 参数类型:", type(other),other)
    return a+b+sum(other)+c

print(add(1,2,3,4,c=5))
执行结果:
当不接受不定长参数的例子:
In[59]:  
def add_3(a,b=2, *, c):
    return a+b+c

print(add_3(1,2, c=3))
执行结果:
但是下面的调用时错误的,因为参数个数不匹配
In[61]:  
print(add_3(1,2,3,4,c=5))
执行结果:

不定长命名参数

在函数调用时接受传入任意多个命名参数。

  • 声明方式:前面带有 ** 的参数。不定长参数的声明一定要在必要参数、默认参数或命名参数之后。
  • 调用解析:以关键字参数形式调用,对于不能匹配的的必要参数或关键字参数,都将放入这个参数中
In[67]:  
def named_p(a=1, **args):
    print(a)
    print("named p:", args)
    
named_p(1,b=2,c=3)
named_p(a=1,b=2,c=3)
执行结果:
以下调用会出现错误
In[209]:  
named_p(a=1,3,4)
执行结果:
现在针对上片的练习可以使用函数实现了:判断三角形形状
In[223]:  
def shape(a,b,c):
    """
    判断三角形形状
    a、b、c 给定三角形三条边长度
    """
    ms,md,mx = sorted([a,b,c])
    
    if ms <= mx-md:
        print("不能组成三角形")
    elif ms==md and md==mx:
        print("等边三角形")
    elif ms==md or md==mx:
        if ms**2 + md**2 == mx**2:
            print("等腰直角三角形")
        else:
            print("等腰三角形")
    elif ms**2 + md**2 == mx**2:
        print("直角三角形")
    else:
        print("普通三角形")
    return

shape(1,3,2)
shape(1,2,2)
shape(3,3,3)
shape(3,4,5)
执行结果:

函数注解

In[55]:  
def func1(a,b,c):
    return a+b+c

func1(1,2,3)
执行结果:
In[62]:  
def func2(a:'spam', b:(1,10), c:float)->int:
    return a+b+c
    
func2(1,2,3)
执行结果:
In[60]:  
func2.__annotations__
执行结果:
In[63]:  
def func2(a:'spam'=4, b:(1,10)=5, c:float=6)->int:
    return a+b+c
func2()
执行结果:

匿名函数:lambda

python 使用 lambda 来创建匿名函数。

所谓匿名,意即不再使用 def 语句这样标准的形式定义一个函数。

  • lambda 只是一个表达式,函数体比 def 简单很多。
  • lambda的主体是一个表达式,而不是一个代码块。仅仅能在lambda表达式中封装有限的逻辑进去。
  • lambda 函数拥有自己的命名空间,且不能访问自己参数列表之外或全局命名空间里的参数。
  • 虽然lambda函数看起来只能写一行,却不等同于C或C++的内联函数,后者的目的是调用小函数时不占用栈内存从而增加运行效率。
In[69]:  
# 可写函数说明
sum = lambda a, b: a + b
 
# 调用sum函数
print ("相加后的值为 : ", sum( 10, 20 ))
print ("相加后的值为 : ", sum( 20, 20 ))
执行结果:

变量作用域

Python 中,程序的变量并不是在哪个位置都可以访问的,访问权限决定于这个变量是在哪里赋值的。

变量的作用域决定了在哪一部分程序可以访问哪个特定的变量名称。Python的作用域一共有4种,分别是:

  • L (Local) 局部作用域
  • E (Enclosing) 闭包函数外的函数中
  • G (Global) 全局作用域
  • B (Built-in) 内建作用域

以 L –> E –> G –>B 的规则查找,即:在局部找不到,便会去局部外的局部找(例如闭包),再找不到就会去全局找,再者去内建中找。

In[71]:  
x = int(2.9)  # 内建作用域
 
g_count = 0  # 全局作用域
def outer():
    o_count = 1  # 闭包函数外的函数中
    def inner():
        i_count = 2  # 局部作用域
Python 中只有模块(module),类(class)以及函数(def、lambda)才会引入新的作用域,其它的代码块(如 if/elif/else/、try/except、for/while等)是不会引入新的作用域的,也就是说这些语句内定义的变量,外部也可以访问,如下代码:
In[76]:  
if True:
    a = 1

print("可以访问 a:", a)

def scope1():
    inner_a = 2
scope1()
print(inner_a)   # inner_a 在这里不可访问
执行结果:

global 和 nonlocal关键字

** 全局变量和局部变量 **

定义在函数内部的变量拥有一个局部作用域,定义在函数外的拥有全局作用域。

局部变量只能在其被声明的函数内部访问,而全局变量可以在整个程序范围内访问。调用函数时,所有在函数内声明的变量名称都将被加入到作用域中。如下实例:

In[79]:  
total = 0 # 这是一个全局变量

# 可写函数说明
def sum( arg1, arg2 ):
    #返回2个参数的和."
    total = arg1 + arg2 # total在这里是局部变量.
    print ("函数内是局部变量 : ", total)
    return total
 
#调用sum函数
sum( 10, 20 )
print ("函数外是全局变量 : ", total)
执行结果:
当内部作用域想修改外部作用域的变量时,就要用到global和nonlocal关键字
In[81]:  
total = 0 # 这是一个全局变量

# 可写函数说明
def sum2( arg1, arg2 ):
    #返回2个参数的和."
    global total
    total = arg1 + arg2 # total在这里是局部变量.
    print ("函数内是局部变量 : ", total)
    return total
 
#调用sum函数
sum2( 10, 20 )
print ("函数外是全局变量 : ", total)
执行结果:
如果要修改嵌套作用域(enclosing 作用域,外层非全局作用域)中的变量则需要 nonlocal 关键字了,如下实例:
In[89]:  
def outer():
    num = 10
    def inner():
        nonlocal num   # nonlocal关键字声明
        num = 100
        print(num)
    inner()
    print(num)
outer()
执行结果:
以下函数执行将产生错误,因为 a 是一个局部变量,但是在使用时候并没有定义。有 2 中修改方式:

  1. = 左边的变量 a 换成其他变量名称(比如 b)。
  2. 将外部 a 作为参数传入
In[91]:  
a = 10
def test():
    a = a + 1
    print(a)
test()
执行结果:

可以使用以下方式查看内建作用域中的变量。

dir(__builtin__) # 或 dir(__builtins__)

这里 __builtin____builtins__ 是有区别的,并且在主模块和子模块中有所不同。

工厂函数:闭包

在函数作用域中,有一个闭包作用域,那怎么产生闭包呢!只要在函数内部定义函数,就会产生闭包作用域。

闭包作用域:外部函数的本地作用域就是闭包函数的闭包作用域。

工厂函数:一个返回值时函数的函数就是,这里的返回值函数也叫闭包函数或内部函数,工厂函数也叫外部函数。

In[94]:  
def marker(n):
    def action(x):
        return x ** n
    return action
In[95]:  
f = marker(2)
f
执行结果:
In[96]:  
print(f(3), f(4))
执行结果:
In[32]:  
f(4)
执行结果:
In[98]:  
g = marker(3)
print(g(4))             # 4 ** 3
print(f(4))             # 4 ** 2
执行结果:
对于简单的函数逻辑,也可指直接返回 lambda 函数
In[35]:  
def marker(n):
    return lambda x: x**n
In[36]:  
h = marker(3)
h(4)
执行结果:

** 闭包使用建议 **

闭包在必须的时候才使用,可以使用类进行替换。类后面会详细介绍。 注意闭包和循环共同使用时,变量的作用域

函数对象

python 秉承着一切皆对象的理念,函数也是一个对象,具有属性(可以使用dir()查询)。作为对象,它还可以赋值给其它对象名,或者作为参数传递。

In[117]:  
def fun_obj1(f, a):
    f(a)

def fun_obj2(v):
    print(v*2)
In[121]:  
fun_obj1(fun_obj2, 3) # 函数对象作为参数
obj1 = fun_obj1       # 函数对象赋值
obj1(fun_obj2, 3)
执行结果:
也可以直接使用匿名函数调用
In[119]:  
fun_obj1(lambda v: print(v ** 2), 3)
执行结果:

生成函数: yield vs. return

在函数返回值的时候我们使用 return,还有一种方式是使用 yield 关键字,此时叫做 生成函数。有以下特点:

  • 含有yield关键字
  • 调用生成器函数会返回一个生成器
  • 调用生成器函数时, 它包含的代码并不会马上执行, 而是等到访问生成器元素时才一段一段地执行.

使用场景:当有大量的数据访问,并且每个数据只访问一次,使用生成器会节省大量空间

对于普通的生成器,第一个next调用,相当于启动生成器,会从生成器函数的第一行代码开始执行,直到第一次执行完yield语句(第4行)后,跳出生成器函数。

然后第二个next调用,进入生成器函数后,从yield语句的下一句语句(第5行)开始执行,然后重新运行到yield语句,执行后,跳出生成器函数,

后面再次调用next,依次类推。

In[168]:  
def gen_func():
    print ('yield 1...')
    yield 1
    print ('yield 2...')
    yield 2
    print ('yield 3...')
    yield 3
    print ('yield 4... 是不是外面调用报错了...')
    return 5

gen_res = gen_func()
print("在函数调用时不会马上执行代码,返回生成器")
print(gen_res)
print("在访问生成器的时候,才会一段一段执行代码")
print("get 1:", next(gen_res))
print("get 2:", next(gen_res))
print("get 3:", next(gen_res))
print("如果已经超出生成器范围,会产生一个错误;但是如果函数里面最后生成器后面还有代码,会先执行")
print("get 4:", next(gen_res))
执行结果:

send vs. next

send 和 next 功能相识,区别是send()可以传递yield表达式的值进去,而next()不能传递特定的值,只能传递None进去。因此,我们可以看做c.next() 和 c.send(None) 作用是一样的。需要提醒的是,第一次调用时,请使用next()语句或是send(None),不能使用send发送一个非None的值,否则会出错的,因为没有Python yield语句来接收这个值。

In[187]:  
def double_inputs():
    while True:
        x = yield
        if not x:
            return
        print(x * 2)
        yield x*2

gen = double_inputs()
gen.send(None)

gen.send(10)
print("get", next(gen))
gen.send(20)
print("get", gen.send(None))
gen.send(30)
print("get", next(gen))

gen.close()
执行结果:

生成器表达式

在之前介绍 列表推导(解析表达式) 的时候,已经见过生成器表达式的形式了,有了上面的生产函数的知识,理解生成器表达式就简单了。

生成器表达式与列表推导在语法上十分相似:

  • 列表推导(解析表达式)使用[]: [i for i in arr]
  • 生成器表达式使用(): (i for i in arr)
In[100]:  
s = (x**2 for x in range(4))
s
执行结果:
In[101]:  
next(s),next(s),next(s)
执行结果:
In[102]:  
type(s)
执行结果:
In[122]:  
a,b,c=(x**2 for x in range(3))

a,b,c
执行结果:

函数递归

有一类函数在使用的时候需要注意一下,就是在函数内部调用自己的函数,称为 递归函数。先看一个例子:

求 1~n 之间所有整数的乘积?

先来看循环的方式实现:

In[198]:  
def sum_n(n):
    result = 1
    while n>1:
        result *= n
        n -= 1
    return result

print(sum_n(5))
print(sum_n(3))
执行结果:
我们也可以使用递归实现
In[203]:  
def sum_n2(n):
    if(n==1): return 1
    return n * sum_n2(n-1)


print(sum_n2(5))
print(sum_n2(3))
执行结果:

我们看到在使用递归的时候,我们不会展开具体的实现过程,只是根据乘积递归定义 n! = n * (n-1)! 用程序表达出来,在执行的时候会自动调用展开。 再看一个例子:

求第 n 个斐波拉契数?

In[208]:  
def f(n):
    if n==1 or n==2: return 1
    return f(n-1) + f(n-2)

print(f(1),f(2),f(3),f(4),f(5),f(6))
执行结果:
使用递归函数注意: 递归函数必须有一个终止条件,不然就是循环了

函数式编程工具

在 python 中提供了一些列和函数式编程的工具和方法,这里将常用列举出来大家有兴趣可以学习使用。

  1. map、filter、reduce、zip
  2. operator、functools 包

程序设计原则

  1. 最小化变量作用于
  2. 最小化跨文件改变
  3. 使用 lambda 表达式时,尽量保持逻辑简单,复杂逻辑使用函数、类或其他方式
  4. 对于很多数据处理,尽量使用生成函数
  5. 慎用闭包