函数基础

时间:2022-09-15 19:46:05

一.函数的定义调用

函数的使用必须遵循先定义,后调用的原则

函数 定义 阶段: 只检测函数体的语法,不执行函数体代码
函数 调用 阶段: 执行函数体代码

Ps:代码从上而下执行,遇到def语句,函数体代码不会执行.但在运行前会进行 预编译.

def 函数名(参数1,参数2,...):
  """
  函数功能描述消息
  :param 参数1:描述
  :param 参数2:描述
  :return: 返回值
  """
  pass

函数返回值
函数中不写return或return不带任何返回值,该函数返回值都是None
不写return默认会在函数的最后一行添加 return None


二.函数参数传递

敲重点, 根据 python在作用域里对变量的赋值操作规则 , 函数的参数就是局部变量!!
既然是局部变量,就需要定义, 即函数的参数传递 可看作是一个赋值操作 a = ? 重点看?的指向是啥..

赋值操作

在理清函数参数传递之前,要先了解两个重要的概念!! 非常重要! 知其然要知其所以然..

1> python在作用域里对变量的赋值操作规则:
若这个变量在该作用域存在(已经定义), 则对其绑定新的对象; 若不存在,则将这次赋值视为对这个变量的定义..
2> python对象的赋值都是引用(内存地址)传递 被赋值 = (右侧的变量们)被引用
"="赋值操作右侧的变量需要在某一个作用域里被找到,不然会报该变量未定义..
Ps:变量的找寻遵循一个规则 依次从 局部 - 全局 - 内置 命名空间找.. 后面会有详细阐述..
在此处,直接理解成 此变量在函数里没有,就去函数外找..

在这里,简述 赋值即引用 带来的坑.. 需求:循环去除列表中的3

"""
分析: 问题出在,B操作会同步改变A操作循环的值,i的取值 num[0]、num[1]、num[2]..这样的顺序来的
		当循环到num[2]时,去除了第一个3,此时列表为[1,2,3,3,4];
		继续循环,轮到nums[3],可此时列表中nums[2]的值为3却被跳过了..
"""
nums = [1, 2, 3, 3, 3, 4]
for i in nums:	# A
    if i == 3:
        nums.remove(i)	# B
print(nums, len(nums))  # [1, 2, 3, 4] 4

# --- 正解 
nums = [1, 2, 3, 3, 3, 4]
for i in nums[:]:
    if i == 3:
        nums.remove(i)
print(nums, len(nums))  # [1, 2, 4] 3
值传递

函数参数 值传递(拷贝) 针对不可变对象(tuple\str\bool\数字\不可变集合)

"""-- 分析
1> 参数a接收到的是值的类型是数字,数字是一个不可变对象 
2> So,会将接收到的值进行一份拷贝! -- 值传递(拷贝)
	 因为func函数内不存在变量a,所以会在func函数里新定义了一个局部变量a,将拷贝的值赋值给局部变量a..
	 注意: func函数新定义的局部变量a与全局变量a无任何瓜葛!!
"""
def func(a):		
    a = a + '3'
    return a

a = '5'
res = func(a)
print(a,res)	# '5' '53'
引用传递

函数参数 引用传递(内存地址) 针对可变对象(list\dict\set)

"""-- 初步分析
1> 参数a接收到的是值的类型是列表,列表是一个不可变对象 
2> So,会对接收到的值进行引用 --  引用传递(内存地址)
	 因为func函数内不存在变量a,所以会在func函数里新定义了一个局部变量a,将接收到的引用给局部变量a..
	 注意: func函数新定义的局部变量a与全局变量a维护/指向的是同一个内存地址!!
"""
def func(a):
    a += [4]	# -- 列表+=操作原地改变 等同于 a.extend([4])
    return a

a = [1,2,3]
res = func(a)
print(a,res)	# [1, 2, 3, 4] [1, 2, 3, 4]

# --- --- ---

def func(a):
    a = a + [4]		# -- 在参数传递的时候,已经定义了局部变量a. 此时[1,2,3]的引用计数为2
									#    根据python在作用域里对变量的赋值操作规则 该赋值操作是在对局部变量a重新赋值
									#    重新赋值后,局部变量a重新绑定了一个新的对象[1,2,3,4]. 导致[1,2,3]的引用计数减1
									#    id(左边a) != id(右边a) 它俩不是同一个对象
    return a

a = [1,2,3]
res = func(a)
print(a,res)	# [1, 2, 3] [1, 2, 3, 4]
UnboundLocalError

官方解释: When a name is not found at all, a NameError exception is raised.
If the name refers to a local variable that has not been bound, a UnboundLocalError exception is raised. UnboundLocalError is a subclass of NameError.

翻译成人话: 若引用了某个变量,此变量在各个作用域里都找不到(详见4.3.1LEGB),就会报错 NameError ;
若引用的变量是局部变量,但还未完成绑定,就会报错 UnboundLocalError ..
UnboundLocalErrorNameError 的一个子类..

"""
代码第二行报错: local variable 'a' referenced before assignment
翻译过来: 局部变量'a'在赋值前被引用
分析: func()函数没有参数,即不存在函数传递过程中定义局部变量
		 函数体内有对a的赋值操作,根据python在作用域里对变量的赋值操作规则,因为func函数内没有局部变量a,则该赋值操作是在定义局部变量a,而在赋值操作的右侧引用了还未被定义完成的局部变量a

Ps:有很长一段时间我是这样理解这个报错的.开始回溯我的理解.理解出错的地方在于(-- --)包含起来的这段文字.
(-- 解释器发现右侧的a变量在局部作用域里还未绑定好,就引用全局变量a-- ),而"="左侧的a是局部变量,在一个赋值操作中,某一变量不可能既充当局部变量,又充当全局变量.. 进而产生了冲突.报错提示不是这样的,显然这样理解有偏差!!
正确的过程应该是,解释器执行到a = a + 4,这个语句,发现它是赋值操作,准备将a变量名写入局部命名空间里,到这一步a变量已经定性为一个局部变量啦,但是此a变量还未完成绑定,正准备将"="右侧的内容与a变量进行绑定时,发现右侧的内容中又引用了还未绑定好的局部变量a..
Python解释器一看这骚操作,很生气,大骂一声搁这套娃呢?果断扔出一张红牌,直接报错..
"""
def func():
    a += 4	# a+=[4]报错 a.extend([4])不会报错
    return a

a = 1	# a=[] 当a是一个列表时
res = func()
print(a,res)

三.函数形参和实参

在上文我们知道 函数的参数传递 可看作是一个赋值操作 a = ? 定义的是函数的局部变量
具体一点, 参数分为形参和实参:
形参 -- 本质就是变量名; 实参 -- 本质就是变量的值

定义函数时 -- 行参

定义函数时 位置形参-默认形参-不定长位置形参-命名关键字形参-不定长关键字形参

形参 特性
位置形参 必须被传值,多一个不行少一个也不行!!
默认形参 意味着调用阶段可以不用为其赋值(可以不用指定对应的实参)!!
不定长位置形参 *args 会将溢出的位置实参全部接收,然后以元祖的形式赋值给*后面的变量args
命名关键字形参 * (*或者*args) 后面的形参对应的实参必须按照key=value的形式进行传值
不定长关键字形参 **kwargs 会将溢出的关键字实参全部接收,
然后以字典的形式赋值给**后面的变量kwargs
调用函数时 -- 实参

调用函数时 位置实参 关键字实参

1> 两种实参可以混用, 但位置实参必须在关键字实参前面!且不能对一个形参重复赋值!
2> 实参可以拆包!
实参中带*,*会将该变量的值循环取出,打散成位置实参.
即以后但凡碰到实参中带*的,它就是位置实参,应该立刻打散成位置实参来看
实参中带**,**会将该变量的值循环取出,打散成关键字实参.
即以后但凡碰到实参中带**的,它就是关键字实参,应该立刻打散成关键字实参去看

def func3(a1, a2, a3, a4=10, *args, a5=20, a6, **kwargs):
    # 		11  22  33  44  20  10  (55, 66, 77)  {'a10': 123}
  	print(a1, a2, a3, a4, a5, a6, args, kwargs)
    
# func3(11, *[22, 33, 44, 55, 66, 77], **{'a6': 10, 'a10': 123})
func3(11, 22, 33, 44, 55, 66, 77, a6=10, a10=123)


