【Python学习手册(第四版)】学习笔记17-作用域

个人总结难免疏漏,请多包涵。更多内容请查看原文。本文以及学习笔记系列仅用于个人学习、研究交流。

本文介绍Python作用域,介绍了变量名解析的LEGB原则,以及内置作用域,全局作用域global语句,嵌套作用域及nonlocal语句。里面也提到了很可能导致bug的一些场景。

本文内容较多且平时较忙导致更新慢。如果你没有学过Python函数作用域可能耗时2h甚至更多,不过这已经是非常通俗易懂的介绍了,文字理解可能稍难需要带着例子去理解。


目录

Python作用域基础

作用域法则

变量名解析:LEGB原则

作用域实例

内置作用域

global语句

最小化全局变量

最小化文件间的修改

其他访问全局变量的方法

作用域和嵌套函数

嵌套作用域的细节

嵌套作用域举例

工厂函数

使用默认参数来保留嵌套作用域的状态

嵌套作用域和lambda

作用域与带有循环变量的默认参数相比较

任意作用域的嵌套

nonlocal语句

nonlocal基础

nonlocal应用

使用nonlocal进行修改

​编辑边界情况

为什么使用nonlocal

使用函数属性的状态


上一个学习笔记介绍了函数定义和调用。这里介绍Python函数的作用域(变量定义以及查找的地方)以及参数传递(传递给函数作为其输入对象的方式)背后的细节。

你将看到:在代码中的何处给一个名字赋值,对于确定这个名字的含义很关键。

还将看到,作用域的用法会对程序维护工作有着重要的影响,例如,过度地使用全局作用域通常是糟糕的事情。

Python作用域基础

已经准备编写函数了,那么需要更正式地了解Python中变量名的含义

当你在一个程序中使用变量名时,Python创建、改变或查找变量名都是在所谓的命名空间(一个保存变量名的地方)中进行的。当谈论到搜索变量名对应于代码的值的时候,作用域这个术语指的就是命名空间。

也就是,在代码中变量名被赋值的位置决定了这个变量名能被访问到的范围

关于所有变量名,包括作用域的定义在内,都是在Python赋值的时候生成的。正如我们所知,Python中的变量名在第一次赋值时已经创建,并且必须经过赋值后才能够使用。

由于变量名最初没有声明,Python将一个变量名被赋值的地点关联为(绑定给)一个特定的命名空间。换句话说,在代码中给一个变量赋值的地方决定了这个变量将存在于哪个命名空间,也就是它可见的范围。

除打包代码之外,函数还为程序增加了一个额外的命名空间层:在默认的情况下,一个函数的所有变量名都是与函数的命名空间相关联的

这意味着:

  • 一个在def内定义的变量名能够被def内的代码使用。不能在函数的外部引用这样的变量名。
  • def之中的变量名与def之外的变量名并不冲突,即使是使用在别处的相同的变量名。一个在def之外被赋值(例如,在另外一个def之中或者在模块文件的顶层)的变量X与在这个def之中的赋值的变量X是完全不同的变量。

在任何情况下,一个变量的作用域(它所使用的地方)总是由在代码中被赋值的地方所决定,并且与函数调用完全没有关系。实际上,正如本文你将学到的,变量可以在3个不同的地方分配,分别对应3种不同的作用域

  1. 如果一个变量在def内赋值,它被定位在这个函数之内。
  2. 如果一个变量在一个嵌套的def中赋值,对于嵌套的函数来说,它是非本地的。
  3. 如果在def之外赋值,它就是整个文件全局的。

我们将其称为语义作用域,因为变量的作用域完全是由变量在程序文件中源代码的位置而决定的,而不是由函数调用决定。

例如,在下面的模块文件中,X=99这个赋值语句创建了一个名为X的全局变量(在这个文件中可见),但是X=88这个赋值语句创建了一个本地变量X(只是在def语句内是可见的)。

x = 99
def func():
    x = 88

尽管这两个变量名都是X,但是它们作用域可以把它们区别开来。

实际上,函数的作用域有助于防止程序之中变量名的冲突,并且有助于函数成为更加独立的程序单元。

作用域法则

这里文字性描述确实多,不过与其他文章相比还是算通俗易懂吧。

在开始编写函数之前,我们编写的所有的代码都是位于一个模块的顶层(也就是说,并不是嵌套在def之中),所以我们使用的变量名要么就是存在于模块文件本身,要么就是Python内置预先定义好的(例如,open)。

函数提供了嵌套的命名空间(作用域),使其内部使用的变量名本地化,以便函数内部使用的变量名不会与函数外(在一个模块或是其他的函数中)的变量名产生冲突。

