1. 为什么要单独讲列表和元组从存一个数到存一串数的跨越1.1 数据变多之后普通变量就不够用了先看一个非常朴素的场景。你现在要写一个程序记录全班三十个学生的考试成绩。用上一课学的知识你会怎么做定义三十个变量score1 78、score2 92、score3 85……然后呢你想求个平均分得写(score1 score2 ... score30) / 30这还没算上后面要做排序、找最高分之类的事情。写到第十个变量的时候你应该已经意识到这不是人干的事了。列表和元组就是用来解决这类问题的。它们都是Python里的序列类型核心作用是把一堆相关数据装进同一个容器里然后统一管理。你可以把列表想象成一个带编号的储物柜每个格子都能放东西柜子本身有一个名字你报出编号就能取到对应的物品。这个编号在编程里叫索引从0开始数。这一课拿列表和元组一起讲是因为它们长得很像初学者经常搞混。但它们的性格差别很大列表可以随便改元组一旦定下来就不能动。搞清楚这两个性格差异以及各自适合干什么比背下一堆API要重要得多因为你写代码的时候每天都要在这两者之间做选择。1.2 序列类型的共同特征有序、可索引、可切片列表和元组都属于序列Sequence这一大家族和它们同族的还有字符串。序列有三个统一的特征理解了这三个特征列表和元组就掌握了一半第一有序。放进列表里的元素顺序是固定的[1, 2, 3]和[3, 2, 1]是两个不同的列表它们的顺序直接影响索引结果。第二可索引。你可以用变量名[编号]这种形式快速取出任意位置的元素。编号从0开始也可以从尾部往前数用负数。第三可切片。你可以一次性取出某个连续范围的元素比如取出第2到第5个元素得到一个小片段。这种操作在字符串里你已经见过了列表和元组也支持同样的语法。剩下的就是对这两兄弟各自特性的深入了解了。下面我会先把列表的完整操作过一遍再讲元组最后聊一聊我在实际编码中踩过的可变性陷阱——这是很多教程不会细讲、但写代码几乎必然要面对的地方。2. 列表的日常操作创建、索引、切片与增删改查2.1 创建列表的三种常见方式列表用一对方括号[]表示元素之间用逗号分隔。最常见的创建方式有下面这些# 方式1直接字面量 scores [78, 92, 85, 66, 90] # 方式2使用 list() 构造器可以把其他可迭代对象转成列表 names list(python) # 结果是 [p, y, t, h, o, n] numbers list(range(1, 6)) # 结果是 [1, 2, 3, 4, 5] # 方式3列表推导式后面会单独讲这里先留个印象 squares [x * x for x in range(1, 6)] # [1, 4, 9, 16, 25]需要注意一个容易踩的坑用list(python)会把字符串拆成一个个字符而不是得到一个包含完整字符串python的列表。如果你想得到[python]需要写成list([python])或者干脆[python]。列表里可以装不同类型的元素比如mixed [1, hello, 3.14, True]这在Python里是允许的。但现实开发中我强烈建议一个列表尽量只装一种类型的数据比如要么全是数字、要么全是字符串、要么全是对象。混合类型会让后续的遍历、排序、过滤变得非常痛苦因为你要反复判断当前元素到底是什么类型。这不是语法限制而是代码可维护性层面的自律。2.2 索引和切片面向新手的细致拆解先定义一个示例列表用来演示索引和切片lst [10, 20, 30, 40, 50]正向索引从0开始lst[0]是10lst[1]是20lst[4]是50。负向索引从-1开始lst[-1]是50lst[-2]是40。这个规律很简单最末尾一个元素永远是-1倒数第二个是-2以此类推。切片语法是lst[起始:结束:步长]包含起始位置不包含结束位置。这是初学者最容易懵的点我来逐一演示lst[1:3] # [20, 30]取索引1和2结束索引3本身不包含 lst[:3] # [10, 20, 30]省略起始表示从开头开始 lst[2:] # [30, 40, 50]省略结束表示一直到末尾 lst[::2] # [10, 30, 50]步长为2跳着取 lst[::-1] # [50, 40, 30, 20, 10]步长为-1实现整个列表翻转切片得到的永远是一个新列表原列表不会被修改。这一步操作不会报IndexError即使你切片的范围超出了列表长度它也只是返回能取到的部分而已比如lst[0:99]会正常返回[10, 20, 30, 40, 50]。关于步长为-1的翻转切片很多人会记住lst[::-1]这个口诀但不太明白原理。简单说步长是负数的时候起始位置默认变成列表末尾结束位置默认变成列表头部之前所以它会从后往前取自然实现了翻转。2.3 增删改查最常用的方法整理列表的核心价值在于可变也就是可以随时往里面加东西、删东西、改东西。我把最常用的方法按功能分类整理成一张表操作类型方法/语法作用备注末尾追加lst.append(x)把x添加到列表末尾每次添加一个元素批量追加lst.extend([1, 2])把一个迭代对象的所有元素追加到末尾等价于循环append指定位置插入lst.insert(索引, x)在指定索引位置插入x原索引处元素往后移删除指定值lst.remove(x)删除第一个等于x的元素如果x不存在会报ValueError弹出末尾lst.pop()删除并返回最后一个元素弹出指定索引lst.pop(0)删除指定索引del lst[索引]删除指定位置的元素也可以删除切片清空列表lst.clear()删除所有元素列表对象本身还在查找索引lst.index(x)返回x第一次出现的索引不存在会报ValueError统计次数lst.count(x)返回x出现的次数不存在返回0排序lst.sort()原地排序从小到大可以加reverseTrue反转lst.reverse()原地反转元素顺序注意和切片反转的区别修改lst[索引] 新值修改指定位置元素索引越界会报IndexError这里有几个细节想多说几句。append和extend的区别非常典型。lst.append([1, 2])会把[1, 2]当成一个整体塞进列表结果变成[..., [1, 2]]而lst.extend([1, 2])是把1和2两个元素分别追加进去。初学者经常把这两个搞混我的记法是append是打包放进去extend是拆开放进去。pop和remove的语义完全不同。pop()按索引删除remove()按值删除。按值删除的时候要注意如果列表里有多个重复值remove只会删掉第一个剩下的不会被影响。如果确实想删除所有等于某个值的元素通常的做法是列表推导式重新生成lst [1, 2, 2, 3, 2] lst [x for x in lst if x ! 2] # 结果是 [1, 3]sort是原地排序也就是说它会直接修改原列表让原列表变成有序的而不返回一个新列表。如果你不想破坏原列表的顺序需要先复制一份再排或者使用sorted(lst)这个内置函数它会返回一个新列表。这个区别在数据被多个地方引用时尤其重要——我在后面讲可变性陷阱时会专门展开。2.4 列表遍历与列表推导式遍历列表最直接的方式是for循环scores [78, 92, 85, 66, 90] for score in scores: print(score)如果还需要同时拿到索引用enumeratefor idx, score in enumerate(scores): print(idx, score)enumerate默认从0开始计数你可以给它第二个参数指定起始数字enumerate(scores, start1)这在打印第几名这类人类可读序号时非常方便。列表推导式是Python里非常有特色的一种语法本质上是用一行表达式生成一个新列表。它的模板长这样[表达式 for 变量 in 可迭代对象 if 条件]三个部分分别是你想生成什么、从哪里迭代、筛选条件。举几个例子# 生成1到10的平方 squares [x * x for x in range(1, 11)] # 筛选出列表中的偶数并乘以10 nums [1, 2, 3, 4, 5, 6] evens_times_10 [x * 10 for x in nums if x % 2 0] # 结果是 [20, 40, 60] # 两层循环生成坐标 coords [(x, y) for x in range(3) for y in range(3)]列表推导式的执行逻辑其实是嵌套的for循环加条件判断但写出来非常紧凑。我见过不少初学者一开始看不懂这种语法我建议的学法很简单先把等价的多层for循环写出来看懂结果再回来对照推导式多写几次就自然习惯了。但是我也要给一个忠告推导式不是越复杂越好。一个推导式里有三层循环再加两个if条件读起来就会非常吃力。我的个人标准是如果一行推导式超过80个字符或者嵌套超过两层就拆成普通循环来写。代码是写给人看的不是写给解释器看的。3. 元组的本质不可变的序列反而在很多场景更可靠3.1 元组的定义方式和不可变性验证元组用圆括号()表示元素之间用逗号分隔coordinates (30, 120) person (Alice, 25, 工程师) single (42,) # 注意这个逗号不能少这里有一个经典陷阱(42)在Python里只是一个普通的整数42一对圆括号在这里只是数学上的分组符号只有写成(42,)才是元组。当元组只有一个元素时那个尾随逗号是语法上必须要的。我每次写单元素元组都会下意识检查一遍这个坑太容易踩了。元组的核心特性是不可变意思是创建之后不能增加元素、不能删除元素、不能修改元素。尝试修改会直接报错t (1, 2, 3) t[0] 100 # 报错TypeError: tuple object does not support item assignment但如果元组里装的是一个列表情况会变得微妙——列表本身是可变的所以你可以修改这个列表的内容只是不能把整个列表替换成另一个列表t (1, [2, 3], 4) t[1].append(99) # t 变成了 (1, [2, 3, 99], 4)这个操作是可以成功的这种外层不变、内层可变的结构经常让人困惑。我在后面讲深拷贝的时候会再次回到这个问题。3.2 元组解包Python里最优雅的语法之一元组一个非常实用的特性是解包unpacking。本质上就是把一个元组的元素一次性赋值给多个变量info (Alice, 25, 工程师) name, age, job info # name Alice, age 25, job 工程师这个写法看起来平平无奇但实际应用场景极多。比如在循环里遍历一个包含元组的列表data [(Alice, 90), (Bob, 85), (Carol, 95)] for name, score in data: print(name, score)再比如经典的交换两个变量的值。在其他很多语言里你需要引入一个临时变量temp a a b b temp而Python里直接用元组解包一行搞定a, b b, a它的原理就是先把b, a构造成一个元组再把这个元组解包赋值给a, b。原来那个临时变量的写法在Python里完全不需要了。每次我用这行代码的时候都感觉Python的设计确实照顾人的直觉。解包还可以和*号配合实现剩余部分全归某个变量的操作first, *middle, last (1, 2, 3, 4, 5) # first 1, middle [2, 3, 4], last 53.3 元组可以作为字典的键列表不行这一点非常能体现两种类型的差异也经常被拿来考细节。字典的键必须满足一个条件可哈希。简单理解就是能根据这个值算出固定不变的哈希值并且在字典的整个生命周期里这个哈希值都不会变。元组不可变天然满足这个条件列表可变哈希值会随着内容改变而改变所以它不能作为字典的键。实际运行一下就明白了d {(1, 2): 位置A} # 正常可以执行 d {[1, 2]: 位置A} # 报错TypeError: unhashable type: list这个特性的实际用途很多。比如在游戏开发里用坐标元组(x, y)作为字典键存储每个位置上的物品信息在数据处理中用(国家, 城市)这种组合键来聚合统计数据。顺便说一个理解误区不是所有元组都能当字典键。如果元组里包含一个列表比如(1, [2, 3])这个元组本身表面上不可变但内部包含可变对象它依然不能被哈希。你可以在程序里试一下会得到同样的TypeError。所以更准确的说法是只有全部元素都不可变的元组才是真正可哈希的元组。3.4 元组和列表的性能对比微弱但真实存在的差异性能对比是很多工程党关心的话题。Python里元组的访问速度通常略快于列表因为元组的底层结构更简单——它在内存里的存储是固定的、连续的不需要像列表那样为了可能的扩容预留额外空间。我用一个简单的测试大致验证过创建和遍历大量元素时元组比列表快大约5%到10%内存占用也更小。但这个差异在实际开发中通常不是决定性因素除非你处理的是海量数据且对性能极其敏感否则不必为了5%的差异纠结。我认为更重要的选择依据是语义这个数据在逻辑上会不会被修改如果数据代表一组固定不变的配置、坐标、常量用元组读代码的人一看就明白这是固定的。如果数据后续要增删改必须用列表元组做不到。如果拿不准默认用元组。原因很简单把它改成列表很容易反过来从列表改回元组意味着你的代码里可能到处有修改列表的操作改起来就麻烦了。4. 最容易翻车的可变性陷阱引用、复制与嵌套结构4.1 赋值不是复制多个变量可能指着同一个列表这是初学者写代码时最隐蔽的坑之一。很多人以为list2 list1是把列表复制了一份但实际上完全不是。list1 [1, 2, 3] list2 list1 list2.append(4) print(list1) # [1, 2, 3, 4] —— list1也变了我刚才那段代码的执行过程是这样的list2 list1只是让list2这个变量指向了list1在内存中的同一个列表对象。你通过list2修改它指向的那个对象list1作为指向同一个对象的另一个名字自然看到的是修改后的状态。列表是可变对象所以两个变量指向同一个对象就等于两个变量共享同一份数据。我用一个生活例子帮理解list1和list2就好比两个人拿到了同一个抽屉的两把钥匙。一个人往抽屉里放东西另一个人打开抽屉当然能看到新东西。如果你想真的复制一份独立的抽屉就需要显式地做复制操作。那怎么真正复制呢常见方法有三种# 方法1切片 list2 list1[:] # 方法2list() 构造器 list2 list(list1) # 方法3copy方法 list2 list1.copy()这三种方法的效果一样都是创建一个新的列表对象原列表和新列表各自独立。修改新列表不会影响原列表反之亦然。4.2 浅拷贝的局限为什么嵌套列表复制之后还是会联动上面的三种复制方式都属于浅拷贝。意思是它们只复制了列表最外层这一层容器而容器里装的元素本身并没有被复制。当列表里的元素是数字、字符串这种不可变对象时浅拷贝已经没有问题了因为这些元素不可能被原地修改替换元素本质上只是让列表中的某个位置指向另一个对象。但当列表里嵌套了列表或者字典、自定义对象等可变对象时浅拷贝就出问题了matrix [[1, 2], [3, 4]] copy matrix.copy() copy[0].append(99) print(matrix) # [[1, 2, 99], [3, 4]] —— 原列表也被修改了整个过程可以这样理解matrix.copy()创建了一个新列表但这个新列表的[0]位置依然指向原来那个内层子列表对象。当你通过copy[0]修改内层列表时这个内层列表同时被matrix[0]引用着所以原列表也变了。需要彻底独立副本的情况下要用到copy模块的deepcopyimport copy matrix [[1, 2], [3, 4]] deep_copy copy.deepcopy(matrix) deep_copy[0].append(99) print(matrix) # [[1, 2], [3, 4]] —— 原列表不受影响deepcopy会把整个对象结构递归地复制一遍所有嵌套的可变对象都创建独立副本。使用它的代价是速度更慢、内存占用更大所以也不是无脑用。我个人的经验原则是列表里全是不可变元素数字、字符串、元组等浅拷贝就够了。列表里嵌套了列表或自定义对象并且你确实需要完全独立用深拷贝。如果只是读取数据不需要修改那连拷贝都不需要直接用原列表即可。4.3 函数参数传引用一不小心就改了外部数据可变性陷阱还有一个高频场景就是函数传参。Python的函数传参机制是传引用也就是说把一个列表传给函数函数内部对列表的修改会直接作用到函数外部的原列表上。def add_item(data): data.append(100) lst [1, 2, 3] add_item(lst) print(lst) # [1, 2, 3, 100] —— 函数内部的修改影响到了外部这个特性有时候很方便比如你确实想让函数填充一个列表但更多时候它是个隐患。比如一个函数本来只是做计算结果不小心调用了传入列表的方法就把原始数据改了这种bug非常难排查因为报错不会立刻出现而是等到后面某个地方发现数据不对的时候位置已经离修改点很远了。如果想保护外部数据不被修改有两种做法一种是在函数内部先复制再操作def process(data): data data[:] # 在内部复制一份 data.append(100) return data另一种是明确约定函数不修改传入的可变对象只返回新值。这其实是函数式编程的一个基本思想。实际项目中我倾向于约定优于防御——先在团队里明确传入列表的函数默认不改外部数据只在确实需要就地修改的场景才利用引用特性。光靠代码层面防可能层层复制导致性能浪费代码也很难看。4.4 别名混用导致的隐性问题除了函数传参别名问题还会出现在多个数据结构共享同一个列表对象的时候。比如你把一个列表同时塞进了两个字典的不同键名下shared [1, 2, 3] d1 {a: shared} d2 {b: shared} d1[a].append(4) print(d2[b]) # [1, 2, 3, 4]这不算bug如果你就是有意共享数据那没问题。但很多时候这种共享是无意产生的尤其在代码里多次赋值之后你在A处修改B处的数据莫名其妙跟着变化排查起来非常折磨。我的建议是任何需要长期保存的列表对象能复制就复制尽量不要多个名字指向同一个对象。尤其是在构建一个稍大的数据结构时每个部分都应该有自己的独立副本除非你明确知道自己在共享。5. 代码实践中积累的常见错误排查与性能小贴士5.1 索引越界与空列表的判断IndexError: list index out of range大概是初写列表时最常见的报错。它的意思很直白你访问了一个不存在的索引。比如列表长度是3有效索引是0、1、2你访问lst[3]就报这个错。触发场景经常是循环里访问lst[i 1]当循环走到最后一轮时i 1就越界了。解决办法是在循环边界上做调整或者使用min(i 1, len(lst) - 1)这类保护。但更推荐的做法是尽量使用Python的遍历语法而不是依赖索引。比如你想成对处理相邻元素可以用zip(lst, lst[1:])它会自动帮你在末尾处停下来lst [1, 2, 3, 4] for prev, curr in zip(lst, lst[1:]): print(prev, curr) # 输出 (1,2) (2,3) (3,4)不会越界判断列表是否为空强烈建议直接用if not lst:而不是if len(lst) 0:。前者的写法更简洁也是Python社区里最主流的风格。对于元组也同理直接if not t:判断一个元组是否为空。5.2 sort排序的坑混合类型、就地排序与sorted的区别先看这个例子lst [1, hello, 3.14, True] lst.sort()这段代码会直接抛TypeError: not supported between instances of str and int。原因很简单Python的排序要求所有元素之间能互相比较大小混合类型元素之间没有定义大小关系自然无法排序。这再次印证了我前面说的列表尽量保持单一类型。sort是就地排序直接改原列表sorted返回新列表原列表不变。两者的选择本质上是要不要保留原顺序。一个常见的需求是给一堆学生按成绩排序但又不希望原始数据被打乱这时候用sorted更合适。另外sort方法支持key参数这在实际处理复杂数据结构时非常有用。比如列表里的元素是元组(姓名, 成绩)想按成绩从高到低排列data [(Alice, 90), (Bob, 85), (Carol, 95)] data.sort(keylambda x: x[1], reverseTrue) # [(Carol, 95), (Alice, 90), (Bob, 85)]5.3 列表推导式 vs 显式循环可读性和性能的权衡取舍很多初学者听说列表推导式更快、更Pythonic就到处用。从性能上说列表推导式确实通常比等价的普通for循环加append略快一些因为它背后的循环在解释器层面做了优化减少了属性查找的开销。但性能差异在绝大多数业务代码中微不足道。我更看重的是可读性。我建议的取舍标准简单的变换和筛选用列表推导式一行清晰明了。逻辑复杂涉及多个步骤、多个条件、需要提前退出等用显式循环。生成器的场景先不展开但你可以知道列表推导式换成圆括号是不多见的。例如下面这段代码用推导式表达就很不合适result [] for x in nums: if x 0: temp x * 2 result.append(temp)虽然可以改写成[x * 2 for x in nums if x 0]并且改写后确实更简洁但一旦操作步骤变多显式循环的结构清晰度优势就会显现。这里本质上是经验问题先以自己读得懂为第一优先级再考虑缩短写法。代码是写给别人看的也是写给几个月后的自己看的没必要追求极致的简洁。5.4 一点关于命名与代码习惯的碎碎念最后分享几个我写Python时长期遵守的小习惯。第一个是命名要有复数感。列表变量名喜欢用复数scores、users、items一看就知道这是容器。元组变量名则经常用单数或者描述性名词因为它更像是一个整体coordinate、person_info虽然它内部包含多个元素但逻辑上你通常整体使用它。第二个是尽量少写索引访问。如果一个循环里总出现lst[i]这样的写法想想能不能直接遍历元素。当然有些场景确实需要索引比如同时操作两个列表对应位置的元素这时用zip通常比写索引循环更安全直观。第三个是善用元组给一组固定属性做建模。比如一个平面坐标(x, y)、一个三维颜色(r, g, b)、一个时间段(start_time, end_time)这些都是元组的典型用法。它们的共同点是元素的个数和含义都是固定的不可能在运行过程中增减。我实际经历过的项目里有一个让我印象很深的细节。当时维护一个数据处理模块里面到处是用列表存配置项但因为列表可变总有人用append往里加数据导致配置在不知不觉中被污染了。后来把所有配置从列表改成了元组问题立刻消失了——因为任何尝试修改代码的行为都会直接报错这种由机制保证不可变性带来的安心感是元组最迷人的地方。所以我一直建议能选元组就不要选列表特别是在表示常量和固定结构的数据时。这个选择看似不起眼实际写代码的时候它帮你挡掉非常多看不见的坑。