def func(*p):
		return sum(p)  
func(1,2,3,4)	# 10


def func(**p):
  	return ''.join(sorted(p))   
func(x=1,z=2,y=3)	# 'xyz'
默认形参陷阱

记住两点:
除非程序结束,否则该共享空间不会被释放; 类似于全局变量...
默认形参的值通常应该定义为不可变类型.

1> 若函数有默认形参,在函数初次调用时,会为其开辟一块共享空间;
2> 往后每一次调用此函数创建的空间都会共享这一块 “共享空间” 里的默认形参
3> 除非程序结束,否则该共享空间不会被释放!!!
Ps: 也有人会说, "默认形参的值只在定义阶段赋值一次,即默认参数的值在函数定义阶段就已经固定死了". 这种说法反复揣摩后,是有一定的道理的, 但不好理解.. 说法不够严谨,不必深究...

def func(a=[555]):
    a.extend([1, 2, 3]) # -- 等同于 a+=[1,2,3]
    
    return a,id(a)

# -- 函数第一次调用时共享空间为[555];第二次调用时没有用共享空间;第三次调用时共享空间为[555,1,2,3];
#		 可以观察到,第一次id(a)和第三次id(a)是一样的..
#    证明第二次调用虽然没有用共享空间会导致[555]的引用计数为0,但该共享空间没有被释放!!
print(func())
print(func([]))
print(func()) 
"""结果
([555, 1, 2, 3], 140573741203776)
([1, 2, 3], 140692741938688)
([555, 1, 2, 3, 1, 2, 3], 140573741203776)
"""


# --- --- ---
# 依照python在作用域里对变量的赋值操作规则
# 注意哦,函数体内对此默认形参重新赋值,是为此变量绑定了一个新的对象!!!
def func(a=[555]):
  	print(a, id(a))
    a = a + [1, 2, 3] # -- 局部变量a重新赋值,其id发生变化
    
    return a,id(a)

# -- 可以观察到函数第一次和第二次调用的共享空间都为[555],其id也没有发生变化!
#		 证明 a = a + [1, 2, 3] 该行代码虽然导致[555]的引用计数为0,但该共享空间没有被释放!
print(func())  # [555, 1, 2, 3]
print(func())  # [555, 1, 2, 3]
"""结果
[555] 140263832453056
([555, 1, 2, 3], 140263832467520)
[555] 140263832453056
([555, 1, 2, 3], 140263832428928)
"""


# --- --- --- 正解
def func0(a,b=None):
  	if b is None:
    		b = []
        
        
# --- --- --- 换个思考角度理解 默认形参陷阱 
"""
默认形参的共享空间可以简单理解成 一个全局变量
"""
def func(a=[555]):pass	func()	func()
b = [555]		def func(a=b):pass	func()	func()
b = [555]		def func(a):pass	func(b)	func(b)

四.函数作用域

4.1 函数对象

4.1.1 函数是第一类对象

函数是第一类对象,可以当作数据进行传递
1> 被引用;
2> 当作参数传递;
3> 返回值是函数;
4> 作为容器类型的元素

def foo():
    print('foo')

def bar():
    print('bar')

dic = {
    'foo': foo,
    'bar': bar,
}
while True:
    choice = input('>>: ').strip()
    if choice in dic:
        dic[choice]()
4.1.2 函数嵌套定义

需求: 将圆相关的计算(面积、周长)集中在一起.

from math import pi

def circle(radius, action='area'):
    def area():
        return pi * (radius**2)
    def perimeter():
        return 2 * pi * radius

    if action == 'area':
        return area()
    elif action == 'perimeter':
        return perimeter()

print(circle(10, 'perimeter'))

# -- Ps: max(max(2,3),8)
4.1.3 打破层级限制

函数对象可以将定义在函数内的函数返回到全局中使用,从而打破函数的层级限制

def f1():
  def inner():
    print('from inner')
  return inner

f = f1() # -- 拿到inner函数的内存地址.

4.2 命名空间/变量名

要改变惯性思维,从命名空间的角度去看待变量!!

4.2.1 命名空间概念

在一个复杂的程序中,会定义成千上万个变量(函数名、类名都是变量), 为了便于追踪这些变量,让它们互不干扰,命名空间就应运而生啦!!

命名空间 namespaces: 这几种说法都正确
存放名字与值绑定关系的地方, 一般简说为存放变量名的地方.
命名空间是键值对的集合! 变量名与值是一一对应的关系..
命名空间是变量名到对象内存地址的映射.
eg:x=1 开辟一块空间,1 存放在内存中; x:id(1) 放在命名空间中

变量存储的角度, 在定义变量时, 变量名与值内存地址的关联关系 存放于栈区stack, 变量值 存放于堆区heap.

4.2.2 命名空间分类

各个命名空间是独立的, 没有任何关系的, 所以一个命名空间中不能有重名
但不同的命名空间是可以重名而没有任何影响

就好比一个文件夹中可以包含多个文件夹, 每个文件夹不能重名, 但不同文件夹中的文件可以重名...

print(len) # <built-in function len>
x=1
if 3>2:
  	z=5
def func():
  	y=2
func()

# -- 内置命名空间中的对象可以通过 dir(__builtins__) 命令查看.
类别 解释
内置命名空间 built-in names 存放python解释器自带的名字 len
全局命名空间 global names 存放模块中定义的名字(该模块中除开内置的和局部的,都是全局的)
x func z
局部命名空间 local names 存放函数调用时函数中定义的名字 y

Ps: 严谨一点, 解释器还为程序使用 import  语句加载的任何模块创建一个全局命名空间
注意: 全局和局部命名空间的实现是字典, 但内置命名空间不是!

4.2.3 命名空间生命周期

指的是命名空间中 变量名!! 不是内存中的变量值的生命周期!! 

内置: 在解释器启动时直接创建加载,直到解释器关闭时失效.
全局: 在文件执行时生效,在文件执行完毕时失效.
局部: 在文件执行过程中,如果调用了某个函数才会临时生效,在函数执行完毕后失效.

局部命名空间的声明周期是自其建立开始,到它们各自的函数执行完毕终止.. 当这些命名空间的函数终止时, Python可能不会立即回收分配给这些命名空间的内存, 但是对其中对象的所有引用都将失效..

4.2.4 变量加载查询顺序

变量加载进命名空间的顺序: 内置 --> 全局 --> 局部
变量在命名空间里查找的顺序: 从当前位置的局部命名空间 --> 全局 --> 内置

通俗点解释下:
python是解释型语言,边翻译边执行,在代码从而下的执行过程中,不断有变量被加载进命名空间.. 值得一提的是,当遇到def语句,会先将函数名字加载进全局命名空间,但函数体代码不会执行,会暂时跳过; 函数内部的变量要等到函数调用的时候才会加载进局部命名空间...
变量在命名空间里查找的顺序(简单理解就是作用域).. 举个例子,函数里使用了x变量,但此变量不在该函数的局部命名空间里,就会去全局命名空间里找... 当然实际情况会更复杂.后文会详细阐述作用域.

"""变量加载进命名空间
第几行代码		全局							内置
1					 func	   
4					 func 	x:1
5					 func		x:1				 y:2
6					 func		x:10			 y:2

记住一句话,作用域关系是在函数定义阶段就固定死了,与函数的调用位置无关!!
"""
def func():
    y = 2
    print(x)
x = 1
func()	# 1
x = 10
func()	# 10 -- 在调用函数之前,全局命名空间里的x的值被改为10啦

# ---  --- ---

x = 10
a = lambda y: x + y
x = 20
b = lambda y: x + y
print(a(10))  # 30
print(b(10))  # 30

4.3 作用域

说白了, 作用域就是变量在命名空间里查找的顺序.. 作用域定义了命名空间里的变量的在多大的范围起作用!!

作用域关系是在函数定义阶段就固定死了,与函数的调用位置无关!!
但凡调用函数都需要跑到定义阶段去找作用域关系!一层套一层的..在定义阶段就套好啦.
实则说的就是变量加载进命名空间以及变量在命名空间查询的顺序.. 不是什么新的知识,换了个说法而已.

小声提醒下: 只有模块module,类class以及函数def、lambda才会引入新的作用域(即开辟新的命名空间), 其它的代码块(如 if/elif/else/、try/except、for/while等)是不会引入新的作用域的..

