Python 基础体系 · 第 8/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python list、tuple 与 range:存储、切片、复杂度和选择

listtuplerange 都属于 Python 的基本序列类型。它们都支持按位置访问、切片、迭代、长度计算和成员测试,但三者解决的问题并不相同:

  • list:存储一组可以变化的对象;
  • tuple:存储一组结构固定、不可重新绑定的对象;
  • range:描述一段整数等差序列,而不是保存这段序列中的全部元素。

如果只记住“列表可变、元组不可变、range 用来循环”,很快会在切片复制、嵌套对象、成员测试和性能上遇到误判。理解它们的关键,是同时区分:

  1. 序列的抽象语义
  2. 对象内部如何存储元素或参数
  3. 操作实际需要处理多少个元素;
  4. 不可变性到底限制了什么;
  5. 什么时候应该保留规则,什么时候必须物化为数据。

一、先建立共同模型:序列是“有顺序、可按整数索引”的对象

序列(sequence)是一类按照位置组织元素的对象。对于长度为 n 的序列:

s = [s[0], s[1], ..., s[n - 1]]

它通常具有以下性质:

  • 元素有稳定的先后顺序;
  • 使用从 0 开始的整数索引;
  • 支持 len(s)
  • 支持 s[i]
  • 支持 s[i:j]s[i:j:k]
  • 可以被迭代;
  • 可以进行成员测试 x in s

Python 文档把 listtuplerange 都列为基本序列类型;它们共享一组通用序列操作,但具体类型可以限制某些操作。例如,range 不支持序列拼接和重复。(docs.python.org)

items = ["a", "b", "c"]

print(len(items))  # 3
print(items[0])    # a
print(items[-1])   # c
print(items[1:])   # ['b', 'c']

for item in items:
    print(item)

1. 索引和负索引

正索引直接表示从左侧开始的位置:

items = ["a", "b", "c", "d"]

print(items[0])  # a
print(items[2])  # c

负索引表示从右侧倒数:

print(items[-1])  # d
print(items[-2])  # c

长度为 n 的序列中,负索引 -k 实际对应:

n - k

因此:

items[-1] == items[len(items) - 1]
items[-4] == items[0]

索引必须最终落在合法位置,否则会抛出 IndexError

items = [10, 20, 30]

items[3]   # IndexError
items[-4]  # IndexError

需要注意,切片和单元素索引的越界行为不同:单元素索引越界会报错,切片边界通常会被截断。


二、切片的形式化定义:startstopstep

切片表达式有三种形式:

s[start:stop]
s[start:stop:step]
s[:]

其核心规则是:

  • 起点 start 包含;
  • 终点 stop 不包含;
  • 步长 step 控制索引变化;
  • step 不能为 0

对于正步长,切片索引可以写成:

start, start + step, start + 2 × step, ...

只要索引满足:

start <= index < stop

对于负步长,索引递减,只要满足:

stop < index <= start

例如:

s = [0, 1, 2, 3, 4, 5, 6]

print(s[1:6:2])

索引依次为:

1, 3, 5

所以输出:

[1, 3, 5]

终点 6 不会被取出。

1. 省略边界的含义

s = [0, 1, 2, 3, 4]

print(s[:3])   # [0, 1, 2]
print(s[2:])   # [2, 3, 4]
print(s[:])    # [0, 1, 2, 3, 4]
print(s[::2])  # [0, 2, 4]

默认值取决于步长方向:

  • 正步长:
    • 省略 start,从序列开头开始;
    • 省略 stop,到序列结尾结束。
  • 负步长:
    • 省略 start,从序列结尾开始;
    • 省略 stop,到序列开头之前结束。
s = [0, 1, 2, 3, 4]

print(s[::-1])   # [4, 3, 2, 1, 0]
print(s[4:1:-1]) # [4, 3, 2]

第二个切片的索引是:

4, 3, 2

stop1,所以索引 1 不包含。

2. [::-1] 不是原地反转

s = [1, 2, 3]

reversed_s = s[::-1]

print(s)          # [1, 2, 3]
print(reversed_s) # [3, 2, 1]
print(s is reversed_s)  # False

[::-1] 创建了一个新的序列对象。它与:

s.reverse()

不同:

  • s.reverse() 修改原列表,并返回 None
  • s[::-1] 创建新列表,原列表不变。

如果只需要反向迭代而不需要新列表,可以使用:

s = [1, 2, 3]

for value in reversed(s):
    print(value)