再一次说明,函数定义了本地作用域,而模块定义的是全局作用域。

这两个作用域有如下的关系。

·内嵌的模块是全局作用域。每个模块都是一个全局作用域(也就是说,一个创建于模块文件顶层的变量的命名空间)。对于外部的全局变量就成为一个模块对象的属性,但是在一个模块中能够像简单的变量一样使用。(这句可能稍难理解,往下看)

·全局作用域的作用范围仅限于单个文件。别被这里的“全局”所迷惑,这里的全局指的是在一个文件的顶层的变量名仅对于这个文件内部的代码而言是全局的。在Python中是没有基于一个单个的、无所不包的情景文件的全局作用域的。替代这种方法的是,变量名由模块文件隔开,并且必须精确地导入一个模块文件才能够使用这个文件中定义的变量名。当你在Python中听到“全局的”,你就应该想到“模块”。

·每次对函数的调用都创建了一个新的本地作用域。每次调用函数,都创建了一个新的本地作用域。也就是说,将会存在由那个函数创建的变量的命名空间。可以认为每一个def语句(以及lambda表达式)都定义了一个新的本地作用域,但是因为Python允许函数在循环中调用自身(一种叫做递归的高级技术),所以从技术上讲,本地作用域实际上对应的是函数的调用。换句话说,每一个函数调用都创建了一个新的本地命名空间。递归在处理不能提前预知的流程结构时是一个有用工具。

·赋值的变量名除非声明为全局变量或非本地变量,否则均为本地变量。在默认情况下,所有函数定义内部的变量名是位于本地作用域(与函数调用相关的)内的。如果需要给一个在函数内部却位于模块文件顶层的变量名赋值,需要在函数内部通过global语句声明。如果需要给位于一个嵌套的def中的名称赋值,开始可以通过在一条nonlocal语句中声明它来做到。

·所有其他的变量名都可以归纳为本地、全局或者内置的。在函数定义内部的尚未赋值的变量名是一个在一定范围内(在这个def内部)的本地变量、全局(在一个模块的命名空间内部)或者内置(由Python的预定义__builtin__模块提供的)变量。

这里还有一些细节需要注意。

首先,以交互命令提示模式输入的代码也遵从这些规则。你可能还不知道,交互模式运行的代码实际上真的输入到一个叫做__main__的内置模块中;这个模块就像一个模块文件一样工作,但是,结果随着输入而反馈。因此,交互模式也在一个模块中创建名称,并由此遵守常规的作用域规则:它们对于交互会话来说是全局的。

还要注意,一个函数内部的任何类型的赋值一个名称都会划定为函数本地的。这包括=语句、import中的模块名称、def中的函数名称、函数参数名称等。如果在一个def中以任何方式赋值一个名称,它都将对于该函数成为本地的。

此外,注意原处改变对象并不会把变量划分为本地变量,实际上只有对变量名赋值才可以。例如,如果变量名L在模块的顶层被赋值为一个列表,在函数内部的像L.append(X)这样的语句并不会将L划分为本地变量,而L=X却可以。通常,记住名称和对象之间的清楚的区分是有帮助的:修改一个对象并不是对一个名称赋值。

变量名解析:LEGB原则

上一节内容看起来有些令人困惑,我们可以总结这样三条简单的原则。

对于一个def语句:

  • 变量名引用分为三个作用域进行查找:首先是本地,之后是函数内(如果有的话),之后全局,最后是内置。

  • 在默认情况下,变量名赋值会创建或者改变本地变量。

  • 全局声明和非本地声明将赋值的变量名映射到模块文件内部的作用域。

也就是说:所有在函数def语句(或者lambda表达式)内赋值的变量名默认均为本地变量。函数能够在函数内部以及全局作用域(也就是物理上)直接使用变量名,但是必须声明为非本地变量和全局变量去改变其属性

Python的变量名解析机制有时称为LEGB法则,这也是由作用域的命令而来的。

  • 当在函数中使用未认证的变量名时,Python搜索4个作用域[本地作用域(L),之后是上一层结构中def或lambda的本地作用域(E),之后是全局作用域(G),最后是内置作用域(B)]并且在第一处能够找到这个变量名的地方停下来。如果变量名在这次搜索中没有找到,Python会报错。
  • 当在函数中给一个变量名赋值时(而不是在一个表达式中对其进行引用),Python总是创建或改变本地作用域的变量名,除非它已经在那个函数中声明为全局变量。
  • 当在函数之外给一个变量名赋值时(也就是,在一个模块文件的顶层,或者是在交互提示模式下),本地作用域与全局作用域(这个模块的命名空间)是相同的。

