Python 作用域问题,int 型变量为什么会有些特殊呢

2019-12-08 19:04:02 +08:00
 whoops

是这样的,做练习时用闭包实现一个计数器,使用整型变量会报错

UnboundLocalError: local variable 'cnt' referenced before assignment

代码如下:

def counter():
   cnt = 0
   def add_one():
       cnt += 1
       return cnt
   return add_one
a=counter()
print(a()) 
#把整型变量换成列表就可以
def counter():
   cnt = [0]
   def add_one():
       cnt[0] += 1
       return cnt[0]
   return add_one
a=counter()
print(a()) 

初学不才,请教一下大家

5988 次点击
所在节点    Python
43 条回复
ethego
2019-12-09 00:17:19 +08:00
https://mail.python.org/pipermail/python-dev/2003-October/039214.html
可以自己进来看看 guido 是怎么样承认这个错误了
ethego
2019-12-09 00:28:08 +08:00
和你说的右值里使用了未定义变量没有关系,add_one 作为一个闭包函数是要向上查找变量定义的,这个语义目前在绝大多数现代语言里都是一样的。实际上 Python 只是无法写而不是不能读,你试试改成 `print(cnt+1)` 你看看能不能不赋值直接使用 cnt,明显不是未定义的问题。
FrankHB
2019-12-09 01:44:28 +08:00
@ipwx 基本意思分析对了,但引入了一些跟 Py 不见得有关的更麻烦的问题……不太容易直接说明白。
就干脆都过一遍吧。顺便当 FAQ 草稿。

首先,这里的问题,跟编译不编译没有关系。
所谓的编译原理只是顺带提到这些内容,因为这其实是语言设计而不是编译这种实现的先决知识,但没专门 PL 的同学就只能勉为其难一下了。

其次,这里的问题的知识背景,只要是典型的有所谓变量(variable) 的语言,纯解释实现一样普遍适用。
典型的这类语言中变量以源代码中的的特定片段,即变量名(variable name) ,通常以文本的形式提供,源代码中的实际表示通常就是字符串。
源代码的文本通过词法分析识别出作为词素(lexeme) 的实例,然后被分析归类成为某一类记号(token) ,这是附加语法用途的文本以外的构造,在语法分析中完成。
被作为变量名的记号是标识符。(有的语言在此之前还有预处理阶段,其中的类似的词素也叫标识符,但不属于记号—— 例如,C 的预处理记号在语法分析中被区分出表示变量名的普通的标识符记号,以及语法意义上的关键字。)
在典型的语言(排除文本宏替换这类 DSL )中,用户实际一般使用是和语法规则区分规定的语义规则明确的抽象,而非语法构造。
标识符在对应的语法处理之后就已经确定存在。(当然,语法上的处理不一定要求是全局 AOT 的形式,这个另当别论。)这和标识符表示什么含义是两回事。
用标识符去代替标识符指称的实体(这里是变量)讨论会显得稀里糊涂,因为实际的处理方式不唯一,而且经常依赖之后的语义处理过程,处理后的内容也没法一一对应(允许一一对应的平凡逻辑还浪费了语言允许的抽象能力,通常就是应该在语言设计中避免的)。

