资讯动态

Python函数中列表参数修改与作用机制全解

发布时间:2026/9/9 9:29:26 来源:尧图企业网站定制
先讲个真实经历。之前维护一个数据处理脚本跑批到一半发现某个列表里的数据莫名其妙被改了不是少了元素就是顺序变了而且诡异的是那段代码看起来只是“读”了一下列表而已。最后从下午查到晚上终于定位到一个工具函数——它接收列表参数后在函数内部顺手做了sort()结果把调用方手里的原始列表也给排了序。那次之后我就把“Python函数中列表参数修改与作用”这个机制彻底啃透了。这个主题看起来简单但如果你去面试、去带新人、去review代码会发现在这里翻车的人特别多。很多人写了好几年 Python遇到def foo(lst[])这种默认参数仍然会踩坑遇到lst lst [x]和lst [x]的区别也说不清楚。这篇内容我从底层逻辑讲到实战场景再讲到调试技巧争取让所有人都能真正理解列表参数在什么情况下会被修改什么情况下不会被修改以及我们应该如何利用这一点写出更稳的代码。1. 参数传递的底层逻辑Python到底传了什么1.1 用id()验证变量只是对象的标签先解决一个最基础的问题Python函数的参数到底是值传递还是引用传递我见过太多人在这上面争论其实都不准确。准确的说法是“对象引用传递”或者说“传的是对象引用的值”。什么意思在Python里变量本身不存储数据它只是一个名字指向内存中的某个对象。你可以把变量想象成贴在对象上的标签而不是装数据的盒子。当我们给一个变量赋值时不是把数据拷进变量里而是把变量这个标签贴到了数据对象上。a [1, 2, 3] b a print(id(a)) # 举例输出140214248897856 print(id(b)) # 举例输出140214248897856 print(a is b) # True这里b a并没有创建新的列表只是把b这个标签也贴到了a指向的那个列表对象上。你可以用id()函数查看对象的内存地址标识两个变量打印出来的 id 完全相同说明它们指向同一个对象。函数参数传递也一样。调用func(lst)时形参lst拿到的是实参对象的引用。也就是说形参和实参都是贴在同一个对象上的标签。只要不重新绑定形参那么通过形参去操作对象时操作的仍然是调用方传入的那个对象本体。理解这一点后面所有的问题都迎刃而解。1.2 可变与不可变列表为什么特殊Python的对象分为可变对象和不可变对象。列表、字典、集合属于可变对象意味着对象内部的元素可以被就地修改而不需要创建一个新对象。整数、字符串、元组属于不可变对象任何“修改”操作都会产生一个新的对象。def change_int(x): x x 10 print(函数内部 x:, x, id:, id(x)) def change_list(lst): lst.append(10) print(函数内部 lst:, lst, id:, id(lst)) num 5 change_int(num) print(函数外部 num:, num, id:, id(num)) data [1, 2, 3] change_list(data) print(函数外部 data:, data, id:, id(data))运行结果是这样的函数内部 x: 15 id: 140214248123456 函数外部 num: 5 id: 140214248123386 函数内部 lst: [1, 2, 3, 10] id: 140214248897856 函数外部 data: [1, 2, 3, 10] id: 140214248897856注意到区别了吗整数参数x在函数内部执行x 10后id发生了变化说明它指向了一个新的对象外部变量num完全不受影响。而列表参数lst执行append后id没变外部变量data也变了。这是因为append是就地修改对象内容并没有创建新对象。所以列表参数之所以特殊根源在于它是可变对象函数内部通过形参就地修改对象时影响会沿着共享的引用传播到函数外部。这正是标题里“列表参数修改”这个现象的最底层原因。2. 列表参数被修改的几种实际效果2.1 原地修改方法append、extend、sort、pop、removePython列表自带一批原地修改方法典型的有append、extend、insert、pop、remove、sort、reverse。这些方法执行后直接改变列表对象内部的状态不返回新列表或返回None。在函数参数中调用它们原列表必然被修改。def add_item(item, target): target.append(item) print(函数内部 target:, target) orders [A001, A002] add_item(A003, orders) print(函数外部 orders:, orders)输出结果函数内部 target: [A001, A002, A003] 函数外部 orders: [A001, A002, A003]再比如排序def sort_scores(scores): scores.sort(reverseTrue) scores [78, 90, 65, 88] sort_scores(scores) print(scores) # 输出 [90, 88, 78, 65]这类方法的特点很鲜明它们直接操作列表对象里的元素序列不会让形参重新指向新对象所以调用方手里的列表自然会被改掉。如果你希望函数内部排序但不影响外部列表就必须先用切片或copy()做一个副本再排序或者用sorted(lst)而不是lst.sort()。2.2 重新赋值与切片赋值的巨大差别真正让新手翻车的是下面这种写法。你觉得lst lst [x]会修改外部列表吗不会。def add_tail(item, target): target target [item] print(函数内部 target:, target) orders [A001, A002] add_tail(A003, orders) print(函数外部 orders:, orders)输出结果函数内部 target: [A001, A002, A003] 函数外部 orders: [A001, A002]这里的target target [item]会先创建一个新的列表对象然后把这个新对象绑定给局部变量target。从这一刻起函数内部的target和函数外部的orders已经不再指向同一个对象了。外部的orders自然一点没变。但如果你改用切片赋值target[:] target [item]情况就完全不同因为切片赋值是“保留原对象把原对象的内容整体替换”def add_tail_slice(item, target): target[:] target [item] print(函数内部 target:, target) orders [A001, A002] add_tail_slice(A003, orders) print(函数外部 orders:, orders)输出结果函数内部 target: [A001, A002, A003] 函数外部 orders: [A001, A002, A003]外部列表被改了。原因是在target[:] ...这种写法下target始终指向原对象只是在原对象内部做了一次整体替换。这是整体赋值和[:]切片赋值最本质的区别。我建议你在自己的代码里牢记这条分界线只改变名字的指向[:]才真正改写对象内容。2.3 运算符的隐藏行为是另一个容易让人迷糊的地方。对于列表来说lst [x]实际等价于lst.extend([x])它是原地修改不是重新绑定。def add_tail_inplace(item, target): target [item] print(函数内部 target:, target) orders [A001, A002] add_tail_inplace(A003, orders) print(函数外部 orders:, orders)输出结果函数内部 target: [A001, A002, A003] 函数外部 orders: [A001, A002, A003]这里有同学会问为什么target target [item]不生效而target [item]生效因为操作符调用的是__add__方法它返回一个新对象而调用的是__iadd__方法对于列表来说__iadd__的实现就是原地extend。也就是说列表的在语义上是“扩展对象”不是“创建新对象”。拿元组对比一下就更好理解了。元组是不可变对象没有__iadd__所以t (1,)会退化为t t (1,)创建新元组。这也是面试题里很喜欢挖的细节同样的作用于可变对象和不可变对象时行为完全不同。3. 什么场景下需要刻意利用列表参数3.1 用列表参数当“结果缓冲区”既然列表参数可以被就地修改那我们在设计函数时就可以利用这个特性把列表当作一个“结果收集器”传进去。这样做最大的好处是函数不需要通过返回值来回传数据尤其是在业务流程比较复杂、需要多个步骤累积结果的时候。def collect_even_numbers(nums, result): for num in nums: if num % 2 0: result.append(num) all_nums [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] even_result [] collect_even_numbers(all_nums, even_result) print(even_result) # [2, 4, 6, 8, 10]这种写法的好处是调用方可以提前创建好容器甚至可以把同一个result传给多个不同的函数让它们分别往里填充结果。例如在爬虫项目中解析函数、清洗函数、去重函数都往同一个result_list里写最后统一处理。这比在每个函数里各自创建一个新列表再层层返回要省事很多。但我要提醒一句这种代码只有在你明确约定“传入列表会被修改”的前提下才建议使用。否则调用方不知道你的函数会改他的列表就容易出现开头说的那种“列表被偷偷改了”的坑。3.2 递归缓存备忘录的经典用法递归优化是列表参数发挥作用的一个重要场景。写递归函数时如果不做缓存重复计算会非常严重。比如斐波那契数列直接递归的复杂度是指数级的而用一个列表作为缓存就能降到线性。def fib(n, memoNone): if memo is None: memo [0] * (n 1) if n 1: return n if memo[n] ! 0: return memo[n] memo[n] fib(n - 1, memo) fib(n - 2, memo) return memo[n] print(fib(10)) # 55 print(fib(50)) # 12586269025这里的巧妙之处在于递归过程不断把同一个memo列表往下传每一层递归都能把中间结果写入这个共享列表避免重复计算。第二次递归调用fib(n-1, memo)时消耗的memo与fib(n-2, memo)共享同一份数据计算结果通过列表参数带回了上层。如果你用字典做记忆化缓存也是一样的道理。这类场景下列表参数的可变性不是缺陷而是“特性”和“能力”。3.3 数据清洗与批量更新还有一个非常实用的场景是数据清洗。假如你有一批原始数据列表写一个清洗函数批量修正其中的字段你希望函数直接对原列表生效而不是拷贝一份再返回新列表——尤其在数据量很大或下游逻辑已经引用了原列表时原地修改是高效方案。def clean_phone_numbers(contact_list): for contact in contact_list: if contact[phone].startswith(86): contact[phone] contact[phone][2:] contacts [ {name: Alice, phone: 8613800138000}, {name: Bob, phone: 13912345678}, ] clean_phone_numbers(contacts) print(contacts) # [{name: Alice, phone: 13800138000}, {name: Bob, phone: 13912345678}]这个例子里函数内部修改的是列表元素中的字典字段并没有重新创建新列表所以调用方手里的contacts直接被清洗干净了。这种函数很像 pandas 里的inplaceTrue语义不需要返回新对象天然高效。但同样的提醒命名要清楚。函数名如果叫clean_phone_numbers那大家默认它会修改原列表这没问题。如果你实际是想返回新列表但函数名让人误以为会修改原列表那就会带来巨大的认知混乱。4. 默认参数引发的连锁灾难4.1 为什么不能把[]写在默认值里来说一个几乎人人都踩过的坑在函数定义时把可变对象作为默认参数。def add_item(item, target_list[]): target_list.append(item) return target_list print(add_item(1)) # 输出 [1] print(add_item(2)) # 输出 [1, 2] print(add_item(3)) # 输出 [1, 2, 3]第二次调用add_item(2)时你可能会天真地以为它会创建一个新的空列表[]然后把2加进去得到[2]。但实际上输出是[1, 2]说明两次调用共享了同一个默认列表。这是因为默认参数是在函数定义时求值并保存的之后每次调用如果不传这个参数都会复用同一个列表对象。这个行为在官方文档里有明确说明默认值只计算一次。当你把列表、字典这类可变对象放在默认值位置时每次调用都会累积上一次的修改结果这几乎必然导致 bug。再举一个更隐蔽的例子def add_student(student, class_list[]): class_list.append(student) return class_list class_1 add_student(张三) class_2 add_student(李四) print(class_1) # [张三, 李四] print(class_2) # [张三, 李四]稍有不慎两个班级就共用了一份学生名单。4.2 正确的默认参数写法正确的做法是把默认值设为None在函数内部再判断并创建新的空列表。def add_item(item, target_listNone): if target_list is None: target_list [] target_list.append(item) return target_list print(add_item(1)) # 输出 [1] print(add_item(2)) # 输出 [2] print(add_item(3, [0])) # 输出 [0, 3]为什么这样可以因为None是不可变对象把None作为默认值不会出现“共享累积”的问题。每次调用如果不传列表参数函数内部都会执行target_list []创建一块全新的列表空间。这从根本上杜绝了多次调用之间的数据串味。顺便提一下如果你用类型注解可以这样写def add_item(item: int, target_list: list | None None) - list: if target_list is None: target_list [] target_list.append(item) return target_list这里list | None的写法需要 Python 3.10 以上如果兼容老版本可以用Optional[list]。类型注解不仅让代码更清晰也能让 IDE 和类型检查工具帮你提前发现这类问题。5. 作用域与列表参数的相互作用5.1 LEGB规则里的“修改”边界很多人会把“函数参数修改”和“全局变量修改”混在一起。其实两者作用域规则不同需要分开理解。在函数内部对列表参数做append、sort这种原地操作时不需要声明global因为操作的是对象本身而不是重新绑定名字。但如果执行lst new_list这只是在函数局部作用域创建了一个新变量名外部列表不会受影响。用 LEGB 规则来梳理局部作用域Local函数内部定义的变量包括形参。外层作用域Enclosing嵌套函数中外层函数定义的变量。全局作用域Global模块级别定义的变量。内置作用域Built-inPython解释器预定义的名称。当你在函数里写lst [1, 2, 3]时Python 会把这个lst当作局部变量处理它和外部的lst没有任何关系。但当你写lst.append(1)时Python 不会为lst创建新名字而是沿着 LEGB 顺序找到lst指向的对象然后修改这个对象内部的数据。这也是为什么“看起来没加 global 却还是改了全局列表”的原因——你改的不是名字是对象。5.2 什么时候才需要global或nonlocal既然列表参数可以通过原地操作影响外部列表那是不是就不需要global了不是。global解决的是另一种情况你想在函数内部把全局变量重新绑定到一个新的对象上。data [1, 2, 3] def replace_data(): # 不写global会报错UnboundLocalError data [100, 200, 300] def replace_data_global(): global data data [100, 200, 300] replace_data() print(data) # [1, 2, 3]没变 replace_data_global() print(data) # [100, 200, 300]变了因为data [100, 200, 300]是重新绑定所以函数内的data是一个局部变量外部data不会变。而加了global data后告诉 Python 这个data指的就是模块级变量重新绑定时会直接改全局名字的指向。nonlocal的作用类似只不过针对的是嵌套函数中的外层函数变量。比如你想在内层函数里给外层函数的列表整体赋新值就需要nonlocal。如果只是对外层函数列表做append那就不需要nonlocal。这里可以总结出一条实用判断规则如果你只是修改列表内部的数据增删改元素不需要global或nonlocal如果你是要让变量名指向一个全新的列表对象才需要考虑作用域关键字。我自己写代码时对于模块级列表更倾向于不直接在函数里改而是通过返回值重新赋值这样可以避免隐式的跨作用域修改给后期排查带来麻烦。5.3 参数与返回值一份清晰的契约设计理解了作用域之后我们还要面临一个工程问题函数到底应该“修改原列表”还是“返回新列表”这两种风格都有大量实践但必须选其一并在命名和文档上明确出来否则调用方会猜。Python 标准库其实给了很好的示范list.sort()是原地排序返回Nonesorted(list)是返回新列表原列表不变。两者语义分明一看函数名就知道行为。你自己写函数时也应该这样def sort_scores(scores): 原地排序直接修改传入列表无返回值 scores.sort(reverseTrue) def get_sorted_scores(scores): 返回排序后的新列表原列表不变 return sorted(scores, reverseTrue)如果你想保守一点最稳妥的办法是在文档字符串中写明是否修改原列表或者用类型注解暗示函数的意图。比如一个返回list的函数通常不会修改入参一个返回None但接受列表参数的函数大概率是原地修改。写注释不丢人出 bug 才丢人。6. 避坑清单与排查调试技巧6.1 三个经典面试题来测一下自己下面三个函数你能不看答案写出输出结果吗这几个题几乎覆盖了我们前面讲的所有核心点。# 题1 def func1(lst): lst lst [100] data1 [1, 2, 3] func1(data1) print(data1) # 答案[1, 2, 3] # 题2 def func2(lst): lst [100] data2 [1, 2, 3] func2(data2) print(data2) # 答案[1, 2, 3, 100] # 题3 def func3(lst): lst[:] lst [100] data3 [1, 2, 3] func3(data3) print(data3) # 答案[1, 2, 3, 100]题1的关键是lst [100]创建了新对象并重新绑定给局部变量原列表不变。题2的对列表来说是原地扩展原列表直接被改。题3的切片赋值也是原地替换原列表被改为[1, 2, 3, 100]。我再出一个变形题考察默认参数和循环的组合def make_adder(step): result [] for i in range(step): result.append(i) return result print(make_adder(3)) # [0, 1, 2] def append_to_list(item, container[]): container.append(item) return container print(append_to_list(1)) # [1] print(append_to_list(2)) # [1, 2]第二个函数的第二次输出是[1, 2]不是[2]。如果你在面试里能立刻反应出这个结果说明默认参数的坑你已经记住了。6.2 快速定位“谁改了列表”的调试方法在实际项目中当排查“这个列表怎么被改了”这类问题时最直接的办法是在可疑函数入口和出口分别打印列表的id()和内容快照。def suspicious_function(data): print(f[调试] 进入函数data id{id(data)}) print(f[调试] 进入函数data{data}) # 函数逻辑... print(f[调试] 离开函数data id{id(data)}) print(f[调试] 离开函数data{data})如果函数入口和出口的id相同但内容变了说明这个函数内部做了原地修改。如果id都不同了说明函数重新绑定了变量名那就去看它什么时候创建了新对象。这个方法虽然土但真的管用比肉眼一行行扫代码快得多。如果要隔离某段逻辑是否误改了原始数据可以在调用前使用copy.deepcopy()做一次完整备份或者用切片data[:]浅拷贝。对于嵌套列表浅拷贝只复制外层列表内层列表仍然共享所以如果内层也要隔离就得用深拷贝。import copy original_data [[1, 2], [3, 4]] backup copy.deepcopy(original_data) # 你的业务逻辑... # 如果发现 original_data 被改了可以通过比对 backup 找到改动位置我甚至见过同事在大型数据处理流程里把所有关键列表的“修改历史”记录到日志里每次append、删除都打一条日志上线后出了数据问题直接查日志定位。这种方式虽然繁琐但对排查线上的神秘数据变动非常有效。6.3 我建议你长期遵守的几条编码约定踩过多次坑之后我给自己定了几条不成文的规矩写在这里供你参考第一函数命名要能看出是否修改入参。如果函数会修改传入的列表名字里尽量出现update、modify、fill、sort_inplace这类词。如果返回新列表用get_、create_、build_开头。这样调用方一看到函数名就知道该怎么用不需要翻源码。第二默认参数永远不要使用可变对象。统一用None占位在函数内部初始化新对象。这条规则没有任何例外哪怕你只是写一个几行的小工具函数也不要去踩“共享默认列表”的坑。第三需要对外提供修改能力时优先考虑“返回新列表”而不是“修改原列表”。虽然原地修改有时效率更高但副作用也更难追踪。只有对性能有明确的硬性要求或是在类似 pandas 那种明确inplace语义的接口里才考虑原地修改。第四团队代码中尽量用类型注解和 docstring 把参数行为写清楚。比如def clean_data(rows: list) - list:表示入参是列表、返回新列表一眼就能看出它不会修改原列表。如果一个函数返回None又接收一个列表参数那它八成会原地修改入参调用时要格外小心。这些约定能帮整个团队减少大量“列表被改”类的隐性 bug。最后再说一个很多人容易忽略的小技巧你在写递归或者缓存类代码时如果要用列表参数作为缓存记得用None做默认值在函数内部再初始化为空列表。我之前见过一个同事在递归函数里写def fib(n, memo[0, 1])结果跑了几千次递归后memo被反复积累某些分支的计算结果莫名其妙地“对不上”。改成None 内部初始化后问题瞬间消失。这些细节看起来不起眼但累积起来就是代码质量的分水岭。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价