码农高天
由于Python在解析函数定义时仅对默认参数求值一次,将列表等可变对象设为默认值会导致未传参实例共享同一内存对象,开发者须改用None占位并在函数体内动态初始化。
AI-assisted summary of 【Python】我精心设计的默认参数,怎么就出问题了呢? — original source. Check the original for full context.
Source published:
Key ideas
- 在 Python 中使用空列表等可变对象作为默认参数会导致实例间数据共享与污染,是工程中极具隐蔽性的经典缺陷。 (0:00)
- 该 bug 的底层成因在于 Python 仅在函数定义阶段对默认参数求值一次,未显式传参的调用都会复用同一个内存对象。 (3:00)
- 官方推荐的解决方案是将默认参数设为 None,并在函数体内通过 if items is None 动态创建新的可变对象。 (4:40)
Detailed notes
## Background 该视频由 YouTube 频道“码农高天”于 2023 年 9 月 27 日发布。视频简介指出,本期主要探讨 Python 中与默认参数相关的一个极其常见且隐蔽的经典 bug,希望通过具体示例帮助有一定经验但仍可能掉坑的开发者加深印象。
## TL;DR 视频通过一个游戏开发中 `Player` 玩家类的具体场景,演示了将默认参数设为空列表 `[]` 所引发的数据污染问题:未传参的玩家会意外共享同一个道具列表。码农高天借助 `id()` 函数和 Python 官方文档解释其底层原理,即默认参数仅在函数定义时求值一次,因此可变对象会被后续调用持续复用。最后,他给出了行业通用的标准解法,即将默认参数设为 `None` 并在函数体内动态初始化空列表,同时强调了使用 `is None` 进行判空的重要性。
## Atmosphere & style 视频整体呈现出轻松平实、注重互动的技术教学氛围,开场便贴心回应观众对手机端代码字号的反馈并调大了编辑器字体。在讲解过程中,主讲人循循善诱,通过直观的代码运行现象引发好奇,再层层剖析至内存地址与官方文档底层,兼具说服力与亲和力。整段演示节奏紧凑自然,既有程序员日常排错的共鸣感,又充满了严谨且易懂的极客分享趣味。
## Bulletin - [00:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=0s) 在 Python 中使用空列表等可变对象作为默认参数会导致实例间数据共享与污染,是工程中极具隐蔽性的经典缺陷。 - [03:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=180s) 该 bug 的底层成因在于 Python 仅在函数定义阶段对默认参数求值一次,未显式传参的调用都会复用同一个内存对象。 - [04:40](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=280s) 官方推荐的解决方案是将默认参数设为 None,并在函数体内通过 if items is None 动态创建新的可变对象。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=0s) 开发者常尝试利用默认参数简化类初始化操作,但在参数为可变对象时极易引发非预期行为。 - [00:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=0s) 通过游戏 Player 类示例展示,Alice 和 Bob 默认使用空列表作为道具包,导致后者的道具追加直接污染了前者的背包。 - [00:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=0s) 显式传入实参的实例拥有独立的内存对象,不会受到默认参数对象污染的影响。 - [03:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=180s) Python 解释器在解析函数定义时仅对默认参数表达式求值一次,并将求得的对象持久绑定为默认值。 - [03:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=180s) 在未传参情况下多次调用函数,接收到的参数都会指向内存中完全相同的对象。 - [03:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=180s) 使用 id() 函数打印内存地址可以清晰验证,不同实例所引用的默认列表 ID 完全一致。 - [03:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=180s) Python 官方文档明确指出,除非开发者极度清楚自己在做什么,否则绝不应随意使用可变对象作为默认参数。 - [04:40](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=280s) 标准的修复范式是将默认参数声明为 items=None,以不可变单例对象进行占位。 - [04:40](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=280s) 在函数体内部必须使用身份运算符 is None 进行判断,在为 None 时动态新建空列表,确保每个实例拥有独立对象。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=0s) Python 默认参数的陷阱与游戏玩家类示例
码农高天在开场时首先回应了上一期视频观众的反馈。很多观众表示在手机上看视频时屏幕里的代码太小、看不清,因此他专门调整了 VS Code 的整体布局,并放大了代码字体大小,同时邀请大家在弹幕和评论区反馈当前字号是否适中。随后他切入正题,介绍 Python 中关于默认参数(Default Arguments)一个极为经典的大坑。为了说明这个问题,他构建了一个简化的游戏开发场景:程序员小明正在写一个非常复杂的系统,其中定义了一个 `Player` 玩家类。
在这个抽象出来的 `Player` 类中,玩家只需要两个属性(attributes):一个是名字 `name`,另一个是身上携带的道具列表 `items`。小明在编写初始化逻辑时发现,大部分普通玩家刚开始进入游戏时身上空空如也,并没有任何道具,比如初始化的 `Alice` 和 `Bob` 刚开始都什么都没有;只有极少数付费玩家一开始才会有道具,比如初始玩家 `Charles` 刚进游戏就拥有一个名为 `sword`(剑)的道具。小明心想,Python 支持通过默认参数来进行非常方便的“重载”,既然绝大多数玩家初始没有道具,为何不直接把 `items` 的默认值设为一个空列表 `[]` 呢?这样在创建 `Alice` 和 `Bob` 时就可以省去传参的麻烦。高天提醒观众:“如果到现在你没有意识到他犯了什么错误的话,那你跟他掉进了同一个大坑。”
```mermaid flowchart TD accTitle: 共享可变默认参数导致的数据污染流程 accDescr: 展示函数定义时求值的空列表被多个未传参的实例共享,导致修改相互污染。
subgraph DefTime["函数定义阶段 (仅求值一次)"] SharedList["默认参数对象: 空列表 [] (内存地址 ID: X)"] end
subgraph Instances["创建玩家实例"] Alice["P1 (Alice): 未传参 items"] Bob["P2 (Bob): 未传参 items"] Charles["P3 (Charles): 显式传入 ['sword'] (独立 ID)"] end
subgraph BugOccurrence["道具追加操作"] AliceAppend["Alice.items.append('armor')"] BobAppend["Bob.items.append('sword')"] end
SharedList --> Alice SharedList --> Bob Alice --> AliceAppend Bob --> BobAppend AliceAppend --> SharedList BobAppend --> SharedList ```
接着在后续的游戏逻辑中,`Alice` 获得了一件防具,通过 `items.append('armor')` 追加到背包;随后 `Bob` 也获得了一件装备,追加了 `'sword'`。表面上看一切符合直觉,但当程序打印 `Alice` 的背包道具并运行脚本时,意外发生了:输出结果显示 `Alice` 身上不仅有自己的 `armor`,竟然还莫名其妙地多出了 `Bob` 获得的 `sword`。
- 小明设想的玩家初始化:`p1 = Player('Alice')`、`p2 = Player('Bob')`,两者的道具均使用默认参数 `items=[]`。 - 显式传入道具的付费玩家:`p3 = Player('Charles', ['sword'])`。 - 逻辑运行结果:只给 `Alice` 加了 `armor`、给 `Bob` 加了 `sword`,但最终打印 `Alice.items` 却同时包含了 `['armor', 'sword']`。
[03:00](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=180s) 默认参数只求值一次的底层原理与官方解释
高天指出,这个 bug 在实际工程编程中往往藏得非常深、极具隐蔽性,不容易排查。其底层原因在于 Python 语言对默认参数的求值机制:**Python 在解析函数定义时,默认参数表达式只会求值(evaluate)一次,然后把求得的对象持久绑定为该参数的默认值**。换而言之,在 `p1`(Alice)和 `p2`(Bob)初始化时,它们默认接收到的 `items` 引用的是内存中的同一个列表(list)对象。
因为列表在 Python 中是可变对象(mutable),所以 Alice 和 Bob 实际上共享(share)了同一个 `items` 列表。为了向观众直观证明这一点,高天在代码中加入 `print(id(self.items))`。在 Python 中,通过 `id()` 函数可以查看对象的内存地址标识,从而判断多个变量指向的是否为同一个对象。重新运行程序后,控制台输出显示前两个玩家(Alice 和 Bob)的 `items` 的 ID 是一模一样的,而第三个付费玩家 Charles 因为显式传入了一个新的列表,其 ID 完全不同。这直接证实了前两者的背包其实是同一个内存实例,当 Bob 往列表追加 `sword` 时,Alice 的背包数据也一同被改变。
高天随后展示了 Python 官方文档中的相关说明: - 当函数被定义时,其默认参数表达式只会被求值一次。 - 每次函数调用时,只要没有传入对应实参,就会重复使用这个预先求值好的对象。 - 官方文档特别强调,当开发者使用可变对象(mutable default argument)作为默认参数时,必须非常清醒地知道自己在干什么。高天评价道:“那在大部分的情况下啊,这么使用都是不正确的,都不是你想要达到的效果。”
[04:40](https://www.youtube.com/watch?v=XRcF_wbSDd4&t=280s) 正确写法:使用 None 占位并保持防范
针对这个经典陷阱,Python 官方文档给出的标准解决方案是:将默认参数设置为不可变单例对象 `None`,并在函数体内部进行动态判断与初始化。
高天将代码进行了重构:在定义构造函数时,将参数声明改为 `items=None`。在函数内部,小明应该添加条件分支判断: ```python if items is None: self.items = [] else: self.items = items ```
在演示编写这部分代码时,高天特意提醒大家细节:“这里注意是 `is None`,我们下一期视频可以讲一下为什么这块必须要是 `is None`。”他强调判断对象是否为 `None` 时应使用身份比较运算符 `is` 而非等于比较运算符 `==`。
重构完成之后再次运行脚本,输出恢复正常,打印出来的 `Alice` 道具列表里就仅有她自己获得的 `armor` 了,数据混乱的问题彻底得到解决。视频最后,高天向提供字幕支持的志愿者杨茜茜表达了感谢,并提醒大家如果喜欢内容请点赞、关注并一键三连。
## Mentioned 码农高天, Python, VS Code, 杨茜茜
## Video description 今天聊一个python中和默认参数有关的非常常见的bug,相信python写了有一定年头的都遇到过这个问题,但是下次可能还记不住。希望通过这个视频,加深一下印象~
[Source](https://www.youtube.com/watch?v=XRcF_wbSDd4)