[Python系列] Python函数及函数式编程(五)

第四节 作用域

作用域是函数调用时非常重要的一个概念,那作用域到底是什么呢?

顾名思义 作用域 其实就是起作用范围

我们先从变量说起.

变量到底是什么呢?可已把它看做指向值的名称.我们在使用变量时,其实和使用字典差不多.

x = 1

变量到值的关系几乎跟字典中的键到值的关系一致,其实,当你声明变量时,Python确实生成了一个你"看不见"的字典

x = 1
scope = vars()
print(scope)

# {'__builtins__': , '__file__': '/Users/shihangwei/Documents/code/01.py', '__package__': None, 'scope': {...}, 'x': 1, '__name__': '__main__', '__doc__': None}

我们通过vars()函数可以显式的看到一个字典,x:1很明显就是我们的变量声明。

x起作用的范围,就是在这个 "看不见"的字典中,我们把这些字典称为命名空间或者作用域

一般而言 Python 不建议通过 vars 的修改来改变变量的值。这会导致一些"不确定"的后果

那么这些"看不见"的字典到底有多少个呢?既然作用域是其作用的范围,那么范围到底怎么划分呢?

块级作用域

先写一个简单的例子

if 1 == 1:
  name = "change"

print(name)

这里我们需要思考变量name起作用的范围了,在条件判断语句代码块以外,name生效吗?

答案是肯定的

"change"

我们同样可以在代码块外打印vars()的返回结果

if 1 == 1:
  name = "change"

print(vars())
# {'name': 'change', '__builtins__': , '__file__': '/Users/shihangwei/Documents/code/01.py', '__package__': None, '__name__': '__main__', '__doc__': None}

在代码块外部,我们可以看到作用域内存在name变量以及其值

这就表明在Python中,代码块里的变量,代码块外可以访问到,Python中不存在块级作用域

在Java/C#中,执行上面的代码会提示name没有定义,这些语言是存在块级作用域的

局部作用域和全局作用域

局部作用域

在代码块之后,我们探究一下函数的声明是否影响了变量起作用的范围,下列代码为例

def fun():
  name = "lisi"

print(name)

我们能够在函数外部打印出name的值吗?答案是否定的

Traceback (most recent call last):
  File "/Users/shihangwei/Documents/code/01.py", line 4, in 
    print(name)
NameError: name 'name' is not defined

有的同学可能认为,函数没有运行,name的赋值动作没有执行,函数外部没有打印出值是很正常的,那我们执行一下fun

def fun():
  name = "lisi" 
  print(vars()) # 1

fun()
print(vars()) # 2
print(name) # 3

运行结果如下

{'name': 'lisi'} # 1
{'__builtins__': , '__file__': '/Users/shihangwei/Documents/code/01.py', '__package__': None, 'fun': , '__name__': '__main__', '__doc__': None} # 2
Traceback (most recent call last): # 3
  File "/Users/shihangwei/Documents/code/01.py", line 7, in 
    print(name)
NameError: name 'name' is not defined

我用# 1# 2# 3标注了每一步的执行结果,我们可以清晰的看到

  1. # 1是函数执行时打印的结果,在该字典中存在name的值,变量name在函数范围内生效
  2. # 2是在函数外部打印的结果,在该字典中存在函数体fun,但并没有变量name,该变量在函数体外不生效,所以# 3才会执行出现错误

函数内部的变量在外部无法直接访问,也无法直接影响外部变量。这也是为什么函数内部的变量可以和外部同名的原因,以下的结果也就不言而喻了

def fun():
  name = "lisi" 

fun()
name = "Lily"
print(name) 
# "Lily"

我们可以依此将作用域划分为以下两种

  • 局部作用域/命名空间:函数声明时创造的作用域,变量只能在被声明的函数内部访问,函数外部使用不了。

  • 全局作用域/命名空间:最外层的作用域

而变量也可以由上述两个作用域被分为局部变量全局变量

除了使用vars函数以外,我们还可以使用globalslocals函数打印当前位置的全局和局部作用域

name = "Lily"
def fun():
  name = "lisi" 
  print(locals())
  print(globals())

fun()
print(name)  
# {'name': 'lisi'}
# {'name': 'Lily', '__builtins__': , '__file__': '/Users/shihangwei/Documents/code/01.py', '__package__': None, 'fun': , '__name__': '__main__', '__doc__': None}
# Lily

也可以清晰的看出局部变量和全局变量的name不同

请注意,locals() 打印的是当前位置的局部作用域,如果我在最外层使用 locals,它得到的结果和 global 应该是一样的


全局作用域

在上文中,我们得出了一个结论,全局作用域不能访问局部作用域中的局部变量,那么反过来,局部作用域能不能访问全局作用域中的全局变量呢?

