Python:函数参数、可变对象、作用域和闭包为什么总让新手犯错

Python 新手写函数,第一阶段通常不会卡在 def 关键字本身,而是卡在代码“能跑,但结果不对”。

最常见的几类问题基本都集中在这里:

  • 函数参数传进去以后,外面的数据怎么也变了
  • 默认参数只写了一次,结果第二次执行时带上了上次的数据
  • 内层函数想改外层变量,直接报错
  • 循环里生成一批函数,执行时全都拿到了最后一个值

这几类问题单看像四个分散知识点,实际写脚本时经常一起出现。

这篇文章不按概念词典展开,直接围绕一个实际脚本场景来讲:做一个批量通知脚本
场景很简单:

  • 有一组用户待通知
  • 不同用户走不同渠道
  • 失败用户要记录下来
  • 有些通知不是立刻发,而是先注册任务,稍后统一执行
  • 最后还要统计成功数量和失败名单

这个小脚本里会自然碰到函数参数、可变对象、作用域和闭包,正好把最容易错的地方一次讲透。

一、这篇文章要解决什么问题

读完这一篇,应该能独立处理下面这些情况:

  • 知道函数参数传的到底是什么
  • 知道什么时候会改到原对象,什么时候不会
  • 避开可变默认参数这个高频坑
  • 能看懂 localglobalnonlocal 各自影响什么范围
  • 知道闭包在循环里为什么总拿错值
  • 能给这类代码补最小测试,而不是只靠肉眼看输出

如果这些动作都能独立做出来,后面再写装饰器、回调、任务调度、数据处理脚本时,代码会稳很多。

二、先看实际场景

先明确这篇文章里要做的脚本。

假设现在有一批待通知用户:

1
2
3
4
5
users = [
{"name": "Tom", "channel": "email"},
{"name": "Lucy", "channel": "sms"},
{"name": "Mike", "channel": "email"},
]

目标是完成四件事:

  1. 逐个发送通知
  2. 记录失败用户
  3. 生成一批“稍后再执行”的任务函数
  4. 打印最终统计

这个需求不大,但非常适合练函数基础。因为它会自然碰到:

  • 参数传值时的副作用
  • 列表和字典这种可变对象的共享问题
  • 嵌套函数里的计数逻辑
  • 循环注册任务时的闭包问题

三、先给一个最小可运行版本

先把最小版本跑起来,不要一开始就把所有坑混在一起。

新建 notify_demo.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
users = [
{"name": "Tom", "channel": "email"},
{"name": "Lucy", "channel": "sms"},
{"name": "Mike", "channel": "email"},
]


def send_notification(user):
if user["channel"] == "sms":
print(f"send to {user['name']} failed")
return False

print(f"send to {user['name']} success")
return True


success_count = 0

for user in users:
if send_notification(user):
success_count += 1

print(f"success_count={success_count}")

执行:

1
python notify_demo.py

输出:

1
2
3
4
send to Tom success
send to Lucy failed
send to Mike success
success_count=2

这个版本还很简单,但已经有一个很明确的函数边界:send_notification(user)

接下来所有问题,都会从这个边界继续长出来。

四、函数参数到底传进去了什么

新手最容易把函数参数理解成“复制了一份值”。这只对一部分情况成立。

先看一个最小例子:

1
2
3
4
5
6
7
def rename_user(user):
user["name"] = user["name"].upper()


item = {"name": "Tom", "channel": "email"}
rename_user(item)
print(item)

执行:

1
2
3
4
5
6
7
8
9
python - <<'PY'
def rename_user(user):
user["name"] = user["name"].upper()


item = {"name": "Tom", "channel": "email"}
rename_user(item)
print(item)
PY

输出:

1
{'name': 'TOM', 'channel': 'email'}

外面的 item 被改了。
原因不是 Python 把字典“按引用传递”这个说法本身多神秘,而是更直接的一件事:

  • useritem 指向的是同一个字典对象
  • 函数里改的是这个字典内部的数据
  • 所以函数外也能看到变化

这就是可变对象带来的副作用。

一个实际错误

下面这个写法在清洗数据时很常见:

1
2
3
4
5
6
7
8
9
10
11
def prepare_user(user):
user["channel"] = user["channel"].lower()
user["name"] = user["name"].strip()
return user