这里不会立即创建一个完整的反向列表。

3. 切片边界会被截断

s = [0, 1, 2]

print(s[-100:100])  # [0, 1, 2]
print(s[100:200])   # []
print(s[2:1])       # []

切片不是单元素索引:

s[100]    # IndexError
s[100:101]  # []

官方序列规则规定,超出范围的切片边界会被调整到合法范围;但切片的起点不小于终点时,结果通常为空。(docs.python.org)


三、slice 对象:切片语法背后的数据结构

切片表达式并不是只能写在 [] 中。Python 还提供了显式的 slice 对象:

s = [0, 1, 2, 3, 4, 5]

part = slice(1, 5, 2)

print(s[part])  # [1, 3]

slice(1, 5, 2) 只描述:

start = 1
stop = 5
step = 2

它本身并不知道目标序列的长度。只有结合长度后,才能把可能省略或越界的边界正规化:

s = [0, 1, 2, 3, 4, 5]

part = slice(None, None, -1)

print(part.indices(len(s)))

输出类似:

(5, -1, -1)

这表示在长度为 6 的序列上,该切片会从索引 5 开始,递减到 -1 之前。

这个模型很重要,因为 listtuplerange 对切片的处理结果不同:

  • list 切片返回新的 list
  • tuple 切片返回新的 tuple
  • range 切片返回新的 range,继续保持惰性描述。

四、list:可变的动态序列

list 是最通用的内置序列类型。它具有三个核心特征:

  1. 有序;
  2. 可变;
  3. 元素可以是任意 Python 对象。
values = [10, "python", None, {"enabled": True}]

values[0] = 20
values.append("new")

print(values)

输出:

[20, 'python', None, {'enabled': True}, 'new']

列表中的元素不要求类型相同:

mixed = [1, "two", 3.0, [4, 5]]

但“列表可变”指的是列表容器可以增删改元素,不代表列表中的每个对象都自动变成可变或不可变。

1. CPython 中的典型存储方式

Python 语言规范不要求所有 Python 实现都使用同一种内存布局。下面描述的是 CPython 的常见实现模型。

CPython 的列表通常由:

  • 当前长度;
  • 已分配容量;
  • 指向元素引用数组的指针;

组成。引用数组中保存的是指向 Python 对象的引用,而不是把所有对象内容直接内嵌在列表中。CPython 源码中的 PyListObject 会维护元素数组和已分配容量,并在容量不足时重新分配存储空间。(github.com)

可以把它近似理解为:

list object
├── size: 当前元素数量
├── allocated: 当前可容纳的引用数量
└── ob_item ──> [引用, 引用, 引用, ...]

例如:

items = [1000, 2000, 3000]

列表通常保存的是三个对象引用:

items ──> 引用 ──> 1000
       ├─ 引用 ──> 2000
       └─ 引用 ──> 3000

这解释了两个现象:

  • items[1] 可以快速定位到第 2 个引用;
  • 列表切片需要复制引用数组,但不会递归复制引用指向的对象。

2. 列表扩容和摊销复杂度

向列表末尾追加元素时,如果当前容量还有空间,通常只需要写入一个引用:

items.append(value)

如果容量不足,CPython 需要:

  1. 分配更大的引用数组;
  2. 复制旧引用;
  3. 把新元素放入新数组;
  4. 释放旧数组。

单次扩容可能需要 O(n) 时间,但扩容不会发生在每次追加上。容量通常会预留一部分空间,因此连续执行 nappend 的总成本通常是 O(n),平均到每次操作上是摊销 O(1)

这不是说每次 append 都严格为常数时间,而是说:

n 次 append 的总成本 / n = O(1)

示意过程如下:

容量 4:追加第 1~4 个元素,无需扩容
容量不足:扩容并复制旧引用
新容量:继续追加若干元素
再次不足:再次扩容

因此:

result = []

for value in source:
    result.append(value)

通常比反复创建新列表更合理。

3. 列表头部插入为什么慢

items.insert(0, value)

若列表当前为:

[a, b, c, d]

在开头插入后,必须将原元素整体右移:

[value, a, b, c, d]

需要移动约 n 个引用,因此典型复杂度是 O(n)

同理:

items.pop(0)

删除第一个元素后,后面的引用需要整体左移,也通常是 O(n)

如果主要需求是两端插入和删除,应考虑 collections.deque,而不是把 list 当作双端队列使用。


五、tuple:不可变的是容器结构,不是递归冻结对象