图17-1描述了Python的四个作用域的关系。注意到第二个E作用域的查找层次(上层def和lambda的作用域)从技术上来说可能不仅是一层查找的层次。

LEGB作用域查找原则。当引用一个变量时,Python按以下顺序依次进行查找:从本地变量中,在任意上层函数的作用域,在全局作用域,最后在内置作用域中查找。第一个能够完成查找的就算成功。变量在代码中被赋值的位置通常就决定了它的作用域。

nonlocal声明也可以迫使名称映射到函数内部的作用域中,而不管是否对其赋值。

后面会接触被验证的属性变量名、继承等概念,不止这里提到的作用域的概念。

作用域实例

用一个例子来说明作用域的概念。假设我们在一个模块文件中编写了下面这个模块文件。

通过使用Python的作用域法则,我们能够将这些变量名进行如下定义。

全局变量名:X,func

因为X是在模块文件顶层注册的,所以它是全局变量;它能够在函数内部进行引用而不需要特意声明为全局变量。因为同样的原因func也是全局变量;def语句在这个模块文件顶层将一个函数对象赋值给了变量名func。

本地变量名:Y,Z

对于这个函数来说,Y和Z是本地变量(并且只在函数运行时存在),因为他们都是在函数定义内部进行赋值的:Z是通过=语句赋值的,而Y是由于参数总是通过赋值来进行传递的。

这种变量名隔离机制背后的意义就在于本地变量是作为临时的变量名,只有在函数运行时才需要它们。在上面例子中,参数Y和加法的结果Z只存在于函数内部。这些变量名不会与模块命名空间内的变量名(同理,与其他函数内的变量名)产生冲突。

本地变量/全局变量的区别也使函数变得更容易理解,因为一个函数使用的绝大多数变量名只会在函数自身内部出现,而不是这个模块文件的任意其他地方

此外,因为本地变量名不会改变程序中的其他函数,这会让程序调试起来更加容易。

内置作用域

内置域比你想象的简单。

内置作用域仅仅是一个名为 builtins 的内置模块,但是必须要import builtins 之后才能使用内置作用域,因为变量名builtin本身并没有预先内置。

内置作用域是通过一个名为builtins的标准库模块来实现的,但是这个变量名自身并没有放入内置作用域内,所以必须导入这个文件才能够使用它。

一旦这样做,就能够运行dir调用,来看看其中预定义了哪些变量名。

import builtins
dir(builtins)
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BaseExceptionGroup', 'BlockingIOError', 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionError', 'ConnectionRefusedError', 'ConnectionResetError', 'DeprecationWarning', 'EOFError', 'Ellipsis', 'EncodingWarning', 'EnvironmentError', 'Exception', 'ExceptionGroup', 'False', 'FileExistsError', 'FileNotFoundError', 'FloatingPointError', 'FutureWarning', 'GeneratorExit', 'IOError', 'ImportError', 'ImportWarning', 'IndentationError', 'IndexError', 'InterruptedError', 'IsADirectoryError', 'KeyError', 'KeyboardInterrupt', 'LookupError', 'MemoryError', 'ModuleNotFoundError', 'NameError', 'None', 'NotADirectoryError', 'NotImplemented', 'NotImplementedError', 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', 'ProcessLookupError', 'RecursionError', 'ReferenceError', 'ResourceWarning', 'RuntimeError', 'RuntimeWarning', 'StopAsyncIteration', 'StopIteration', 'SyntaxError', 'SyntaxWarning', 'SystemError', 'SystemExit', 'TabError', 'TimeoutError', 'True', 'TypeError', 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', 'WindowsError', 'ZeroDivisionError', '__build_class__', '__debug__', '__doc__', '__import__', '__loader__', '__name__', '__package__', '__spec__', 'abs', 'aiter', 'all', 'anext', 'any', 'ascii', 'bin', 'bool', 'breakpoint', 'bytearray', 'bytes', 'callable', 'chr', 'classmethod', 'compile', 'complex', 'copyright', 'credits', 'delattr', 'dict', 'dir', 'divmod', 'enumerate', 'eval', 'exec', 'exit', 'filter', 'float', 'format', 'frozenset', 'getattr', 'globals', 'hasattr', 'hash', 'help', 'hex', 'id', 'input', 'int', 'isinstance', 'issubclass', 'iter', 'len', 'license', 'list', 'locals', 'map', 'max', 'memoryview', 'min', 'next', 'object', 'oct', 'open', 'ord', 'pow', 'print', 'property', 'quit', 'range', 'repr', 'reversed', 'round', 'set', 'setattr', 'slice', 'sorted', 'staticmethod', 'str', 'sum', 'super', 'tuple', 'type', 'vars', 'zip']

