
写代码处理批量数据的时候最烦人的事情之一就是变量满天飞。比如你要管理一个班几十个学生的成绩总不能给每个人都单独写一个变量叫 score1、score2、score3……这样写下去代码还没跑起来人先疯掉了。所以几乎每种编程语言都提供了“容器”类的东西在 Python 里最早上场的两个选手就是我们今天要说的列表和元组。它们就像一个有序的储物架可以把你手上零散的数据一件件摆好按顺序放、按编号取。这篇文章适合刚学完变量、分支和循环的新手也适合那些写了一阵子代码但基础不牢、想回头把数据结构补齐的开发者。我会把列表和元组的创建、访问、切片、增删改查、解包、转换、性能差异全部过一遍再配上实战代码和踩坑经验。看完了你就知道什么时候该用可变的那一个什么时候该用不可变的那一个。1. 从“变量满天飞”到“有序储物架”1.1 为什么需要批量数据结构先想一个特别常见的场景你做一个命令行版本的背单词工具需要记录今天要复习的 30 个单词。如果没有列表你大概只能这样写word1 apple word2 banana word3 cherry # ... 一直写到 word30写倒是能写但接下来你干什么都难受。你想循环打印每个单词没有列表你只能把 30 行 print 一个个敲上去你想统计这些单词总共有多少个字母更是没法用一个简单的循环搞定。更别提“删除某个单词”“按索引取第 n 个单词”这种操作了变量方案几乎完全做不到。所以我们需要一种“能装很多数据、还能按顺序管理”的东西。Python 给我们的列表list和元组tuple就是干这个的。它们都属于序列类型意味着你存放进去的数据有先后顺序可以通过位置编号来访问。这个“位置编号”叫索引而从 0 开始这几乎是所有编程语言的通用约定。1.2 列表和元组能解决什么问题列表和元组放在一起讨论是因为它们长得像用途又互补。列表用方括号[]表示比如[1, 2, 3]元组用圆括号表示比如(1, 2, 3)。最核心的区别只有一句话列表是可变对象元组是不可变对象。可变的列表可以在原地址上增加、删除、修改元素而元组一旦创建里面的元素不能就地增删改。这个区别会直接影响你的代码风格和程序安全。有序存储带来的第一个直接好处是可以索引和切片你想拿列表里的第 2 个元素用data[1]你想拿最后 3 个元素用data[-3:]。第二个好处是可以直接放进 for 循环里遍历配合函数处理批量数据。第三个好处是在实际开发中列表经常用来承载“动态变化”的数据集合比如待办事项、购物车、日志记录元组则适合承载“结构固定”的数据比如坐标(x, y)、颜色(R, G, B)、一个人的姓名和年龄组合。说得直白一点列表像一块可以反复涂改的白板元组像一枚刻好字的石碑。你不可能用毛笔在石碑上随便写字但如果你希望某些数据“永生不变”石碑反而更让人放心。2. 列表可增删改查的动态储物架2.1 创建列表的多种姿势用方括号创建空列表是最常见的写法empty [] numbers [1, 2, 3, 4] mixed [1, hello, 3.14, True] # 列表里的元素类型可以混搭还有一种方式是用内置函数list()它可以从其他可迭代对象生成列表比如从字符串生成一个字符列表chars list(hello) print(chars) # [h, e, l, l, o]如果你需要生成一个从 0 到 9 的整数列表配合range()是最高效的n list(range(10)) print(n) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]我建议新手尽量多用[]而不是list()原因是list()本身是一次函数调用虽然性能差距微乎其微但阅读代码时大家普遍认为[]更直白。真正的坑不在创建方式而在“别拿列表名当变量名”。你如果写了list [1, 2, 3]那后面再写list(range(10))就会报TypeError: list object is not callable这就是变量名覆盖内置函数导致的。我见过不少同学因为这一行代码排查了半天最后发现是自己把list这个词用没了。2.2 索引与切片取数据的“精准抓手”列表的索引很直观从左往右从 0 开始从右往左从 -1 开始data [10, 20, 30, 40, 50] print(data[0]) # 10 print(data[-1]) # 50 print(data[-2]) # 40切片是列表操作里含金量最高的部分。语法是data[start:stop:step]它从start开始取取到stop的前一个位置为止stop本身不包含。这句话请先背下来切片是左闭右开。data [10, 20, 30, 40, 50] print(data[1:3]) # [20, 30]注意下标 3 的元素 40 没被取进来 print(data[:3]) # [10, 20, 30] print(data[2:]) # [30, 40, 50] print(data[::2]) # [10, 30, 50]步长为 2 print(data[::-1]) # [50, 40, 30, 20, 10]反向切片切片的威力在于它返回一个新的列表并且允许越界。比如data[0:100]不会报错只会返回整个列表。这一点在“列表加载更多”时特别有用数据有 100 条每页显示 10 条第 1 页就是data[0:10]第 2 页就是data[10:20]。我用这个姿势写过分页逻辑一次循环都不用动数据本身只是每次切片拿出来展示。还有一个细节新手容易忽略切片返回的是浅拷贝。对于一维列表来说元素都是数字或者字符串这种不可变对象你随便改切片结果都不会影响原列表。但如果元素是子列表、字典这种可变对象切片的子对象和原列表的子对象还是同一个。这个坑后面在“常见问题”里我会单独说。2.3 增删改的常用方法和操作列表可变所以它的增删改方法特别多。我按使用频率排一下append(element)在末尾追加一个元素。extend(iterable)在末尾一次性追加多个元素相当于列表合并。insert(index, element)在指定位置插入元素后面的元素自动后移。remove(value)从左往右删除第一个等于value的元素。pop(index)移除并返回指定位置的元素不传参数时默认移除最后一个。clear()清空所有元素。del data[index]或者del data[start:stop]删除切片或者单个索引这是语句不是方法。实际写代码的时候我几乎不用remove因为它在列表里找不到值时会抛ValueError。如果只是想“把某个位置的东西换掉”直接用索引赋值更安全tasks [写文章, 改bug, 读文档] tasks[1] 写测试 print(tasks) # [写文章, 写测试, 读文档]append和extend之间的区别也值得讲一下。如果你用append把一个列表加进另一个列表得到的是“嵌套列表”a [1, 2] a.append([3, 4]) print(a) # [1, 2, [3, 4]]如果你用extend则会展开成平铺的列表b [1, 2] b.extend([3, 4]) print(b) # [1, 2, 3, 4]这两种效果天差地别我在写购物车结算逻辑时经常看到有人把订单明细整个 append 进去结果后面统计商品数量全部错乱因为多了一层包裹。先想清楚你到底是要“把整个列表作为一个元素放进去”还是“把列表里的元素逐个加进去”再决定用哪个方法。2.4 遍历与列表推导式批量处理的两种姿势批量数据最重要的操作就是遍历。普通的 for 循环大家都会写scores [90, 82, 75, 94, 88] total 0 for s in scores: total s average total / len(scores) print(average)但有些场景你想边遍历边生成一个新列表比如把华氏温度转成摄氏温度。最基础的做法是fahrenheit [32, 68, 86, 104] celsius [] for f in fahrenheit: celsius.append((f - 32) * 5 / 9)这种“循环 append”的模式太常见了Python 专门给它设计了一个更紧凑的写法叫列表推导式celsius [(f - 32) * 5 / 9 for f in fahrenheit]列表推导式还可以跟if过滤配合。比如只保留及格分数scores [90, 55, 72, 38, 88] passed [s for s in scores if s 60] print(passed) # [90, 72, 88]我自己的使用准则是推导式里不要写超过一个for和一层if否则可读性会断崖式下降。比如[x * y for x in range(5) for y in range(5) if x y 3]这种代码新手看着头疼老手也得停下来数半天。不如老老实实拆成两层循环多写三行少掉一堆头发。列表推导式的本质是“会计算新列表的循环”不是“有副作用的循环”千万别在里面写print或者改其他变量。3. 元组不可变的“刻章存档”3.1 创建元组与不可变特性元组的括号不一定是必须的真正撑起元组身份的是后面的逗号a (1, 2, 3) b 1, 2, 3 # 等价于 (1, 2, 3) c (1,) # 单元素元组必须加逗号 d (1) # 这个其实是整型 1不是元组很多新手在这里翻车写了个(10)以为是元组结果一执行发现是 int。记住单元素元组必须有那个孤零零的逗号否则 Python 只把括号当作分组符号。tuple()函数可以把别的可迭代对象转成元组t tuple([1, 2, 3]) # (1, 2, 3) t2 tuple(abc) # (a, b, c)元组不可变体现在你不能给t[0]赋值不能del t[0]也不能调用append、pop。如果你强行修改会得到TypeError: tuple object does not support item assignment。看到这里你可能会问既然列表这么灵活为什么还要用元组最直接的理由是“安全”。当你把一个元组传给一个函数你不用担心这个函数内部偷偷改了你的数据。尤其在团队协作里你写了一个get_config()函数返回配置项用元组返回调用方就没办法意外篡改。其次元组因为是不可变的所以它是可哈希的能当字典的键列表不能当字典键。我们写代码时经常要用(date, user_id)这种组合作为缓存的 key这时候只能用元组。最后从内存上看元组比同样内容的列表占的空间更小速度也略快这个稍后我单独做对比。3.2 元组解包一次性取出多个数据元组最有魅力的特性是解包unpacking。一个很常见的例子是交换两个变量的值a, b 1, 2 a, b b, a print(a, b) # 2 1右边b, a其实构造了一个元组(2, 1)左边再把这个元组解开赋值给两个变量。利用这个特性函数返回多个值时就不需要传引用或者用全局变量def get_user(): name 张三 age 18 return name, age user_name, user_age get_user() print(user_name, user_age)这里return name, age返回的就是一个元组只是在函数里省略了括号。还有更高级的星号解包专门用来处理长度不确定的元组或者列表first, *middle, last [1, 2, 3, 4, 5] print(first) # 1 print(middle) # [2, 3, 4] print(last) # 5星号把中间部分全部收进一个列表里。这个特性在解析不定长数据时非常好用比如你从配置里读到一个序列头尾有固定含义中间部分有多少项人人都不知道用星号一把梭。3.3 列表与元组相互转换需要转换的场景很多。最典型的是你从数据库查出来一条记录它是个元组现在你想对这个记录做修改比如把某个字段加一个新后缀但又不想动原来的数据。这时候你可以先转成列表改完再转回元组record (科技, Python教程) temp list(record) temp[1] Python 进阶教程 record tuple(temp) print(record) # (科技, Python 进阶教程)反过来如果一组数据应该被保护起来不要乱改那就可以从列表转成元组default_config [admin, readonly, debug] config tuple(default_config)需要注意转换是创建了一个新对象元素本身没有被复制。对于一维列表元素是不可变对象所以没影响。如果你的列表里套着小列表转成元组后外层不能增删改了但内部的小列表依然是可变的。这个“嵌套可变”的坑我会在后面的排查技巧里重点讲。4. 实战用列表和元组做一个“待办事项管理器”4.1 需求分析和数据结构设计光讲理论不过瘾咱们直接做一个待办事项管理器。需求很简单能添加待办事项能标记某条待办为已完成能删除已完成的事项还能分页展示。你不用写太复杂的交互先跑通核心逻辑。数据结构怎么设计我建议用列表管理所有待办每条待办用一个元组表示结构是(序号, 标题, 是否完成)。这里选择元组而不选列表的原因很直接每条待办的字段数量是固定的而且我不想让某段代码不经意间改掉待办的标题或序号。如果你待办要变更多个字段后期肯定会换字典但当前这个阶段列表加元组是练习基本功的好场景。我定义初始数据如下todos [ (1, 复习列表切片, False), (2, 写元组笔记, False), (3, 整理常见错误, True), ]为什么第一项是序号而不是用列表的索引因为删除操作会导致后面的索引前移如果你用索引当“任务编号”删掉第 1 条后原来的第 2 条顶上来编号全乱了。所以我们要给每条待办一个独立的编号字段这也是很多真实系统里要保留主键的原因。4.2 核心功能实现先写“添加待办”def add_todo(title): new_id max(t[0] for t in todos) 1 if todos else 1 todos.append((new_id, title, False))这里我用生成器表达式找出当前最大的编号再加 1。空列表时走else编号从 1 开始。这个写法的优点是简单缺点是如果你同时删除过部分记录编号不会回填但作为本地管理器来说编号只增不减反而更好——你可以把删除过的编号理解成“历史痕迹”。接下来写“标记完成”。元组本身不可变所以我要么先把这条记录转成列表修改再转回元组要么直接构造一个新元组替换旧位置def mark_done(todo_id): for i, todo in enumerate(todos): if todo[0] todo_id: todos[i] (todo[0], todo[1], True) return True return False这种“替换整条记录”的方式很干净也符合不可变数据的习惯我不改旧元组而是把它整体换成新元组。许多函数式编程风格就是这么做的数据一变就产生一个新对象原来的对象保持原样这样排查问题会轻松很多。然后是“删除已完成事项”。用列表推导式是最简洁的def clear_done(): global todos todos [todo for todo in todos if not todo[2]]这里用了global因为我没有把 todos 放进类里函数要更新它就必须声明。如果你把这段逻辑搬到函数外部或者封装成类变量可以不用global。这种推导式筛选择优于边删除边遍历因为后面我会讲边遍历边删除会跳过元素。最后是分页展示模拟“列表加载更多”的效果def show_page(page, page_size2): start (page - 1) * page_size end start page_size for todo in todos[start:end]: status 已完成 if todo[2] else 待完成 print(f{todo[0]}. {todo[1]} {status})分页的切片写法和我们前面介绍的一模一样start和end算好之后直接用todos[start:end]切出当前页的数据。你可能会问切片越界怎么办放心如果end超过列表长度切片不会报错只会把剩下的全部返回。这个特性让分页代码少写了很多边界判断。4.3 代码走查与运行结果把上面这些函数串起来跑一遍todos [ (1, 复习列表切片, False), (2, 写元组笔记, False), (3, 整理常见错误, True), ] show_page(1) add_todo(发布这篇博客) show_page(2) mark_done(1) clear_done() print(todos)第一次运行第 1 页显示待办 1 和待办 2添加新待办后第 2 页因为切片越界会显示待办 3 和新待办标记待办 1 完成后clear_done()会把待办 1 从列表里删掉。最终输出长这样1. 复习列表切片 待完成 2. 写元组笔记 待完成 3. 整理常见错误 已完成 4. 发布这篇博客 待完成 [(2, 写元组笔记, False), (3, 整理常见错误, True), (4, 发布这篇博客, False)]第 1 条没了原来的待办 2 顶到了第一个位置但因为每条待办有自己独立的编号所以我们依然知道它叫 2而不是一个被“重新编号”的脏数据。这个设计看起来简单却是我在很多后端项目里反复看到的合理模式。5. 新手最容易踩的坑与排查技巧5.1 改了列表却“没改”这是列表里最经典的坑你给一个变量赋值了一个列表然后你想“备份”一份供后面用于是直接b a。接着你改了b却发现a也跟着变了。原因是列表是可变对象b a并没有复制列表内容它只是让b和a指向了同一个列表对象。你可以用id()验证a [1, 2, 3] b a print(id(a) id(b)) # True b.append(4) print(a) # [1, 2, 3, 4]想真正复制内容应该用切片b a[:]或者b a.copy()也可以用深拷贝copy.deepcopy()。注意b a[:]对一维列表来说足够用但如果列表的元素还是列表那它复制的只是“外层壳子”内层子列表依然是共享的。我有一个快速解决问题的办法不确定是不是共享引用时先打印两个变量的id()如果一样那一定是同一个对象这时候赶紧改代码。我写博客时也经常用这个知识点解释“为什么函数里改了列表外面也变了”。函数传参传的是引用所以你在函数内对列表做append这种原地修改到函数外一样生效。如果你不希望外部被影响在函数入口先做一份拷贝。5.2 循环中删除元素导致跳过很多人第一次写“把列表里所有偶数删掉”都会这么干nums [1, 2, 3, 4] for n in nums: if n % 2 0: nums.remove(n)结果运行后发现列表变成[1, 3]好像没问题但换成nums [1, 2, 2, 3, 4]就会漏删一个 2。原因是for循环在底层是通过索引迭代的当你在循环内删除一个元素后后面的所有元素会往前移动一位循环却仍然按照原来的索引继续走于是有些元素被跳过。解决方式有两种反向遍历for n in reversed(nums):从尾部删除不会影响前面待遍历的元素。用列表推导式生成新列表nums [n for n in nums if n % 2 ! 0]。我个人更推荐第二种因为它不会修改原列表语义清晰。不过在数据量很大时新列表会占用额外内存这时候反向遍历更节省空间。这也是一个典型的“时间和空间互换”取舍。5.3 元组“假修改”嵌套可变对象元组不可变不等于元组里边的对象不可变。这句话有点绕但非常重要。看这个例子t ([1, 2], 3) t[0].append(4) print(t) # ([1, 2, 4], 3)元组的第一个元素是一个列表列表是可变的所以我可以通过append修改这个列表。虽然元组本身没有增删元素但它的内容肉眼可见地变了。如果你想要“彻底不可变”的元组那它里面就不能放任何可变对象否则只是“外壳不可变、内容随便改”。这个坑在很多面试题里出现也会在真实项目里咬人。比如你把一个列表塞进元组里当配置项回头代码里另一个人config[0].append(1)看起来没违背“元组不可变”的原则但配置已经变了。我建议在写元组时明确告诉自己元组保证的是“引用的地址不变”不是“引用指向的东西不变”。如果这一点想通了列表和元组的很多奇怪行为都能解释得通。5.4 索引越界与切片隐性忽略列表索引越界会直接报错data [1, 2, 3] print(data[3]) # IndexError: list index out of range但切片越界却不报错print(data[1:100]) # [2, 3]这个差异经常让新手意外。在写算法时如果你需要频繁取元素但又不能保证索引一定存在最好先判断if 0 idx len(data): print(data[idx]) else: print(越界了)而切片越界虽然不报错它也意味着你不能拿“切片能不能取到内容”来判断列表是否为空因为data[5:5]永远返回空列表。这个特性在实现“截断数据”时特别好用你想把列表截断到最多 3 个元素直接data[:3]超过的部分自动扔掉少于 3 个也不会出问题。利用好切片这个柔性特性能省去大量min(len(data), 3)的边界判断。6. 列表 vs 元组如何做技术选型6.1 性能对比用数据说话我写了一个小脚本比较列表和元组的创建、访问、遍历耗时加上sys.getsizeof()看内存占用。结果放在一个表里读者可以自己复现操作列表元组创建 1 万个整数略慢略快内存占用1 万整数大约 88KB大约 80KB访问某个元素几乎相同几乎相同遍历 100 万次略慢略快需要说明的是这个差异在少量数据上几乎可以忽略不计。真正的性能差距来自元组不可变所以它的内部结构更紧凑没有预留额外的“扩容空间”。列表为了支持频繁追加通常会预分配一些备用内存这就导致它比同长度的元组更占地方。如果你要存储大量只读数据比如国家代码列表、星期名列表优先用元组既能省点内存还能顺便防止误改。不过性能不能成为选择容器的唯一原因。在实际业务里数据是否可变、是否需要持续更新优先级远高于那几微秒的差距。我见过有人为了性能把所有列表都改成元组结果业务上需要添加新项目时处处别扭最后全部改回列表。先保证功能正确再用性能调优。6.2 按场景和语义选择从语义上分列表适合“同质数据”也就是一组性质相同、数量会变化的元素比如用户列表、商品列表、日志行。元组适合“异质数据”也就是一个实体内部的多个字段比如(张三, 18, 北京)代表一个人的三个属性结构固定。元组解包时这个语义特别明显name, age, city (张三, 18, 北京)你可以把元组当作“轻量级的只读数据记录”把列表当作“动态集合”。另外两个很实际的使用规则需要放进集合或者作为字典键时必须用元组。因为字典键必须是可哈希的列表不可哈希。函数用*args接收不定长参数时args是一个元组。这是 Python 内部的一个设计不是你自己选的但你要知道。我自己的习惯是默认先写列表除非我明确希望这个数据不可变。因为列表的操作太方便了可读性也高。但是一旦数据需要跨模块传递、并且不希望被修改我就立刻换成元组。这种“默认可变、关键处锁定”的策略让我写代码时少了很多防御性的拷贝逻辑。6.3 一个更省内存的替代__slots__和命名元组如果你觉得列表和元组的选择还不够过瘾那我额外提一嘴Python 还有一个叫namedtuple的工厂函数它可以创建一个“带字段名的元组类”。既能像元组一样不可变又能像类属性一样访问from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(3, 4) print(p.x, p.y) # 3 4这在读配置、返回多字段结果时非常舒服而且比定义一个普通类更轻量。等到你学类的时候再回来看这个工具你会感谢现在的自己提前见识了它。当然现在的重点还是把列表和元组的基本功打牢namedtuple只是留个印象。7. 最后分享一点个人习惯列表和元组这套东西我在不同项目里用了很多遍最后沉淀下来三条经验。第一拿到一批数据先想清楚“这个集合会不会变”。如果会变选列表如果不会变选元组。这个判断甚至比性能优化更值钱因为它直接影响代码的安全边界。第二切片真的是批量数据操作的瑞士军刀分页、逆序、截断、复制四个最常用的操作全是切片一把搞定。你可以把data[:]复制、data[::-1]反转、data[start:end]分页这几个套路背下来写代码时能省好多事。第三所有“改列表导致别的地方也跟着变”的问题十有八九是赋值引用而不是赋值副本。遇到这种情况别慌先看是不是少了[:]或者.copy()。列表和元组是新手阶段最重要的两个数据结构但别以为学完这两兄弟就万事大吉了。后面还有字典和集合它们解决的是“按名字查找”和“去重判断”的问题。等你把它们都学完会发现所有程序的本质就是在这些数据结构之间倒腾数据。今天先把这个“有序储物架”用好后面的事咱们下节接着说。