Python默认参数仅定义时求值一次 共享可变对象引发状态污染

【Python】我精心设计的默认参数,怎么就出问题了呢? — 码农高天

码农高天

由于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)

中文简报

Python默认参数仅定义时求值一次 共享可变对象引发状态污染 由于Python在解析函数定义时仅对默认参数求值一次,将列表等可变对象设为默认值会导致未传参实例共享同一内存对象,开发者须改用None占位并在函数体内动态初始化。 视频通过一个游戏开发中 `Player` 玩家类的具体场景,演示了将默认参数设为空列表 `[]` 所引发的数据污染问题:未传参的玩家会意外共享同一个道具列表。码农高天借助 `id()` 函数和 Python 官方文档解释其底层原理,即默认参数仅在函数定义时求值一次,因此可变对象会被后续调用持续复用。最后,他给出了行业通用的标准解法,即将默认参数设为 `None` 并在函数体内动态初始化空列表,同时强调了使用 `is None` 进行判空的重要性。 在 Python 中使用空列表等可变对象作为默认参数会导致实例间数据共享与污染,是工程中极具隐蔽性的经典缺陷。 (0:00) 该 bug 的底层成因在于 Python 仅在函数定义阶段对默认参数求值一次,未显式传参的调用都会复用同一个内存对象。 (3:00) 官方推荐的解决方案是将默认参数设为 None,并在函数体内通过 if items is None 动态创建新的可变对象。 (4:40)

Reading summary

码农高天指出,在 Python 中使用列表等可变对象作为函数默认参数会导致所有未传参的调用共享同一内存对象,进而引发隐蔽的数据污染问题。 可变默认参数导致的数据串扰现象 (0:00) 在模拟游戏系统的场景中,程序员小明将玩家类 Player 的初始道具参数默认值设为空列表 items=[],旨在方便普通玩家 Alice 和 Bob 在不传参的情况下默认拥有空背包,而付费玩家 Charles 则显式传入初始道具列表。然而,当 Alice 追加防具 armor、Bob 追加武器 sword 后,打印 Alice 的背包却意外同时出现了 armor 与 sword。高天指出,这种设计使未传参实例意外跨越逻辑边界互相干扰,是工程中极具隐蔽性的经典缺陷。 定义期求值机制与对象共享原理 (3:00) 高天结合 Python 官方文档解释,该现象的根源在于 Python 解析函数定义时,默认参数表达式仅被求值(evaluate)一次,求得的对象会被持久绑定为该参数的默认值。由于列表属于可变对象(mutable object),Alice 和 Bob 接收到的默认道具参数在底层引用的是内存中的同一个列表对象。通过在代码中加入 id(self.items) 验证,控制台输出显示 Alice 与 Bob 的道具列表内存地址完全一致,而显式传参的 Charles 拥有独立的内存地址,从而证实了修改操作是对同一共享对象的直接写入。 以 None 占位的规范重构策略 (4:40) 针对可变默认参数带来的风险,Python 官方给出的通用解决方案是将默认参数设置为不可变对象 None,并在函数体内进行动态初始化。高天将构造函数参数重构为 items=None,并在内部通过 if items is None: self.items = [] else: self.items = items 实现分支赋值。重构后运行程序,Alice 的背包仅保留了自身获得的防具,彻底消除了实例间的数据污染。高天同时提醒,在判断是否为 None 时应严格使用身份运算符 is,而非等于运算符 ==。 可变默认参数在定义时只求值一次的特性极易导致状态共享,官方推荐方案为将其设为 None 并在函数体内动态创建新对象;关于为何判断条件必须使用 is None 而非 ==,高天表示留待后续内容展开解答。

Original transcript

[0:00] 今天我们聊一个有关python的默认参数的一个大坑大家可以看看之前有没有掉到这个坑里过谢谢大家在开始之前呢我首先说一下在之前的视频里有人会说我的代码太小了啊看不清我没有想到那么多人是用手机来看视频的所以这一期呢我把整个的VSCode的布局变了一下然后把代码放大了一下这个代码的大小够不够大大家可以把这个反馈给发到弹幕里或者发到评论里我会看的好 我们说回这个大坑啊 小明呢在写一个游戏啊他这个游戏本身非常复杂的 我把它抽象一下总之呢 他就是说我要写一个class 这个class是一个玩家对吧这个玩家现在只需有两个 attribute一个是他的名字 还有一个他身上带的道具 对吧 那么小明想好 比如说我初始化一个玩家叫PE对吧 PE等于player 呃他的名字叫Alice 对不对 他开始说什么都没有 然后再有一个PR等于player 报了对吧 报了他开始的时候什么都没有然后小明就发现了一个问题说他好像初始化的大部分的玩家刚开始都没有道具可能只有很少数的玩家 付费玩家 刚开始可能才有一个道具 比如说这个查尔斯 他很厉害 他刚开始有一个道具叫做大部分的玩家都没有道具小朋友就讲了我会重载对不对会重载python可以通过默认参数的方式进行非常方便的重载那既然大部分的玩家刚开始都没有道具的话我可以直接让这个items默认成空的这样的话我在创建Alice跟创建Bob或者创建其他没有items的玩家的时候不就方便了吗如果到现在你没有意识到他犯了什么错误的话那你跟他掉进了同一个大坑我们来看一下这是一个什么坑啊好假如说在后面的游戏中比如说Alice得到了一些道具 我们直接很简单的items are penned 不用其他复杂的函数 比如说他得到了一个armor对吧然后呢 bob也得到了一个道具 我们假设他得到了一个跟这个付配玩家charles一样的sword好一切看起来很正常对不对我们打印Alice的道具运行一下这个程序好我们会发现Alice有一个Armor还有一个Sword这个bug在实际的编程中经常会藏的比较深比较隐蔽不太容易发现那这个bug的原理是什么呢 你很有可能已经猜到了 就是python在做这种默认参数的时候他只会把这个默认参数evaluate一遍他只会求一次值然后把这个值当做默认参数换句话说你这个P1初始化的时候和P2初始化的时候这个items用的是同一个list而List在python里面是mutable的所以Alice跟Bob share了一个item list那为了确认我们这个猜想呢我们可以尝试在这里面打印一下这个他的ID print self.id self.items在python里面打印ID是可以确认两个object是不是同一个object的方法好我们再运行看这个程序python default 2.0块我们可以看到前面打印出来的两个ID是一模一样的而第三个爱迪是不一样的这说明Alice跟Bob确实共享了同一个list而Charles因为传进去了新的list里面有个sword所以跟他们两个人都不一样也正因如此Alice得到一个ArmorBob得到一个Sword这两个东西被放到了同一个list里就出bug了实际上Python是有官方说明解释这个事情的我们看这段 这段就是说 当这个函数被定义的时候他的这个表达式只会被求职一次然后每一次调用的时候 默认参数都会使用这个值后面这句话很重要当你使用一个muteable的默认参数的时候你一定要理解你在干什么那在大部分的情况下啊这么使用都是不正确的都不是你想要达到的效果而下面派档的官方文档其实也给出了怎么解决这个问题就是使用Nine回到我们的程序这个问题应该怎么解决呢我们应该把这个默认参数变为Nan然后在这里面写if items [5:00] 这里注意是ETH NONE我们下一期视频可以讲一下为什么这块必须要是ETH NONE然后它等于空档,else,叫 their items等于 items嗯?谢谢大家把程序变成这个样子之后我们再运行就会发现这次正常了我们打印的这个Alice的道具就只有Armor了好那这期视频就到这里喜欢的话点一下关注记得一键三连哦字幕志愿者 杨茜茜