ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python3列表与元组:可变与不可变的数据容器实战指南

Python3列表与元组:可变与不可变的数据容器实战指南 如果你是按顺序学Python3的上一章我们刚把变量、数字、字符串这些基础语法过完如果你是直接翻到这一章也没关系只要知道怎么运行Python脚本、能理解最基本的变量赋值就可以往下看。无论是哪种情况学到这里你多半已经冒出一个问题——程序里不可能每份数据都单独起个变量名吧比如一个班级40个人的成绩总不能写40个变量。当这个问题出现就轮到列表和元组登场了。列表和元组是Python3里最基础的两个序列类型几乎所有真实项目都离不开它们。列表解决的是“能改、能加、能删”的动态数据集合元组解决的是“固定结构、不允许改动”的数据契约。这一章我按真实使用频率来安排先讲列表创建、索引切片、增删改查再讲排序和列表推导式然后把元组的不可变原理和应用场景讲透最后给一套实际开发中怎么选列表还是元组的判断标准。学完这一章你应该能独立完成“从一堆数据里取子集、排序、过滤、生成新列表”这类最日常的编程任务。1. 序列类型为什么值得单独开一章从“变量存数据”到“容器管数据”1.1 列表和元组在Python3中的定位先补一个概念列表和元组都属于序列类型。所谓序列你可以理解成“排好队的一串数据”。字符串其实也算序列hello就是h、e、l、l、o五个字符排成一队只是字符串的元素只能是字符而且创建之后不能改。列表和元组则通用得多数字、字符串、布尔值甚至另一个列表或元组都能往里装。为什么要单独开一章讲它们因为真实程序里的数据很少是孤零零一个。爬虫抓下来的网页标题、接口返回的一批订单号、成绩单里的分数都是成组出现的数据。没有列表和元组之前你要想处理40个人的成绩只能写40个变量然后逐个去算有了它们之后你面对的是“一个整体”可以整体排序、整体切片、整体传给函数处理。这种从“变量”到“容器”的思维转换是编程能力上一个很明显的台阶。1.2 这一章真正要练成的三种能力列表和元组的知识点看起来很碎索引、切片、方法、推导式、排序一会儿是方括号一会儿是圆括号新手很容易背完就忘。我自己的经验是别去死记方法列表而是抓住三种核心能力读拿到一个现成的列表能快速取出第几个元素、某一段子列表这是索引和切片写能往列表里加元素、删元素、改元素这是增删改查炼能从旧数据里高效生成新数据、按自定义规则排序这是推导式和排序。这三种能力对应的是最日常的编程任务。比如“从一堆成绩里取出前五名重新排序”“过滤出所有长度大于3的单词”“把两个列表按索引位置配对”。后续学for循环、函数、字典、文件读写这些能力会被反复调用。所以这一章虽然是基础但值得多花点时间亲手敲代码把每一个动作的“手感”练出来。1.3 和传统语言数组的一个关键区别如果你是转行过来的尤其是从Java或C转过来第一次看到Python列表很可能不习惯它居然可以随便混装类型。C语言里数组一旦声明成int里面就不能放浮点数Java里整型数组也只能放整型。Python的列表不一样它里面存的是对象的引用不是数据本身。所以同一个列表里放整数、字符串、列表、函数对象都不报错。这个设计让Python写起来特别快但也带来了后面我们要专门讲到的浅拷贝、深拷贝问题。这一章先记住“列表能混装”就够了遇到需要完整复制一份列表的时候记得用切片或copy()方法直接赋值给另一个变量并不会产生副本这个问题我下面会专门展开。2. 列表的本质一个可以随意改动的“收纳箱”2.1 创建列表的几种姿势创建列表最直接的方式就是用方括号empty_list [] # 空列表 nums [1, 2, 3, 4] # 直接写元素 mixed [1, two, 3.0, [4, 5]] # 混装另外两个创建方式也经常用到。一个是list()函数它可以把任何可迭代对象转成列表chars list(hello) print(chars) # [h, e, l, l, o]另一个是配合range()生成整数序列nums list(range(10)) print(nums) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]这两招在处理循环和批量数据时非常常用。比如你要生成一组从1到100的数list(range(1, 101))一行就搞定了不需要手动写100个数。2.2 列表为什么能“什么都能装”我之前说过列表存的是引用可以用一个更生活的类比来理解把列表想成墙上的一排挂钩每个钩子上贴着一张标签标签写着“某份数据放在这个位置”。列表本身只管理这一排挂钩数据对象各自散落在内存里。因为每个钩子只是贴标签所以没有任何理由规定“这个钩子只能贴数字标签”或“那个钩子只能贴字符串标签”。这个机制带来的一个常见进阶操作是嵌套列表也就是列表里的元素还是列表。比如表示一个二维矩阵matrix [[1, 2, 3], [4, 5, 6], [7, 8, 9]] print(matrix[0]) # [1, 2, 3] 第一行 print(matrix[1][2]) # 6 第二行第三列很多新手看到matrix[1][2]就蒙了。其实拆开看就很清晰matrix[1]先取出下标为1的元素也就是[4, 5, 6]这个列表然后对取出的列表再取下标为2的元素得到6。二维列表在表格数据、图像像素处理、棋盘游戏里到处都是理解它的索引方式很重要。2.3 可变性带来的第一个坑赋值不是复制列表是可变对象这个“可变”直接用起来是方便但也藏着新手最容易踩的坑。看一段代码a 5 b a b 10 print(a) # 5a不受影响 a_list [1, 2, 3] b_list a_list # 这里并没有复制列表 b_list.append(4) print(a_list) # [1, 2, 3, 4]a_list居然跟着变了数字是不可变类型你把b改成10只是让b指向了一个新对象a还是指向原来的5。列表是可变类型b_list a_list这句并没有创建新列表只是让b_list和a_list指向同一个列表对象。你用b_list往里面追加元素等于透过第二个名字修改了同一个列表a_list当然能看到变化。如果想让两个变量指向完全独立的列表有几种正确姿势c_list a_list.copy() # 用copy方法 d_list a_list[:] # 用切片切片会生成新列表 e_list list(a_list) # 用list函数转换提示b_list a_list是引用赋值不是复制。判断自己有没有踩坑最简单的办法是修改其中一个变量后打印另一个看看是否跟着变了。这个坑几乎每个Python初学者都会遇一次早遇到比晚遇到好。等后面学到函数传参、对象引用、深拷贝和浅拷贝你就明白这个“引用”思维贯穿了Python的方方面面。3. 索引与切片操作列表最值得练熟的基本功3.1 索引正着数、倒着数列表里的每个元素都有下标也就是索引。索引从0开始这是老生常谈但真正写代码时还是要留个心眼第1个元素的下标是0第5个元素的下标是4。nums [10, 20, 30, 40, 50] print(nums[0]) # 10 print(nums[4]) # 50Python比很多语言方便的一点是支持负索引。负索引从-1开始表示从右往左数print(nums[-1]) # 50最后一个元素 print(nums[-2]) # 40倒数第二个元素这个特性在不知道列表长度时特别有用。比如你想取“最后一条记录”不用先算len(nums)直接nums[-1]就行了。有一点要提醒索引越界会直接报IndexError。实践中如果你不确定下标是否安全可以先len(nums)确认长度或者用try处理异常。这里不用死记但要知道报错是正常的看懂了报错信息也是一种能力。3.2 切片完整语法与缺省行为切片是列表操作里含金量很高的基本功语法是list[start:stop:step]。它和索引最大的区别是切片返回的是一个新列表浅拷贝原始列表不会被动。还是用具体例子来看nums [10, 20, 30, 40, 50] print(nums[1:3]) # [20, 30] 从下标1到下标2不包含3 print(nums[:3]) # [10, 20, 30] 从头开始取 print(nums[3:]) # [40, 50] 一直取到末尾 print(nums[:]) # [10, 20, 30, 40, 50] 复制整个列表 print(nums[::2]) # [10, 30, 50] 隔一个取一个 print(nums[::-1]) # [50, 40, 30, 20, 10] 反转列表新手最容易迷糊的是切片区间到底包不包括结尾。记住一个词左闭右开。nums[1:3]取的是下标1和2不包含下标3。另一种记忆方式是“stop减去start就是step为1时的元素个数”所以nums[1:3]取2个元素nums[1:4]取3个元素。当start、stop、step缺省时Python有默认行为start缺省表示从头开始stop缺省表示取到末尾step缺省为1。三个都不写nums[:]就是完整复制一份列表这个写法在很多场景里比list(nums)更简洁也常被用来做浅拷贝。切片还有一个让新手安心的特性它不会因为越界而报错。nums[100:]得到的是空列表[]nums[-100:]得到的是整个列表。这个特性在处理不确定长度的数据时非常实用不用自己判断边界就能安全截取在真实业务代码里很常见。3.3 切片不只是“取”还能用来赋值和删除很多教程只讲切片用来“取”其实Python还允许用切片做赋值和删除这是很容易被忽略但很强大的操作。看这段代码nums [10, 20, 30, 40, 50] nums[1:3] [2, 3] print(nums) # [10, 2, 3, 40, 50]nums[1:3]原本指向[20, 30]赋值后整个片段被替换成[2, 3]。如果替换的长度不一样列表会自动伸缩nums [10, 20, 30, 40, 50] nums[1:3] [100] print(nums) # [10, 100, 40, 50]同样可以用del删除切片nums [10, 20, 30, 40, 50] del nums[1:3] print(nums) # [10, 40, 50]我第一次知道切片可以直接赋值时还挺惊讶的。因为它相当于把“批量替换”和“批量插入”合并成了同一招——比如你想在列表中间插入一组元素不需要先切再拼直接对空切片赋值就行nums [10, 50] nums[1:1] [20, 30, 40] print(nums) # [10, 20, 30, 40, 50]这种写法虽然不如append和extend常用但遇到“在指定位置批量插入”的需求时它是非常优雅的解决方案。4. 增删改查列表核心方法的执行逻辑与易错点4.1 append、extend、insert三个写入方法的差别先看一段经典对比a [1, 2] a.append([3, 4]) print(a) # [1, 2, [3, 4]] b [1, 2] b.extend([3, 4]) print(b) # [1, 2, 3, 4] c [1, 2] c.insert(0, 0) print(c) # [0, 1, 2]append(x)把x当作一个元素追加到末尾哪怕x本身是一个列表它也是作为一个整体进去的。extend(iterable)则会把传入的可迭代对象拆开把里面每个元素逐个追加进去。所以extend(hi)得到的是[h, i]而不是[hi]。insert(i, x)可以在任意位置插入元素但要注意它插到的是下标i之前。比如insert(0, 0)就是插到最前面等效于“头部插入”。insert的代价是插入位置越靠前后续元素移动的成本越高所以如果只是往末尾追加数据不要用insert(len(a), x)直接用append效率高得多。4.2 删除元素的几种姿势remove、pop、del 到底怎么选删除列表元素有好几种写法新手容易混。我这里列一张对照表方法/语句删除依据是否返回被删元素注意点remove(x)按值否只删第一个匹配项找不到抛ValueErrorpop(i)按索引是不传i默认删除最后一个元素del ls[i]按索引/切片否是语句不是方法还能删除整个变量clear()全部否清空所有元素但保留列表对象本身remove是按值删除你告诉它“把值等于x的那个元素删掉”。如果列表里有多个相同的值它只会删掉第一个想全部删掉得循环处理或用列表推导式重建列表。pop是按索引删除并且会把删掉的那个元素返回给你这在“取出并移除”的场景里很常用比如处理队列任务时job tasks.pop(0)就把第一个任务取出来了。del是Python语句不是列表方法。它不止能删列表元素还能删除变量本身nums [1, 2, 3] del nums[0] # 删下标0的元素 print(nums) # [2, 3] del nums # 直接把整个列表变量删掉还有一个细节remove找不到值会抛异常pop越界也会抛异常。实际开发中如果数据来源不可控建议先判断再删除或者用try包一层。处理重复值删除时我通常更推荐用列表推导式重建因为代码可读性更好我最开始学的时候为了删干净重复值还写过循环里嵌套remove的代码后来发现一旦没删干净反而容易出bug。4.3 查找与统计in、index、count判断一个元素在不在列表里最简单的是用innames [小明, 小红, 小明] print(小红 in names) # True print(小刚 in names) # False如果要同时拿到元素的下标用index(x)print(names.index(小红)) # 1但index有两点要注意第一它只返回第一个匹配项的下标列表里如果有多条同名记录你只能拿到最靠前的那个第二元素不存在时会抛ValueError所以用之前最好先in判断一下或者用try处理。统计出现次数用countprint(names.count(小明)) # 2index和count的实现本质都是线性扫描数据量小无所谓但如果列表很大又频繁调用时间复杂度就是O(n)这时就要考虑用字典或Counter来优化这就是后话了。4.4 判断列表是否满足条件all() 和 any()有一个需求很常见——判断列表里的数据是否全都满足某个条件或者是否存在至少一个满足条件的元素。很多新手会写循环加标志位其实Python提供了两个内置函数all()和any()。scores [78, 92, 61, 85] print(all(s 60 for s in scores)) # True所有成绩都及格 print(any(s 90 for s in scores)) # True存在90分以上的成绩all()要求所有元素都为True才返回Trueany()只要有一个为True就返回True。这里的s 60 for s in scores就是后面要讲的生成器表达式它不会一次性先生成一个完整列表而是边遍历边判断非常适合这种“只问结果不要过程”的场景。对应“判断列表中的数是否低于某个数值”这类需求用any(n threshold for n in data)一行就能拿下不需要为这个写一个好几行的循环。5. 排序与列表推导式让列表操作从“能用”到“高效”5.1 sort() 与 sorted()原地排序还是返回新列表排序是列表最高频的需求之一。Python提供了两种主要方式区别必须分清楚nums [3, 1, 4, 1, 5] nums.sort() print(nums) # [1, 1, 3, 4, 5]原列表被原地修改了 nums [3, 1, 4, 1, 5] new_nums sorted(nums) print(nums) # [3, 1, 4, 1, 5]原列表没变 print(new_nums) # [1, 1, 3, 4, 5]list.sort()是列表对象自己的方法它直接在原列表上排序返回Nonesorted()是内置函数它对传入的可迭代对象排序后返回一个新列表原数据不动。如果你的原始数据之后还要用用sorted()如果原始数据已经不需要了用sort()还能省一点内存。两个函数都支持reverseTrue参数实现逆序nums.sort(reverseTrue) # [5, 4, 3, 1, 1]有个容易忽略的细节sort()返回的是None所以千万别写my_list my_list.sort()这样会把原来的列表变量变成None属于经典事故。正确做法是my_list.sort()然后继续用my_list。5.2 key参数排序的核心进阶玩法如果只是对数字排大小默认行为就够了。但真实需求往往没这么简单——比如按字符串长度排、按绝对值排、按对象的某个属性排。这时候就要用key参数它指定“按什么规则取值来排序”。words [banana, apple, cherry] words.sort(keylen) print(words) # [apple, banana, cherry]按长度从小到大 nums [-3, 1, -7, 2] nums.sort(keyabs) print(nums) # [1, 2, -3, -7]按绝对值从小到大 students [(小明, 17), (小红, 18), (小刚, 16)] students.sort(keylambda s: s[1]) print(students) # [(小刚, 16), (小明, 17), (小红, 18)]按年龄排这里的lambda s: s[1]是匿名函数意思就是“传入一个元素返回它的第二个字段”排序时Python会拿这个返回值作为比较依据。新手第一次看到lambda可能会觉得抽象其实它就相当于一个临时用的小函数你完全可以用普通函数替代def get_age(student): return student[1] students.sort(keyget_age)用lambda只是因为它在一行里就能写完更简洁。这个key参数的威力在于它把你从“只能按元素本身排序”解放出来变成了“按元素提取出来的任意规则排序”在真实业务里按时间字段、按拼音、按自定义优先级排序全都靠它。5.3 列表推导式一行代码构建新列表列表推导式是Python里很有代表性的写法也是很多新手觉得“看起来好酷但我写不出来”的语法。先看最基础的形态squares [x * x for x in range(10)] print(squares) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]它等价于下面的for循环写法squares [] for x in range(10): squares.append(x * x)推导式的写法把“创建空列表”“循环”“调用append”“得到结果”这四步压缩成了一行。它并不是新的编程逻辑只是语法糖。我的建议是新手阶段不用急着追求一气呵成先用for循环写通逻辑再对照着改成推导式多练几次就熟了你自然会在很多场景里优先想到它。5.4 条件过滤与嵌套推导式的进阶形态在推导式后面加一个if就能实现过滤evens [x for x in range(20) if x % 2 0] print(evens) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]还可以在表达式部分用三目运算符根据条件决定生成不同的值scores [55, 78, 91, 60, 42] labels [及格 if s 60 else 不及格 for s in scores] print(labels) # [不及格, 及格, 及格, 及格, 不及格]推导式里还可以有多个for对应嵌套循环。比如我要生成一个(x, y)坐标对同时要求x不能等于ypairs [(x, y) for x in range(3) for y in range(3) if x ! y] print(pairs) # [(0, 1), (0, 2), (1, 0), (1, 2), (2, 0), (2, 1)]理解顺序很关键for x in range(3)是外层循环for y in range(3)是内层循环if x ! y是对每一对(x, y)做过滤。翻译成普通循环就是pairs [] for x in range(3): for y in range(3): if x ! y: pairs.append((x, y))推导式写起来短但一旦嵌套超过两层可读性就会明显下降。我个人的习惯是简单的过滤和映射用推导式没问题如果逻辑里出现了两三层嵌套我会选择写普通循环因为代码是写给人看的可维护性比少写几行更重要。5.5 列表推导式和生成器表达式的区别把列表推导式的方括号换成圆括号得到的是生成器表达式squares_list [x * x for x in range(10)] # 列表推导式返回列表 squares_gen (x * x for x in range(10)) # 生成器表达式返回生成器对象 print(type(squares_gen)) # class generator它俩的核心区别在内存。列表推导式会一次性把所有元素算出来存进一个完整的列表生成器表达式则更像“边做边给”它在被遍历时才一个个计算并不把所有结果同时放在内存里。用生活类比就是列表推导式像你点了10道菜店家一次性全端上来摆满桌生成器表达式像你点了菜后厨做一道上一道吃完再上下一道桌子永远不挤。所以数据量小、需要反复访问某个元素时用列表推导式数据量很大、只需要从头到尾遍历一次时用生成器表达式更省内存。前面我用的s 60 for s in scores就是生成器表达式它把判断逻辑延迟到all()遍历时才执行不会因为构建中间列表白白耗掉内存。6. 元组那个“不可变”的序列究竟在解决什么问题6.1 元组的创建与单元素陷阱元组用圆括号表示创建方式很灵活t1 (1, 2, 3) t2 1, 2, 3 # 省略括号也可以 print(type(t2)) # class tuple这里就有一个经典陷阱单元素元组必须在元素后面加逗号。single (5,) print(type(single)) # class tuple not_tuple (5) print(type(not_tuple)) # class int(5)在Python里就是普通的括号表达式计算结果就是整数5不是元组。真正决定元组身份的是逗号不是括号。所以5,也是一个合法元组(5,)只是写得更明确而已。这个坑几乎每个学Python的人都踩过建议在代码里尽可能把括号写全可读性更好。6.2 “不可变”真正不可变的是什么元组最核心的特性是不可变意思是元组创建之后你不能再给它的某个位置绑定新对象也不能删除或替换元素。如果你试图修改会直接报错t (1, 2, 3) t[0] 100 # TypeError: tuple object does not support item assignment但这里的“不可变”是浅层的。如果元组里的元素本身是可变对象那这个可变对象的内部还是可以变的t (1, [2, 3], 4) t[1].append(99) print(t) # (1, [2, 3, 99], 4)看起来有点矛盾元组不是不可变吗怎么又能改需要厘清的是元组保证的是“每个位置的引用不会变”它不保证“被引用的对象内部不会变”。t[1]从创建到结束始终指向同一个列表对象这一点没有变变化的是那个列表对象内部的数据。这个知识点在面试里几乎必考理解分两层就清楚了。6.3 元组解包与变量交换元组有一个列表没有的方便特性解包。把一个元组的元素一次性赋给多个变量point (3, 5) x, y point print(x, y) # 3 5解包不只对元组有效对列表、字符串都有效但和元组的组合最自然。更精彩的是Python里交换两个变量的写法a 1 b 2 a, b b, a print(a, b) # 2 1这个写法背后其实就有元组的封包和解包右边b, a先组成了一个元组(2, 1)然后解包赋给左边的a, b。不需要像其他语言那样引入临时变量确实很优雅。Python3还支持扩展解包用*收集多余元素first, *rest (1, 2, 3, 4) print(first) # 1 print(rest) # [2, 3, 4]注意rest是一个列表不是元组。这种写法在“只关心开头几个数据剩下的打包处理”的场景里很实用。6.4 元组的核心应用场景元组最常见的三个使用场景我分别用代码来说明。第一个是函数返回多个值。函数只能返回一个值但如果这个值是元组就相当于可以返回多个数据了def get_user(): return 小明, 18 # 返回一个元组 name, age get_user() print(name, age) # 小明 18第二个是作为字典的键。字典的键要求是不可变类型列表可变所以不能当键元组可以。比如用坐标作为字典键locations {} locations[(35.68, 139.76)] 东京 print(locations[(35.68, 139.76)]) # 东京第三个是具名元组namedtuple。它是元组的增强版保留元组不可变、轻量的特点同时给每个字段起个名字可以用属性访问from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(3, 5) print(p.x, p.y) # 3 5 print(p[0]) # 3也可以像普通元组一样按索引取值namedtuple在读取数据库一行记录、解析CSV文件行数据时特别有用它让代码比“按索引取字段”可读性好很多又比定义一个完整的类更轻量。7. 列表与元组的选择资深开发者的取舍标准7.1 性能维度元组确实更轻谈取舍之前先说一个事实元组在内存和速度上通常更占优势。因为元组不可变解释器知道它的结构不会变可以分配精确的内存列表要为可能的增删预留空间所以整体开销更大。但我想说句实在话对绝大多数业务代码来说这个性能差异并不值得纠结。真正的决定性因素是你要这个数据集合“可不可变”而不是“快那么零点几秒”。选错类型的代价不在性能而在代码的安全性和可读性。7.2 语义维度不可变本身就是一种契约这也是我在实际开发里最重要的体会元组的不可变不是一个语法限制而是一种设计信号。当你把一组数据放进元组等于是对读代码的人说“这几个数据是作为一个整体出现的它们的结构是固定的不允许任何人改。”反过来用列表意味着“这是一个可以动态增删的集合。”举个最直白的例子坐标(x, y)年月日(year, month, day)数据库一行记录这些数据天然就是“固定结构”用元组。用户列表、待办事项、一批待处理的任务这些数据需要随时增删用列表。代码的自解释性往往就体现在这些细节里别人读你的代码时看到元组就知道这个数据不该被修改看到列表就知道它是动态的。7.3 一个必须避开的经典坑可变对象作为函数默认参数这个坑完美地展示了列表和元组的差异在实际工程里的后果。看代码def add_item(item, items[]): items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [1, 2]第二次调用居然带着上次的结果Python中函数的默认参数只会在函数定义时创建一次之后所有不传items的调用共享同一个列表对象。第二次调用add_item(2)时items还是之前那个列表里面已经装了1所以结果变成了[1, 2]。这是所有Python进阶者都踩过的坑。正确做法是用不可变的值作为默认参数通常用None然后在函数内部再创建列表def add_item(item, itemsNone): if items is None: items [] items.append(item) return items print(add_item(1)) # [1] print(add_item(2)) # [2]两次调用互不干扰这个坑的本质就是可变对象作为默认参数会被所有调用共享。当你在设计函数时如果这个默认值不想被修改用元组比用列表安全得多。说句实在话列表和元组的语法都不难难的是在和它们相处的过程中真正建立起“我改的到底是这个对象还是这个变量”的意识。这个意识一旦建立后面学字典、集合、对象引用都会顺很多。练的时候建议把每一段示例代码都自己敲一遍再故意改点东西看看报错报错才是真正开始理解的时候。列表与元组这一章学扎实了Python3基础的地基就算是稳稳打下一半了。
返回列表