tuple 是不可变序列。创建后,不能替换、增加或删除其中的元素:

point = (10, 20)

point[0] = 99

会抛出:

TypeError: 'tuple' object does not support item assignment

元组的语法本质上依赖逗号,而不是圆括号:

empty = ()
one = (1,)
also_one = 1,
many = 1, 2, 3

下面这个不是元组:

not_tuple = (1)
print(type(not_tuple))  # <class 'int'>

只有一个元素时,必须写逗号:

one = (1,)

官方语言参考明确指出,单元素元组由元素后的逗号形成,括号主要用于分组或消除语法歧义。(docs.python.org)

1. CPython 中的典型存储方式

CPython 中的元组通常保存:

  • 元组长度;
  • 固定大小的对象引用数组。

与列表相比,元组不需要为后续增长预留动态容量。CPython 的元组对象会按目标长度分配存储,元素访问直接从引用数组读取。(github.com)

可以近似表示为:

tuple object
├── size: 固定长度
└── ob_item ──> [引用, 引用, 引用, ...]

所以列表和元组都可以实现 O(1) 的按索引访问;它们的核心区别不是“列表连续、元组不连续”,而是:

  • 列表允许改变引用数组的长度和内容;
  • 元组不允许通过 Python 语法重新绑定其中的引用。

2. 元组的不可变性是浅层的

record = ("alice", [1, 2])

record[1].append(3)

print(record)

输出:

('alice', [1, 2, 3])

元组没有改变第二个槽位中的引用;变化发生在引用指向的列表对象内部。

因此:

tuple 不可变
≠
tuple 中的对象全部不可变

更准确的说法是:

元组不能改变其元素引用集合,但元素对象自身是否可变,由元素对象的类型决定。

3. 元组何时可以作为字典键或集合元素

元组本身不可变,但它是否可哈希取决于所有元素:

good = (1, "a", (2, 3))
hash(good)  # 可以

bad = (1, [])
hash(bad)   # TypeError: unhashable type: 'list'

原因是元组的哈希值必须稳定。如果元组包含一个可变且不可哈希的列表,整个元组不能作为哈希表键。

这也是“元组不可变”与“元组一定可以作为字典键”之间的区别:

(1, 2)       # 通常可哈希
(1, [2, 3])  # 不可哈希

六、range:保存整数序列的规则,而不是保存所有整数

range 表示一个不可变的整数序列,常见于固定次数的循环:

for i in range(5):
    print(i)

输出:

0
1
2
3
4

range(5) 不包含 5。它表示:

0 <= i < 5

range 有两种常见构造形式:

range(stop)
range(start, stop, step)

对应规则:

range(5)          # 0, 1, 2, 3, 4
range(2, 6)       # 2, 3, 4, 5
range(2, 10, 3)   # 2, 5, 8
range(5, 0, -1)   # 5, 4, 3, 2, 1

参数必须是整数,或者实现了 __index__() 的对象;浮点数不能直接作为 range 参数。步长为零会抛出 ValueError。(docs.python.org)

range(0, 5, 0)
# ValueError: range() arg 3 must not be zero

range(0.0, 5.0)
# TypeError

1. range 的数学定义

对于:

r = range(start, stop, step)

i 个元素是:

r[i] = start + step × i

其中 i >= 0,并且:

  • step > 0 时,要求 r[i] < stop
  • step < 0 时,要求 r[i] > stop

例如:

r = range(3, 12, 4)

逐步计算:

i = 0: 3 + 4 × 0 = 3
i = 1: 3 + 4 × 1 = 7
i = 2: 3 + 4 × 2 = 11
i = 3: 3 + 4 × 3 = 15,不满足 15 < 12

因此:

list(r)  # [3, 7, 11]

2. 空 range 的判断

range(5, 0)

默认步长为 1,但从 5 递增无法满足“小于 0”,因此为空:

list(range(5, 0))  # []

反向范围必须使用负步长:

list(range(5, 0, -1))  # [5, 4, 3, 2, 1]

判断是否为空,关键不是单看 startstop,而是看步长方向:

step > 0:start >= stop 时为空
step < 0:start <= stop 时为空

3. range 的存储优势

对于:

r = range(0, 1_000_000_000)

range 不会立即创建十亿个整数对象,也不会保存十亿个引用。它主要记录:

start = 0
stop  = 1_000_000_000
step  = 1

访问第 i 个元素时,再根据公式计算:

0 + 1 × i

