Python 的列表和元组到底差在哪?拆开 PyListObject 和 PyTupleObject,为什么一个能改一个不能

简介: list 和 tuple 的差别不只是能不能改:CPython 3.13 实测 tuple 字面量创建快 6.7 倍、迭代快约 16%、索引快约 18%、空容器省 16 字节。根在结构——PyTupleObject 定长不带 allocated 字段,PyListObject 多带扩容冗余。本文用实机数据拆开两者底层,告诉你什么时候该用哪个。

结论先行:list 和 tuple 的差别不只是"能不能改"这么简单。实测 CPython 3.13.14:tuple 字面量创建比 list 快 6.7 倍(0.012s vs 0.078s,各 100 万次)、迭代快约 16%、索引快约 18%、空容器省 16 字节。代价只有一个:创建后不能改。本文用实机数据拆开两者的底层结构,告诉你什么时候该用哪个。

1. 先看内存:同样装 1000 个元素,差了多少

import sys
for n in [0, 3, 10, 1000]:
    l = list(range(n)); t = tuple(range(n))
    print(n, sys.getsizeof(l), sys.getsizeof(t))

实测输出(CPython 3.13.14,64 位):

元素数 list tuple 差值
0 56 B 40 B 16 B
3 88 B 64 B 24 B
10 136 B 120 B 16 B
1000 8056 B 8040 B 16 B

为什么 list 永远更胖? 拆开 C 源码看结构:PyTupleObject 只需要存一个定长的指针数组;而 PyListObject 除了指针数组,还多带一个 allocated 字段——它要记录"预分配了多少空间",为后面的 append 扩容做准备。这个字段就是 8 字节,加上对象头差异,list 天生比 tuple 重。

2. 创建速度:tuple 字面量快 6.7 倍,但有个反转

import timeit
print(timeit.timeit("[1,2,3,4,5]", number=1000000))   # 0.078s
print(timeit.timeit("(1,2,3,4,5)", number=1000000))   # 0.012s

100 万次字面量创建:list 0.078s,tuple 0.012s,tuple 快 6.7 倍。

原因藏在编译期:只含常量的 tuple 字面量会被 CPython 做常量折叠——编译时就算好存进常量表,运行时直接从常量表里取引用,几乎零成本;而 list 字面量每次都要真刀真枪地调 BUILD_LIST 重建一个新对象。可变的东西没法折叠,这是"可变"的第一笔税。

但别急着下结论,换个写法就反转了:

print(timeit.timeit("list(range(1000))", number=1000))   # 19.4ms
print(timeit.timeit("tuple(range(1000))", number=1000))  # 21.0ms

从迭代器构造时,list 反而略快(19.4ms vs 21.0ms)。因为 tuple(...) 对任意可迭代对象要先走一遍通用迭代协议,而 list(...) 有专门的快速路径。所以"tuple 一定快"这个说法,只在字面量场景成立。

3. 读操作:tuple 全面小胜

l = list(range(10000)); t = tuple(range(10000))
print(timeit.timeit("for x in l: pass", globals={
   'l': l}, number=1000))  # 103.8ms
print(timeit.timeit("for x in t: pass", globals={
   't': t}, number=1000))  #  89.4ms
print(timeit.timeit("l[500]", globals={
   'l': l}, number=1000000))  # 0.0359s
print(timeit.timeit("t[500]", globals={
   't': t}, number=1000000))  # 0.0295s

迭代 10000 元素 × 1000 次:tuple 快 16%;索引取值:tuple 快约 18%。

两者底层都是指针数组 + 随机访问,为什么 tuple 还快?因为 tuple 是定长不可变对象,解释器对它少了边界和容量检查的分支,GC 跟踪也更轻。差距不大,但在热循环里是白捡的。

4. 写操作:append 68 纳秒 vs 拼接 430 纳秒

l = [0]*100
print(timeit.timeit("l.append(1)", globals={
   'l': l}, number=1000000))  # 0.068s → 68ns/次
t = tuple(range(100))
print(timeit.timeit("t + (1,)", globals={
   't': t}, number=10000))       # 430ns/次

这就是"不可变"的真正代价:tuple 没有 append,想"加"一个元素只能 t + (1,) 重建一个全新对象,把旧元素整个拷贝一遍。n 元素的 tuple 拼一次是 O(n);而 list 的 append 靠超额分配,摊还下来是 O(1),实测 68 纳秒一次。

更关键的是 allocated 字段的扩容策略:list 的空间不是用一个分一个,而是按约 1.125 倍超额分配,所以连续 append 1000 次,真正重新分配内存的次数只有十几次。这就是为什么循环里往 list 追加很快,往 tuple"追加"是灾难。

5. 一个常被忽略的功能差异:能不能当字典的 key

d = {
   (1, 2): "ok"}   # 正常
d = {
   [1, 2]: "ok"}   # TypeError: unhashable type: 'list'

tuple 在元素全部可哈希时自己也可哈希,能当 dict 的 key、能进 set;list 不能。做坐标点、复合键、缓存键的时候,tuple 是唯一选择。 另外函数默认参数、*args 收进来的东西,CPython 一律给的是 tuple——因为解释器要保证你拿到的这份东西不会被别处悄悄改掉。

6. 选型清单(收藏这张表)

场景 用哪个 原因
常量序列 / 字面量 tuple 常量折叠,创建快 6.7 倍
需要增删改 list append 68ns vs tuple 重建 O(n)
当 dict key / 进 set tuple 可哈希
热路径只读遍历 tuple 迭代/索引快 16~18%
函数返回值(多值) tuple 不可变,调用方改不坏
从迭代器大批量构造 list 专门快速路径,略快

小结:tuple 的"快"来自不可变——常量折叠省创建、定长省检查、省 16 字节头部;list 的"灵活"也贵在有预分配字段和扩容策略。一句话:数据是"定死的一坨"就用 tuple,是"会长大的容器"就用 list,选反了不会报错,但会白白交性能税。

你在项目里因为把 list 当 tuple(或反过来)踩过坑吗?评论区聊聊。

实测环境:CPython 3.13.14(64 位 Windows),timeit 默认计时,各场景取文中标注的次数。数值随机器有波动,但相对关系稳定。

相关文章
|
7月前
|
人工智能 Linux API
OpenClaw全自动小红书运营实战:从0到1全流程部署、技能配置与内容自动化发布指南
在内容自动化运营场景中,OpenClaw(Clawdbot)凭借高度可扩展的Skill体系与多任务执行能力,可实现从热点追踪、文案创作、封面生成到笔记发布、互动管理的全流程自动化。本文基于2026年最新环境,完整讲解如何通过阿里云轻量服务器或本地Windows11/macOS/Linux部署OpenClaw,安装并配置小红书运营Skill,完成Cookie登录、内容生成、笔记发布、数据监控,并接入阿里云百炼Coding Plan免费大模型与QMD记忆优化系统,实现低成本、7×24小时无人值守小红书运营。全文无营销词汇,所有命令可直接复制,零基础用户也能快速跑通全流程。
3467 9
|
13天前
|
缓存 Python Windows
Python 的 __new__ 到底管什么?和 __init__ 的分工一次讲透
__new__ 管造对象、__init__ 管初始化:11 组实验画出分工边界——两类事非 __new__ 不可(改造不可变类型构造、控制实例创建),日常 99% 初始化仍该写在 __init__。
|
21天前
|
安全 Python 容器
Python 深拷贝和浅拷贝到底差在哪?6 个让你半夜 debug 的案例 + 对照表
浅拷贝只复制第一层,嵌套的可变对象(list/dict/set)仍共享同一块内存;深拷贝递归复制所有层,新旧对象彻底解耦。6 个半夜 debug 的真实案例 + 对照表,把 copy.copy / copy.deepcopy 的坑一次讲清。
|
22天前
|
缓存 Python 容器
Python 里 `is` 和 `==` 到底差在哪?5 个让你怀疑人生的案例 + 对照表
is 比的是对象身份(id 相同),== 比的是值(__eq__ 结果)。本文用 5 个真实案例讲透二者区别,附对照表与最佳实践,并避坑小整数池 / 字符串驻留陷阱。
|
2天前
|
缓存 编译器 Sentinel
Python 里 is 和 == 到底差在哪?拆开小整数池、字符串驻留与 0ns 身份比较
is 比 == 快不是 Python 偷懒,而是它只比对象身份(指针),0ns 级别;== 要走 __eq__ 还可能触发哈希。但小整数池和字符串驻留会让 is 在「本不该相等」时也返回 True,这是 80% 初学者踩的坑。
|
3天前
|
Python
Python 拼字符串为什么别在循环里用 +=?拆开 join 的两遍账:191.8 微秒与 6.8 微秒
循环 += 拼接 1000 段要 191.8 微秒,已有列表直接 join 只要 6.8 微秒:join 先扫一遍算总长再一次性分配,+= 每轮都可能复制整个已拼好的串。CPython 对 += 的原地扩容依赖引用计数,加一个别名后涨到 459.8 微秒。3 片段场景 f-string 约 251.8ns,join 反而 306.4ns。
|
4天前
|
C++ Python
Python 的函数调用到底贵在哪?拆开一次 call:51 纳秒栈帧与 *args 的 40 纳秒罚单
Python 函数调用一次约 100ns:空调用 97.8ns,其中栈帧创建机制约 51ns;*args 打包 tuple 贵 27%,**kw 建 dict 贵 15%;绑定方法比类函数调用快 40%。fib(30) 的 200ms 几乎全是调用开销,内联优化上限约 48ns。7 组实测数据。
|
10天前
|
存储 缓存 编译器
Python 的整数为什么永远不会溢出?拆开大整数对象,看 30 位一节的存储与运算代价
Python 整数不会溢出,靠的是把数字拆成 30 位小节用对象数组存:每个整数至少 28 字节,每多 30 位加 4 字节;加法近似线性、乘法超线性——30 万位乘法是 30 位的 29 万倍;str 转换默认 4300 位护栏,to_bytes 比 str 快约 300 倍。用 9 组实验算清「无限精度」的账单。
|
11天前
|
Java 索引 Python
Python 字典为什么这么快?一次查找背后的哈希表、扰动函数和开放寻址
Python 字典查找平均 O(1):算一次哈希直接跳到桶位,实测 1 万 key 取值 4.6ms,同规模列表成员判断要 55 秒,差 12000 倍;但哈希全碰撞时慢 2111 倍——用 4 组实验讲透开放寻址、扰动函数和空间换时间。
|
12天前
|
JSON 缓存 C语言
Python 的 True + True 为什么等于 2?bool 是 int 子类,这个设计坑了多少人
True+True==2、isinstance(True,int) 为真:bool 是 int 的子类,True 就是 1。9 组实验讲透这个 C 时代设计遗产的顺手与埋雷——字典键碰撞、JSON 序列化、== 与 is 混用三大坑一次避开。