第三,大多数用户在这里没有区分清楚所谓“变量”所指的确切含义,于是稀里糊涂程度翻倍。
虽然不少语言设计中根本没说清楚什么叫变量,一般地,变量区分于其它语言概念的关键性质就是保证存在标识变量的变量名。
注意:
1.变量总是被命名。语言层面上没有所谓的“匿名变量”,因为这是逻辑上的自相矛盾。
2.变量名是标识符,但反过来不一定,因为标识符指称实体,但不一定命名变量。它可能是特殊的语法构造,如宏。
3.变量(的值)是不是支持可被修改,对是不是变量无关紧要。像纯函数式语言中就有不可修改的变量。(但是近来很多语言设计者会误用可修改的对象作为变量的含义,另当别论。)
4.有的语言中,函数名被单独区分,剩下的实体叫做对象(object) (注意先来后到,这和面向对象毫无关系),特指明确需要存储资源的、可以“储存”值(value) 并可能明确支持修改值的实体,如 C (题外话,ISO C 直接回避了“变量”的概念)。其它一些语言不强调这点,可以把函数也作为对象。
实际上严格定义(如 IEC 2382 )中变量可被形式化为命名变量的标识符、指称(denote) 的实体(entity) 和上下文信息的元组。
其中,上下文一般能明确被指称的实体在不同位置中不冲突,也就是源代码中允许引入相同的标识符指称不同实体。
为了消歧义,可以利用上下文中不同的作用域(scope) ,通过名称解析(name resolution) 明确某个标识符作为变量名无歧义的指称到底是同名变量的哪一个。这是语义分析中的一种基本操作。
而用户使用一个变量,既用的是变量名,也可以仅是变量指称的实体。日常所谓的“变量”可能只是指后者,都是严格意义上的变量。为了突出变量构成中的实体以外的作用,可以强调为变量绑定(variable binding) (这也可以是实现名称解析时使用的数据结构之一)。而绑定一个变量则指在程序中引入变量绑定的操作。
用户阅读源代码,看到的首先是语法上的标识符,然后也需要人肉做名称解析以完成可能需要的消歧义,以确定变量到底指称什么实体,才能明白含义。
人肉实现名称解析,它的结果在字面含义上,就是实体的引用(reference) 。
虽然一般用户不一定意识到这点,但实际上语言的机制比一般人直接见名知意复杂得多。这是因为语言的规则要求明确性,需要处理所有情形,又要和其它语法一致。
所以典型的语言中,这不是简单的语法替换过程,而是以标识符构成表达式(expression) ,对表达式求值(evaluate) 之后确定表达式具有的值(value) 。
标识符构成的表达式的求值具有这样的性质:若被求值的表达式中的标识符指称一个实体,则求值后表达式的值引用被指称的实体。
这个意义上,表达式的值同样也是所谓的引用(reference) 。
虽然具体语言设计中不一定提供一等引用(first-class reference) 给用户,但只要是通过表达式求值而不是直接语法替换的形式提供变量名称解析、同时需要区分变量同一性(identity) (基本上只要不是纯函数语言就不可能回避)的语言设计,不可能避免等价于这里的引用的概念。例如,C 没提供引用,但它有左值(lvalue) 。
所以语法意义上标识符确实就只是标识符,但唐突和对象或引用割裂开来,是无助于分清楚这些理由的。

