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的内容也会跟着变。很多初学者第一次在代码里遇到这种"我不想改a,a却变了"的诡异现象,十有八九都是因为忽略了这个基本机制。元组之所以用起来"省心",恰恰是因为它没有这种隐性的修改入口,一个元组对象从创建到销毁,内容始终如一,这在并发编程、函数传参、缓存处理里都是非常大的优势。
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]", number=10_000_000) tuple_time = timeit.timeit("(1, 2, 3, 4, 5)", number=10_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]", setup="l = [1, 2, 3, 4, 5]", number=100_000_000) access_tuple_time = timeit.timeit("t[3]", setup="t = (1, 2, 3, 4, 5)", number=100_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, container=None): 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 数据模型的直觉。这一篇更像是一次系统性梳理,把平时零散的知识点归拢在一起,既能帮新手打底,也能让写了几个月代码的人重新审视自己有没有踩过那些隐蔽的坑。实操上遇到具体问题,欢迎在实践中慢慢验证,亲自踩过一遍的坑,记得最牢。