raw_user = {"name": " Tom ", "channel": "EMAIL"}
prepared_user = prepare_user(raw_user)

print(raw_user)
print(prepared_user)

输出:

1
2
{'name': 'Tom', 'channel': 'email'}
{'name': 'Tom', 'channel': 'email'}

这里本来只想“得到一个新结果”,结果顺手把原始数据改掉了。
如果后面还要打印原始输入、做失败重试、写审计日志,这种副作用就会直接造成混乱。

修复方式

如果想返回新数据,而不是修改原数据,更合适的写法是先复制:

1
2
3
4
5
6
7
8
9
10
11
12
def prepare_user(user):
normalized_user = dict(user)
normalized_user["channel"] = normalized_user["channel"].lower()
normalized_user["name"] = normalized_user["name"].strip()
return normalized_user


raw_user = {"name": " Tom ", "channel": "EMAIL"}
prepared_user = prepare_user(raw_user)

print(raw_user)
print(prepared_user)

输出:

1
2
{'name': ' Tom ', 'channel': 'EMAIL'}
{'name': 'Tom', 'channel': 'email'}

这里最重要的判断不是“会不会复制”,而是先想清楚:

  • 这个函数是要“修改输入”
  • 还是要“基于输入产出新结果”

这一步不想清楚,后面代码越长越难查。

五、可变默认参数为什么会留下上一次的数据

这几乎是 Python 新手最经典的坑。

先看错误写法:

1
2
3
4
5
6
7
def collect_failed_user(name, failed_users=[]):
failed_users.append(name)
return failed_users


print(collect_failed_user("Lucy"))
print(collect_failed_user("Bob"))

执行:

1
2
3
4
5
6
7
8
9
python - <<'PY'
def collect_failed_user(name, failed_users=[]):
failed_users.append(name)
return failed_users


print(collect_failed_user("Lucy"))
print(collect_failed_user("Bob"))
PY

输出:

1
2
['Lucy']
['Lucy', 'Bob']

第二次调用时,failed_users 不是一个新的空列表,而是沿用了第一次的那个列表。

为什么会这样:

  • 默认参数在函数定义时就已经创建好了
  • 不是每次调用函数时都重新创建
  • 列表又是可变对象
  • 所以后一次调用会接着改前一次的列表

在实际脚本里会怎么出错

把这个问题放回通知脚本里:

1
2
3
4
5
6
7
8
9
10
def send_notification(user, failed_users=[]):
if user["channel"] == "sms":
failed_users.append(user["name"])
return False, failed_users

return True, failed_users


print(send_notification({"name": "Lucy", "channel": "sms"}))
print(send_notification({"name": "Jack", "channel": "sms"}))

输出:

1
2
(False, ['Lucy'])
(False, ['Lucy', 'Jack'])

如果这是一个长时间运行的脚本,失败名单会越积越多,排查时非常难受,因为看起来像是“这次运行带上了上次残留”。

修复方式

默认值用 None,在函数内部再创建新列表:

1
2
3
4
5
6
7
8
9
def send_notification(user, failed_users=None):
if failed_users is None:
failed_users = []

if user["channel"] == "sms":
failed_users.append(user["name"])
return False, failed_users

return True, failed_users

这个写法应该直接记成固定动作:

  • 默认参数如果是列表、字典、集合
  • 先不要直接写在参数上
  • None 占位,函数内部再初始化

六、函数里改了参数,为什么外面的配置也被改了

除了默认参数,另一个高频问题是“函数只是帮忙补个默认值,结果把调用方的配置也改了”。

错误写法:

1
2
3
4
5
6
7
8
9
10
11
def fill_options(options):
options["timeout"] = 3
options["retry"] = 2
return options


job_options = {"channel": "email"}
new_options = fill_options(job_options)

print(job_options)
print(new_options)

输出:

1
2
{'channel': 'email', 'timeout': 3, 'retry': 2}
{'channel': 'email', 'timeout': 3, 'retry': 2}

job_options 被直接改掉了。

如果调用方原本还要复用这份配置,这种副作用就会向后扩散。

修复方式:

1
2
3
4
5
def fill_options(options):
normalized_options = dict(options)
normalized_options["timeout"] = normalized_options.get("timeout", 3)
normalized_options["retry"] = normalized_options.get("retry", 2)
return normalized_options