name = "lisi"

def fun():
  print(name)

fun()

答案竟然是肯定的!

"lisi"

我们再来看一看各自作用域中的变量

name = "lisi"

def fun():
    print(locals())
    print(globals())
    print(name)

fun()

# {}
# {'name': 'lisi', '__builtins__': , '__file__': '/Users/shihangwei/Documents/code/01.py', '__package__': None, 'fun': , '__name__': '__main__', '__doc__': None}
# lisi

不出意料,局部作用域是空的,但是我们的name变量看上去像是跳出了函数的作用域,到全局中寻找了。

其实我们简单思考一下就能明白其中的原因。

单个Python文件可以看作一个全局的作用域,但是有没有比单个Python文件更为广阔的变量存储的空间呢?当然有,我们的内置函数,内置变量,其实在Python解释器刚跑起来的时候已经被创建了。

我们已经习惯了在Python文件中调用没有自定义的内置函数,这其实是一个更大范畴的向上寻找。局部和全局也都是相对应的,而局部作用域可以访问全局作用域中的变量

局部访问全局时,我们仍然需要注意一些问题

  1. 如果局部变量和全局变量同名,则会发生"覆盖”

    道理很简单,你在局部已经有了,干嘛还要跳出来向全局要啊?

    name = "lisi"
    
    def fun():
        name = "lily"
        print(name)
    
    fun()
    # lily
    
  2. 在函数内部不要尝试修改全局变量

    虽然我们可以通过某些方法达到这个目的,但是尽量还是不要在局部作用域中修改全局变量的值,这样会给项目带来莫名其妙的bug

    name = "lisi"
    
    def fun():
        name = "Hello" + name
        print(name)
    
    fun()
    # UnboundLocalError: local variable 'name' referenced before assignment
    

局部作用域可以访问全局作用域中的变量,这里我们先得到一个结论,接下来我们将更深入的介绍作用域的划分。

作用域的划分

在上文中我们了解到全局作用域和局部作用域和他们的访问规则,也了解到了在全局作用域之外,系统也给我们准备了一系列的内置变量/函数,那么在Python当中,作用域到底有哪些呢?

  1. L(Local)局部作用域

    局部变量:包含在def关键字定义的语句块中,即在函数中定义的变量。每当函数被调用时都会创建一个新的局部作用域。在函数内部的变量声明,除非特别的声明为全局变量,否则均默认为局部变量。局部变量域就像一个 栈,仅仅是暂时的存在,依赖创建该局部作用域的函数是否处于活动的状态。所以,一般建议尽量少定义全局变量,因为全局变量在模块文件运行的过程中会一直存在,占用内存空间。

  2. E(enclosing)嵌套作用域

    E也包含在def关键字中,E和L是相对的,E相对于更上层的函数而言也是L。与L的区别在于,对一个函数而言,L是定义在此函数内部的局部作用域,而E是定义在此函数的上一层父级函数的局部作用域。

  3. G(global)全局作用域

    即在模块层次中定义的变量,每一个模块都是一个全局作用域。也就是说,在模块文件顶层声明的变量具有全局作用域,从外部开来,模块的全局变量就是一个模块对象的属性。

    注意:全局作用域的作用范围仅限于单个模块文件内

  4. B(built-in)内置作用域

    ​ 系统内固定模块里定义的变量,即系统自带的的变量函数之类的。

我们用以下代码和图片表明LEGB的作用范围

name = "lisi" #G
def get_name():
  name = "wangwu" #E
  def get_inner_name:
    name = "lucy" #L
    
print(__name__) #B

LEGB 作用范围依次增大。很明显,我们在上文的到的结论"局部作用域可以访问全局作用域中的变量"就可以做一些更改了。同样,我们做一下实验

name = "lisi"
def get_name():
  def get_outer_name():
    print(name)
  get_outer_name()

get_name()
# "lisi"
print(vars)
# 

答案显而易见,Python 中寻找变量时,如果在局部作用域中有,就直接使用,如果没有,就去上级作用域去寻找,直到全局作用域,全局作用域还没有,再去内置作用域寻找,再没有,就会报错。

但是反过来,无论如何,上级作用域是无法访问到下级作用域的。

def get_name():
  def get_outer_name():
    name = "lisi"
  get_outer_name()
  print(name)
get_name()
# NameError: global name 'name' is not defined

一句话总结,在寻找变量时,查找的顺序是LEGB,并且不能反过来寻找。


作用域练习题

name = "lisi"
def f1():
    name = "wangwu"
    def f2():
        name = "lucy"
        print(name)
    f2()
f1()

Python中有作用域链,变量会由内到外找,先去自己作用域去找,自己没有再去上级去找,直到找不到报错,根据执行顺序,函数打印时的name就是离他最近的那个,所以结果为lucy

