ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python列表与元组全解析:可变性、性能与选型指南

Python列表与元组全解析:可变性、性能与选型指南 列表和元组Python 里最容易被“一眼带过”的两个内置容器。我见过太多人学了两三个月 Python列表用得很溜元组只当它是一个“不能改的列表”问起来区别答一句“可变和不可变”就算完。可真到项目里该用元组的地方用列表该用列表的地方纠结半天一不留神还踩进可变默认参数、切片复制、多变量解包这些坑里。这篇文章我就把这些事彻底讲透列表和元组到底差在哪为什么 Python 要同时提供这两种看起来差不多的东西实际开发中怎么选、怎么用才不容易出错。不管你是刚入门的新手还是已经写过一阵子 Python、想在细节上再补一补的开发者这篇文章都会有用。1. 列表和元组到底差在哪1.1 可变与不可变所有区别的根源先看最基本的定义。列表是一个可变序列创建后可以随时增删元素、修改某个位置的值元组是一个不可变序列创建后不能修改、不能增删。直接看代码最直观lst [1, 2, 3] lst[0] 100 lst.append(4) print(lst) # [100, 2, 3, 4] tup (1, 2, 3) tup[0] 100 # TypeError: tuple object does not support item assignment tup.append(4) # AttributeError: tuple object has no attribute append这两段报错大概是很多人第一次真正意识到“元组不能改”的时刻。但仅仅记住“能改 vs 不能改”远远不够你得明白为什么 Python 要提供一个“不能改”的容器。想想看如果你的代码里有多个变量、多个函数共享同一个数据列表这种能随时修改的东西很容易在某个不起眼的地方被改动然后引发一串难以排查的副作用。而元组不可变意味着它在创建之后的值是稳定、可信任的无论传到哪个函数、被多少处代码引用都不会悄悄发生变化。我用一个生活类比来帮你记列表像一张草稿纸你可以随便写、随便改、随手撕掉重来元组像一份已经打印好并盖了章的合同条款定下来就是定下来了想改只能重新出一份新合同而不是在原件上涂改。这个“想改只能重新创建”的特性就是不可变对象的本质。1.2 可变性带来的连锁反应内存、哈希、性能可变和不可变不只是“能不能改”这么简单它会带来一连串连锁反应。首先看内存布局。列表为了支持动态增删在底层会预留一部分额外空间每次 append 或 insert 时如果剩余容量不够Python 会重新分配一块更大的内存再把原有元素搬过去。元组不需要这些它创建时一次性分配恰好能装下所有元素的内存结构非常紧凑没有额外开销。这也是为什么迭代同一个数据时元组通常比列表稍微快一点内存占用也更小。当然这种差异在元素少的时候几乎感觉不到但当你需要创建成千上万个小型数据结构时就能看出差距。其次是复制行为。列表的copy()或切片[:]会生成一个新列表但属于浅拷贝新列表里的元素本身还是和原列表共享的。元组因为不可变拷贝时 Python 很多时候直接返回同一个对象反正改不了多一个引用完全不影响。然后是哈希能力这条非常重要。Python 中只有不可变对象才能计算哈希值所以元组可以作为字典的键、可以放进集合列表不行d {(a, 1): value} # 合法 d {[1, 2]: value} # TypeError: unhashable type: list s {(x, 2)} # 合法报错信息里的unhashable说白了就是“这个对象没法哈希”。哈希的本质是把一个对象映射成一个固定整数要求对象内容不变、哈希值稳定。列表内容可变同一个列表今天[1, 2]明天[1, 2, 3]哈希值没法稳定所以直接被排除在可哈希类型之外。最后是整体性能差异。我做一个非常粗略的对比你用sys.getsizeof可以自己验证同样是装 5 个小整数的容器列表要占 80 字节左右元组只要 72 字节左右构建速度上元组也比列表快一些因为少了很多扩容检查的逻辑。这些数字在不同 Python 版本里会有变化但趋势是一致的。2. 什么时候该用列表什么时候该用元组2.1 从“数据性质”做第一判断说实话很多 Python 开发者的默认习惯就是“凡是集合一律用列表”元组只在解包返回值时被用到。我不打算劝你把所有列表都改成元组但确实有一类数据天生就适合元组判断标准很简单这个数据集合的长度是否固定这个数据集合的内容在创建后是否需要保持稳定这个数据是否要作为字典键或集合成员使用如果三个问题里有两个回答是“是”你就该认真考虑用元组。举几个最常见的例子。坐标点(x, y)、RGB 颜色(255, 128, 0)、经纬度(39.9, 116.4)这些数据的维度是固定不变的放在元组里语义最清晰还能防止别人不小心改掉其中一个值。数据库查询返回的一行记录比如(id, name, created_at)结构固定用元组也比用列表更合适。反过来用户列表、日志列表、待办事项这类长度会动态变化、需要不断添加或删除的数据列表就是不二之选。有一个很微妙但很好用的经验元组表达的是“这些数据放在一起是有结构的”列表表达的是“这些数据是一组同类的可枚举项”。同样是三个字符串(张三, 李四, 王五)让人感觉这三个名字是固定的一组标签[张三, 李四, 王五]则更像一份可以继续追加名字的花名册。语义上的细微差别其实也是 Python 设计者提供两种容器的原因之一。2.2 从“运行场景”做第二判断数据性质判断完之后再看具体的使用场景。有几个高频场景是元组的“主场”第一个场景是函数返回多个值。Python 里return a, b本质上返回的是元组(a, b)接收时用x, y func()拆包。这是元组最常见的用途甚至很多新手根本意识不到自己每天都在用元组。第二个场景是作为字典键或放进集合。当你要用一个组合信息作为键时比如用(user_id, month)作为统计数据的键只能选元组因为列表不可哈希。这种情况下用元组不是可选项是必选项。第三个场景是可变参数。def func(*args)里的args就是一个元组Python 这样做是故意的函数接收到的不定长参数不应该在函数内部被随意改动用元组能保证参数不被意外篡改。第四个场景是性能敏感的大规模数据处理。如果你要创建几百万个小结构对象用元组能省下可观的内存和时间。不过我要提醒一句不要为了那一点性能牺牲代码可读性先判断语义再考虑优化。如果你想既享受元组的不可变性又想让字段带有名字可以用namedtuple或 Python 3.7 的dataclass。namedtuple特别适合那种“结构固定、字段不多、需要轻量表示”的数据比如from collections import namedtuple Point namedtuple(Point, [x, y]) p Point(3, 5) print(p.x, p.y) # 3 5它本质上还是元组能拆包、能哈希、能比较只是多了属性名访问的便利。3. 切片、拆包与“看上去很妙”的实操细节3.1 列表切片你会用但不一定用对切片是列表里最高频的操作之一但很多人在细节上栽过跟头。基本语法是list[start:stop:step]左闭右开也就是包含start位置不包含stop位置nums [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(nums[2:5]) # [2, 3, 4] print(nums[:5]) # [0, 1, 2, 3, 4] print(nums[5:]) # [5, 6, 7, 8, 9] print(nums[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] print(nums[::2]) # [0, 2, 4, 6, 8]最容易犯的错误是把“切片结果”当成“视图”。很多从 NumPy 转过来的同学会用 NumPy 的习惯认为切片是原数组的一个视图改切片会影响原数组。Python 列表完全不是这样列表切片会创建一个全新的列表对象修改切片不影响原列表nums [1, 2, 3, 4, 5] sub nums[1:3] sub[0] 99 print(nums) # [1, 2, 3, 4, 5]原列表没变但要注意如果列表里的元素本身是可变对象比如嵌套列表切片产生的新列表里的子列表仍然是引用修改子列表内容时原列表也会变。这是浅拷贝的固有特性一定要留个心眼。切片还有一个特别实用的场景是浅复制copy nums[:]或者copy nums.copy()两者都能得到一份独立的新列表但同样都是浅拷贝。如果你需要完全独立的深层复制得用copy.deepcopy()。3.2 元组的拆包不止多变量赋值拆包是元组最优雅的特性。最基本的用法是左右同时赋值coordinates (10, 20) x, y coordinates print(x, y) # 10 20在此基础上可以实现一行交换变量a, b b, a这个写法背后的原理就是右侧b, a先组成一个元组然后按顺序拆包赋值给左侧的a, b。Python 求值顺序保证了取到的都是旧值所以交换非常安全。更进阶的是星号拆包Python 3 引入的语法处理“开头几个、中间一堆、最后几个”这类场景特别方便first, *rest [1, 2, 3, 4, 5] print(first) # 1 print(rest) # [2, 3, 4, 5] head, *middle, tail [1, 2, 3, 4, 5] print(head, middle, tail) # 1 [2, 3, 4] 5注意*rest收集到的总是列表不管被拆的对象是元组还是列表。这里的应用很广比如拿列表的第一个元素当标题、剩余元素当正文再比如递归处理嵌套结构时head, *tail items几乎是最简洁的写法。函数调用时的参数解包也依赖这个机制def add(a, b, c): return a b c nums [1, 2, 3] print(add(*nums)) # 6 params {a: 1, b: 2, c: 3} print(add(**params)) # 6*用在函数调用的形参列表里表示收集位置参数**表示收集关键字参数。每当你看到def func(*args, **kwargs)args就是元组kwargs就是字典。这种设计保证了被收集进来的参数具有不可变性函数内部没法意外篡改调用方传入的值。3.3 “元组不可变”的边界情况这里必须泼一盆冷水元组的不可变是“浅层的”不是“深层的”。如果元组里的某个元素本身是可变的比如一个列表那你依然可以修改这个列表的内容tup (1, [2, 3], 4) tup[1].append(99) print(tup) # (1, [2, 3, 99], 4)报错会迟到但不会缺席你确实不能给元组整体赋值tup[1] [...]但你可以通过.append()或者tup[1][0] 100去修改那个嵌套列表。这会让很多人大吃一惊我第一次踩到这个坑的时候也懵了很久。理解方式很简单元组存储的是元素的“引用”它保证的是引用关系不可变也就是不能换掉某个位置上的元素对象但元素对象本身如果是可变的它的内部状态不受元组约束。所以如果你真的需要一个“彻底不可变”的数据结构光用元组不够还得保证它内部所有元素都是不可变的比如全部由数字、字符串、布尔值或其他不包含可变对象的元组组成。这正是元组可以用作字典键的条件不仅要本身是元组内部元素的类型也必须是可哈希的。当你尝试把(1, [2])作为字典键时会得到TypeError: unhashable type: list就是这个原因。4. 常见问题与排查技巧实录4.1 经典误区速查表这几年带过不少新人也帮人 review 过很多代码下面这些问题是出现频率最高的整理成一张速查表问题/报错原因解决方案()以为创建了空元组结果类型是元组但没法赋元素()确实是空元组但很多人写tup ()后直接tup[0] 1报错空元组很少用需要空序列时用空列表[]通常更合理写tup (1)后发现type(tup)是 int单个元素没有逗号时括号只表示优先级不是元组必须写成tup (1,)那个逗号才是元组的标志TypeError: tuple object does not support item assignment试图修改元组的某个元素改成列表或者重新创建新的元组元组里有列表改列表内容后元组也变了元组只保证引用不变不保证内部可变对象不变需要完全不可变时避免在元组里放列表可变默认参数共享状态def func(items[])时多次调用会共享同一个列表默认参数用None函数内部再创建列表拆包时ValueError: too many values to unpack左边变量数量与右边元素数量不匹配用first, *rest data或确认数据长度列表作为字典键报unhashable列表可变无法稳定哈希改成元组或转成字符串4.2 我踩过的几个坑第一个坑是可变默认参数。写函数时图省事写了def process(data[])第一次调用往里加了数据一切正常第二次调用发现在上次结果基础上继续追加数据越滚越大。原因就是默认参数在函数定义时创建一次后续所有调用共享同一个列表对象。正确写法是def process(dataNone): if data is None: data [] ...第二个坑是在“需要保留原列表”时忘了复制直接做了别名赋值。b a不是复制只是让b和a指向同一个列表对象改其中一个另一个跟着变。需要独立副本时用b a[:]或b a.copy()。第三个坑是列表的和元组的语义不同。列表的是原地修改元组的看起来像修改实际上创建了一个新元组lst [1, 2] lst [3] print(lst) # [1, 2, 3]原列表被修改 tup (1, 2) tup (3,) print(tup) # (1, 2, 3)实际上 tup 已经指向新元组你可以用id()验证列表前后id不变元组前后id会变。这个区别在写某些算法题时容易踩坑尤其是当你以为“反正都是原地操作”时。4.3 性能实测记录我用一段简单的代码实测了列表和元组的性能差异。创建 100 万个元素的小型容器元组比列表快 10%~20%内存占用少 10% 左右。迭代遍历也有微小差距但说实话在绝大多数业务代码里这个差距不会成为瓶颈。真正值得注意的是不要为了省这点性能把明显该用列表的场景硬改成元组。代码的可读性和数据语义的正确性优先级远高于这种微小的性能优化。如果你真的到了要大规模创建固定结构数据的阶段可以先用元组试试能省多少内存一测便知。import sys import timeit a [0, 1, 2, 3, 4, 5] b (0, 1, 2, 3, 4, 5) print(sys.getsizeof(a)) # 通常 88 左右 print(sys.getsizeof(b)) # 通常 72 左右 print(timeit.timeit([0, 1, 2, 3, 4, 5])) print(timeit.timeit((0, 1, 2, 3, 4, 5)))这段代码你在自己机器上跑出来的数字很可能和我不同版本、平台都会有影响但趋势是一致的元组更省更快。结尾我个人在实际使用中体会最深的一点是不要死记“列表可变、元组不可变”就完事要形成一种条件反射——看到一个数据集合先问自己“这个数据后续还要不要变”。要变就用列表不变、或者变了会有风险就用元组。这个习惯能帮你少写很多 bug。最后再分享一个小技巧当你写函数需要返回多个值时别在文档里写“返回的是数组”直接写清楚“返回的是元组可以用两个变量接收”。文档里多写这一句能帮后来的维护者省下大量翻代码的时间。很多问题不是 Python 难而是我们太习惯用默认方式写忽略了这些基础结构背后真正的设计意图。
返回列表