
1. 为什么要单独把列表和元组拿出来讲1.1 列表和元组在Python中的地位Python入门的教程千千万但不管哪一套讲下来列表和元组都是绕不过去的两道坎。原因很直接你写Python代码几乎不可能不碰它们。跑数据分析用列表装数据写自动化脚本用列表攒结果做Web接口序列化出来的JSON对象本质上也离不开列表和元组的组合嵌套。可以这么说列表和元组就是Python内置容器类型的地基地基打不牢后面学字典学集合、学函数式写法、学面向对象都会磕磕绊绊。我自己带过不少新人发现一个很有意思的现象很多初学者对列表上手极快因为列表太听话了——能加、能删、能改、能排序怎么折腾都行。但一碰到元组就开始犯迷糊这玩意不就是不能修改的列表吗功能还比列表少学它干什么这种疑问很正常但如果你只把元组理解成只读列表那后续写代码会遇到很多让你挠头的问题比如函数传参的坑、多变量赋值的写法、用元组作为字典键时报的 TypeError这些背后都跟元组的不可变性脱不开关系。所以这篇教程不打算只列API讲语法而是把列表和元组放在一起对照着讲把它们各自的性子、适用场景、隐藏的坑一次说清楚。这篇内容适合谁刚学Python语法的新手、准备笔试面试的求职者、或者写了两三个月脚本但总觉得对数据结构理解不深的半新手。有多年经验的老手可以直接跳到最后两节那部分有性能实测数据和几个日常开发里特别容易踩的细节问题。1.2 一个基本原则可变与不可变在开始写代码之前必须先建立一个核心认知Python里每个对象都有两个身份证件一个是它的数据类型另一个是能不能被修改。列表是典型的可变对象mutable你可以随时随地往里面追加元素、删除元素、修改某个位置的值元组则是不可变对象immutable一旦创建元素内容就不能再增删改。这个差异是列表和元组之间一切差别的根源包括它们的用法、性能、内存占用、能干什么不能干什么全部由这个最基本的事实派生出来。理解可变与不可变最关键的是要区分两个概念变量绑定和对象修改。比如你写a [1, 2, 3]变量a只是绑定到了列表对象[1, 2, 3]上当你执行a.append(4)时你并没有换掉这个列表而是在原列表对象内部加了新元素。但如果你写b a两个变量现在绑定了同一个列表对象此时你用b.append(4)a的内容也会跟着变。很多初学者第一次在代码里遇到这种我不想改aa却变了的诡异现象十有八九都是因为忽略了这个基本机制。元组之所以用起来省心恰恰是因为它没有这种隐性的修改入口一个元组对象从创建到销毁内容始终如一这在并发编程、函数传参、缓存处理里都是非常大的优势。2. 列表日常开发中使用频率最高的数据结构2.1 创建列表的几种姿势列表的创建看着简单其实也有讲究。最直观的方式是字面量语法numbers [1, 2, 3, 4, 5] names [张三, 李四, 王五] mixed [1, hello, 3.14, True]注意列表并不强制要求元素类型一致甚至能嵌套列表比如matrix [[1, 2], [3, 4]]这是后面处理二维数据的基础。除了字面量还有两种实用的创建方式。第一种是用list()构造方法chars list(python) # [p, y, t, h, o, n] nums list(range(10)) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]这个方法在把其他可迭代对象字符串、range、元组、字典的键等转成列表时非常方便。第二种是列表推导式可以在一行内完成循环处理收集的全过程squares [x**2 for x in range(10)] # [0, 1, 4, 9, ..., 81] even_squares [x**2 for x in range(10) if x % 2 0] # 带过滤条件列表推导式的执行效率比等价的 for 循环往空列表里 append 要高因为它的内部实现经过了优化。我在实际项目里更喜欢用推导式来处理数据清洗的场景比如从原始数据中筛选出满足条件的记录、对整列数据做映射转换代码看起来特别清爽。创建列表还有一个容易被忽略的坑用乘号*复制列表。[0] * 5会得到[0, 0, 0, 0, 0]这个没问题但如果你想创建一个包含三个子列表的二维列表写成[[0] * 3] * 3表面上看没问题实际得到的结果是三个子列表引用了同一个对象修改任意一个子列表的元素其他两个也跟着变。这是列表操作中最经典的深坑之一后面避坑部分我会专门展开。2.2 增删改查列表的常用操作与坑列表的增删改查是入门必会的操作我把常用的罗列一遍然后重点说几个容易出问题的细节。增加元素list.append(obj)尾部追加单个元素原地修改返回值是 Nonelist.insert(index, obj)在指定位置插入元素插入位置后面的元素会统一后移list.extend(iterable)将一个可迭代对象的所有元素逐个追加到尾部相当于批量追加切片赋值list[0:0] [x, y, z]可以在头部插入一组元素list[len(list):] [...]等效于 extend删除元素list.pop(index-1)弹出指定位置元素并返回默认弹出最后一个。这是最常用的取出来并删除操作实现栈结构非常方便list.remove(value)按值删除第一个匹配项。注意如果列表里没有这个值会抛出 ValueError所以实际使用前最好先判断value in listlist.clear()清空列表列表对象本身还在只是元素全没了del list[index]/del list[start:stop]按索引或切片删除元素。切片删除可以一次删掉一堆元素修改元素list[index] new_value按索引直接赋值切片整体替换list[0:2] [a, b, c]这个操作是先删除切片范围的元素再插入新元素所以新列表长度可以跟原切片长度不一致这一点跟许多语言不同这里必须重点提醒一个细节append和extend的区别。很多新手以为a.append([1, 2])和a.extend([1, 2])是一回事实际完全不同。前者把整个[1, 2]作为单个元素追加进去得到一个[[1, 2]]后者是把 1 和 2 分别追加进去得到[1, 2]。我见过好几回新人在这上面把数据弄成嵌套结构后面调接口时怎么都对不上字段值。另一个高频坑是循环删除。有人在遍历列表时用remove去删元素my_list [1, 2, 3, 2, 4] for item in my_list: if item 2: my_list.remove(item)这段代码执行结果并不是把所有 2 都删干净。原因是for循环在遍历时用的是内部索引删除元素后列表长度变化索引会跳过某些元素。正确的做法是遍历列表的拷贝或者用列表推导式过滤出要保留的元素比如my_list [item for item in my_list if item ! 2]。这个问题在面试里出现频率极高看起来简单但真让手写代码时能准确答出原因的人不多。2.3 切片一个极易踩坑又极其强大的特性列表切片是Python里让无数其他语言使用者羡慕的功能但同时也是新手理解门槛比较高的一块。基本语法是list[start:stop:step]它从原列表复制出一段新的列表左闭右开即包含start索引指向的元素不包含stop索引指向的元素。data [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] data[0:3] # [0, 1, 2]从索引0到索引2 data[:4] # [0, 1, 2, 3]start可以省略 data[5:] # [5, 6, 7, 8, 9]stop可以省略 data[:] # 全部元素复制的完整列表 data[::-1] # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]反转列表 data[::2] # [0, 2, 4, 6, 8]隔一个取一个 data[-3:] # [7, 8, 9]倒数三个负索引从末尾算起切片有几个细节想强调一下。第一data[:]是创建原列表的一个浅拷贝不是把变量重新指向原对象。所以如果想在不影响原列表的前提下复制一份数据来做临时处理直接用new_data data[:]最省事。第二步长不能为 0否则会报ValueError: slice step cannot be zero。第三切片后返回的始终是新列表即使切出来的范围和原列表完全一样两个列表对象的 id 也是不同的你可以验证一下data[:] is data结果是 False。切片的强大之处在于它不仅用于读取还能用于赋值和删除。data[1:3] [20, 30, 40]会把原列表中索引 1、2 的两个元素替换成新列表的三个元素整体长度会变。del data[1:3]可以直接删除一段元素。这个特性让切片在实现排序算法、窗口滑动的场景里特别顺手。需要特别留神的是切片的浅拷贝含义。如果原列表里装的是一堆子列表或字典切片复制出来的是外层容器的引用关系内层对象仍然指向同一份。这种情况下修改切片里内层对象的内容原列表对应位置的内容也会改变。想做到完全独立的副本得用copy.deepcopy或者自己写逐层复制这个场景在数据处理时遇到得很多后面避坑章节再展开。3. 元组那个总是被忽略的不可变列表3.1 元组的定义与基本操作元组的定义很朴素用圆括号包裹一组元素元素之间以逗号分隔。但第一个坑马上就来了——只带一个元素的元组必须加那个多余的逗号。t1 (1) # 这是整数1不是元组 t2 (1,) # 这是元组包含一个元素1 t3 1, 2, 3 # 这也是元组括号其实可以省略 t4 1, # 这也是元组(1,)很多新手第一次写单元素元组就栽在这里没有逗号的(1)被Python解释为纯数字。我自己也曾经在一段代码里因为这种写法把一个元组当成整数传给了接口排查了半天才发现是这个坑。为什么Python要做这样的设计因为圆括号在Python里还有表达式分组的含义(1)更像是数学表达式里对数字加括号而不是元组语法。所以Python语法规定元组的关键是逗号不是括号。这个知识点虽然小但笔试面试里出镜率非常高。元组的常用操作和列表高度重合比如索引访问t[0]、切片t[1:3]、拼接t1 t2、重复t * 3、成员判断x in t等都能正常使用。但因为是不可变对象元组没有append、insert、pop、remove、clear这些修改类方法也不能做索引赋值。从这个角度说元组确实像一个只读列表。但注意元组的不可变性也有做不到底的情况。如果元组里面装的元素本身是可变对象比如t (1, [2, 3])那么你不能给元组重新绑定元素比如t[1] [4, 5]会报 TypeError但你完全可以通过t[1].append(4)去修改那个内部列表对象此时元组的内容实际上是变了的。这是元组不可变语义经常被人误解的地方严格地说元组的不可变是引用层面的不可变不是内容深度上的不可变。理解这一点对阅读别人代码、排查诡异数据变更问题非常有帮助。3.2 元组解包优雅的数据拆解元组解包tuple unpacking是Python里最让我觉得顺手的特性之一。它让你可以一次性把元组中的多个值赋给多个变量point (10, 20) x, y point # x 10, y 20这个语法不仅适用于元组也适用于列表、字符串等任何可迭代对象但元组因为固定的不可变语义用起来语义最清晰。实际开发中解包最常见的几个场景一是函数返回多个值时直接拆开接收比如status, result func()二是交换两个变量的值a, b b, a不需要第三个临时变量三是遍历字典时同时拿到键和值for key, value in dict.items()。更高级的用法是带星号的可变长度解包。这个特性在Python 3中引入能在解包时把多余的元素聚合成一个列表first, *middle, last [1, 2, 3, 4, 5, 6] # first 1, middle [2, 3, 4, 5], last 6这个写法在需要第一个元素和最后一个元素做特殊处理的场景里非常实用比如处理查询结果的边界值。还有一种是嵌套元组解包employees [(张三, 28, (技术部, 后端)), (李四, 30, (市场部, 品牌))] for name, age, (dept, position) in employees: print(f{name} 在 {dept} 部门担任 {position} 岗位)嵌套解包让我觉得Python代码可以写得跟英语句子一样直观尤其适合处理结构化数据。需要提醒的是解包时变量个数必须和元组元素个数匹配否则会抛ValueError: too many values to unpack或not enough values to unpack。想要忽略某些值可以用下划线占位a, _, c (1, 2, 3)这也是一种常见的约定写法。3.3 元组作为字典键和集合元素元组不可变带来的另一个现实收益是它可以作为字典的键和集合的元素。这是列表无法替代的如果你尝试{[1, 2]: value}或{[1, 2]}Python会直接抛TypeError: unhashable type: list因为列表可变它的哈希值没办法保持稳定。而元组内容一旦确定就不会变所以天生自带稳定的哈希值。这个特性的实际应用很多。比如你要统计二维平面上的某个坐标点出现的次数直接用元组作为字典键from collections import Counter points [(1, 2), (3, 4), (1, 2), (5, 6)] counter Counter(points) # { (1, 2): 2, (3, 4): 1, (5, 6): 1 }如果你用的是列表points [[1, 2], [3, 4]]Counter 会直接报错原因就是上面说的类型限制。再比如你有一组固定参数的配置项想判断某个组合是否已经出现过用一个元组集合set就能做到 O(1) 的去重判断。缓存场景里也经常用元组作为函数缓存的 key把函数参数打包成元组查字典命中就直接返回结果。不过要注意如果元组内嵌了可变对象那这个元组就不能作为字典键。比如([1, 2], 3)作为键依然会报 TypeError因为内部列表是不可哈希的。所以在设计程序时想用元组做键必须确保元组内部的所有层级的元素都不可变。4. 列表和元组的性能与内存对比4.1 性能实测为什么元组更快很多文章会一笔带过元组比列表快但没有具体数据初学者容易对这个结论将信将疑。我在自己的机器上做过一次简单的基准测试用timeit分别测试两种类型在创建和访问上的耗时。import timeit # 创建耗时 list_time timeit.timeit([1, 2, 3, 4, 5], number10_000_000) tuple_time timeit.timeit((1, 2, 3, 4, 5), number10_000_000) print(f列表创建耗时: {list_time:.4f}s) print(f元组创建耗时: {tuple_time:.4f}s) # 索引访问耗时 t (1, 2, 3, 4, 5) l [1, 2, 3, 4, 5] access_list_time timeit.timeit(l[3], setupl [1, 2, 3, 4, 5], number100_000_000) access_tuple_time timeit.timeit(t[3], setupt (1, 2, 3, 4, 5), number100_000_000) print(f列表访问耗时: {access_list_time:.4f}s) print(f元组访问耗时: {access_tuple_time:.4f}s)在我本地的 CPython 3.10 环境里元组创建要比同字面量列表快得多差距大概在 20% 到 30% 左右。元组索引访问也会比列表快一些虽然单次访问差距很小但在循环次数达到千万级以上的场景里累积起来的区别是可以感知的。这个性能差异的根源在于底层实现。CPython 中列表被设计为可以动态扩容的结构创建时预留了额外空间以便未来 append 操作不必每次都重新分配内存。同时列表对象内部还有保存长度和容量等额外字段对某个索引读写时还需要做边界检查和可能的容量更新。元组作为不可变对象创建时一次性分配刚好容纳所有元素的内存不需要预留多余容量内部结构也更紧凑解释器对元组的 ref count 和内存访问路径都有专门优化。简单理解就是元组是按需裁剪、一把定死列表是预留空间、随时扩编前者运行时负担自然更小。4.2 内存占用元组的隐藏优势除了速度内存占用也是元组的优势所在。我用sys.getsizeof做过验证import sys l [1, 2, 3, 4, 5] t (1, 2, 3, 4, 5) print(sys.getsizeof(l)) # 在64位Python 3.10上是120字节 print(sys.getsizeof(t)) # 在64位Python 3.10上是80字节同样是 5 个整数元素元组比列表少占 40 字节。原因很好理解列表需要额外的容量指针数组元组只需要固定大小的数组。列表为了支持 append 而预留的那些空位元组一个都不需要。这里我还想多说一个实际场景。我在处理大数据量时遇到过内存紧张的问题当时要把一百多万条记录加载进内存做统计每条记录是一组固定字段。一开始图省事用列表存结果内存占用一眼见涨。后来把那些字段打包成元组存储内存立刻降了一截。虽然每条只省几十字节但百万级的量积少成多效果非常可观。如果你的程序里有一些创建之后只读遍历的数据集合优先用元组既安全又省资源。另外一个优化细节是Python 对小整数和足够短的元组有缓存机制。CPython 内部会缓存长度为 0 和 1 的元组以及部分小整数对象因此反复创建相同短元组时有可能直接复用同一份对象不会重复分配内存。列表则没有这种缓存机制每次创建都是新的。5. 常见问题与避坑指南5.1 浅拷贝陷阱一个列表改了其他都改了这一节值得所有初学者反复看几遍。Python 里等号赋值不会复制对象只是让多个变量指向同一个对象。所以a [1, 2, 3] b a # b 和 a 指向同一个列表对象 b.append(4) print(a) # [1, 2, 3, 4] —— a 也跟着变了这是新手最容易踩的坑。想复制一份独立列表至少要用切片a[:]或list(a)或者copy.copy(a)。但注意这三种方式都是浅拷贝只能复制外层容器如果列表里嵌套了列表或字典内层仍然是同一份引用。a [[1, 2], [3, 4]] b a[:] # 外层是新列表内层两个子列表仍是同一份 b[0].append(99) print(a) # [[1, 2, 99], [3, 4]] —— a 的内层也被改了我印象很深的一次事故就发生在配置管理上。当时一个配置项是嵌套列表我复制了一份做本地修改结果把原始配置给污染了排查了半天才定位到是浅拷贝的问题。从那以后只要涉及嵌套可变对象的复制我直接先想清楚到底需要浅拷贝还是深拷贝。需要完全独立的副本用copy.deepcopy最省心但它性能开销也大所以要权衡使用。判断对象是否独立的快捷方式是a is b或者id(a) id(b)。如果两个列表的 id 相同说明是同一个对象修改一个另一个必然跟着变。5.2 可变对象嵌套与安全使用建议承接上面的坑再往深走一步当列表和元组作为函数的默认参数时会遇到另一个反直觉的现象。def add_item(item, container[]): container.append(item) return container print(add_item(1)) # [1] print(add_item(2)) # [1, 2] —— 这个列表被记住了这是Python经典面试题。原因在于默认参数container[]在函数定义时只创建一次之后每次调用如果没有传入新的列表用的都是同一个列表对象。正确的做法是默认参数写 None函数内部再做判断def add_item(item, containerNone): if container is None: container [] container.append(item) return container这样每次不传参时都会创建新的列表不会互相污染。类似的坑也会出现在类属性的默认值里如果你在类里写class Foo: items []这个列表是所有实例共享的一个实例往里面添加元素所有实例的数据都会变。想在每个实例里维护独立列表必须在__init__方法里初始化。还有元组嵌套列表的场景。虽然元组不可变但如果元组里装了列表这个元组的不可变性就只是面子上的。设计数据结构时如果要求彻底不可变最好用多层元组组合或者用typing.FrozenSet、dataclasses配合 frozen 模式。5.3 用列表推导式写出更干净的转换逻辑在处理列表和元组的转换时列表推导式不光是语法糖更是保持数据逻辑清晰的手段。常用的场景从一个元组列表里提取某个位置的值、对元素做类型转换、过滤空值。比如raw_data [(张三, 28), (李四, 30), (王五, None), (赵六, 25)] valid_ages [age for name, age in raw_data if age is not None]再比如把整个元组列表转成字典user_dict {name: age for name, age in raw_data}这种写法逻辑清晰、可读性高也避免了 for 循环加 append 的啰嗦。还有一个很实用的小技巧是用zip组合多个列表。比如把三个列表按位置打包成元组列表names [张三, 李四, 王五] ages [28, 30, 25] cities [北京, 上海, 深圳] combined list(zip(names, ages, cities)) # [(张三, 28, 北京), (李四, 30, 上海), (王五, 25, 深圳)]配合*号还能做反向操作把一组元组列表拆成多列list(zip(*combined))得到的又是三个元组分别对应上面三列数据。这个手法在实际处理表格数据时特别有用。5.4 列表与元组的选用建议表最后我整理一份快速选型表方便你写代码时拿不准该用哪个时直接查维度列表元组是否可变可变不可变适用场景需要增删改、元素数量动态变化固定数据集合、函数参数、结构化打包可作为字典键/集合元素不可以可以嵌套对象安全性需要自行管理浅拷贝/深拷贝只保证一层引用不可变性能相对较慢动态扩容、需要预留空间更快固定大小结构紧凑内存占用相对更大需要预留扩容空间更小常用操作append / pop / remove / extend / sort / reverse解包、索引、成员判断、拼接内置缓存无短元组有缓存优化在实际开发中我的建议是如果元素集合的数据结构和数量在业务逻辑里是相对固定的就优先用元组只有当追加、删除、排序、替换这些操作确实需要时才用列表。这不是性能洁癖而是数据语义的表达——用元组等于告诉读代码的人这里的数据是一组打包好的固定字段别随意改对代码的可维护性帮助很大。我自己还有一个习惯函数返回值如果需要一个结果 几个附带信息这种结构优先返回元组。这样调用方可以同时接收比如ok, msg, data request_xxx()比返回列表或者散装变量要清晰得多。如果附带信息多了就升级成命名元组namedtuple或者数据类dataclass但那已经是更高阶的内容了这篇先点到为止。列表和元组看似基础但把基础整理透了后面学字典、集合、函数式处理、类与对象都会顺手许多。我当年入门Python时就是在这些基础容器上花了不少时间反复写小例子才慢慢建立起对 Python 数据模型的直觉。这一篇更像是一次系统性梳理把平时零散的知识点归拢在一起既能帮新手打底也能让写了几个月代码的人重新审视自己有没有踩过那些隐蔽的坑。实操上遇到具体问题欢迎在实践中慢慢验证亲自踩过一遍的坑记得最牢。