为什么Python没有块级作用域?
免费编程软件「pythonpycharm」链接https://pan.quark.cn/s/48a86be2fdc0一个从JavaScript转Python的朋友去年有个朋友从前端转后端开始学Python。他写了一段很简单的代码for i in range(5): message f当前数字是{i} print(message) # 最后一行想打印最后一次的message在JavaScript里这段代码会报错——因为message定义在for循环的块里面外面访问不到。但Python输出了当前数字是4他愣住了“等等message不是在循环里面定义的吗为什么外面还能用”我告诉他“Python里没有块级作用域。for、if、while这些代码块不会创造新的作用域。”他更困惑了“为什么其他语言都有啊这样不会造成混乱吗”这个问题问得特别好。今天我们就来聊聊Python为什么没有块级作用域先搞清楚什么是块级作用域“块”block通常指一对花括号{}括起来的代码区域。在C、Java、JavaScriptES6之后用let这些语言里你在一个块里面定义的变量只在这个块里有效。看个JavaScript的例子if (true) { let x 10; // 用let声明块级作用域 console.log(x); // 10 } console.log(x); // 报错x is not definedx只在if的那个花括号里活着出来就没了。同样for循环也是for (let i 0; i 3; i) { let temp i * 2; } console.log(temp); // 报错temp is not defined这就是块级作用域每个{}都是一个独立的小房间房间里的变量出不去。很多程序员习惯了这种规则转到Python时就会踩坑。Python的实际情况只有函数能创造新作用域在Python里能创造新作用域的只有一种东西函数。def定义的函数、lambda表达式都会创造一个新的局部作用域。但if、for、while、with、try/except这些都不会。验证一下# if块 if True: a 100 print(a) # 100a还在 # for循环 for i in range(3): b i * 2 print(b) # 4b还在最后一次循环的值 # while循环 count 0 while count 3: c count * 10 count 1 print(c) # 20c还在 # with块 with open(test.txt, w) as f: d 写入的内容 print(d) # 写入的内容d还在 # try/except块 try: e 1 / 1 except: e 0 print(e) # 1.0e还在所有在代码块里创建的变量都会“泄漏”到外面。唯一会让变量“消失”的是函数def test(): f 500 test() print(f) # 报错NameError: name f is not defined这就是Python和那些有块级作用域的语言最大的区别。为什么Python要这样设计这个问题没有官方文档直接回答过但我们可以从Python的设计哲学和历史里找到答案。理由一简单Python的作者Guido van Rossum在设计这门语言时有一个核心原则简单明了减少规则。块级作用域意味着要增加一套规则哪些代码块创造作用域哪些不创造如果块嵌套了怎么办而且如果Python支持块级作用域那就需要有区分变量的关键字——就像JavaScript的var和let。Python的设计哲学是“一种事情最好只有一种做法”不想引入这么多关键字。Guido本人的一句话很能说明问题A block is a piece of Python program text that is executed as a unit. The following are blocks: a module, a function body, and a class definition. ... Not blocks: a conditional block, a loop block.翻译过来就是只有模块、函数体、类定义是块。条件语句块、循环块不是。这个选择让Python的规则变得简单记住一点就够了——只有函数创造作用域。理由二Python是动态的Python是动态语言变量不需要事先声明。你在任何地方写x 10Python就在当前作用域里创建变量x。如果引入块级作用域这个简单的规则就复杂了在一个块里写x 10是创建块级变量还是往外层找JavaScript的var就因为这个问题搞得一团糟直到ES6引入了let和const才解决。Python不想走这条路。理由三实际影响不大Guido可能认为缺少块级作用域在实际编程中并不会造成大问题。大多数情况下你希望在循环里用的临时变量循环结束后本来也不需要了。Python的做法只是让它们多活了一会儿并不会导致程序错误——只要你注意不要重用变量名就行。而且Python通过函数提供了足够的作用域隔离手段。如果一个循环太复杂应该把它拆成函数。这是更好的代码组织方式。但这个设计确实带来了问题当然没有块级作用域也不是完美的。有几个常见的坑每个Python新手几乎都会踩到。坑1循环变量泄漏最经典的例子for i in range(10): # 做一些事情 pass print(i) # 9i还在你可能以为循环结束后i就消失了但它没有。如果你后面不小心又用到了i可能会得到意外的值。更危险的是这个items [1, 2, 3] for item in items: if item 2: break print(item) # 2item还在你本来想检查列表里有没有2然后想用item做别的事情但item保留的是最后一个被赋值的元素。坑2列表推导式里的变量泄漏Python 2这个问题在Python 2里非常经典# Python 2代码 x 10 squares [x**2 for x in range(5)] print(x) # 4外面的x被覆盖了列表推导式里的循环变量x泄漏到了外部覆盖了原来的x。这是个著名的设计失误。Python 3修复了这个问题列表推导式有自己的作用域了。但注意字典推导式、集合推导式也一样。# Python 3 x 10 squares [x**2 for x in range(5)] print(x) # 10没问题了坑3意外重用变量名# 想根据条件设置不同的值 if user_is_admin: status 管理员 else: status 普通用户 # 后面又用status做别的事情 status check_user_status(user_id) # 覆盖了上面的值因为if块没有作用域status从一开始就是当前函数的局部变量。如果你不小心重用了这个名字就会覆盖。坑4lambda函数里的坑这个坑和块级作用域有点关系但更复杂funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 2 2 2不是0 1 2很多人期望输出0 1 2但实际都是2。原因所有lambda函数都引用了同一个变量i而循环结束后i的值是2。当lambda被调用时它使用的是i的当前值。如果Python有块级作用域每次迭代创建一个新的i这个问题就不会出现。解决方案让每个lambda捕获当前i的值funcs [] for i in range(3): funcs.append(lambda ii: i) # 把i作为默认参数固定下来 for f in funcs: print(f()) # 0 1 2其他语言是怎么做的对比一下其他语言的设计能更好地理解Python的选择。C / Java严格的块级作用域// C语言 for (int i 0; i 10; i) { int temp i * 2; } // i 和 temp 在这里都不可见花括号里的变量只在花括号里有效。简单、严格、安全。JavaScript混乱到清晰JavaScript早期只有var它没有块级作用域if (true) { var x 10; } console.log(x); // 10x泄漏了这导致了很多bug。直到ES6引入了let和const才有了真正的块级作用域。if (true) { let y 10; } console.log(y); // 报错y不在这个作用域里Ruby和Python类似# Ruby if true x 10 end puts x # 10x还在Ruby也没有块级作用域除非你用特定的语法。这点和Python很像。Go有块级作用域但很灵活Go语言有块级作用域花括号{}创建新的作用域。if true { x : 10 } fmt.Println(x) // 编译错误x未定义Go不仅支持块级作用域还通过包package控制可见性。如果没有块级作用域怎么写出干净的代码Python虽然没有块级作用域但我们有一些好习惯可以让代码更清晰、更安全。方法1用函数隔离如果一段逻辑比较复杂把它放进一个函数里。def process_items(items): result [] for item in items: temp item * 2 # temp只在函数里有效 result.append(temp) return result函数是Python唯一的作用域边界用它来隔离临时变量。方法2循环后删除临时变量如果你担心循环变量泄漏可以手动删除它for i in range(10): # 处理逻辑 pass del i # 删除i后面再用就会报错不过这种做法不常见通常不会造成问题。方法3用小函数替代复杂循环如果你的循环很长或者有嵌套循环考虑拆成小函数# 不推荐长循环里有很多临时变量 for i in range(100): temp1 i * 2 temp2 temp1 ** 2 # 很多行... # 推荐把逻辑抽成函数 def process_single_item(i): temp1 i * 2 temp2 temp1 ** 2 return temp2 for i in range(100): result process_single_item(i)方法4写清晰的名字避免重用最简单的办法不要在同一个函数里重用同一个变量名做不同的事情。# 不推荐 status active # ... 50行代码 ... status check_user_status() # 复用status但意思变了 # 推荐 initial_status active # ... 50行代码 ... current_status check_user_status()如果Python加入块级作用域会怎样想象一下。假设Python的下一个版本突然支持了块级作用域用let关键字if condition: let x 10 # 块级变量 print(x) # 10 print(x) # 报错会发生什么现有的代码会大量报错因为很多人依赖变量泄漏的行为Python需要引入新关键字let或block语言变得更复杂初学者要学习两套规则这显然不符合Python“渐进式改进”和“不破坏已有代码”的原则。所以Python不太可能加入块级作用域。Guido在多个场合提到过保持简单是Python的核心价值。回到开头的故事我那个从JavaScript转过来的朋友后来慢慢适应了Python的方式。他说“习惯之后其实觉得也没什么。反正写代码的时候知道函数才是作用域边界。循环里的变量就让它去吧只要我注意命名不会出问题。”他说得对。Python没有块级作用域这不叫缺陷这叫设计选择。每种语言都有自己的特点和哲学。JavaScript的设计者觉得块级作用域很重要所以加进了语言。Python的设计者觉得保持简单更重要所以选择了不加入。作为开发者我们的任务是理解自己用的语言是怎么设计的然后按它的方式来写代码。一张表总结场景是否创造新作用域变量是否泄漏函数def✅ 是函数内的变量外面访问不到类class✅ 是类内部的变量通过类名.变量访问if条件块❌ 否块内变量会泄漏到外层for循环块❌ 否循环变量会泄漏while循环块❌ 否块内变量会泄漏with上下文块❌ 否块内变量会泄漏try/except块❌ 否块内变量会泄漏列表推导式Python 3✅ 是有自己的作用域不会泄漏列表推导式Python 2❌ 否会泄漏已修复最后一句Python没有块级作用域。记住这一条就够了只有函数才能创造新天地其他地方都是共享的。这个特点让Python变得简单、直观也带来了一些小坑。理解了它你就能写出更地道的Python代码。