这个列表中的变量名组成了Python中的内置作用域。概括地讲,前一半是内置的异常,而后一半是内置函数。由于LEGB法则Python最后将自动搜索这个模块,将会自动得到这个列表中的所有变量名。

也就是说,你可以使用这些变量名而不需要导入任何模块。

有两种方法引用一个内置函数:通过LEBG法则带来的好处,或者手动导入__builtin__模块。

zip    #第一种
<class 'zip'>

import builtins    #第二种
builtins.zip
<class 'zip'>

第二种实现方法有时在更复杂的任务中是很有用的。由于LEGB查找的流程,会使它找到第一处变量名的地方生效。也就是说,在本地作用域的变量名可能会覆盖在全局作用域和内置作用域的有着相同变量名的变量,而全局变量名有可能覆盖内置的变量名

例如下面一个函数创建了一个名为open的本地变量并将其进行了赋值:

def hider():
    open = 'spam'

    
open('NEWS.txt') #无法打开文件

这里会将存储于内置(外部)作用域的名为open的内置函数隐藏起来。这也往往是个Bug,并且让人头疼的是,因为Python对于这个问题并不会处理为警告消息(在高级编程的场合你可能会很想通过在代码中预定义变量名来替代内置的变量名)。

函数也能够简单地使用本地变量名隐藏同名的全局变量。

x = 88
def func():
    x = 99

    
func()

print(x)
88

这里,函数内部的赋值语句创建了一个本地变量X,它与函数外部模块文件的全局变量X是完全不同的变量。正是由于这一点,如果在def内不增加global(或nonlocal)声明的话,是没有办法在函数内改变函数外部的变量的


global语句

global语句是Python中唯一看起来有些像声明语句的语句。但是,它并不是一个类型或大小的声明,它是一个命名空间的声明。它告诉Python函数打算生成一个或多个全局变量名。也就是说,存在于整个模块内部作用域(命名空间)的变量名。

再总结下全局变量名。

  • 全局变量是位于模块文件内部的顶层的变量名。
  • 全局变量如果是在函数内被赋值的话,必须经过声明。
  • 全局变量名在函数的内部不经过声明也可以被引用。

换句话说,global允许修改一个模块文件的顶层的一个def之外的名称。后面将看到nonlocal语句几乎是相同的,但它应用于嵌套的def的本地作用域内的名称,而不是嵌套的模块中的名称。

global语句包含了关键字global,其后跟着一个或多个由逗号分开的变量名。当在函数主体被赋值或引用时,所有列出来的变量名将被映射到整个模块的作用域内。

例如:

这个例子增加了一个global声明,以便在def之内的X能够引用在def之外的X,这次它们有相同的值。

还有一个global使用的例子:

这里,x、y和z都是all_global函数内的全局变量。y和z是全局变量,因为它们不是在函数内赋值的;x是全局变量,因为它通过global语句使自己明确地映射到了模块的作用域。如果不使用global语句的话,x将会由于赋值而被认为是本地变量。

y和z并没有进行global声明。Python的LEGB查找法则将会自动从模块中找到它们。

此外,注意x在函数运行前可能并不存在。如果这样的话,函数内的赋值语句将自动在模块中创建x这个变量。

最小化全局变量

在默认情况下,函数内部注册的变量名是本地变量,所以如果希望在函数外部对变量进行改变,必须编写额外的代码(global语句)。

尽管有些时候global语句是有用的,然而在def内部赋值的变量名默认为本地变量,通常这都是最好的约定。将其改为全局变量会引发一些软件工程问题:由于变量的值取决于函数调用的顺序,而函数自身是任意顺序进行排列的,导致了程序调试起来变得很困难。

思考一下下面的模块文件。

假设你的任务就是修改或重用这个模块文件。这里X的值将会是什么?确切地说,如果不确定引用的时间,这个问题就是毫无意义的。X的值与时间相关联,因为它的值取决于哪个函数是最后进行调用的。

实际的结果就是,为了理解这个代码,你必须去跟踪整个程序的控制流程。此外,如果重用或修改了代码,你必须随时记住整个程序。在这种情况下,如果使用这两个函数中的一个的话,必须要确保没有在使用另一个函数。它们通过全局变量而变得具有相关性(也就是说,是耦合在一起的)。这就是使用全局变量的问题:不像那些依赖于本地变量的由自包含的函数构成的代码,全局变量使得程序更难理解和使用。

另一方面,不使用面向对象的编程方法以及类的话,全局变量也许就是Python中最直接保持状态信息的方法(函数在其下次被调用时需要记住的信息):本地变量在函数返回时将会消失,而全局变量不是这样。

另一种技术,例如,默认可变参数以及嵌套函数作用域,也能够实现这一点,但是它们与将值推向全局作用域来记忆这种方法相比过于复杂了。