这里最实用的判断标准是:

  • 函数名字如果是 update_*append_*mark_* 这类动作,很可能就是原地修改
  • 函数名字如果是 build_*prepare_*normalize_*create_*,更适合返回新对象

名字和行为不一致,代码阅读时最容易误判。

七、作用域为什么会让计数器直接报错

接下来把统计逻辑写成函数。

第一版很容易写成这样:

1
2
3
4
5
6
7
8
9
10
11
def run_notifications(users):
success_count = 0

def mark_success():
success_count += 1

for user in users:
if user["channel"] == "email":
mark_success()

return success_count

执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
python - <<'PY'
def run_notifications(users):
success_count = 0

def mark_success():
success_count += 1

for user in users:
if user["channel"] == "email":
mark_success()

return success_count


users = [
{"name": "Tom", "channel": "email"},
{"name": "Lucy", "channel": "sms"},
]

print(run_notifications(users))
PY

报错:

1
UnboundLocalError: local variable 'success_count' referenced before assignment

原因是:

  • mark_success() 里,只要写了 success_count += 1
  • Python 就会把 success_count 当成这个内层函数的局部变量
  • 但这个局部变量还没初始化,所以报错

修复方式一:用 nonlocal

1
2
3
4
5
6
7
8
9
10
11
12
def run_notifications(users):
success_count = 0

def mark_success():
nonlocal success_count
success_count += 1

for user in users:
if user["channel"] == "email":
mark_success()

return success_count

输出:

1
1

nonlocal 的作用很直接:

  • 告诉 Python:这里要改的是外层函数里的变量
  • 不是当前内层函数新建一个局部变量

修复方式二:少绕一层,直接写清楚

如果只是简单计数,其实没有必要一定写成嵌套函数:

1
2
3
4
5
6
7
8
def run_notifications(users):
success_count = 0

for user in users:
if user["channel"] == "email":
success_count += 1

return success_count

这也是一个很实际的判断:

  • 嵌套函数不是不能用
  • 但如果只是为了“看起来高级”多包一层,出错概率反而会上去

八、闭包为什么在循环里总拿到最后一个值

现在进入最容易把人绕晕的地方:闭包。

先不要背定义,先看一个实际需求:

  • 通知任务不是立刻执行
  • 要先生成一批任务函数
  • 稍后统一调用

闭包这一段第一版通常会写成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def build_tasks(users):
tasks = []

for user in users:
def task():
print(f"send to {user['name']}")

tasks.append(task)

return tasks


users = [
{"name": "Tom", "channel": "email"},
{"name": "Lucy", "channel": "sms"},
{"name": "Mike", "channel": "email"},
]

tasks = build_tasks(users)

for task in tasks:
task()

执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
python - <<'PY'
def build_tasks(users):
tasks = []

for user in users:
def task():
print(f"send to {user['name']}")

tasks.append(task)

return tasks


users = [
{"name": "Tom", "channel": "email"},
{"name": "Lucy", "channel": "sms"},
{"name": "Mike", "channel": "email"},
]

tasks = build_tasks(users)

for task in tasks:
task()
PY

输出:

1
2
3
send to Mike
send to Mike
send to Mike

这就是闭包在循环里的高频坑。

问题不在于函数保存错了,而在于它保存的是变量 user,不是当时那个变量对应的值。
等到真正执行 task() 时,循环已经结束,user 最后停在了 Mike

修复方式一:把当前值变成参数默认值

1
2
3
4
5
6
7
8
9
10
def build_tasks(users):
tasks = []

for user in users:
def task(current_user=user):
print(f"send to {current_user['name']}")

tasks.append(task)

return tasks

输出:

1
2
3
send to Tom
send to Lucy
send to Mike

这里不是为了偷懒才写默认值,而是借默认值把“当前这次循环的值”固定下来。

修复方式二:单独再包一层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def create_task(user):
def task():
print(f"send to {user['name']}")

return task


def build_tasks(users):
tasks = []

for user in users:
tasks.append(create_task(user))

return tasks

这种写法通常更清楚,也更适合后面继续扩展逻辑。

九、把这几个问题放回一个完整脚本里

前面几个坑分开看过之后,再把它们串到一个完整点的版本里。

