
Python变量几乎每个人都是从a 1开始接触这门语言的可你有没有停下来想过这行代码在内存里到底做了什么我带新人做项目时发现一种普遍现象很多人写了半年 Python语法都熟但一遇到列表嵌套、默认参数、闭包循环这类场景就翻车。问题的根源几乎都和同一个概念有关——变量到底是什么。这篇文章就把 Python 变量从头到尾拆开讲一遍。从最基础的赋值机制到内存层面的引用关系再到作用域、深浅拷贝以及实战里的各种“雷区”该给的代码、该讲的原理、该避的坑都会讲到。不管你是刚入门的小白还是写过一段时间但经常被引用问题搞晕的开发者都可以从里面找到需要的东西。1. 变量机制拆解名字绑定而非盒子装值1.1 等号到底在做什么名称与对象的绑定关系很多教材喜欢用“盒子”来比喻变量说a 1是把 1 这个值装进了一个叫a的盒子里。这个比喻方便当然是方便但它是错误的而且会直接导致你对后续所有引用问题的理解偏差。Python 官方文档里对变量的定义是“引用”或者“绑定”。a 1这行代码实际上做了两件事先在内存里创建了一个整数对象1然后把名字a绑定到这个对象上。你可以把a理解成一张贴纸贴在了1这个对象身上。对象本身在内存里有一个固定地址a只是“指”向它。为什么这个区别很重要因为当你写下b a的时候Python 并不是把a的值复制了一份给b而是把a这张贴纸的内容“复印”了一份——它让b也指向了同一个对象。也就是说此时a和b并不是两个独立的变量而是同一个对象的两个名字。关于这一点我是强烈建议你用接下来要说的方式亲手验证一下。1.2 用 id() 亲眼看看内存变化光听理论没用建议你在自己的 Python 环境里敲一遍这段代码a 1 b a print(id(a), id(b)) # 输出4304017888 4304017888 不同机器地址不同但两者一定相同 b 2 print(id(a), id(b)) # 输出4304017888 4304018016 a 没变b 指向了全新的对象id()返回的是对象在内存中的唯一标识在 CPython 里就是对象的内存地址。第一次打印时a和b的地址相同说明它们指向同一个对象。第二次执行b 2后b的地址变了但a的地址一点没变。这解释了关键的一点b 2并没有“修改”原来那个1对象而是让b重新绑定到了另一个对象2上。这个机制的另外一个推论是当你给变量重新赋值时旧对象如果没有其他变量引用它就会被垃圾回收机制自动清理掉。所以 Python 里“删除变量”并不是真的把内存擦除而是断开变量和对象的绑定关系让对象等待回收。这也是为什么你有时候感觉 Python 操作超大列表时内存一直不降——如果还有其他引用存在解释器是不会回收的。1.3 为什么理解绑定关系是后续所有坑的基石说句掏心窝的话我见过太多开发者在“引用困境”里浪费时间比如用列表做函数默认参数导致数据越攒越多或者改了一个变量结果另一个也跟着变了。这一类问题的共同根源都是没有建立起“名字绑定对象”的心智模型。一旦你接受了“变量是贴纸而不是盒子”这个设定后面遇到的大部分问题都能自己推理出答案。为了加强这个认知我再给你说一个例子Python 中两个变量交换值a, b b, a这行代码之所以能工作原理就是先读取右侧的两个对象引用然后一次性做重新绑定。不需要临时变量更不会牵涉到底层的内存复制。你能轻松写出这行代码说明你已经下意识理解了绑定机制。2. 命名规则与类型体系基础约束决定代码质量2.1 合法命名规则与常见命名习惯Python 对变量名的约束不算多但基本规则还是要遵守。标识符只能由字母、数字和下划线组成而且不能以数字开头。下面的表列出了一些合法与非法命名的对比新手可以先看一眼省得到时候被解释器提示SyntaxError搞得一头雾水。合法命名非法命名非法原因user_name1username数字不能开头_private_varmy-variable连字符不属于合法字符username2class关键字不能用作变量名__innerfor关键字不能用作变量名命名习惯方面PEP 8 是最常被接受的风格指南。常规变量和函数名建议使用小写字母加下划线如total_count类名建议使用驼峰命名法如UserProfile。以单下划线开头的_name通常表示“内部使用”以双下划线开头的__name会触发解释器改名机制这个名字改写机制在后面讲类的时候会用到现在可以先留个印象。如果你刚起步我建议你从第一天就遵循这些惯例因为代码首先是写给同事和自己看的其次才是给解释器跑的。2.2 动态类型与强类型Python 的变量不锁死类型动态类型是 Python 的一项特色同一个变量名开始可以指向整数下一秒可以重新绑定到字符串。我把这部分单独拎出来讲是想顺便厘清一个常见误解——有人觉得“动态类型”就等于“类型不严谨”。item 42 print(type(item)) # class int item hello print(type(item)) # class str这里你需要留意的是type(item)每次查的都是item当前绑定的对象的类型而不是某种被限制的变量类型。虽然变量本身不锁死类型但 Python 在关键操作上其实特别“较真”比如字符串和整数直接相加会立刻报TypeError。这种“运行时动态但操作时严格”的行为业内叫做“强类型”。很多人刚接触时觉得这很烦等你把代码写到几万行以后就会理解这种严格恰恰是防止隐性 bug 的防火墙。2.3 类型转换的注意点别让 bool() 骗了你Python 提供了一系列常用的类型转换函数int()、float()、str()、list()、tuple()、set()、bool()。这些函数不会修改原来的对象而是基于原对象创建并返回新的对象。初学者在真值判断上特别容易丢分比如flag False if bool(flag): print(这段会打印出来)因为任何非空字符串转成布尔值都是True哪怕内容是False也一样。只有空字符串、整数0、浮点数0.0、空列表[]、空字典{}、None等“空”或“零”值转出来才是False。这个规律在条件判断时非常有用但也提醒你如果写程序时需要严格判断业务语义应该用flag False而不是bool(flag)。3. 可变与不可变对象一切引用问题的源头3.1 什么是可变对象、什么是不可变对象进入这个章节之前我先说一句扎心的实话如果你只能从这篇文章带走一个知识点那一定要是这个——对象的可变与不可变性。Python 里的内置对象可以按是否支持原地修改分为两类不可变对象数字int、float、complex、字符串str、元组tuple、布尔值bool、frozenset。可变对象列表list、字典dict、集合set、以及大部分自定义对象。所谓“不可变”是指对象创建之后它的内容无法被修改。你调用了字符串的各种方法比如replace()、upper()拿到的是新字符串对象原来的字符串保持原样。而列表调用append()或extend()则是在原地修改那个列表对象本身所有指向它的变量都会看到变化。# 不可变对象示范 name cat another name name name.upper() print(another) # 仍然是 cat不受影响 # 可变对象示范 lst1 [1, 2] lst2 lst1 lst1.append(3) print(lst2) # 变成 [1, 2, 3]因为 lst1 和 lst2 指向同一个列表这个区别用一句话总结就是不可变对象的“修改”本质上是绑定到新对象可变对象的“修改”是就地改动原有对象。3.2 赋值只给了你一把钥匙浅拷贝与深拷贝在写代码的过程中我们很多时候是想复制一个独立的对象而不是把两个变量重新绑定到同一个对象上。这时就要用到拷贝。Python 里的拷贝分成“浅拷贝”和“深拷贝”它们的差别我用一个现实中常见场景来类比浅拷贝像复印了一份目录目录本身是新的但目录里指向的各个文件还是源文件深拷贝则是把所有被引用的文件也一起复印了一份拿到的是完整独立的一份副本。import copy original [1, [2, 3]] shallow original.copy() # 浅拷贝等价于 list(original) deep copy.deepcopy(original) # 深拷贝 original[1].append(4) print(shallow) # [1, [2, 3, 4]] —— 内部列表还是同一个 print(deep) # [1, [2, 3]] —— 完全独立浅拷贝适合一维列表等简单场景如果对象内部还嵌套了可变结构你就得认真考虑深拷贝了。同样的逻辑也适用于字典和自定义对象copy模块里的copy()和deepcopy()就是你在正确场景里的标准工具。3.3 业务代码里的经典案例默认参数为啥会“粘”数据可变对象的特性在函数默认参数里的表现是 Python 开发者最容易踩的坑之一。我直接看代码def add_item(item, container[]): container.append(item) return container print(add_item(a)) # [a] print(add_item(b)) # [a, b]而不是 [b]这段代码的诡异之处在于函数定义时默认参数container[]只执行一次那个空列表对象会被所有未传入参数的游戏调用共享。第二次调用时container仍然指向那个已经被修改过的列表于是数据就“粘”上去了。修复方案也很简单——用不可变对象做默认参数函数内部再实例化可变对象def add_item(item, containerNone): if container is None: container [] container.append(item) return container这里None是不可变对象每次调用都会走一次container []自然就不会串数据了。记住这个套路以后本身也是一个通用设计思路默认参数尽量只绑定不可变对象。4. 作用域与生命周期变量也有自己的活动范围4.1 变量查找顺序LEGB 规则作用域是决定变量从哪来、在哪生效、什么时候失效的规则体系。Python 在查找一个名字时会按照 Local → Enclosing → Global → Built-in 的顺序依次找也就是先查局部作用域再向外层嵌套函数找再到模块级的全局作用域最后查内置作用域比如len、print这些名字。level global def outer(): level outer def inner(): level inner print(level) inner() print(level) outer() print(level)运行结果依次是inner、outer、global。这个输出本身并不难懂但很多混淆发生在“函数内部直接使用全局变量”这个场景。你如果在函数里写print(level)那么 Python 会沿 LEGB 规则找到全局的level但如果你在函数里写了level level 1解释器就会给当前作用域编译出一个局部变量level于是后续读取同一个名字时它只会读局部的这个——而它还没有被赋值过于是抛出UnboundLocalError。4.2 global 与 nonlocal跨作用域修改的正确姿势想要在函数内部修改全局变量你需要显式声明globalcount 0 def increment(): global count count 1 increment() increment() print(count) # 2如果要在嵌套函数里修改外层函数的变量则需要用nonlocaldef outer(): count 0 def inner(): nonlocal count count 1 inner() inner() return count print(outer()) # 2nonlocal和global的关键点是它们都是在告诉解释器“这个变量不是当前局部作用域新建的请去上层作用域找”。如果没有这个声明count 1里的count就会被当成当前函数的局部变量从而报错。很多新手觉得这两个关键字是 Java 之类语言里才需要的东西其实不是这是 Python 作用域规则的自然延伸用对了可以让跨函数状态管理变得非常清晰。4.3 闭包的陷阱循环里的 lambda 为什么会“集体迟到”闭包指的是一个内层函数引用了外层函数的变量并且这个内层函数可以在外层函数执行完毕之后仍然被调用。它的一个经典问题出现在循环里创建 lambda 的场合funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 输出2 2 2很多人期待输出0 1 2实际却得到2 2 2。原因是三个 lambda 函数引用的不是循环变量i的“快照”而是外层作用域中i这个变量的绑定。循环结束后i停留在2所以三个函数全都返回2。这就是所谓的“迟绑定”。解决办法有两个。最常用的是把i作为默认参数传入 lambda让它在定义时立即绑定funcs [] for i in range(3): funcs.append(lambda xi: x) for f in funcs: print(f()) # 输出0 1 2另一种方式是在外部额外的函数参数里固定住这个值比如写一个普通带参函数再调用它。实际开发里如果业务场景比较复杂我会更推荐后者因为它可读性更好只是代码量会稍多一点。5. 实战技巧从“会用一个功能”到“用得舒服”5.1 多元赋值与解包最舒服的赋值方式Python 的多元赋值可以说是我最常用的特性之一。它的完整形态应该在各种 Python 风格的代码里都见过从字典取键值对、从列表取首尾元素、返回多个值的函数接收结果都用得到。# 同时赋多个值 name, age, city 张三, 28, 上海 # 交换变量 a, b b, a # 剩余元素打包 head, *middle, tail list(range(5)) print(head, middle, tail) # 0 [1, 2, 3] 4 # 只取前两个 x, y, _ (10, 20, 30)解包时注意两侧的元素数量要对上否则会抛出ValueError。不过你完全可以用星号表达式来“接住”多余部分这样代码健壮性会好很多。星号解包在 Python 3 里也能用在函数参数上比如def func(*args)内部拿到的是一个元组这让函数的参数数量可以非常灵活。5.2 缓存与驻留机制别用 is 判断值相同Python 在底层做了一些性能优化导致某些不可变对象会被“复用”。最典型的就是小整数缓存在 CPython 实现中-5到256之间的整数是预先创建好的无论你在代码里写多少次a 100; b 100它们实际指向的都是同一块内存。而超出这个范围的整数通常会临时创建新对象。a 100 b 100 print(a is b) # True因为都在缓存范围 c 1000 d 1000 print(c is d) # 可能是 False具体结果取决于实现和运行环境字符串也有类似机制比如短字符串、符合标识符规则的字符串也可能会被驻留intern。所以实践中有一条铁律判断值是否相等用判断两个变量是否指向同一个对象才用is。你写业务代码时绝对不能依赖字符串驻留这种实现细节来判断相等性因为它很容易在不同环境、不同版本中表现不一样。5.3 调试变量的几个高效招数我平时调试 Python 变量问题时会结合使用几个工具。print()当然是最直接的但变量多了以后容易乱。比较实用的是id()加type()的组合能快速看清对象身份和类型。调试局部变量与全局变量时可以随手打印locals()和globals()这两个字典字面意思就是当前作用域内的所有变量名——值对。你能一眼看出某个变量是不是在预期的作用域里从而排查类似UnboundLocalError的诡异问题。还有一个比较深度的工具是sys.getrefcount(obj)它会返回某个对象被引用的次数。注意这个函数本身也会临时增加一次引用所以显示的数值一般比真实值多 1。排除循环引用干扰后它可以帮助你判断一个对象“到底有没有被真正释放”排查内存上涨问题时非常有用。6. 常见问题排查实录踩过的坑都在这了6.1 高频问题速查表问题现象根本原因解决办法函数里对全局变量赋值报UnboundLocalError解释器把函数内同名变量当作局部变量在函数内声明global函数默认参数是列表多次调用数据累积默认参数对象在函数定义时创建并被共享默认参数改为None函数体内部再创建修改list1后list2也跟着变两个变量绑定到同一个列表对象需要独立数据时用copy()或deepcopy()循环里的 lambda 总是返回最后一个值闭包捕获变量是引用而不是创建时的值lambda 用默认参数提前绑定或用额外函数包装字符串拼接时is判断有时对有时错字符串驻留机制依赖实现和运行环境值比较一律用别用is删除大列表后内存没释放还有别的变量或数据结构保留对列表的引用检查所有引用点必要时显式del并将容器里的引用清空这个表本身就能当一张“避坑速查卡”在面试、写代码或做 code review 时都管用。6.2 一次真实排查闭包循环里的 lambda我之前给一个项目做代码走查发现有一段从数据表动态生成筛选函数的代码样式很像前面的funcs.append(lambda...)例子。由于那批函数是在循环里生成的线上跑到后面阶段时所有筛选函数都用了同一个阈值导致一批查询结果不稳定。排查过程大致是这样我先确认生成筛选函数时没有立即传入参数然后在函数外部打印f.__closure__这个属性可以看到闭包引用的外部变量。结果确实发现所有函数都指向同一个i变量单元。修起来也很简单在 lambda 的定义里加上resultthreshold这种默认参数让每个函数快照住当时的阈值。重新部署之后数据就恢复正常了。这类问题最难的地方不是看不懂解法而是想不到自己写的代码正在“引用共享”。一旦脑子里有了“变量绑定对象”的模型这个坑就变得非常容易识别和绕开。收尾关于变量我的两点经验总结做了这么多年项目我在变量这个问题上被折磨过很多次现在已经养成两个习惯第一凡是看到变量之间互相赋值先问一句“这个对象是否可变”再看操作是原地修改还是重新绑定第二凡是函数默认参数可能传入可变对象的场景一律先写成None哨兵值再在函数内部初始化。最后一个实操小技巧分享给大家如果你在排查某个变量相关的问题时思路混乱先把id()加进 print 里看关键节点对象的地址变化。地址如果没变说明是原地修改地址如果变了说明发生了重新绑定。这一步能帮你把绝大多数变量的疑难杂症快速归因省下大量调试时间。掌握好变量机制很多看似花哨的高级特性——装饰器、闭包、生成器——学起来都会轻松得多。