Python 新手写函数,第一阶段通常不会卡在 def 关键字本身,而是卡在代码“能跑,但结果不对”。
最常见的几类问题基本都集中在这里:
- 函数参数传进去以后,外面的数据怎么也变了
- 默认参数只写了一次,结果第二次执行时带上了上次的数据
- 内层函数想改外层变量,直接报错
- 循环里生成一批函数,执行时全都拿到了最后一个值
这几类问题单看像四个分散知识点,实际写脚本时经常一起出现。
这篇文章不按概念词典展开,直接围绕一个实际脚本场景来讲:做一个批量通知脚本。
场景很简单:
- 有一组用户待通知
- 不同用户走不同渠道
- 失败用户要记录下来
- 有些通知不是立刻发,而是先注册任务,稍后统一执行
- 最后还要统计成功数量和失败名单
这个小脚本里会自然碰到函数参数、可变对象、作用域和闭包,正好把最容易错的地方一次讲透。
一、这篇文章要解决什么问题
读完这一篇,应该能独立处理下面这些情况:
- 知道函数参数传的到底是什么
- 知道什么时候会改到原对象,什么时候不会
- 避开可变默认参数这个高频坑
- 能看懂
local、global、nonlocal 各自影响什么范围
- 知道闭包在循环里为什么总拿错值
- 能给这类代码补最小测试,而不是只靠肉眼看输出
如果这些动作都能独立做出来,后面再写装饰器、回调、任务调度、数据处理脚本时,代码会稳很多。
二、先看实际场景
先明确这篇文章里要做的脚本。
假设现在有一批待通知用户:
1 2 3 4 5
| users = [ {"name": "Tom", "channel": "email"}, {"name": "Lucy", "channel": "sms"}, {"name": "Mike", "channel": "email"}, ]
|
目标是完成四件事:
- 逐个发送通知
- 记录失败用户
- 生成一批“稍后再执行”的任务函数
- 打印最终统计
这个需求不大,但非常适合练函数基础。因为它会自然碰到:
- 参数传值时的副作用
- 列表和字典这种可变对象的共享问题
- 嵌套函数里的计数逻辑
- 循环注册任务时的闭包问题
三、先给一个最小可运行版本
先把最小版本跑起来,不要一开始就把所有坑混在一起。
新建 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 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 把字典“按引用传递”这个说法本身多神秘,而是更直接的一件事:
user 和 item 指向的是同一个字典对象
- 函数里改的是这个字典内部的数据
- 所以函数外也能看到变化
这就是可变对象带来的副作用。
一个实际错误
下面这个写法在清洗数据时很常见:
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
|
输出:
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 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 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 为什么经常跟作用域这个词一起出现
这一篇不是终点,而是把后面会高频出现的地基先打稳。
十五、一个实际练习
可以直接把这篇的通知脚本再补一版,要求如下:
- 邮件通知成功,短信通知失败
- 失败用户要记录真实姓名,不要只记录
"sms user"
- 增加一个
retry 参数,默认重试 2 次
- 每个任务函数执行后返回字典,包含:
name
channel
success
retry_count
- 补 3 个测试:
- 原始输入不能被污染
- 每个任务必须绑定自己的用户名
- 不同函数调用之间失败名单不能互相串数据
练完以后,再回头看这篇里的四个坑,会更容易真正记住。
十六、最后把几个结论收一下
这一篇里最需要真正记牢的,不是术语,而是几个判断动作:
- 参数进函数以后,先判断自己操作的是不是可变对象
- 默认参数如果是列表、字典、集合,优先改成
None
- 想返回新结果时先复制,不要顺手改原对象
- 内层函数改外层变量时,先判断是不是该用
nonlocal
- 循环里生成函数时,先检查是不是把循环变量晚绑定了
这些问题一开始看像语法细节,实际写脚本时会直接影响:
- 数据会不会被污染
- 统计结果会不会错
- 延迟任务会不会跑偏
- 问题出了以后能不能快速定位
把这一层写稳,后面的装饰器、回调、任务调度、测试夹具才不会学着学着又乱掉。