4.3.1 LEGB

在表达式中引用变量时,Python解释器将按照如下顺序遍历各作用域,以解析该引用:
局部作用域/局部命名空间 -- 当前函数的范围 L Local
内嵌作用域/内嵌命名空间 -- 函数嵌套里外围函数的范围 E Enclosing
全局作用域/全局命名空间 -- 当前py模块的范围(不包含import的py模块) G Global
内置作用域/内置命名空间 -- 包含len及str等函数的那个scope B Built-in

若在这些地方都找不到名称相符的变量,就会抛出NameError异常..
Ps: LEGB规则在Python文献中会经常看到, 但Python官方文档中并没有实际出现这个术语..Hhh

def f1():
    x = 1	# -- 提一嘴,f1函数结束后,x变量还能被函数嵌套中的内部函数inner访问到..Why?因为x是*变量!
					#    后续的闭包部分会详细阐述!!
    def inner():
        print(x)	# -- 作用域关系是在函数定义阶段就固定死了,与函数的调用位置无关!!
    return inner
  
f = f1()

def bar():
    x = 111
    f() # -- 与调用位置无关
    
bar() # 1
4.3.2 globals()、locals()

python提供了两个内置函数globals()和locals(),前者返回全局命名空间的字典,后者返回局部命名空间字典..

>>> type(globals()),type(locals())
(<class 'dict'>, <class 'dict'>)

注意:globals()返回全局命名空间的实际引用; 随便怎么折腾都是在操作全局命名空间, 类似于 a = b

"""
可以通过引用对象的变量名x,以常规的方式访问该对象..亦可以通过全局命名空间字典间接访问它
"""
>>> x = 'foo'
>>> 'x' in globals()			# -- 注意,变量名是以字符串的形式作为键的
True
>>> x
'foo'
>>> globals()['x']
'foo'
>>> x is globals()['x']		# -- x的值与"x"键所对应vlaue值的内存地址相同
True

"""
可以使用globals()函数在全局命名空间中创建和修改全局变量
"""
>>> y
NameError: name 'z' is not defined	# -- 全局命名空间中没有变量y
>>> glo = globals()				# -- 注意:glo是全局命名空间的引用
>>> glo = 100	# -- 创建	等同于 globals()['y'] = 100
>>> y
100
>>> glo['y'] = 123	# -- 修改
>>> y
123

"""
globals结合format的应用
"""
>>> x = 1
>>> y = 2
>>> "{x},{y}".format(**globals())
'1,2'

locals()返回的不是对局部命名空间的引用!! 那到底返回的是什么呢?
查阅资料,很多博客说是拷贝、副本.. 云里雾里的. 自己做了实验后, 发现这样的描述都不够严谨..

先说结论: locals()函数返回的是对局部命名空间的一个"拷贝" (打了引号哦) ,但此拷贝有点特殊,它具备浅拷贝的一些特点,同时当我们再次调用locals()函数时,它会 **同步更新 **"拷贝"的值..