最后,对比上面的通用设计框架,顺带看看 Python 的具体规定。(以下照搬 3.8 的文档,URL 略。)
2.3. Identifiers and keywords
Identifiers (also referred to as names) are described by the following lexical definitions.
...
[一坨具体语法略。]
……果然比上面说得还简单。
3.1. Objects, values and types
Objects are Python’s abstraction for data. All data in a Python program is represented by objects or by relations between objects. (In a sense, and in conformance to Von Neumann’s model of a “stored program computer,” code is also represented by objects.)
这里对象用的是存储实体的概念。
The value of some objects can change. Objects whose value can change are said to be mutable; objects whose value is unchangeable once they are created are called immutable. (The value of an immutable container object that contains a reference to a mutable object can change when the latter’s value is changed; however the container is still considered immutable, because the collection of objects it contains cannot be changed. So, immutability is not strictly the same as having an unchangeable value, it is more subtle.)
...
Python 不提供一等引用,但引用在语言规范中就没被回避。(否则一坨 reference-counting 就更没法说了。)
4.2. Naming and binding
Names refer to objects. Names are introduced by name binding operations.
The following constructs bind names: formal parameters to functions, import statements, class and function definitions (these bind the class or function name in the defining block), and targets that are identifiers if occurring in an assignment, for loop header, or after as in a with statement or except clause. The import statement of the form from ... import * binds all names defined in the imported module, except those beginning with an underscore. This form may only be used at the module level.
A target occurring in a del statement is also considered bound for this purpose (though the actual semantics are to unbind the name).
Each assignment or import statement occurs within a block defined by a class or function definition or at the module level (the top-level code block).
If a name is bound in a block, it is a local variable of that block, unless declared as nonlocal or global. If a name is bound at the module level, it is a global variable. (The variables of the module code block are local and global.) If a variable is used in a code block but not defined there, it is a free variable.
Each occurrence of a name in the program text refers to the binding of that name established by the following name resolution rules.
4.2.2. Resolution of names
A scope defines the visibility of a name within a block. If a local variable is defined in a block, its scope includes that block. If the definition occurs in a function block, the scope extends to any blocks contained within the defining one, unless a contained block introduces a different binding for the name.
When a name is used in a code block, it is resolved using the nearest enclosing scope. The set of all such scopes visible to a code block is called the block’s environment.
When a name is not found at all, a NameError exception is raised. If the current scope is a function scope, and the name refers to a local variable that has not yet been bound to a value at the point where the name is used, an UnboundLocalError exception is raised. UnboundLocalError is a subclass of NameError.
If a name binding operation occurs anywhere within a code block, all uses of the name within the block are treated as references to the current block. This can lead to errors when a name is used within a block before it is bound. This rule is subtle. Python lacks declarations and allows name binding operations to occur anywhere within a code block. The local variables of a code block can be determined by scanning the entire text of the block for name binding operations.
到这里为止,LZ 的问题也好引用的理解也罢,包括具体报错的理由,应该都比较明确了。
Python 的设计和一众小作坊设计一样共享一个经典糟粕——赋值这个依赖已有对象的(修改对象的值)操作和引入变量绑定这两种逻辑上根本不同(绑定原则上要求指定的变量绑定不存在,赋值要求变量绑定在之前必须存在)的操作混在一起了。
所以在人肉解析名称之前,读者还需要多做一次消歧义,先确定这到底是个真正(纯粹)的赋值,还是带变量绑定的所谓赋值。否则就可能出现 LZ 这样的稀里糊涂。
虽然这个例子里要消歧义很简单(都不用照抄 Python 实现的语义,重写 cnt = 0 成为类似 let cnt = 0 这样的伪码,总之能跟后面真正的赋值区分清楚即可),使用户一般性地被迫阅读至少整个块才能做到确定有哪些局部变量绑定是明显糟烂的设计。
(正常的设计中,变量绑定是单独的操作,这往往被设计成变量的初始化声明的语法单独提供。Python 文档在这方面倒有些自知之明,知道是 subtle,但实际上为了使“块中到处可用”也不需要这样的设计,把声明改成表达式即可。)
FrankHB
2019-12-09 05:04:26 +08:00
随便一刷新还有新回复……

@ethego 这说法明显有问题。算了,再过一遍原始问题好了……

LZ 的例子中为什么“替换成列表”就没有错误?看起来是纯粹的语义问题,但实际按 Python reference 理解,还就直接被语法上下文决定了。
注意 += 左边的 cnt 是个标识符,而 cnt[0] 不是。因此这两种情形直接被 assignment statement 的语法区别对待了,适用不同的语义规则:
执行 cnt += 1 会被解释为先限制 cnt 为局部变量再要求引用局部变量 cnt 的这个绑定构造,而被引用的局部变量 cnt 此时没有绑定成功自然失败;
执行 cnt[0] += 1 会被解释成不引入变量绑定的 augmented assignment statement,于是 cnt 就是先前 nearest enclosing scope 引入的变量。
这里 spec 中 Binding of names 一节所谓的 targets that are identifiers if occurring in an assignment 的 assignment 没说就不能是 augmented assignment statement,也没有链接到具体章节,后面 Assignment statements 这节还包含 Augmented assignment statements 这小节,所以这里所谓的 assignment 是包含 augmented assignment statement 的(乃至 3.8 新增的 assignment expression )——尽管 assignment_stmt 和 augmented_assignment_stmt 作为文法元素是并列的,也没有拿 normal assignment statement 以外就不适用来辩解的余地。
于是这跟什么列表是不是 immutable 没直接关系( Python 中什么东西是不是 immutable 根本取决于 object type,这里涉及的反正都没不允许 mutate 根本就不用管)。
只是因为恰巧这些奇葩的语义规则的组合导致了只能 read-only 可用的假象,所以 GvR 发现这里自己挖坑需要补。不过 其实根本也没反省到家,所以也就是多糊了个关键字而已。
(所谓想要照搬 Scheme 也是扯淡,虽然 Scheme 的 top-level 和 local 确实也有很二的问题,但 Scheme 里可没类似 += 的东西给一致性添乱,就算 SRFI-17 也不是这样整的。)