到目前为止,在不熟悉编程的情况下,最好尽可能地避免使用全局变量(试试通过传递函数然后返回值来替代一下)。

最小化文件间的修改

这是另一个和作用域相关的问题:尽管我们能够直接修改另一个文件中的变量,但是往往我们都不这样做。

为了说明它们与作用域之间的关系,考虑下面这两个模块文件:

第一个模块文件定义了变量X,这个变量在第二个文件中通过赋值被修改了。

必须在第二个文件中导入第一个模块才能够得到它的值。每个模块都是自包含的命名空间(变量名的封装),而且我们必须导入一个模块才能在从另一个模块中看到它内部的变量。这是关于模块的一个要点:通过在每个文件的基础上分隔变量,它们避免了跨文件的名称冲突。

事实上,一个模块文件的全局变量一旦被导入就成为了这个模块对象的一个属性:导入者自动得到了这个被导入的模块文件的所有全局变量的访问权,所以在一个文件被导入后,它的全局作用域实际上就构成了一个对象的属性。

在导入第一个模块文件后,第二个模块就将其变量赋了一个新的值。这个赋值的问题就在于,这样的做法过于含糊了:无论是谁负责维护或重用第一个模块,都不一定知道有一个不知道在哪的模块位于导入链上可以修改X。实际上,第二个模块可能在完全不同的一个目录下,而且很难找到。

尽管这样的跨文件变量在Python中总是可能修改的,但它们通常比我们想要的更微妙。

再者,这会让两个文件有过于强的相关性:因为它们都与变量X的值相关,如果没有其中一个文件的话很难理解或重用另一个文件。这种隐含的跨文件依赖性,在最好的情况下会导致代码不灵活,最坏的情况下会引发bug。

这里再说一次,最好的解决办法就是别这样做:在文件间进行通信最好的办法就是通过调用函数,传递参数,然后得到其返回值。在这个特定的情况下,我们最好使用accessor函数去管理这种变化。

这需要更多的代码,但是这在可读性和可维护性上有着天壤之别:当人们仅阅读第一个模块文件时看到这个函数,会知道这是一个接入点,并且知道这将改变变量X。换句话说,它删除了令人惊讶的元素,在软件项目中过多地使用它们并非好事。虽然无法避免修改文件间的变量,但是通常的做法是最小化文件间变量的修改。

其他访问全局变量的方法

由于全局变量构成了一个被导入的对象的属性,我们能够通过使用导入嵌入的模块并对其属性进行赋值来仿造出一个global语句,就像下边这个模块文件的例子一样。这个文件中的代码先通过变量名然后通过索引sys.modules导入了嵌套的模块

运行时,这将会给全局变量加3(只有第一个函数不会影响全局变量):

这很有效,并且这表明全局变量与模块的属性是等效的,但是为了清晰地表达你的想法,这种方法要比直接使用global语句需要做更多的工作。


作用域和嵌套函数

到现在为止,忽略了Python的作用域法则中的一部分(有意而为,因为它在实际情景中很少见)。

现在到了深入学习一下LEGB查找法则中E这个字母的时候了。它包括了任意嵌套函数内部的本地作用域。嵌套作用域有时也叫做静态嵌套作用域。实际上,嵌套是一个语法上嵌套的作用域,它是对应于程序源代码的物理结构上的嵌套结构。

嵌套作用域的细节

在增加了嵌套的函数作用域后,变量的查找法则变得稍微复杂了一些。对于一个函数:

  • 一个引用(X)首先在本地(函数内)作用域查找变量名X;之后会在代码的语法上嵌套了的函数中的本地作用域,从内到外查找;之后查找当前的全局作用域(模块文件);最后再内置作用域内(模块builtins)。全局声明将会直接从全局(模块文件)作用域进行搜索。
  • 在默认情况下,一个赋值(X=value)创建或改变了变量名X的当前作用域。如果X在函数内部声明为全局变量,它将会创建或改变变量名X为整个模块的作用域。另一方面,如果X在函数内声明为nonlocal,赋值会修改最近的嵌套函数的本地作用域中的名称X。

注意:全局声明将会将变量映射至整个模块。当嵌套函数存在时,嵌套函数中的变量也许仅仅是引用,但它们需要nonlocal声明才能修改。

嵌套作用域举例

下面是一个嵌套函数作用域的例子。

def是一个简单的可执行语句,可以出现在任意其他语句能够出现的地方,包括嵌套在另一个def之中。

这里,嵌套的def在函数f1调用时运行;这个def生成了一个函数,并将其赋值给变量名f2,f2是f1的本地作用域内的一个本地变量。在此情况下,f2是一个临时函数,仅在f1内部执行的过程中存在(并且只对f1中的代码可见)。

值得注意的是f2内部发生了什么。当打印变量x时,x引用了存在于函数f1整个本地作用域内的变量x的值。因为函数能够在整个def声明内获取变量名,通过LEGB查找法则,f2内的x自动映射到了f1的x。

这个嵌套作用域查找在嵌套的函数已经返回后也是有效的。例如,下面的代码定义了一个函数创建并返回了另一个函数。

在这个代码中,命名为f2的函数的调用动作的运行是在f1运行后发生的。f2记住了在f1中嵌套作用域中的x,尽管f1已经不处于激活状态。

工厂函数

根据要求的对象,这种行为有时也叫做闭合(closure)或者工厂函数——一个能够记住嵌套作用域的变量值的函数,尽管那个作用域或许已经不存在了

尽管类(后续文章介绍)是最适合用作记忆状态的,因为它们通过属性赋值让这个过程变得很明了,像这样的函数也提供了一种替代的解决方法。

例如,工厂函数有时用于需要及时生成事件处理、实时对不同情况进行反馈的程序中(例如,用户的输入是无法进行预测的)

例如下面的这个函数:

这定义了一个外部的函数,这个函数简单地生成并返回了一个嵌套的函数,却并不调用这个内嵌的函数。

如果我们调用外部的函数:

得到的是生成的内嵌函数的一个引用。这个内嵌函数是通过运行内嵌的def而创建的。

如果现在调用从外部得到的那个函数:

它将会调用内嵌的函数。也就是说,maker函数内部的名为action的函数。这一部分最不平常用的就是,内嵌的函数记住了整数2,即maker函数内部的变量N的值,尽管在调用执行f时maker已经返回了值并退出。实际上,在本地作用域内的N被作为执行的状态信息保留了下来,返回其参数的平方运算。

如果再调用外层的函数,将得到一个新的有不同状态信息的嵌套函数——得到了参数的三次方而不是平方,但是最初的仍像往常一样是平方。

为什么可以奏效?因为像这样对一个工厂函数的每次调用,都得到了自己的状态信息的集合。在例子中,我们赋给名称g的函数记住了3,f记住了2,因为每个函数都有自己的状态信息由maker中的变量N保持。

这是一种相当高级的技术,除了那些拥有函数式编程背景的程序员们,以后在实际使用中也不会常常见到。

另一方面,嵌套的作用域常常被lambda函数创建表达式使用(稍后将介绍)——因为它们是表达式,它们几乎总是嵌套在一个def中。

此外,函数嵌套通常用作装饰器(第38学习笔记介绍)——在某些情况下,它是最为合理的编码模式。

通常来说,类是一个更好的像这样进行“记忆”的选择,因为它们让状态变得很明确。不使用类的话,全局变量、像这样的嵌套作用域引用以及默认的参数就是Python的函数能够保留状态信息的主要方法了。

使用默认参数来保留嵌套作用域的状态

有时候会将默认参数值传递给(记住)一个内嵌作用域内的对象:

这段代码会在任意版本的Python中工作,而且你也仍会在一些现存的Python代码中看到这样的例子。

简而言之,出现在def头部的arg=val的语句表示参数arg在调用时没有值传入进来的时候,默认会使用值val

通过修改了f2,x=x意味着参数x将会默认使用嵌套作用域中x的值,这是由于第二个x在Python进入内嵌的def之前是验证过的,所以它仍将引用f1中的x。实际上,默认参数记住了f1中x的值(也就是,对象88)。

上面这些都相当的复杂,而且它完全取决于默认值进行验证的时刻。

实际上,嵌套作用域查找法则之所以加入到Python中就是为了让默认参数不再扮演这种角色。如今,Python自动记住了所需要的上层作用域的任意值,为了能够在内嵌的def中使用。

当然,最好的处方就是简单地避免在def中嵌套def,这会让程序更加得简单。下面的代码就是前边例子的等效性形式,这段代码就避免了使用嵌套。注意到,就像这个例子一样,在某一个函数内部就调用一个之后才定义的函数是可行的,只要第二个函数定义的运行是在第一个函数调用前就行在def内部的代码直到这个函数运行时才会被验证

如果使用这样的办法避免嵌套,几乎都可以忘记Python中的嵌套作用域,除非需要编写之前讨论过的工厂函数风格的代码,至少对于def是这样

嵌套作用域和lambda

尽管对于def本身来说、嵌套作用域很少使用,但是当开始编写lambda表达式时,就要注意。