官方文档明确说明,range 的内存占用与它表示的范围大小无关,因为它保存的是 startstopstep,并按需计算元素和子范围。(docs.python.org)

r = range(10**9)

print(r[0])       # 0
print(r[999_999_999])  # 999999999

只有显式物化时,才会创建完整列表:

values = list(range(10**9))

这一步可能消耗大量内存,并可能因内存不足失败。


七、list、tuple 和 range 的存储对比

假设三者表示相同的值:

values_list = [0, 2, 4, 6]
values_tuple = (0, 2, 4, 6)
values_range = range(0, 8, 2)

它们的抽象内容相同:

0, 2, 4, 6

但存储模型不同:

list
└── [引用 0, 引用 2, 引用 4, 引用 6],可扩容、可改写

tuple
└── [引用 0, 引用 2, 引用 4, 引用 6],长度和槽位不可改写

range
└── start=0, stop=8, step=2,按公式生成元素

因此:

  • listtuple 的空间复杂度通常是 O(n)
  • range 的空间复杂度通常是 O(1)
  • listtuple 已经保存了每个元素的引用;
  • range 保存的是生成元素的规则。

这里的 O(1) 是针对 range 对象自身而言。如果执行:

list(range(10**9))

结果列表仍然需要 O(n) 的空间。


八、三者都支持索引,但索引的成本和意义不同

1. list 和 tuple 的索引

在 CPython 的典型实现中,列表和元组通过引用数组直接定位元素:

items = ["a", "b", "c", "d"]

items[2]  # 直接定位到第 3 个槽位

因此:

list[i]:典型 O(1)
tuple[i]:典型 O(1)

这里的 O(1) 指的是访问引用槽位的成本,不包括元素对象后续被使用时的成本。

2. range 的索引

对于:

r = range(10, 30, 3)

i 个元素是:

10 + 3 × i

因此:

print(r[0])  # 10
print(r[2])  # 16
print(r[-1]) # 28

也不需要从头生成前面的元素。CPython 的实现会保存范围的长度,并根据索引计算对应值。(github.com)


九、切片复杂度:关键变量是切片结果长度

设原序列长度为 n,切片结果长度为 k

1. list 和 tuple 切片

items = list(range(1_000_000))
part = items[100_000:200_000]

切片需要:

  1. 计算切片索引;
  2. 创建结果容器;
  3. 复制 100_000 个元素引用。

因此复杂度与结果长度 k 相关:

list[start:stop:step]:O(k)
tuple[start:stop:step]:O(k)

即使元素本身没有被深度复制,也必须复制引用:

original = [[1], [2], [3]]
copied = original[:]

copied[0].append(99)

print(original)  # [[1, 99], [2], [3]]

这里发生的是浅复制:

original[0] is copied[0]  # True

新列表只复制了外层引用数组,没有复制内部列表对象。

2. range 切片

r = range(0, 1_000_000_000, 2)
part = r[100:200]

结果仍然是一个 range

print(part)
# range(200, 400, 2)

它不需要复制一百个整数对象,而是重新计算新的:

start
stop
step

因此创建 range 切片通常是 O(1) 的对象级操作;实际大整数运算的成本还取决于整数位数。

r = range(0, 20, 2)

print(r[:3])     # range(0, 6, 2)
print(type(r[:3]))  # <class 'range'>

这是 range 与列表、元组的重要差异:

list(range(0, 20, 2))[:3]
# [0, 2, 4]

range(0, 20, 2)[:3]
# range(0, 6, 2)

前者已经物化为列表,后者仍然是规则描述。官方文档明确规定 range 支持切片,但不支持拼接和重复。(docs.python.org)


十、成员测试:in 并不总是同样快

1. list 和 tuple:通常需要线性搜索

items = ["a", "b", "c", "d"]

"x" in items

解释器通常需要依次比较:

"x" == "a"
"x" == "b"
"x" == "c"
"x" == "d"

因此:

x in list:O(n)
x in tuple:O(n)

最坏情况下,要检查全部 n 个元素。

这也是为什么不应该使用大型列表反复进行成员判断:

allowed = ["read", "write", "delete"]

if permission in allowed:
    ...

如果成员测试是主要操作,集合通常更合适:

allowed = {"read", "write", "delete"}

这属于 dictset 的哈希查找主题。

2. range:整数成员测试可以直接计算

对于:

r = range(10, 100, 3)