"""
什么是拷贝、副本、视图?
参考链接: https://blog.csdn.net/Reborn214/article/details/124539097
		简单来说,副本、视图是python numpy数组中的专业名词,但其具备的特性跟拷贝差不多.
		numpy中引用 = python中引用; numpy中视图 = python中浅拷贝; numpy中副本 = python中深拷贝
		值得一提的是,引用一般发生在赋值操作(python赋值都是引用"内存地址"传递)
		
还要注意一个坑!!
id():返回对象的“标识值”.该值是一个整数,在此对象的生命周期中保证是唯一且恒定的.
		 两个生命期不重叠的对象可能具有相同的id()值..
举个例子
	分析 -- 没有将a[:]赋值给变量进行引用,当执行完a[:]后,这个对象就释放啦.但这块内存可能并没有来得及释放.
				 当我们新执行a[:]的时候,用的将会是同一个内存,没有申请新的内存.
				 这会让我们觉得两个a[:]与a[:]是同一个对象
	>>> a = [1,2,3]
	>>> id(a[:])
	140417051683648
	>>> id(a[:])
	140417051684544
	>>> id(a[:])
	140417051684544
	>>> id(a[:]) is id(a[:])	# -- 让其处于同一生命周期,每次浅拷贝都会生成不同的对象,结果肯定为False
	False
"""
def func():
  	# -- 验证了id()的坑.
    print(id(locals()))  # 140204424022656
    print(id(locals()))  # 140204424022656
    print(id(locals()) is id(locals()))  # False

    m = [1, 2, 3]
    n = 66
    loc = locals()  # -- loc是局部命名空间的“拷贝” loc是对此"拷贝"的引用!!
    print(loc)  # {'m': [1, 2, 3], 'n': 66}

    """来,简化一下
    import copy
    scope = {'m': [1, 2, 3], 'n': 66}		  # -- 指代局部命名空间
		loc = copy.copy(scope)								# -- 指代局部命名空间的浅拷贝
		loc['m'].append(4)
		scope.append(5)
		scope['n'] = 88
		scope['x'] = 20
		loc['n'] = 77
		print(scope)		# -- {'m': [1, 2, 3, 4, 5], 'n': 88, 'x': 20}
		print(loc)			# -- {'m': [1, 2, 3, 4, 5], 'n': 77}
    """
    loc['m'].append(4)	# -- 试图通过"拷贝"过来的字典修改局部命名空间里的可变类型的变量m 成功
    m.append(5)  # -- 在局部命令空间里修改了m变量对应的值
    n = 88  # -- 在局部命令空间里修改了n变量对应的值
    x = 20  # -- 在局部命名空间里添加了x变量
    # -- 可以发现此"拷贝",具备浅拷贝的特性
    print(loc)  # {'m': [1, 2, 3, 4, 5], 'n': 66}
    loc['n'] = 77		# -- 试图通过"拷贝"过来的字典修改局部命名空间里的可变类型的变量m 失败
    print(locals())  # {'m': [1, 2, 3, 4, 5], 'n': 88, 'loc': {...}, 'x': 20}
    
    # -- 再次调用locals()后,"拷贝"同步更新了.
    print(loc)  # {'m': [1, 2, 3, 4, 5], 'n': 88, 'loc': {...}, 'x': 20}

func()
4.3.3 global、nonlocal

回顾下Python在作用域里对变量的赋值操作规则:
若这个变量在该作用域存在(已经定义), 则对其绑定新的对象; 若不存在,则将这次赋值视为对这个变量的定义..

global -- 不管是在函数嵌套的哪一层,对x变量的赋值操作都是修改全局作用域里的那个x变量!!
nonlocal -- 如果在闭包内给x变量赋值,那么修改的其实是闭包外那个作用域里的x变量..
nonlocal的唯一限制在于,不能延伸到模块级别,这是为了防止它污染全局变量..
Ps: 提一嘴闭包,后续会详细阐述. 函数嵌套,内部函数引用外部函数的参数或变量,就构成了闭包..

"""
★ --global
		在局部若想修改全局的不可变类型,需要借助global声明
		在局部若想修改全局的可变类型,不需要借助任何声明,可以直接修改
		
		若全局中没有x变量,global语句和赋值的组合(不管处于嵌套的哪一层)可以间接在局部中创建x全局变量
"""
x = []

def func():
    global m
    m = 10
    globals()['n'] = 20		# -- n变量在全局中存在的话,此赋值操作就是在修改;通常不会这么做,完全没必要
    x.append(1)

func()
func()
print(x)  	 # [1,1]
print(m, n)  # 10,20


"""
★ --nonlocal
"""
x = 1

def f1():					# E f1的参数对f2而言
    x = 111 			# E 此处的x对f2而言
    def f2(): 		# E f2的参数对f3而言
        x = 222 	# E	此处的x对f3而言
        def f3():
            nonlocal x 	# -- 若f1函数嵌套里没有x 则报错找不到x变量
            x = 333 		# -- 改的是最近的x = 222的值
        f3()
        print(x)  # 333
        # {'f3': <function f1.<locals>.f2.<locals>.f3 at 0x7fc60c71c9d0>, 'x': 333}
        print(locals()) # f2的局部作用域
    f2()
    print(x)  # 111
    # {'x': 111, 'f2': <function f1.<locals>.f2 at 0x7fc60c71c790>}
    print(locals()) # f1的局部作用域
    
f1()
print(x)  # 1