尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python变量不是盒子:对象引用与可变性全解析

Python变量不是盒子:对象引用与可变性全解析 1. 变量不是盒子是贴在对象上的名字1.1 新同事的杯子比喻让我决定写这篇笔记前阵子新同事入职看到我在终端敲 Python凑过来问了一句变量是不是跟杯子一样往里面倒水就存水倒可乐就存可乐我说你试试这段代码a [1, 2, 3] b a b.append(4) print(a) # 输出 [1, 2, 3, 4]他愣住了杯子理论完全解释不了这个现象。如果b是另一个杯子往b里加东西为什么a也跟着变很多 Python 初学者甚至写了几年 Python 的人对变量的理解都停留在纸面上。这事其实不难一句话就能说清变量不是装数据的容器而是贴在对象上的名字或者叫标签。b a不是复制了一份数据放到新杯子里而是同一个对象身上多贴了一个名字。既然是同一个对象你通过b去修改它a看到的自然也是改完的结果。这篇笔记我就从变量讲起把 Python 里数据类型的分工、可变与不可变对象的区别、类型转换的坑以及实际项目里怎么写才能少踩雷一次性讲明白。内容不算新但都是我这些年带人和自己写代码时反复遇到、值得记录的东西。1.2 用 id() 和 type() 验证变量的真实身份要真正理解 Python 的变量与其背定义不如动手验证。Python 里有两个内置函数专门用来查看对象的身份证明id(obj)返回对象的唯一标识。在 CPython 里这个值可以理解为对象在内存中的地址。type(obj)返回对象的类型也就是这个对象是谁家的孩子。a [1, 2, 3] b a print(id(a)) # 2468080927680每次运行可能不同 print(id(b)) # 2468080927680和 a 完全一样 print(a is b) # Trueis 判断的就是是不是同一个对象看到没a和b的id一样说明它们指向的是同一个list对象。那什么时候id会变呢当你给变量重新赋值的时候a [1, 2, 3] print(id(a)) # 0x1f1d8...list 对象 a [1, 2, 3] print(id(a)) # 0x1f1d9...新创建的另一个 list 对象 x 100 print(id(x)) # 0x123... x 100 print(id(x)) # 注意结果可能和上一行相同第一块代码里两次[1, 2, 3]创建了两个不同的列表对象所以id不同。第二块代码里小整数100是解释器提前缓存好的所以两次赋值的id大概率相同。这个区别背后正好牵着 Python 的另一个重要话题——哪些对象会被缓存和复用后面讲类型的时候再展开。1.3 为什么说 Python 变量的设计反直觉如果你学过 C 语言对比会更明显。C 语言里变量是一块真实的内存空间有类型、有固定大小你把一个int变量理解成装了数字的盒子完全没问题。但 Python 不是这样的。Python 里的对象数字、字符串、列表、实例持有真实的类型信息和内存空间变量只是一个指向对象的指针或者说引用。赋值语句a 10的本质是先在内存里创建10这个整数对象然后把名字a贴到它身上。改值的时候比如a 11并不是把10这个对象改成11整数对象本身是永远不能变的而是另外创建了一个11对象再把a这张标签撕下来贴到11上面。a 10 print(id(a)) # 某个地址 A a 11 print(id(a)) # 另一个地址 B这也解释了为什么新手经常会觉得 Python 慢——大量对象的创建和回收是非常频繁的。理解了这一点你对 Python 的很多诡异行为都能找到根源。比如b a之后两个名字共享一个对象再比如把变量传进函数函数内修改它外面的变量有时候变、有时候不变区别就在于你操作的是可变对象还是不可变对象这个我在第 3 章仔细讲。2. Python 里的核心数据类型一份够用的清单2.1 数字家族int、float、complex 和伪装者boolPython 3 里的整数int是不限大小的。你在别的语言里写int可能溢出Python 不会只要内存够10**100都能算这背后是大整数支持别管实现细节先把整数随便造记在脑子里。浮点数float对应的是 C 语言的double双精度 64 位。它的问题在于二进制没法精确表示0.1所以才有那句经典的0.1 0.2 ! 0.3。这不是 Python 的 bug而是 IEEE 754 浮点数标准的固有限制所有语言都一样。复数complex很少用到但它确实存在比如1 2j这样的字面量。写信号处理、电路仿真的时候会碰到平时可以先不管。最容易被忽略的是bool。True和False其实是int的子类在 Python 里True的算数值就是1False是0。所以下面这段代码是合法的print(True True) # 2 print(False * 10) # 0这个设计有时候会让人很困惑。比如你写sum([True, False, True])结果是2。在某些场景下这很好用比如统计列表中满足条件的元素个数。但它也能制造麻烦比如判断两个值是否相等时1 True的返回值是True新手很容易被绕晕。2.2 字符串 str不可变但支持花式操作字符串大概是 Python 里最常用的类型了。两个关键点第一字符串是不可变的。你不能通过s[0] a去修改某个字符。所有看起来像修改的操作比如s.upper()、s.replace(...)、字符串拼接s x实际都是创建了一个新字符串对象。频繁做大量拼接时可以用.join(list_of_strings)效率会高很多。第二字符串和字节串不是一回事。str表示 Unicode 字符序列适合人类阅读bytes表示字节序列适合网络传输和文件读写。你从文件里读出来、从 socket 里接到的数据默认都是bytes要先decode()成str才能做字符串操作。s 你好 print(len(s)) # 2中文按字符算 print(s.encode()) # b\xe4\xbd\xa0\xe5\xa5\xbd print(s.encode().decode()) # 你好2.3 容器类型list、tuple、dict、set 到底怎么选这是初学者最容易纠结的地方但其实记忆量并不大。list可变、有序、可重复。日常 90% 的场景用它。tuple不可变、有序、可重复。适合表示结构固定的数据比如坐标(x, y)或者作为 dict 的 key。dict键值对键必须可哈希。查询速度极快是 Python 里的王牌数据结构。set无序、不重复。适合去重、交集、并集等集合运算。alist [1, 2, 3] atuple (1, 2, 3) adict {name: python, age: 30} aset {1, 2, 3} print(type(alist)) # class list print(type(atuple)) # class tuple print(type(adict)) # class dict print(type(aset)) # class set判断该用哪种时先问自己三个问题这个数据要不要改不需要改就选tuple要不要通过名字取数据要就选dict要不要去重或做集合运算要就选set实在不确定就先用list后面有性能问题再优化。2.4 别忘了 NoneType那个无处不在的没有None是 Python 里挺特别的一个值它表达的是这里什么都没有。它不代表0不代表空字符串也不代表False它是它自己——一个NoneType类型的单例对象。x None print(type(x)) # class NoneType print(x False) # False print(x 0) # False print(x is None) # True函数如果没有显式return默认返回None。变量初始化时也可以用它占位比如result None后面再判断if result is not None来决定是否继续处理。要注意跟False区分开虽然它们都能用来表示没找到之类的语义但在类型上完全不同。3. 可变与不可变理解 Python 内存模型的钥匙3.1 共享引用为什么 b.append(4) 会改变 a先看一段对比代码这是理解可变对象不可变对象最好的练习题# 不可变类型的表现 x 10 y x y 20 print(x) # 10x 没有变 # 可变类型的表现 m [1, 2, 3] n m n.append(4) print(m) # [1, 2, 3, 4]m 跟着变了为什么会这样因为 x 和 y 指向的是同一个整数对象10。当执行y 20时并不会修改那个10对象而是创建了一个新的整数对象20再把y这个标签撕下来贴到20上。整个过程跟x没关系所以x还是 10。但 m 和 n 指向的是同一个列表对象。append(4)是在原来的列表对象上做修改这个列表对象本身从来没有换过所以通过 m 和 n 看到的都是修改后的结果。一句话总结不可变类型的任何修改实际都是创建新对象并重新绑定变量可变类型的修改是在原对象上动手。3.2 函数传参传的是引用不是副本这是面试里最常考的 Python 陷阱之一也是个高频坑。Python 函数参数传递官方说法叫传对象引用。什么意思def modify(lst): lst.append(100) data [1, 2, 3] modify(data) print(data) # [1, 2, 3, 100]函数里改了外面就变了 def replace(lst): lst [100, 200] data [1, 2, 3] replace(data) print(data) # [1, 2, 3]直接赋值不会影响外面区别非常清楚append是在原列表上操作所以外部能感知到lst [100, 200]只是把参数lst这个局部名字重新指向了一个新对象对原来的对象没有影响。很多人误以为 Python 函数传参是按值传递还是按引用传递其实答案介乎两者之间——传的是对象的引用。你要做的是分清函数内部对传入对象做的是原地修改还是重新绑定。如果希望函数内外的数据互不影响就得主动复制。浅拷贝用copy.copy()或切片list(data)深拷贝用copy.deepcopy()。嵌套列表里还有内容只用浅拷贝可能会导致内层还是共享的这点要特别注意。3.3 可变对象做默认参数经典翻车现场这个坑我见过太多次了。看下面的代码def add_item(item, acc[]): acc.append(item) return acc print(add_item(1)) # [1] print(add_item(2)) # [1, 2] print(add_item(3)) # [1, 2, 3] 为什么第二次调用时acc还保留着第一次的结果因为默认参数[]在函数定义时只创建一次后续每次调用如果没传acc用的都是同一个列表对象。连续调用三次实际上是把 1、2、3 都追加进了同一个列表。正确的写法是def add_item(item, accNone): if acc is None: acc [] acc.append(item) return acc这种写法你会在很多第三方库里看到原因就在这。道理很简单不要把可变对象放在默认参数位置上用None做占位在函数体里创建新列表。4. 类型转换显式、隐式与那些容易翻车的边界4.1 显式转换的规则与常见的报错int()、float()、str()、list()、tuple()、dict()、set()这些内置函数就是 Python 的显式类型转换工具。用起来不算难但有几条边界值得记牢。print(int(123)) # 123 print(int(3.99)) # 3对浮点数是直接截断不是四舍五入 print(int(3.14)) # ValueError: invalid literal print(float(3.14)) # 3.14 print(float(1e3)) # 1000.0 print(str(123)) # 123最容易踩的是int(3.14)字符串里带小数点就是不行必须先转成 float 再转成 int。另一个容易踩的是int()能接收字符串但字符串不能是 3.14 这种必须是纯数字字符串。容器类型之间互转也很常用list((1, 2, 3)) # [1, 2, 3] tuple([1, 2, 3]) # (1, 2, 3) set([1, 2, 2, 3]) # {1, 2, 3}顺带去重 list(hello) # [h, e, l, l, o] dict([(a, 1), (b, 2)]) # {a: 1, b: 2}4.2 布尔转换记住这串假值就够了Python 里每个对象都能直接转成布尔值。规则可以精简成一条记忆清单下面这些值在布尔上下文中是FalseFalse0和0.0空字符串[]、()、{}、set()None其他所有值都是True。也就是说空容器变成假值哪怕里面只有 0 个元素。这个规则让代码可以写得很简洁items [] if not items: print(列表是空的) name None if name is None: print(没有名字)注意判断列表是否为空用if not items:是 Python 里约定俗成的写法比if len(items) 0:更地道。但要注意0和False在比较时可能产生干扰比如if 0 False结果是 True判断参数类型时别拿它们混用。4.3 隐式转换数字的自动升级Python 在运算符两侧类型不一致且可以隐式转换时会自动帮你转换。最常见的是数值类型之间的隐式转换print(1 2.5) # 3.5int 自动转成 float print(2 / 4) # 0.5两个 int 相除结果是 float print(True 1) # 2bool 自动转成 int规则是bool-int-float-complex方向只能从低级往高级转反过来不行。比如1 2.5会先把1转成1.0再做加法结果3.5。但字符串可不能自动转数字print(1 1) # TypeError: can only concatenate str这种报错其实是好事它避免了 JavaScript 里1 1得到字符串11之类的隐式陷阱。Python 的设计哲学是显式优于隐式你写代码时就得自己处理好转换。一个非常常见的场景input()返回的一定是字符串做算术前必须先int()。age input(请输入年龄) # 用户输入 25 print(age 1) # TypeError print(int(age) 1) # 26正确做法4.4 和 is一个比的是值一个比的是身份很多新手在判断变量是否相等时会分不清和is。规则很简单比较的是值是否相等会调用对象的__eq__方法。is比较的是是不是同一个对象也就是id()是否相同。a [1, 2, 3] b [1, 2, 3] print(a b) # True内容一样 print(a is b) # False两个不同的列表对象 c a print(c is a) # True同一个对象那为什么初学者很容易踩到is的坑因为 Python 对小整数通常是 -5 到 256和某些字符串做了缓存导致内容一样的整数变量is的结果经常是True看起来很像同一对象。a 256 b 256 print(a is b) # True小整数被缓存复用 c 257 d 257 print(c is d) # False超出缓存范围各自创建对象实际项目里判断某个变量是否为None时应该用is None而不是 None这是行业惯例。但判断两个变量的值是否一样时永远用。别拿is去比数字和字符串因为缓存行为在不同实现版本上可能不一样很容易写出在自己机器上能跑到别人机器上报错的代码。5. 建立类型意识从变量命名到实际项目的避坑经验5.1 命名规范变变量名是你的第一道文档理解了变量和类型接下来要解决的是怎么写才不容易出问题。变量命名是 Python 代码可读性的第一关。Python 官方规范 PEP 8 给出了一套约定俗成的标准普通变量、函数名采用snake_case全小写加下划线比如user_name、is_valid。类名采用CapWords比如UserProfile。常量全大写加下划线比如MAX_RETRIES、DEFAULT_TIMEOUT。私有变量约定俗成以单下划线开头比如_internal避免和 Python 关键字冲突时加单下划线后缀比如class_。命名本身也是一种类型提示。变量叫user_list你就知道它应该是 list叫user_dict你就有预期它是 dict。这不是语法约束但对降低认知负担极其有效。不要做的一件很坑的事用同一个变量名一会儿存字符串、一会儿存整数、一会儿存列表。Python 虽然允许但这会让后续维护你代码的人崩溃。类型不一致的变量名是很多线上 bug 的源头。5.2 用类型注解让代码自己说明自己Python 3.6 之后支持类型注解Type Hints。它不会改变程序运行时的行为但能通过静态检查工具提前发现类型不匹配的问题。举个例子def calculate_total(prices: list[float], discount: float 0.0) - float: 计算总价支持折扣。 total sum(prices) return total * (1 - discount) def get_user_info(user_id: int) - dict[str, str]: ...prices: list[float]表示参数应该是一个元素为浮点数的列表- float表示函数返回浮点数。现代编辑器VS Code、PyCharm能根据这些注解给出智能提示配合mypy做静态检查可以在运行之前就揪出一大批类型错误。类型注解不是强制要求按项目情况决定用多细。写函数的时候给参数和返回值加上注解的成本很低但对后来者包括三个月后的你自己帮助巨大。有人觉得 Python 是动态类型语言加注解多此一举但动态类型是 Python 的灵活性类型注解是让这份灵活性可控的工具两者并不矛盾。5.3 几个我建议你提前知道的易错场景说了这么多理论最后分享几个我在项目中踩过、也看别人反复踩的坑。场景一dict 的 key 用了不可哈希类型data {} key [1, 2] data[key] value # TypeError: unhashable type: listdict 的 key 必须是可哈希的也就是不可变类型。tuple可以list不行。防呆做法是可以先转成tuple再作为 key。场景二混合类型的容器导致排序失败data [apple, 1, banana] data.sort() # TypeError: not supported between instances of int and str列表里混着不同类型排序时比较运算符直接报错。Python 是动态类型不代表你能随心所欲混装。处理异构数据时想清楚用list还是dict别把所有东西塞进同一个列表里。场景三误把字符串当数字做减法a 500 b 200 print(a - b) # TypeError: unsupported operand type(s) for -: str and str同样的问题换成了字符串就报错。凡是input()读进来的用户输入一开始都是字符串想做计算必须先做类型转换。场景四浮点数比较不要用 print(0.1 0.2 0.3) # False因为浮点数的二进制表示误差永远不要用比较浮点数。要么用abs(a - b) 1e-9这样的阈值判断要么用math.isclose()函数。这些场景单独看都很简单但恰恰是这些简单的地方最容易让人在深夜排查几个小时的 bug。先说这么多。变量和数据类型是 Python 的第一课但绝不是看过就会的内容。带新人的时候我总建议他们别急着往下学先花几天把 id()、type()、可变对象、不可变对象这些概念玩熟。玩熟之后后面学函数、学类、学装饰器都会轻松很多。你要是现在正卡在某个诡异的Python 变量变了/没变问题里回头翻翻这篇文章多半能找到答案。
返回列表