判断 x 是否属于其中,不必逐个生成元素,只需检查:

  1. x 是否位于方向正确的边界内;
  2. x - start 是否能被 step 整除。

当步长为正数时,条件近似为:

start <= x < stop
(x - start) % step == 0

例如:

r = range(10, 100, 3)

print(16 in r)  # True
print(17 in r)  # False

因为:

16 >= 10
16 < 100
(16 - 10) % 3 == 0

而:

(17 - 10) % 3 == 1

所以 17 不在范围内。

Python 文档特别说明,range 对整数成员测试支持常数时间判断;但对于非整数对象,成员测试可能退化为迭代搜索。(docs.python.org)

r = range(0, 10, 2)

print(4 in r)    # True
print(True in r) # True,bool 是 int 的子类
print(4.0 in r)  # 取决于比较和实现路径,不应将其当作整数快速路径设计

工程代码中,如果需要表达整数范围,优先使用整数判断:

def in_range(value: int, start: int, stop: int, step: int = 1) -> bool:
    if step == 0:
        raise ValueError("step must not be zero")
    return value in range(start, stop, step)

十一、操作复杂度总表

下表描述常见的算法级成本。对于 list 的扩容、内存分配和对象比较,实际成本还会受到 Python 实现、元素类型和内存系统影响。

设:

  • n:原序列长度;
  • k:切片结果长度;
  • m:另一个序列长度。
操作 list tuple range
len(s) O(1) O(1) O(1)
s[i] O(1) O(1) O(1)
s[i:j] O(k) O(k) 通常 O(1)
x in s O(n) O(n) 整数通常 O(1)
s + t O(n + m) O(n + m) 不支持
s * p O(n × p) O(n × p) 不支持
尾部 append 摊销 O(1) 不支持 不支持
中间插入 O(n) 不支持 不支持
尾部删除 典型 O(1) 不支持 不支持
头部删除 O(n) 不支持 不支持
排序 list.sort() 原地排序 需转为列表 需转为列表

tuple 的拼接和重复会创建新元组。对于不可变序列,反复使用 + 构建结果会反复分配和复制,累计成本可能达到二次复杂度。官方文档也建议,对于逐步构建的元组,应先扩展列表,最后再转换为元组。(docs.python.org)

反例:

result = ()

for value in range(10_000):
    result += (value,)

每次拼接都要创建一个更大的新元组并复制旧引用。更好的方式是:

result = []

for value in range(10_000):
    result.append(value)

result = tuple(result)

十二、可变性决定了哪些语法成立

1. list 支持元素赋值、切片赋值和删除

items = [0, 1, 2, 3, 4]

items[1] = 10
items[1:3] = [20, 30, 40]
del items[0]

print(items)

逐步变化:

初始:        [0, 1, 2, 3, 4]
items[1]=10: [0, 10, 2, 3, 4]
切片赋值:    [0, 20, 30, 40, 3, 4]
删除第 0 项: [20, 30, 40, 3, 4]

普通切片赋值可以改变列表长度:

items = [1, 2, 3]
items[1:2] = [10, 20, 30]

print(items)  # [1, 10, 20, 30, 3]

2. 扩展切片赋值必须保持长度一致

当步长不是 1 时,切片指定的是离散位置。此时替换元素数量必须与目标位置数量相同:

items = [0, 1, 2, 3, 4, 5]

items[::2] = [10, 20, 30]

print(items)  # [10, 1, 20, 3, 30, 5]

items[::2] 对应索引:

0, 2, 4

恰好有三个位置,所以右侧也必须有三个元素:

items[::2] = [10, 20]

会抛出:

ValueError: attempt to assign sequence of size 2 to extended slice of size 3

这是因为解释器不能通过扩展或收缩一个带步长的离散位置集合来完成普通替换。

3. tuple 和 range 不支持赋值

point = (1, 2)
point[0] = 10
r = range(5)
r[0] = 10

二者都会因为对象不可变而失败。不可变序列可以创建新对象:

point = (1, 2)
point = (10,) + point[1:]

r = range(5)
r = range(10, 15)

但这不是修改原对象,而是重新绑定变量。


十三、重复操作的陷阱:引用会被重复,不会深度复制

序列重复使用 * 时,重复的是元素引用:

matrix = [[0] * 3] * 3

matrix[0][0] = 1

print(matrix)

输出:

[[1, 0, 0], [1, 0, 0], [1, 0, 0]]

原因是:

matrix[0] is matrix[1]  # True
matrix[1] is matrix[2]  # True

三个外层位置引用的是同一个内部列表。

正确构造独立子列表的方式是使用推导式:

matrix = [[0] * 3 for _ in range(3)]

matrix[0][0] = 1

print(matrix)
# [[1, 0, 0], [0, 0, 0], [0, 0, 0]]

这里每次循环都执行一次 [0] * 3,因此产生新的内部列表对象。

这个问题不仅存在于列表:

row = [0, 0]
data = (row, row)

row[0] = 1

print(data)  # ([1, 0], [1, 0])

元组自身不可变,但重复引用的内部列表仍然可变。


十四、复制:赋值、浅复制和深复制不是一回事

1. 普通赋值不复制对象

a = [1, 2, 3]
b = a

b.append(4)

print(a)  # [1, 2, 3, 4]
print(a is b)  # True

ab 是两个变量名,指向同一个列表对象。

2. 列表切片创建浅副本

a = [1, 2, 3]
b = a[:]

b.append(4)

print(a)  # [1, 2, 3]
print(b)  # [1, 2, 3, 4]

外层列表不同:

a is b  # False

但嵌套对象仍然共享:

a = [[1], [2]]
b = a[:]

b[0].append(99)

print(a)  # [[1, 99], [2]]

3. 需要深复制时显式使用 copy.deepcopy

import copy

a = [[1], [2]]
b = copy.deepcopy(a)

b[0].append(99)

print(a)  # [[1], [2]]
print(b)  # [[1, 99], [2]]

深复制并不总是正确选择。包含文件句柄、锁、连接、模块对象或自定义资源的对象,未必适合深复制。是否深复制应该由对象的所有权和生命周期决定,而不是看到嵌套结构就机械调用。


十五、list(range(...)):何时必须物化

range 节省空间,但它不能替代真正的数据列表。

适合保留为 range

只需要:

  • 循环迭代;
  • 按整数索引访问;
  • 判断整数是否属于范围;
  • 表示连续或等差的整数区间;
  • 将范围传给接受可迭代对象的函数。
for index in range(100):
    process(index)
if page_number in range(1, 101):
    print("valid page")

必须转为 list 的场景

需要:

  • 修改元素;
  • 使用列表方法,例如 appendsort
  • 保存一次生成后的具体结果;
  • 传给明确要求列表的 API;
  • 多次对结果进行列表语义上的修改。
values = list(range(5))
values.append(99)
values[0] = -1

转换过程是物化:

range 描述:
start=0, stop=5, step=1

list(range(5)):
创建 0、1、2、3、4 对应的元素引用

所以不要为了“看起来像列表”而无条件写:

values = list(range(10**8))

如果后续只是循环,直接保留 range(10**8) 更合理。


十六、range 不是通用的惰性生成器

range 只表示整数等差序列:

range(0, 10, 2)

它不能表示:

1, 2, 4, 8, 16, ...

也不能直接使用浮点步长:

range(0.0, 1.0, 0.1)  # TypeError

如果需要任意计算规则,应使用生成器或其他迭代器:

def powers_of_two(limit):
    value = 1
    while value <= limit:
        yield value
        value *= 2

for value in powers_of_two(100):
    print(value)

range 的优势来自严格的整数等差结构:

第 i 项 = start + step × i

一旦元素不能通过这个公式直接确定,range 就不再适用。


十七、range 的切片仍然保留等差结构

设:

r = range(10, 30, 3)

原序列是:

10, 13, 16, 19, 22, 25, 28

执行:

part = r[1:5:2]

先在原序列的位置上取:

位置 1, 3

对应值为:

13, 19

结果仍可表示为:

range(13, 22, 6)

因为新步长是:

原步长 × 切片步长 = 3 × 2 = 6
print(part)        # range(13, 25, 6) 或等价表示,具体 stop 取决于切片边界正规化
print(list(part))  # [13, 19]

这里不能只凭 reprstop 形式判断内容,真正的语义由生成出的序列决定。不同参数可能表示相同的值序列:

range(0, 3, 2) == range(0, 4, 2)
# True

因为二者都表示:

0, 2

官方文档规定,range 的相等比较按其表示的序列比较,而不是简单比较 startstopstep 三个属性。(docs.python.org)


十八、比较:list、tuple 和 range 的规则并不统一

1. list 和 tuple 按元素顺序比较,但类型不同通常不相等

[1, 2] == [1, 2]    # True
(1, 2) == (1, 2)    # True
[1, 2] == (1, 2)    # False