notification_runner.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
users = [
{"name": "Tom", "channel": "email"},
{"name": "Lucy", "channel": "sms"},
{"name": "Mike", "channel": "email"},
]


def normalize_user(user):
normalized_user = dict(user)
normalized_user["name"] = normalized_user["name"].strip()
normalized_user["channel"] = normalized_user["channel"].lower()
return normalized_user


def create_send_task(user):
def task():
if user["channel"] == "sms":
print(f"send to {user['name']} failed")
return False

print(f"send to {user['name']} success")
return True

return task


def run_notifications(raw_users):
success_count = 0
failed_users = []
tasks = []

for raw_user in raw_users:
user = normalize_user(raw_user)
tasks.append(create_send_task(user))

for task in tasks:
result = task()
if result:
success_count += 1
else:
failed_users.append("sms user")

return success_count, failed_users


success_count, failed_users = run_notifications(users)
print(f"success_count={success_count}")
print(f"failed_users={failed_users}")

执行:

1
python notification_runner.py

输出:

1
2
3
4
5
send to Tom success
send to Lucy failed
send to Mike success
success_count=2
failed_users=['sms user']

这个版本虽然还很小,但已经具备了几个很关键的函数边界:

  • normalize_user:输入原始数据,返回新对象,不污染调用方
  • create_send_task:负责生成闭包任务
  • run_notifications:负责流程编排和统计

这就是比“所有代码堆在一个 for 循环里”更稳的写法。

十、怎么测试这些知识点

这类问题最怕只看命令行输出,因为很多错误不是“直接崩”,而是结果悄悄不对。

最小测试可以直接这样写。

test_notification_runner.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def prepare_user(user):
normalized_user = dict(user)
normalized_user["name"] = normalized_user["name"].strip()
normalized_user["channel"] = normalized_user["channel"].lower()
return normalized_user


def collect_failed_user(name, failed_users=None):
if failed_users is None:
failed_users = []

failed_users.append(name)
return failed_users


def create_task(user):
def task():
return user["name"]

return task


def test_prepare_user_should_not_modify_raw_input():
raw_user = {"name": " Tom ", "channel": "EMAIL"}

new_user = prepare_user(raw_user)

assert raw_user == {"name": " Tom ", "channel": "EMAIL"}
assert new_user == {"name": "Tom", "channel": "email"}


def test_collect_failed_user_should_not_share_previous_call():
first_result = collect_failed_user("Lucy")
second_result = collect_failed_user("Bob")

assert first_result == ["Lucy"]
assert second_result == ["Bob"]


def test_closure_should_hold_current_user():
task_1 = create_task({"name": "Tom"})
task_2 = create_task({"name": "Lucy"})

assert task_1() == "Tom"
assert task_2() == "Lucy"

执行:

1
pytest -q

输出:

1
2
...                                                                  [100%]
3 passed in 0.03s

这些测试看起来简单,但已经覆盖了三类最容易悄悄出错的行为:

  • 输入对象是否被误修改
  • 默认参数是否共享了旧数据
  • 闭包是否拿到了当前应该拿的值

十一、一个实际错误示例和修复示例

把最容易踩的几个错误集中看一次,后面写代码时会更有警觉。

错误 1:默认参数共享列表

错误写法:

1
2
3
def add_fail(name, failed=[]):
failed.append(name)
return failed

修复写法:

1
2
3
4
5
def add_fail(name, failed=None):
if failed is None:
failed = []
failed.append(name)
return failed

错误 2:函数里直接修改输入字典

错误写法:

1
2
3
def normalize_options(options):
options["timeout"] = 3
return options

修复写法:

1
2
3
4
def normalize_options(options):
new_options = dict(options)
new_options["timeout"] = 3
return new_options

错误 3:循环里创建闭包任务

错误写法:

1
2
3
for user in users:
def task():
print(user["name"])

修复写法:

1
2
3
for user in users:
def task(current_user=user):
print(current_user["name"])

错误 4:内层函数直接改外层变量

错误写法:

1
2
3
4
5
def outer():
count = 0

def inner():
count += 1

修复写法:

1
2
3
4
5
6
def outer():
count = 0

def inner():
nonlocal count
count += 1

十二、一个实际排错场景