题外话:
虽然“未定义变量”的说法不那么靠谱(随手写 a = a 或者 a += 1 还真不一样,上下文相关),但 UnboundLocalError 这个错误就表示不可能和名称解析失败没关系——而且就实现提供的错误消息来说,还真有关系……
纠结实现,UNBOUNDLOCAL_ERROR_MSG 和 NAME_ERROR_MSG 是不同的错误消息。只有后者才会字面上纠结“定义”,画风是这样的:
UnboundLocalError: local variable 'cnt' referenced before assignment
NameError: name 'cnt' is not defined
然后 UnboundLocalError is a subclass of NameError,所以说成“未定义”,倒也算无可厚非。

另外,引起这样折腾的也并不只是 GvR 承认错误这么简单了:
https://stackoverflow.com/questions/30157655
(光是这个反直觉设计就不要指望“和大多数语言”一样了。)
hehheh
2019-12-09 05:21:03 +08:00
我记得 int 是可以取,可是不能修改的,直觉上感觉和 immutable 有关系,可是如果你写 cnt[:] = [1]也不会报错。所以应该是其他原因,懒得去查为什么了。
hehheh
2019-12-09 05:22:38 +08:00
然后这种情况下 list 在 locals 和 globals 里都能看见,immutable 对象就看不见。感觉这个设计有点奇怪。
ethego
2019-12-09 11:15:40 +08:00
@FrankHB
你错了,替换成列表没有问题的原因在于对于 list 成员的操作亦然属于只读 list 而不是写 list,对于目前的 python 来说在没有 nonlocal 的情况下仅仅只是不能对变量本身进行操作,而对 list 成员的操作并不涉及 list 变量本身 —— 它只是一个富指针。

除非你认为 Guido 说的有问题,那你找他去,上面就是 Guido 觉得需要加入 nonlocal 关键字的观点与原因。
ethego
2019-12-09 11:27:37 +08:00
不好意思没仔细看你后面的结论,我认为你说的把声明和赋值放在一起是导致让人混淆是对的,但并不是问题的根本原因。来看看 Rust 是怎么做对这道题的:
https://play.rust-lang.org/?version=stable&mode=debug&edition=2018&gist=2d41aeaaaba8bf4aae2306ea9cdf77f0
ethego
2019-12-09 11:31:37 +08:00
ethego
2019-12-09 11:55:19 +08:00
理论上来说在声明或赋值时,如果在本地的 context 没有查找到定义的变量都直接按照闭包语义向上查找就可以了,只是这样做会引入额外的开销。加入 nonlocal 关键字,还是把赋值和声明用 let 或者 var 之类的东西区分开只是一个问题的两种解决途径而已。
ethego
2019-12-09 12:05:53 +08:00
https://gist.github.com/ethe/cad17be3abf81935f61f20bd2937fc5c.js
<script src="https://gist.github.com/ethe/cad17be3abf81935f61f20bd2937fc5c.js"></script>
看下 Ruby 是怎么在不区分声明与赋值下做对的。
FrankHB
2019-12-09 14:19:33 +08:00
@FrankHB 我就是清楚“替换成列表没有问题的原因在于对于 list 成员的操作亦然属于只读 list 而不是写 list”(实际上还不仅是对 list )才有这里的奇葩规则混在一起的结论,因为真没发现能通过依赖这条路径就可以推出这里出的问题——这是目前 Python reference 里直接明确的东西。
反过来,即便认为和 immutability 直接扯上关系的推理更直白,也覆盖不了 reference 里已有的描述,特别是不能解释清楚什么时候出现具体什么错误,一样至少需要这里用到的名称解析规则。
所以需要改动 reference 才能支持你的推理。
(我其实我同意按你想要的说法在直觉上还更容易让人明白一点;但 Py 特么现在就用的不是这个理由,那就凉拌吧,我也就不去找麻烦了……)
GvR 的邮件里的说法不管是否正确地反映了他想要的东西,都不可能取代正式文档的表述(而且这人在 PL 的一般议题上误会和偷换概念不是没前科的,要他支持 PTC,大讲了一通 TCO 如何如何,最终却认怂 feel educated 了)。
至于“这道题”,这就是送分题(区分 mutable,或至少按默认局部作用域规则一致地 capture ),Python 在搞混 binding construct/assignment 的情况下继续找事情,所以说特别奇葩。