到第19学习笔记才会深入学习lambda,但是简短地说,它就是一个表达式,将会生成后面调用的一个新的函数,与def语句很相似。由于它是一个表达式,尽管能够使用在def中不能使用的地方,例如,在一个列表或是字典常量之中。

像def一样,lambda表达式引入了新的本地作用域。多亏了嵌套作用域查找层,lambda能够看到所有在所编写的函数中可用的变量。因此,以下的代码现在能够运行,但仅仅是因为如今能够使用嵌套作用域法则了。

参考之前对嵌套作用域的介绍,程序员需要使用默认参数从上层作用域传递值给lambda,就像为def做过的那样。

例如,下面的代码对于所有版本的Python都可以工作。

由于lambda是表达式,所以它们自然而然地(或者更一般的)嵌套在了def中。因此,它们也就成为了后来在查找原则中增补嵌套函数作用域的最大受益者。在大多数情况下,给lambda函数通过默认参数传递值没有什么必要

作用域与带有循环变量的默认参数相比较

在已给出的法则中有个值得注意的特例:如果lambda或者def在函数中定义,嵌套在一个循环之中,并且嵌套的函数引用了一个上层作用域的变量,该变量被循环所改变,所有在这个循环中产生的函数将会有相同的值——在最后一次循环中完成时被引用变量的值

例如,下面的程序试图创建一个函数的列表,其中每个函数都记住嵌套作用域中当前变量i的值。

这并不怎么有效:因为嵌套作用域中的变量在嵌套的函数被调用时才进行查找,所以它们实际上记住的是同样的值(在最后一次循环迭代中循环变量的值)。也就是说,将从列表中的每个函数得到4的平方的函数,因为i对于在每一个列表中的函数都是相同的值4。

这是在嵌套作用域的值和默认参数方面遗留的一种仍需要解释清楚的情况,而不是引用所在的嵌套作用域的值。也就是说,为了让这类代码能够工作,必须使用默认参数把当前的值传递给嵌套作用域的变量。因为默认参数是在嵌套函数创建时评估的(而不是在其稍后调用时),每一个函数记住了自己的变量i的值。

这是一种相当隐晦的情况,但是它会在实际情况中发生,特别是在生成应用于GUI一些部件的回调处理函数的代码中(例如,按钮的事件处理)。后面其他文章会继续讲解。

任意作用域的嵌套

作用域可以做任意的嵌套,但是只有内嵌的函数(而不是类,后面文章介绍)会被搜索:

Python将会在所有的内嵌的def中搜索本地作用域,从内至外,在引用过函数的本地作用域之后,并在搜索模块的全局作用域之前进行这一过程。

尽管如此,这种代码不可能会在实际中这样使用。在Python中,我们说过平坦要优于嵌套。如果你尽可能地少定义嵌套函数,那么你和同事的生活都会变得更美好。


nonlocal语句

上面已经介绍了嵌套函数可以引用一个嵌套的函数作用域中的变量的方法,即便这个函数已经返回了。

事实上,我们也可以修改这样的嵌套作用域变量,只要我们在一条nonlocal语句中声明它们,嵌套的def可以对嵌套函数中的名称进行读取和写入访问

nonlocal语句是global的近亲,前面已经介绍过global。nonlocal和global一样,声明了将要在一个嵌套的作用域中修改的名称。和global的不同之处在于,nonlocal应用于一个嵌套的函数的作用域中的一个名称,而不是所有def之外的全局模块作用域;而且在声明nonlocal名称的时候,它必须已经存在于该嵌套函数的作用域中——它们可能只存在于一个嵌套的函数中,并且不能由一个嵌套的def中的第一次赋值创建。

换句话说,nonlocal即允许对嵌套的函数作用域中的名称赋值,并且把这样的名称的作用域查找限制在嵌套的def。直接效果是更加直接和可靠地实现了可更改的作用域信息,对于那些不想要或不需要带有属性的类的程序而言。

nonlocal基础

nonlocal语句,它只在一个函数内有意义:

这条语句允许一个嵌套函数来修改在一个语法嵌套函数的作用域中定义的一个或多个名称。在Python 3.0中,在一条nonlocal语句中声明嵌套的作用域,使得嵌套的函数能够赋值,并且由此也能够修改这样的名称。

这提供了一种方式,使得嵌套的函数能够提供可写的状态信息,以便在随后调用嵌套的函数的时候能够记住这些信息。允许状态修改,这使得嵌套函数更有用(例如,想象嵌套的作用域中的一个计数器)。

除了允许修改嵌套的def中的名称,nonlocal语句还加快了引用——就像global语句一样,nonlocal使得对该语句中列出的名称的查找从嵌套的def的作用域中开始,而不是从声明函数的本地作用域开始。也就是说,nonlocal也意味着“完全略过我的本地作用域”。