name = "lisi"
 
def f1():
    print(name)
def f2():
    name = "wangwu"
    f1()
f2()

这段代码看似很简单,但是也需要一定的思考。f2执行当中有f1的调用,那么f1在寻找name这个变量的时候,到底是从f2作用域中寻找还是在全局中寻找呢?

这里需要明确一点,函数的作用域在声明时就已经决定了,和他在声明地方调用,没有任何关系。

函数的作用域取决于声明时,而不取决于调用时

所以,答案显而易见,最终的结果是lisi

global和nonlocal

上文中我们已了解到 Python 中变量的查找规则。接下来我们探讨这样一个问题,如何通过函数修改全局变量呢?

num = 10
def change_num():
  num = 20
change_num()
print(num)

这种做法我们可以直接否定了。刚才我们已经知晓了,局部作用域中的变量声明只是声明了局部变量,和全局变量没有什么瓜葛。所以无论函数内声明的变量值为多少,都不是对全局变量的复制,只是对自身的赋值。

那么这种做法呢?

num = 10
def change_num():
  num = num + 10
change_num()
print(num)

如果这样书写,会直接报错,

UnboundLocalError: local variable 'num' referenced before assignment

这也很好理解,我们在函数内不想声明num,而是希望拿到全局中的num 运算后重新赋值。

但在运算操作实质上是想要改变num的引用,num由10变为20,不只是值发生了变化,num所指向的地址也会发生变化,对于上级/全局变量的修改不能如此随意的在局部作用域完成,否则我们的代码会出现很多bug,因为函数可以随意的污染全局变量。

>>> num = 10
>>> id(num)
4447551920
>>> num = num + 10
>>> id(num)
4447552240

可以看到,num的地址被改变了。

如果我们想要在局部作用域修改全局变量,可以使用global关键词在变量声明时进行修饰

num = 18
def change_num():
  global num
  print(num) # 18
  num += 20
change_num()
print(num) #38

这样我们就可以在局部作用域访问并修改全局变量了。

并且global关键字可以应用到任何位置

num = 10
print(id(num)) # 140195471071504
def change_outer():
  num = 20
  def change_inner():
    global num
    print(num) # 10 
    print(id(num)) # 140195471071504
  change_inner()
change_outer()

虽然有函数嵌套,但是用global修饰的变量永远和全局同名变量保持一致


除了局部作用域和全局作用域出现上述场景,局部作用域和嵌套作用域同样会出现,因为都是由小范围的作用域,访问较大范围的作用域

def change_outer():
  num = 10
  def change_inner():
    num = num + 10
	change_inner()
change_outer()
# UnboundLocalError: local variable 'num' referenced before assignment

出现了同样的错误,我们做一下类比,很容易就能想到,change_outer函数作用范围比较大,相当于"全局",而change_inner函数嵌套在内部,可以看作"局部",同理,上述代码运行也会报错,因为同样是内部作用域尝试改变外部作用域中变量的引用,这是绝对不允许的。

但是此时我们不能过用global关键字,因为global关键字无论在何处声明变量,关联的均为全局变量。现在我们想要在嵌套作用域中访问并修改上级作用域中的变量,可以使用nolocal关键字

def change_outer():
  num = 10
  def change_inner():
    nonlocal num
    num = num + 10
	change_inner()
  print(num) # 20
change_outer()

这样,通过nonlocal修饰,在嵌套作用域可以访问并修改上层的局部作用域中的变量。我们也能理解,范围的大小是相对而言的。

同样,两层的变量num如果不做更改,实质上是同一个引用

def change_outer():
  num = 10
  print(id(num)) # 4562293168 
  def change_inner():
    nonlocal num
    print(id(num)) # 4562293168
	change_inner()
 
change_outer()

同时需要注意,nonlocal既然是修饰嵌套作用域内的变量,那就不能够出现在全局,也不能出现在最外层函数内部。以下的写法都是错误的

num = 10
def change_outer():
  nonlocal num
  num = 20
 
change_outer()
# SyntaxError: no binding for nonlocal 'num' found
nonlocal num

def change_outer():
  num = 20
 
change_outer()
# SyntaxError: nonlocal declaration not allowed at module level

闭包

闭包是一个很有趣的语法现象。在讲解闭包之前,我们首先需要了解一下Python中函数的几个有意思的特殊用法。

函数名可以赋值

>>> def func():
...     print("hello")
...
>>> func

>>> func2 = func
>>> func2

>>> func2()
hello

我们可以把函数名看作函数体的一个地址的引用。

赋值操作其实是在传递函数体的地址的值。


函数名可以当作参数传递

def func():
  print(123)