序列比较通常按字典序进行:

[1, 2, 9] < [1, 3, 0]  # True

比较过程:

  1. 比较第一个元素:1 == 1
  2. 比较第二个元素:2 < 3
  3. 已经确定结果,不再比较第三个元素。

如果前缀相同,较短序列通常小于较长序列:

[1, 2] < [1, 2, 0]  # True

2. range 按表示的值序列比较相等性

range(0, 3, 2) == range(0, 4, 2)
# True

但不要把 range 与列表混用比较:

range(3) == [0, 1, 2]
# False

二者都可以迭代出相同值,但类型不同,序列对象的相等关系也不同。


十九、函数参数中的 list 和 tuple:可变输入与固定结构

1. list 表示调用方可能继续修改的数据

def add_default(values):
    values.append("default")
    return values

调用者传入的列表会被原地修改:

values = ["a"]
result = add_default(values)

print(values)  # ['a', 'default']
print(result is values)  # True

这是一种明确的共享可变状态。函数如果不希望修改调用方数据,应创建副本:

def add_default(values):
    result = list(values)
    result.append("default")
    return result

2. tuple 表示结构固定的返回值或记录

def split_name(full_name):
    first, last = full_name.split(maxsplit=1)
    return first, last

这里:

return first, last

返回的是二元组。

元组适合表达:

固定字段数量、固定位置含义、整体不可重新排列

例如:

user = ("alice", 18, True)
name, age, enabled = user

如果字段依赖位置会降低可读性,可以考虑 dataclassNamedTuple 或字典。元组并不是所有“多个值”场景的默认答案。


二十、不要把“不可变”误认为“性能一定更快”

元组通常比列表更紧凑,因为它不需要列表的动态容量管理;在某些场景下,元组也可能更适合作为稳定结构。但不能得出“所有元组操作都比列表快”的结论。

实际成本取决于:

  • 操作类型;
  • 元素对象;
  • 是否发生分配;
  • 是否需要复制引用;
  • Python 实现;
  • CPU 缓存和内存分配器;
  • 元素比较和哈希的成本。

例如,查找某个元素时,列表和元组都需要线性扫描:

x in list:O(n)
x in tuple:O(n)

把列表换成元组并不会自动把成员测试变成常数时间。

同样,下面两种操作都需要复制大量引用:

new_list = old_list + [value]
new_tuple = old_tuple + (value,)

如果是在循环中不断构建结果,应优先使用可增长的列表,最后再转换。


二十一、列表推导式与 range 的关系

range 经常作为推导式的输入:

squares = [x * x for x in range(5)]

print(squares)
# [0, 1, 4, 9, 16]

这里有两个不同的过程:

  1. range(5) 提供 04 的迭代;
  2. 列表推导式创建并填充一个新的列表。

因此结果是列表,不是 range

type(range(5))       # range
type([x for x in range(5)])  # list

如果不需要一次性保存所有结果,可以使用生成器表达式:

squares = (x * x for x in range(5))

print(next(squares))  # 0
print(next(squares))  # 1

三者的空间含义不同:

list_values = [x * x for x in range(1_000_000)]
tuple_values = tuple(x * x for x in range(1_000_000))
generator = (x * x for x in range(1_000_000))
  • 列表:保存全部结果,空间 O(n)
  • 元组:保存全部结果,空间 O(n)
  • 生成器:通常只保存当前计算状态,结果按需产生。

range 只负责提供整数等差序列,不决定外层结果最终是列表、元组还是生成器。


二十二、常见误解与失败表现

误解一:tuple 中的列表不能修改

错误理解:

data = ([1, 2], 3)
data[0].append(4)

这段代码是合法的,因为修改的是内部列表,不是元组槽位。

正确区分:

data[0] = [9]      # 试图替换槽位,失败
data[0].append(4)  # 修改槽位指向的列表,成功

误解二:list.copy() 是深复制

a = [[1]]
b = a.copy()

b[0].append(2)

print(a)  # [[1, 2]]

copy() 是浅复制。需要深复制时必须显式使用 copy.deepcopy,并确认被复制对象适合深复制。

误解三:range 已经生成了全部数字

r = range(10**12)

这只创建了一个范围对象,不会立即创建一万亿个整数元素。真正危险的是:

list(r)

物化会把整个范围变成列表。

误解四:range 的终点包含在内

list(range(1, 5))
# [1, 2, 3, 4]

如果想生成 15,应写:

range(1, 6)

误解五:[[]] * n 会创建 n 个独立列表

rows = [[]] * 3

rows[0].append("x")

print(rows)
# [['x'], ['x'], ['x']]

它复制的是同一个内部列表的引用。独立构造应使用:

rows = [[] for _ in range(3)]

误解六:任何整数范围都适合用 range

range 只支持整数等差序列:

range(0, 10, 2)  # 合适

对于浮点步长、复杂递推或依赖外部状态的序列,应使用生成器或专门的数据结构。


二十三、如何根据问题选择类型

可以按照“是否需要保存每个值”和“是否允许改变结构”来选择。

选择 list

当满足以下条件时使用 list

  • 元素数量会变化;
  • 需要按索引读取和修改;
  • 需要排序、追加、删除;
  • 需要保存具体结果;
  • 需要构建二维或多层可变结构。
pending_tasks = []

pending_tasks.append("download")
pending_tasks.append("parse")
pending_tasks.remove("download")

选择 tuple

当满足以下条件时使用 tuple

  • 结构长度固定;
  • 字段位置有稳定含义;
  • 不希望调用方原地修改结构;
  • 需要表示不可重新绑定的记录;
  • 元组中的元素都可哈希,并且需要作为字典键或集合元素。
rgb = (255, 128, 0)
coordinates = (120.15, 30.28)

如果固定结构需要按名称访问,简单元组可能不够清晰:

user = ("alice", 18, True)

此时:

user[1]

不如带字段名的结构直观。

选择 range

当满足以下条件时使用 range

  • 序列是整数等差序列;
  • 主要用途是迭代、索引或范围判断;
  • 不需要修改其中的“元素”;
  • 不希望为所有值分配存储;
  • 希望切片后仍保持惰性范围表示。
for attempt in range(3):
    retry()
valid_ids = range(1000, 2000)

if item_id in valid_ids:
    process(item_id)

选择其他结构

如果主要需求是:

  • 快速成员测试:考虑 set
  • 按键查找:考虑 dict
  • 两端高效进出:考虑 collections.deque
  • 大量同质数值、希望节省对象引用开销:考虑 array 或第三方数值数组;
  • 复杂惰性规则:考虑生成器。

listtuplerange 是序列工具,不应该承担所有集合、映射和流式处理职责。


二十四、一个完整示例:分页任务的三种表示

假设程序需要处理第 1000 页到第 1999 页。

方案一:直接使用 range

pages = range(1000, 2000)

for page in pages:
    fetch_page(page)

这里最合理,因为页面编号是连续整数,且只需要迭代。

方案二:物化为 list

pages = list(range(1000, 2000))

pages[0] = 999
pages.append(2000)
pages.sort()

当程序确实需要修改、排序或保存具体页面编号时,列表才有意义。

方案三:转换为 tuple

pages = tuple(range(1000, 2000))

当需要固定一份页面编号快照,不希望后续代码修改其结构时,可以使用元组。但它仍然保存全部元素引用,不能获得 range 的常量级表示空间。

三种表示的语义不同:

range:一条生成规则
list:一组可修改的数据
tuple:一组结构固定的数据

它们即使迭代出相同的值,也不应被视为可以无条件互换。


二十五、最终判断框架

面对一个新序列需求,可以按以下顺序判断:

第一步:元素是否能由整数等差公式表示

如果可以:

start + step × i

优先考虑 range

第二步:是否需要保存所有具体元素

如果不需要,保留 range 或其他迭代器。

如果需要,再考虑 listtuple

第三步:结构是否需要修改

  • 需要追加、删除、替换:list
  • 不需要改变结构:tuple

第四步:元素是否需要按名称访问

如果位置含义不够清晰,考虑带字段名的数据结构,而不是继续增加元组位置约定。

第五步:核心操作是什么

  • 按索引访问:三者都可以;
  • 线性扫描成员:listtuple 都是 O(n)
  • 整数范围成员测试:range 通常更适合;
  • 哈希成员测试:考虑 set
  • 可变顺序数据:list
  • 固定顺序结构:tuple

listtuplerange 的真正区别,不只是可变与不可变,而是它们分别代表三种不同的数据模型:

list  = 已保存、可修改的元素集合
tuple = 已保存、不可重新绑定的固定结构
range = 描述整数等差序列的规则

一旦先判断“我要保存的是元素,还是生成元素的规则”,再判断是否需要修改结构,类型选择通常就会变得明确。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。