题外话,关于子对象 mutablity 一般应该怎么搞的问题还是挺麻烦的:
https://www.v2ex.com/t/626109#reply76
FrankHB
2019-12-09 14:21:26 +08:00
ethego
2019-12-09 14:37:27 +08:00
@FrankHB 不区分声明与赋值并不是在闭包中无法写被闭包捕获变量的理由,你看上面 Ruby 和我的解释就知道了,Python 这么做纯粹就是因为 Guido **希望** 对被捕获变量只读。这点在邮件里被 Guido 很明白地表达了。
ethego
2019-12-09 14:50:40 +08:00
而 Guido 在后来他自己对闭包的理解错了,而 Scheme 那种才是对的,而如果希望在这里不破坏兼容或者不增加额外性能开销的前提下,只能加入一个新的语法用于表示 “nonlocal” 的变量。
FrankHB
2019-12-09 14:51:25 +08:00
@ethego 如果不说 Python,照常理当然应该这样(捕获变量和什么时候引入声明没逻辑上的依赖,本来就该正交),但 Python 在结果上就是不按这条路来有啥办法……
Python 不是个人作品,所以讲理由的时候还是按官方的正式文档来,况且尽管奇葩,现在的文档用的那套逻辑上确实能自圆其说。设计者另外的表示就算意图再明确,在文档修正前只能作为间接参考。(我怀疑 Py 烂摊子太多,根本就没人管得上这个。)
ethego
2019-12-09 15:22:30 +08:00
@FrankHB Python 的正式文档只是从语义上对 reference 做出了解释。如果观察一下所有引用类型( Python 文档里显然没有什么引用类型和值类型),Object list dict 啥的,对属于这些类型的 captured variable 的属性或者成员直接声明赋值都是可以的,但是无法直接写变量本身。所以切片语义( cnt[1])啥的不是根本原因,只是因为 Python 只允许读,切片语义对原变量来说是一个读操作而已。
FrankHB
2019-12-09 15:48:24 +08:00
@ethego 关于区分读写……如果一个语言不要求明确 identity 也不在显式类型系统上暴露(而要求用户需要保证 const correctness 之类的 type safe )的话,其实是很偏向实现细节且并不总是那么容易自然推理出来的东西。
很多用户都已经习惯了这些设计,实现也是这样搞的,reference 或者 spec 里反而不写出来了,这种风格我只能表示呵呵。要么直接写清楚,要么都不保证,哪个都比现在这种做法干净。
(包括 Scheme R7RS 比之前多明确某些特定的 variable 是不是具有 location,也有点设计过度了。)
FrankHB
2019-12-09 15:54:48 +08:00
多跑个题,考虑到语言设计者本身的理解偏差的话,真要深层挖起来这里其实是有点其它方面的意思的。
不同 C-like 这种直接在语法上明确区分构造而不会混淆的设计,Scheme 这种全局使用近似的语法却单独对顶级(top level) 上下文约定的设计显然会把语言搞复杂。
这样做的显然是有目的的。我的理解是,这是要同时支持 REPL 的解释党和编译党的妥协。
Scheme 的特殊规则首先是顶级的 define 在绑定已存在时和 set! 等价,也就是一个绑定构造(binding construct) 被替换为赋值。这在 REPL 加载同一个源文件时比较方便。(考虑到 define 首先总能被当作是个 绑定构造,倒不会引起 Python 那么大的混乱。)
与此相对,非顶级的内部定义(internal definition) 则被认为明确地只是绑定构造而不是赋值。这允许 define 被明确地被实现为 letrec* 这种可变性绑定(non immutable binding) 的语法糖。变量绑定的存在性和名称解析的错误的因果性使后者的语义中隐含一种顺序上的限制,保证内部定义能进行这样的语法变换。这实质上给允许“编译”提供一种保证。
Python 同样有这样琐碎的问题:如何保证变量绑定的存在性能在早期被确定?不像 Scheme 这种长期两派党争下可以有简单妥协,Python 一开始基本就是 CPython 一家说了算,而其中提供 LOAD_FAST/LOAD_DEREF 之类的实现细节是否容易被静态确定以及 CPython 的“编译”倾向混在一起,客观上加剧了语言设计上的混乱。
从语言设计的角度,这里主要的本质差异是变量绑定所在的上下文不同。
Scheme 这样的 LISP 变体中,提供绑定的上下文数据结构称为环境(environment) ,传统上是表示变量绑定映射的关联列表(associated list) ,但实际上不要求明确内部的具体数据结构实现,只要提供能确定名称对应的实体的接口即可。
如 Scheme 这种顶级绑定放在顶级环境和 letrec* 这样依赖的 lambda 的局部环境(local environment) 是原则上不一样的,前者是“全局”,后者是(闭包的)“局部”。
(对应地,Python 里有不同的 namespace ; Python 的 environment 概念只是绑定的集合。)
但区分不同环境的这种设计并非原则上必要。全局环境在实现中的唯一本质区别保证开放(open) 即不总是需要具有源代码局部就能确定有限集合的绑定,而闭包里的局部环境则不是(只要不是打算让一个翻译单元支持翻译半个闭包的语法构造)。然而谁也没说闭包环境中枚举绑定的操作就该是 deterministic 的,只要名称解析能保证 total (可终止)。因为,枚举所有变量绑定根本就不是一个环境需要支持的常规操作——至少被静态绑定的闭包的变量就不是。如果需要反射变量本身的信息,则需要其它和实现相关的辅助操作,如 MIT Scheme 的 procedure-enviornment。这种操作的存在会暴露一些脱离传统闭包语义的实现细节,可能影响用户程序的可观察行为而使语法变换不能保证语义等价,反而会阻碍编译(所以往往只是作为调试辅助接口来用)。
另一方面,允许语言设计这样区分环境的现实是,这些语言并没有把环境暴露给用户操作的一等对象(first-class object) ,即提供一等环境(first-class environment) 。也正因为是这样,区分底层的环境不会让语言特性的复杂性失控(比如至少需要提供完全不同的两套 eval ……更别说如 R5RS 的残废 eval 本身就已经够让编译党抓狂了,以至于 Stalin 这样的实现停留在 R4RS )。但这是以用户程序的表达能力缺失为代价的。例如,Scheme 的宏不能直接和用户定义的环境交互,也无法作为一等对象像过程这样的普通函数一样被作为参数传递或作为返回值。当然,提供宏来变通这种设计,本身就是隐含了“编译优先”的思路了。这某种意义上是一种过度优化,因为一等环境原则上不阻碍对闭包代码进行编译——本质上这里只是允许“尽早”而非严格静态确定绑定(如 J.Shutt 在 Kernel 语言中提供的 $let-safe )。
至于 Python 连宏都没有……就略过算了。
为啥要多在乎某些语言设计者的肤浅的个别理解呢?
FrankHB
2019-12-09 15:58:24 +08:00
Typo:可变性绑定(non immutable binding) →非可变性绑定(immutable binding)
(草,错位了……)

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/627037

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX