资讯动态

Python列表与元组详解:可变性、性能与选型

发布时间:2026/9/20 4:40:56 来源:尧图企业网站定制
列表和元组——Python 里的“爱情”列表善变元组长情我刚学 Python 那会儿总有人跟我说“列表和元组差不多差一个能不能改而已。”真到了写项目的时候才发现这个“能不能改”背后藏着 Python 里一套非常讲究的设计哲学——一个像年轻时候的爱情热烈、直接、随时随地可以调整另一个像走过几十年的伴侣稳定、可靠、说出去的话就不会变。所以 Python 官方给它们起了个非常形象的外号列表是“可变序列”mutable sequence元组是“不可变序列”immutable sequence。中文社区里也有个特别接地气的说法——列表善变元组长情。这个比喻不是硬凑的是真的能从语言层面找到依据。列表用方括号[]元组用圆括号()这俩符号本身就暗示了它们的气质方括号像一扇随时能打开的门圆括号像一对合拢的手掌。今天这篇就围绕这个“爱情”比喻把列表和元组的底层机制、操作方法、性能差异、使用禁忌聊透让正在学 Python 基础语法的朋友一次搞明白什么时候该用善变的列表什么时候该用长情的元组。如果你是刚看完 Python 安装教程、正准备往深处走的新手这篇文章可以直接当你的第四课来读。我会把每个知识点掰开揉碎配合大量能直接复制的代码示例读完你就能在真实项目里做出合理选型而不是只会背“列表可变、元组不可变”这一句口诀。1. 列表为什么“善变”——把可变性拆开看清楚先说结论列表的“善变”指的是它的内容可以在原地修改——可以追加元素、删除元素、替换元素、调整顺序而且修改之后列表在内存中的“门牌号”也就是对象 id不会变。这意味着你操作的是同一个列表对象不是重新造了一个。1.1 原地修改append、insert、pop 都动了什么Python 列表底层是一个动态数组dynamic array你可以把它想象成一条会自己变长的停车位划线区。初始时可能只划了 4 个车位但车多了它会自动在旁边多划几个而且整个区域的地址不变车还是停在同一片地方。看这段代码nums [1, 2, 3] print(id(nums)) # 输出某个内存地址比如 140261892292928 nums.append(4) nums.insert(0, 0) print(nums) # [0, 1, 2, 3, 4] print(id(nums)) # 地址和上面完全一样append是在末尾添一个元素insert是在指定下标位置插入。这两个操作执行完后id(nums)没有变化说明是典型的内存原地修改。pop和remove也一样nums.pop() # 弹出最后一个返回 4 nums.remove(0) # 删除第一个值为 0 的元素 print(nums) # [1, 2, 3]我见过不少从 C 语言转过来的朋友一开始总有个疑惑“数组不是定长的吗Python 列表怎么能随便加东西”这就要说到动态数组的扩容机制当元素个数超过当前容量时Python 会申请一块更大的内存通常是原容量的 1.125 倍左右然后把旧元素全部拷贝过去。这个操作是自动完成的用户无感知。也正因为容量会预分配列表的append操作平均时间复杂度是 O(1)——注意是“平均”偶尔触发扩容时会慢一下。1.2 切片操作列表最“善变”的高光时刻切片slice是列表操作里最灵活、也最容易让人眼前一亮的功能。它用[start:stop:step]的语法从列表里截取一段而且这个截取支持连续读、跳着读、倒着读。alist [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] alist[2:5] # 下标 2 到 4结果是 [2, 3, 4] alist[:3] # 从头到下标 2结果是 [0, 1, 2] alist[5:] # 从下标 5 到末尾结果是 [5, 6, 7, 8, 9] alist[::2] # 每隔一个取一个结果是 [0, 2, 4, 6, 8] alist[::-1] # 整个列表倒过来结果是 [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]切片有个容易忽略的细节步长为负数时start和stop的默认值会互换。比如alist[::-1]默认就是从最后一个元素开始一直取到第一个元素所以实现了翻转。切片还能直接用来批量替换元素这是“善变”的极致体现alist[2:5] [20, 30, 40, 50] print(alist) # [0, 1, 20, 30, 40, 50, 5, 6, 7, 8, 9]注意这里我把 3 个元素替换成了 4 个列表长度会自动变化。如果赋值的是一堆空列表就等价于删除alist[2:5] [] print(alist) # [0, 1, 5, 6, 7, 8, 9]这个行为比del alist[2:5]稍微隐蔽一点但原理一致。1.3 内存模型为什么列表里存的是“引用”而不是“值”很多新手会在这里翻车list2 list1之后修改list2却发现list1也变了。原因在于 Python 的变量名本质是指向对象的标签列表对象本身只有一个两个变量名指向的是同一块内存。a [1, 2, 3] b a # 把 a 这个“标签”贴到同一个列表上 b.append(4) print(a) # [1, 2, 3, 4]a 也跟着变了想真正复制一份独立的列表要使用切片b a[:]或者b a.copy()如果列表里嵌套了子列表浅拷贝还不够得用copy.deepcopyimport copy matrix [[1, 2], [3, 4]] shallow matrix.copy() # 外层新列表内层还是同一个子列表对象 deep copy.deepcopy(matrix) # 完全独立复制 shallow[0].append(99) print(matrix) # [[1, 2, 99], [3, 4]]matrix 被污染了 print(deep) # [[1, 2], [3, 4]]deep 不受影响这是一个非常重要的实战细节。做数据清洗、算法题时如果没搞清浅拷贝和深拷贝的区别经常会出现“明明复制了一份改着改着把原数据也改了”的诡异 bug。2. 元组为什么“长情”——不可变性的三重表现如果说列表是“写下来还能改的草稿”元组就是“刻在石头上的誓言”——一旦创建内部的元素引用就不能再增、删、改。用专业术语说元组是不可变序列。2.1 元组的创建方式与“裸元组”陷阱最常见的创建方式是圆括号t1 (1, 2, 3) t2 () # 空元组 t3 (1,) # 单元素元组注意这个逗号这里有一个所有初学者都会踩的坑t (1)并不是元组它只是一个整数 1因为圆括号在 Python 里还承担着“分组”的作用(1)就是普通的数字。想要创建单元素元组必须在元素后面加一个逗号(1,)。还有一种创建方式不显眼但很实用t4 1, 2, 3 print(type(t4)) # class tuple逗号才是元组的本质圆括号经常只是装饰。这个知识点会引出后面要讲的“元组解包”也是 Python 里非常优雅的语法糖。2.2 不可变到底意味着什么不能增删改但可以整体替换先看最直白的报错场景t (1, 2, 3) t.append(4) # AttributeError: tuple object has no attribute append t[0] 100 # TypeError: tuple object does not support item assignment del t[0] # TypeError: tuple object doesnt support item deletion这三行每一行都会直接抛异常。元组没有append、insert、pop、remove这些修改方法也没有__setitem__这个特殊方法所以任何“原地修改”的念头都是非法的。但要注意“不可变”不等于“不能重新赋值”。下面的操作是合法的t (1, 2, 3) t (4, 5, 6) # 这不是修改元组而是让变量 t 指向一个全新的元组对象就像你戴了十年的手表不能换零件但你可以整块换一块新手表。变量名只是一个标签标签可以撕下来贴到别的对象上。这个区别在函数传参时特别重要——如果函数内部对参数重新赋值外部变量不会受影响但如果函数内部修改的是可变对象的内部内容外部变量就会跟着变。2.3 元组里的隐藏可变性元素本身是列表时元组“长情”是指它保存的引用列表不能变但不代表这些引用指向的对象不能变。如果元组的某个元素恰好是一个列表这个列表的内容是可以被修改的t (1, [2, 3], 4) t[1].append(99) print(t) # (1, [2, 3, 99], 4)元组没“变”但元组里的列表变了这个细节在面试中几乎必考在实际开发中也经常害人。比如你在元组里存了一个列表作为配置数据某段代码不小心改了那个列表元组看起来还是那个元组但行为已经变了。这就是为什么“不可变”要打引号——更准确的说法是元组的浅层不可变。2.4 元组解包把“长情”变成优雅的代码元组解包tuple unpacking是 Python 里极其好用的特性语法极其自然point (3, 5) x, y point print(x, y) # 3 5 a, b b, a # 经典的交换变量值写法本质就是元组打包然后解包这个语法背后就是“逗号创建元组”的机制b, a先临时打包成一个元组然后解包赋值给左边。Python 的return语句也常配合这个特性一次性返回多个值def get_user(): return 张三, 28, [篮球, 摄影] name, age, hobbies get_user()你完全可以用列表来返回多个值但元组更合适——返回的数据是“完整的一份结果”调用方不需要也不能在你返回之后偷偷改。这种语义上的清晰正是元组在实际项目中最大的价值。还有一个解包玩法叫星号解包Python 3 之后才支持特别好用first, *rest [1, 2, 3, 4, 5] print(first) # 1 print(rest) # [2, 3, 4, 5]它能把列表或元组的头部和剩余部分一次性拆开配合循环非常顺手。3. 列表和元组的区别从性能到语义的全面对比标题里的“爱情”比喻已经点出了核心区别但真要在代码里做选择光有比喻不够。我把两者的差异按五个维度整理出来这节内容可以直接当参考表用。3.1 五维对比可变性、性能、哈希性、占用内存、语义维度列表 list元组 tuple可变性可变支持增删改不可变不能增删改存储开销更大需要预留扩容空间更小精确分配内存性能稍慢动态扩容、引用维护稍快结构更简单哈希性不可哈希不能做字典键元素均为可哈希时可哈希可做字典键语义“动态集合”数据会变“固定记录”结构不变内存和性能差异在数据量很小时几乎感觉不到但如果是百万级元素元组的迭代速度通常会快 10% 到 20%内存占用也少一截。原因很直接元组的内存是固定大小、一次性分配的而列表需要维护一个“容量”概念还要为可能的扩容预留空间。3.2 哈希值元组能做字典键列表不能这个是列表和元组区别里最“硬核”的一个知识点很多面试官喜欢问。Python 的字典键要求对象是可哈希的hashable也就是对象必须有稳定的哈希值。列表是可变的内容一变哈希值就乱了所以它不能做字典键。元组不可变只要元组内的所有元素都可哈希这个元组就是可哈希的能安全地作为字典键使用。# 合法元组作为字典键 positions { (0, 0): 起点, (1, 3): 宝箱, (4, 2): 出口, } print(positions[(1, 3)]) # 宝箱 # 非法列表不能作为字典键 positions { [0, 0]: 起点, # TypeError: unhashable type: list }这个特性在游戏开发、路径规划、表格数据处理中特别常用——坐标、行列号这种“固定结构”天然适合用元组表示。3.3 安全性与语义表达为什么函数默认参数建议用元组可变对象作为函数默认参数是 Python 里最经典的坑之一。看这段代码def add_task(task, tasks[]): tasks.append(task) return tasks print(add_task(写博文)) # [写博文] print(add_task(改bug)) # [写博文, 改bug]默认值被记住了问题在于函数定义时默认列表[]只创建一次之后每次调用如果没传新列表用的都是同一个列表对象。第一次调用往里加了元素第二次调用看到的自然就是带元素的结果。如果把默认值改成元组这个坑就不会发生——元组不可变就是想往里塞东西也塞不了def add_task(task, tasks()): tasks tasks (task,) # 新建一个元组原元组不受影响 return tasks这里tasks tasks (task,)创建了一个全新的元组对象原默认值永远不变。安全性一下子就上来了。从语义角度说元组天然适合表达“一个固定的记录结构”比如坐标点、颜色值 (r, g, b)、数据库查询返回的一行数据。列表适合表达“数量会变的一组东西”比如购物车、待办事项、日志行。选错容器代码会显得别扭还会埋下“某个函数不经意间改了共享数据”的隐患。3.4 一道经典题的生动演示在列表和元组上的不同命运这个操作符在列表和元组上的表现可以说是“善变”与“长情”最生动的对比lst [1, 2, 3] lst [4, 5] print(lst) # [1, 2, 3, 4, 5]同一个列表原地扩充了 tup (1, 2, 3) tup (4, 5) print(tup) # (1, 2, 3, 4, 5)看起来变了但 id(tup) 已经变了列表的相当于调用了extend方法原地修改元组的则是创建了一个全新元组再把变量名绑定过去。你可以打印id验证元组的id前后完全不同。有个著名面试题问元组里的列表用会不会报错答案是会报错但列表已经变了t (1, [2, 3], 4) try: t[1] [99] except TypeError as e: print(e) # tuple object does not support item assignment print(t) # (1, [2, 3, 99], 4)报错是因为元组不允许“把新列表赋回下标 1”但内部的逻辑是先修改列表内容再尝试把结果赋回元组位置。这个“先斩后奏”的行为非常魔鬼理解了元组的浅层不可变之后这题就再也骗不到你了。4. 什么时候用列表什么时候用元组——实战选型思路看完对比最后要落到实际代码里真实项目里到底怎么选我总结了一套简单好记的判断标准分三种情况。4.1 口诀式判断标准结构固定用元组数量动态用列表最简单的判断口诀是如果数据量会变——比如用户列表、日志记录、缓存结果用列表。如果形状不会变——比如坐标、RGB 颜色、日期 (年, 月, 日)、数据库一行记录用元组。这个口诀和前面“爱情”比喻完全一致列表在成长在变化元组自始至终保持同一副模样。写代码和选伴侣一样三观合不合很多时候一开始就写在类型里了。4.2 综合案例从需求描述推导容器选型假设要写一个小工具管理一个“英雄角色信息”系统包含角色的名字、职业、初始血量以及一个可以持续获得和消耗的物品背包。这里显然有两种不同性质的容器# 角色基础信息创建后不该变用元组 base_info (亚瑟, 战士, 3000) # 背包动态增减用列表 backpack [治疗药水, 铁剑, 木盾] backpack.append(魔法书) backpack.remove(木盾)如果把角色基础信息做成列表可能某个函数手滑就base_info[0] 改名了整个数据的一致性就崩了。用元组编译器替你守住“不该变的不变”这条规则这是类型设计带来的最直接收益。用 Python 的collections.namedtuple还能让元组更“高级”——字段带名字可读性提升一个档次from collections import namedtuple Hero namedtuple(Hero, [name, job, hp]) hero Hero(亚瑟, 战士, 3000) print(hero.name) # 亚瑟 print(hero.job) # 战士 # 而且它依然是元组支持解包、索引 name, job, hp hero这可以说是元组的“升级形态”既有元组的不可变安全性和低开销又有类似类的属性访问体验非常适合表达固定结构的业务数据。4.3 需要“伪列表”语义时使用tuple()保护数据有时候你拿到一个列表是别人传进来的或者是从文件里读出来的但你接下来要把这份数据当作“整体”传给下游函数并且不希望下游函数意外修改它。这时候可以临时转成元组相当于给数据加了一层只读保护config_version v1.0 allowed_users [alice, bob, carol] safe_users tuple(allowed_users) def check_user(user, user_pool): # 这里哪怕写了 user_pool.append(...) 也不会影响外部 allowed_users return user in user_pool print(check_user(alice, safe_users)) # True不过要诚实地说一句Python 没有绝对的强制只读真要绕过也有很多方法。元组的意义更多是约定和提醒——看到元组所有维护代码的人都明白这里面的数据别乱动。4.4 细节提醒什么时候元组反而麻烦元组也不是银弹有些场景下它确实不够用。比如你维护一个爬虫任务队列任务可能随时插队、取消、调整优先级这种场景用列表或者collections.deque更合适。再比如你要经常对一组数据做排序、去重、倒序列表的list.sort()、sorted(lst)用起来比元组顺手得多——元组因为不可变想排序只能sorted(tup)返回新列表或者手动转列表操作再转回元组多一层不必要的来回。所以选型不是“元组更高端所以都用元组”而是看数据本质倾向于“被使用、被读取”用元组倾向于“被构建、被修改”用列表。这个标准几乎适用于所有场景。5. 从零开始先跑起来再深挖顺便避开这三个新手坑新手阶段最怕的不是概念不懂而是“明明按教程敲了结果第一行就报错”。我这里给三条最容易在入门阶段绊倒人的踩坑经验全是实操教训。5.1 花括号、圆括号、方括号分不清的破局方法Python 里三种集合形态新手最容易混列表[1, 2, 3]元组(1, 2, 3)或1, 2, 3集合{1, 2, 3}花括号创建的是集合或字典它有两个重要特征无序、自动去重。如果你用{1, 2, 3}却指望它像列表一样保持顺序、允许重复就会踩坑s {3, 1, 2} print(s) # {1, 2, 3}顺序不保证 s.add(3) print(s) # 还是 {1, 2, 3}不会出现两个 3所以看到方括号下意识想到“可以改”看到圆括号想到“固定”看到花括号想到“无序去重”这套条件反射建立起来三个容器就不会再混淆。5.2 元组单元素漏写逗号一个让老手也会笑出声的 bug我再重复一遍这个坑因为它真的最高频、最低级、也最坑value (5) print(type(value)) # class int不是元组 value (5,) print(type(value)) # class tuple新手写元组时最后一个元素后面拖个逗号看起来“多余”实际上才是元组的标志。更隐蔽的情况是在函数返回时def get_value(): return 5,如果你只写return 5调用方接到的是整数写成return 5,调用方接到的是(5,)元组。这个逗号丢没丢直接决定下游代码能不能解包。建议在项目里定个规矩函数返回固定多条数据时统一用元组并显式加括号比如return (5, 6)既清楚又没有歧义。5.3 修改列表时不要“边遍历边删除”新手写数据过滤时最常犯的错是这样nums [1, 2, 3, 4, 5, 6] for n in nums: if n % 2 0: nums.remove(n) print(nums) # 你可能以为会得到 [1, 3, 5]实际是 [1, 3, 5]这里看似没问题如果数据换成[1, 2, 4, 5, 6]再跑一次就会发现结果不对因为遍历时索引会前移删除元素后有些元素会被跳过nums [1, 2, 4, 5, 6] for n in nums: if n % 2 0: nums.remove(n) print(nums) # [1, 4, 5]——4 被跳过了原因很简单遍历到 2 时把它删了列表变成 [1, 4, 5, 6]但迭代器内部的下标已经走到了 1 位置下一次取到的就是 54 被跳过。正确做法是包装一份副本或者使用推导式nums [1, 2, 4, 5, 6] nums [n for n in nums if n % 2 ! 0] print(nums) # [1, 5]列表推导式是 Python 里最值得花十分钟彻底学会的语法之一能把“创建新列表并过滤”压缩成一行可读性极好性能也快。它本质上就是“善变”的列表和新列表之间的高效桥梁。6. 进一步掌握切片、推导式与排序——列列表和元组都适用的进阶技巧列表和元组都属于“序列类型”所以很多操作是通用的——切片、索引、in判断、len()、拼接、*重复在两者身上都能用。这一节把使用频率最高的几个技巧集中讲透。6.1 切片在元组上的同时效果元组也支持切片而且返回的结果仍然是元组t (0, 1, 2, 3, 4, 5) print(t[1:4]) # (1, 2, 3) print(t[::-1]) # (5, 4, 3, 2, 1, 0)所以“想反转一个元组”直接切片翻转即可不需要先转列表。同样字符串也有一样的切片语法三种序列在此刻表现完全统一。学会一次三种类型通吃。6.2 列表推导式与元组生成器只差一个括号列表推导式写多了你可能自然想到“元组推导式”直接写(x for x in ...)得到的并不是元组而是生成器对象gen (x * x for x in range(5)) print(type(gen)) # class generator print(tuple(gen)) # (0, 1, 4, 9, 16)用 tuple() 转换才得到真元组这是新手很容易踩的另一个坑。想直接用推导式得到元组写法是这样的t tuple(x * x for x in range(5))等价于先把生成器转成元组。这个细节看起来只是“多转了一下”但在处理大数据时反而成了优势生成器是惰性求值不会一次性在内存里堆出所有元素性能友好。6.3 sorting列表原地排元组排完变列表列表有sort()方法原地排序不返回新对象元组不能原地排序只能用全局函数sorted()nums [3, 1, 2] nums.sort() print(nums) # [1, 2, 3] t (3, 1, 2) sorted_t sorted(t) print(sorted_t) # [1, 2, 3]是列表不是元组 print(tuple(sorted_t)) # 想保持元组类型手动转一下如果不关心返回类型sorted()对两种序列都能直接使用还支持reverseTrue参数和key关键字。key的用法值得单独练一下words [banana, apple, cherry, date] sorted_by_len sorted(words, keylen) print(sorted_by_len) # [date, apple, banana, cherry]6.4 用*解包操作合并多个列表或元组Python 3.5 之后*解包不仅能用于函数参数还能直接用在字面量里拼接序列list_a [1, 2] list_b [3, 4] merged_list [*list_a, *list_b] print(merged_list) # [1, 2, 3, 4] tup_a (1, 2) tup_b (3, 4) merged_tuple (*tup_a, *tup_b) print(merged_tuple) # (1, 2, 3, 4)这个写法比list_a list_b更灵活因为可以在任意位置插入新元素merged [0, *list_a, 99, *list_b] print(merged) # [0, 1, 2, 99, 3, 4]对元组也能做类似操作比如在一个元组中间插入元素再生成新元组t (1, 3) new_t (*t[:1], 2, *t[1:]) print(new_t) # (1, 2, 3)这类小技巧在你处理数据合并、参数透传时非常实用能省掉大量临时变量。7. 写在最后的个人体会列表和元组的区别是 Python 入门阶段最值得你停下来多想几分钟的知识点。它不只是一个语法规则而是理解“引用 vs 对象”“可变 vs 不可变”“修改 vs 重新赋值”这些底层概念的绝佳入口。搞懂了它后面学字典的键值约束、函数的默认参数、类里的__hash__、甚至多线程下数据共享的安全问题都会顺很多。我在实际项目里的体会是新人往往偏爱列表老手则越来越喜欢元组。因为元组提醒你“结构固定”这个事实强制你写出更稳的代码列表则给你无限自由同时也给了你搞乱自己的机会。两者没有高下之分用对场景就是好代码。最后分享一个我自己常备的小技巧在写函数、写配置、写接口返回值的时候先问自己一句“这个数据将来会被修改吗”答案是“不会”——就用元组答案是“会”——才用列表。这个问题想清楚一遍代码的健壮性和可读性都会有肉眼可见的提升。这篇基础课就到这里接下来可以继续往字典、集合这些进阶数据结构上走每一步踩实了后面的 Python 之路会走得特别稳。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价