把前面的内容放到一个很常见的现场里。

现象是这样的:

  • 批量通知脚本一共发 3 个用户
  • 输出里明明打印了 3 次
  • 但每次都是最后一个用户 Mike
  • 失败名单还会越来越长,第二次执行比第一次多

这时最容易先怀疑:

  • 数据源是不是重复了
  • 读取用户列表时是不是覆盖了
  • 任务循环是不是执行了多次

但这个问题其实往往出在两个地方:

  1. 闭包抓的是循环变量,不是当前值
  2. 失败名单用了可变默认参数

可以这样查。

第一步:先打印任务执行时真正拿到的值

把任务函数临时改成:

1
2
3
4
5
6
7
8
9
10
11
12
def build_tasks(users):
tasks = []

for user in users:
print(f"register task for {user['name']}")

def task():
print(f"run task for {user['name']}")

tasks.append(task)

return tasks

如果注册阶段输出正常:

1
2
3
register task for Tom
register task for Lucy
register task for Mike

执行阶段却变成:

1
2
3
run task for Mike
run task for Mike
run task for Mike

就说明问题不在数据源,而在闭包绑定方式。

第二步:再确认失败名单是不是跨调用共享

临时打印列表对象的标识:

1
2
3
4
def add_fail(name, failed=[]):
print(id(failed))
failed.append(name)
return failed

如果连续两次调用输出的是同一个数字,说明两次调用用的是同一个列表对象。

修复以后再看:

1
2
3
4
5
6
def add_fail(name, failed=None):
if failed is None:
failed = []
print(id(failed))
failed.append(name)
return failed

这时每次调用都会是新的列表对象。

第三步:补最小测试,别再靠人工盯输出

排掉一次不代表以后不再出。
这类 bug 最好的收尾动作不是“手动试过了”,而是把它写成测试固定下来。

十三、什么时候应该用闭包,什么时候别硬用

闭包不是错误源头,问题是很多场景根本不需要强行上闭包。

更适合用闭包的情况:

  • 想生成一批延迟执行的任务
  • 想把某个上下文值先绑定住
  • 想做简单工厂函数

不太适合硬用闭包的情况:

  • 只是做普通计数
  • 只是传一个参数就能解决
  • 写完以后函数层层嵌套,读起来比展开还难

对新手更稳的原则是:

  • 能普通函数解决,先普通函数
  • 真要延迟执行、绑定环境,再用闭包
  • 一旦用了闭包,就主动检查循环变量绑定问题

十四、这一节学完后还要继续补什么

把这一篇学明白以后,后面几个方向会更顺:

  • 装饰器为什么本质上也是闭包
  • 类的方法为什么也会带作用域和状态问题
  • lambda 为什么在循环里也会出现同样的变量绑定问题
  • pytest fixture 为什么经常跟作用域这个词一起出现

这一篇不是终点,而是把后面会高频出现的地基先打稳。

十五、一个实际练习

可以直接把这篇的通知脚本再补一版,要求如下:

  1. 邮件通知成功,短信通知失败
  2. 失败用户要记录真实姓名,不要只记录 "sms user"
  3. 增加一个 retry 参数,默认重试 2 次
  4. 每个任务函数执行后返回字典,包含:
    • name
    • channel
    • success
    • retry_count
  5. 补 3 个测试:
    • 原始输入不能被污染
    • 每个任务必须绑定自己的用户名
    • 不同函数调用之间失败名单不能互相串数据

练完以后,再回头看这篇里的四个坑,会更容易真正记住。

十六、最后把几个结论收一下

这一篇里最需要真正记牢的,不是术语,而是几个判断动作:

  • 参数进函数以后,先判断自己操作的是不是可变对象
  • 默认参数如果是列表、字典、集合,优先改成 None
  • 想返回新结果时先复制,不要顺手改原对象
  • 内层函数改外层变量时,先判断是不是该用 nonlocal
  • 循环里生成函数时,先检查是不是把循环变量晚绑定了

这些问题一开始看像语法细节,实际写脚本时会直接影响:

  • 数据会不会被污染
  • 统计结果会不会错
  • 延迟任务会不会跑偏
  • 问题出了以后能不能快速定位

把这一层写稳,后面的装饰器、回调、任务调度、测试夹具才不会学着学着又乱掉。