def func2(f):
  f()
func2(func)

函数名可以作为返回值

name = "wangwu"
def func():
  name = 'lisi'
  def func1():
    print(name)
  return func1

func2 = func()
func2()

我们知道,函数名记录的是函数体在内存中的地址,无论以什么方式传递,只是对地址的传递。

那么问题来了,上面的代码,结果是lisi还是wangwu?

如果我们把func的返回值看作一个独立的函数,和上下文没有关系,那么结论可能是输出wangwu,因为我在全局执行的嘛。但是实际上并不是这样。

我们打印一下地址

name = "wangwu"
def func():
  name = 'lisi'
  def func1():
    print(name)
  print(id(func1)) # 4504575784
  return func1

func2 = func()
print(id(func2)) # 4504575784
func2()

外部返回的函数仍然是我们定义的局部函数!

在大部分语言当中,func被调用执行,则申请内存,执行完毕,内部的局部变量随着函数的退出而销毁,name='lisi'的依赖已经消失。

但是在Python当中name='lisi'这个变量却仍然能被func1捕捉。即使func执行完毕,通过func1仍然能访问该变量。

这种情况:当内嵌函数体内引用到体外的变量时,将会把定义时涉及到的引用环境和函数体打包成一个整体返回。我们称之为闭包


闭包其实就是由函数及其相关的引用环境所组成的实体,所谓的引用环境,其实就是函数在调用时的那一瞬间,和它相关的各种约束(包括变量和变量指向的值)组成的一个集合。

简单来说在上面的例子中,func1就是外部调用的函数,而函数内部没有变量name,需要从它定义时所处的环境中寻找,那么name="lisi"其实就可以看作引用环境。

我们来看一个例子

def func(n):
    sum = n
    def func1():
        return sum+1
    return func1

myfunc = func(10)
print(myfunc())
# 11
myfunc2 = func(20)
print(myfunc2())
# 21

当调用分别由不同参数调用func得到的函数时,myfuncmyfunc1得到的结果是隔离的。也就是每次调用func后都生成并保存一个新的局部变量sum。其实此处func返回的就已经不是函数本身了。而是一个闭包。

由于闭包把函数和运行时的环境打包成为一个整体,它已经不同于函数了。闭包在运行时可以有多个实例,不同环境的引用和函数的组合可以产生多个实例。


闭包实例

上文我们讲了一些概念,现在我们讲几个闭包的应用

先看下面的例子

flist = []
for i in range(3):
    def foo(x): print(x + i)
    flist.append(foo)

for f in flist:
    f(2)

这是一个经典闭包应用。通过阅读代码同学们可能觉得结果是2,3,4,但是结果却是4,4,4

这是为什么呢?容易忽略的点就是,在for循环内定义函数,没有产生作用域嵌套,因为for语句并没有能力产生块级作用域。

所以当我们循环调用flist中的函数时,i的值一直是第一个循环的最终值,2!

如何解决呢?很简单,我们只需要在第一个循环时,将i装进函数就行了

flist = []
for i in range(3):
    def foo(x,y=i): print(x + )
    flist.append(foo)

for f in flist:
    f(2)

在实际开发过程当中,闭包一般用于保持函数的运行环境。如果你希望函数的每次执行结果都是基于上次的运行结果实现的,就可以使用闭包。

假设我们要写一个下棋的游戏。很显然,如果想做下棋的动作,棋子的移动必定是相对于上次位置。并且每次移动的最终位置又要作为下次移动的初始值。这时候闭包的优势就体现出来了

origin = [0,0] # 棋盘原点
def create(pos = origin):
  def move(direct,step): # direct 是运动方向 [1,0]表示x轴正方向 step是运动长度
    new_x = pos[0] + direct[0]*step
    new_y = pos[1] + direct[1]*step
    pos[0] = new_x
    pos[1] = new_y #下次出发的位置,保存在pos中,留在闭包内
    return pos
  return move
move = create()
print(move([1,0],10)) # [10] x轴移动10步之后的位置 
print(move([0,1],20)) # [] 在上一步的基础上,y轴正方向移动20步

小结

  1. Python 不存在块级作用域,我们可以使用def、class、lambda等语句产生局部作用域

  2. 作用域查找的顺序是LEGB,由小到大,反之不行。

  3. 函数的作用域取决于声明时,不取决于调用时

  4. 作用域的大小是相对的,内外也是相对的。通过global可以获得全局变量的引用,通过nonlocal获得上层作用域变量的引用

  5. nonlocal不可用于全局或者最外层函数内部。

  6. 闭包其实就是由函数及其相关的引用环境所组成的实体,在访问函数时,函数定义时所处的环境仍然起作用

你可能感兴趣的:(Python,python,python基础,python函数式编程)