实际上,当执行到nonlocal语句的时候nonlocal中列出的名称必须在一个嵌套的def中提前定义过,否则,将会产生一个错误。直接效果和global很相似:global意味着名称位于一个嵌套的模块中,nonlocal意味着它们位于一个嵌套的def中。nonlocal甚至更严格——作用域查找只限定在嵌套的def。也就是说,nonlocal名称只能出现在嵌套的def中,而不能在模块的全局作用域中或def之外的内置作用域中。

nonlocal的添加并没有改变通用的名称引用作用域规则;它们仍然像以前一样工作,即前面所描述的每条"LEGB"规则。nonlocal语句主要作用是允许嵌套的作用域中的名称被修改,而不只是被引用。然而,当在一个函数中使用的时候,global和nonlocal语句都在某种程度上限制了查找规则:

  • global使得作用域查找从嵌套的模块的作用域开始,并且允许对那里的名称赋值。如果名称不存在于该模块中,作用域查找继续到内置作用域,但是,对全局名称的赋值总是在模块的作用域中创建或修改它们。
  • nonlocal限制作用域查找只是嵌套的def,要求名称已经存在于那里,并且允许对它们赋值。作用域查找不会继续到全局或内置作用域。

nonlocal应用

在下面的代码中,tester构建并返回函数nested以便随后调用,nested中的state引用使用超常规的作用域查找规则来映射tester的本地作用域:

默认情况下,不允许修改嵌套的def作用域中的名称;

使用nonlocal进行修改

如果我们在nested中把tester作用域中的state声明为一个nonlocal,我们就可以在nested函数中修改它了。即便我们通过名称F调用返回的nested函数时,tester已经返回并退出了,这也是有效的:

通常使用嵌套作用域引用时,可以多次调用tester工厂函数,以便在内存中获得其状态的多个副本。嵌套作用域中的state对象基本上附加到了返回的nested函数对象,每次调用都产生一个新的、独特的state对象,以至于更新一个函数的state不会影响到其他的。如下代码继续前面的交互式程序:

边界情况

有几件事情需要注意。首先,和global语句不同,当执行一条nonlocal语句时,nonlocal名称必须已经在一个嵌套的def作用域中赋值过,否则将会得到一个错误——不能通过在嵌套的作用域中赋给它们一个新值来创建它们:

其次,nonlocal限制作用域查找仅为嵌套的def,nonlocal不会在嵌套的模块的全局作用域或所有def之外的内置作用域中查找,即便已经有了这些作用域:

一旦你意识到Python不能普遍地知道在哪个嵌套的作用域中创建一个全新的名称,这些限制就有意义了。在前面的程序中,spam应该在tester中赋值,还是在模块之外赋值?由于这不明确,Python必须在函数创建的时候解析nonlocal,而不是在函数调用的时候。

为什么使用nonlocal

假设有了极其复杂的嵌套函数,你会为一团糟乱而感到吃惊。尽管很难在前面的小示例中看到这点,但在很多程序中,状态信息变得很重要。

在Python中,有各种不同的方法来“记住”跨函数和方法的信息。尽管都有利有弊,对于嵌套的作用域引用,nonlocal确实起到了改进作用——nonlocal语句允许在内存中保持可变状态的多个副本,并且解决了在类无法保证的情况下的简单的状态保持。

正如我们在前面小节所看到的,如下的代码允许在一个嵌套作用域中保持和修改状态。对tester的每次调用都创建了可变信息的一个小小的自包含包,可变信息的名称不会与程序的其他部分产生任何冲突:

使用函数属性的状态

作为一种状态保持选项,我们有时候可以使用函数属性实现与nonlocal相同的效果——用户定义的名称直接附加给函数。

示例:它用附加给嵌套的函数的一个属性替代了nonlocal。

尽管这种方法可能对某些人来说不那么容易理解,但它允许从嵌套的函数之外访问状态变量(使用nonlocals,只能在嵌套的def内部看到状态变量):

这段代码依赖于一个事实:函数名nested是包围nested的tester作用域中的一个本地变量;同样,它可以在nested内自由地引用。这段代码还依赖于这样一个事实:本地修改一个对象并不是给一个名称赋值;当它自增nested.state,它是在修改对象nested引用的一部分,而不是指定nested本身。由于我们不是要真的在嵌套作用域内给一个名称赋值,所以不需要nonlocal。

全局、非本地、类和函数属性都提供了状态保持的选项。全局只支持共享的数据,类需要OOP的基本知识,类和函数属性都允许在嵌套函数自身之外访问状态

通常,你的程序的最好的工具取决于程序的目的。

  • 21
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

兴焉

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值