一句话结论:a[1:5:2] 里的 1:5:2 不是语法里的一段特殊记号,Python 会先在栈上造出一个 slice 对象,再把它整个交给 __getitem__。所以「切片怎么工作」这个问题,本质是「一个对象收到 slice 之后做了什么」。
本文所有输出均来自本机 CPython 3.13.14 实测,代码可直接粘贴运行。
一、先看 __getitem__ 到底收到了什么
写个只负责打印参数的类,把各种切片语法都喂给它:
class Probe:
def __getitem__(self, k):
print(' __getitem__ got', type(k).__name__, repr(k))
return k
p = Probe()
p[1:5:2]
p[:: -1]
p[:]
p[1:3, 2:4]
本机输出:
__getitem__ got slice slice(1, 5, 2)
__getitem__ got slice slice(None, None, -1)
__getitem__ got slice slice(None, None, None)
__getitem__ got tuple (slice(1, 3, None), slice(2, 4, None))
四个结论一次给全:
- 写全的切片 →
slice(1, 5, 2); - 省略的位置是
None,不是 0 也不是len; a[:]也会造slice(None, None, None),并不是「什么都没传」;- 多维切片(NumPy 那种写法)传进去的是一个 tuple,里面每个元素是一个 slice——语言层面根本没有「多维」概念,只是把多个对象打包成元组。
反汇编能验证这确实是两步:
import dis
def f_slice(a): return a[1:5:2]
print([i.opname for i in dis.get_instructions(f_slice)
if 'SLICE' in i.opname or 'SUBSCR' in i.opname])
# ['BUILD_SLICE', 'BINARY_SUBSCR']
BUILD_SLICE 造对象,BINARY_SUBSCR 调用下标协议。就这么简单。
二、indices() 是负步长与越界的翻译器
slice 自己只存三个属性,不关心序列多长。真正把「负数、超界、负步长」翻译成「可遍历的 start/stop/step」的是 slice.indices(length):
s = slice(1, 5, 2)
print(s.start, s.stop, s.step) # 1 5 2
print(s.indices(6)) # (1, 5, 2)
print(slice(None, None, -1).indices(5)) # (4, -1, -1)
print(slice(-100, 100, 2).indices(5)) # (0, 5, 2)
注意第二行:::-1 作用在长度 5 的序列上,被翻译成 (4, -1, -1),stop 是 -1 而不是 0——因为按 -1 的步长走,只有走到 -1 才能包含索引 0。这是手写反转循环最容易错的地方。第三行则演示越界自动夹到合法区间。
对应到真实序列:
a = list(range(10))
print(a[1:5:2]) # [1, 3]
print(a[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
print(a[8:2:-2]) # [8, 6, 4]
只要你要自己实现 __getitem__ 支持切片,就该用 k.indices(len(self.data)),不要自己算负数——上面这些边界你自己写几乎必漏。
三、切片返回的是拷贝还是视图
这是面试里最容易答糊的一问,实测一遍就清楚:
| 类型 | 切片结果 | 是否新对象 | 备注 |
|---|---|---|---|
list | list | 是(浅拷贝) | 嵌套元素仍共享 |
str | str | 通常是 | 但 st[:] is st → True,解释器会复用整串 |
bytes | bytes | 是 | 不可变 |
tuple | tuple | 是(可能复用) | 不可变 |
range | range | 是 | 仍是惰性的 range 对象 |
memoryview | memoryview | 是(但共享缓冲区) | 零拷贝 |
st = 'abcdefg'
print(st[:] is st) # True:整串切片直接复用原对象
nested = [[1], [2], [3]]
shallow = nested[:]
shallow[0].append(99)
print(nested[0]) # [1, 99] ← 改副本里的子列表,原列表跟着变
mv = memoryview(b'abcdef')
print(bytes(mv[2:5]), mv[2:5].obj is mv.obj) # b'cde' True
两处要划重点:
- 列表切片是浅拷贝,不是深拷贝。
nested[:]只复制了外层的引用,里面三个子列表还是原来那三个。想彻底隔离得用copy.deepcopy。 str的s[:]可能就是s本身,因为字符串不可变,复用没有副作用。所以别写if s[:] is not s之类的判断,它不成立。- 真正需要「不复制数据」的场景用
memoryview,它的切片共享底层 buffer,改 view 会影响原缓冲区。
四、赋值和删除切片是另外两个协议
下标语法不只是读。a[1:3] = ... 走 __setitem__,del a[1:4] 走 __delitem__,两者收到的同样是 slice 对象:
class Probe:
def __setitem__(self, k, v):
print(' __setitem__ got', type(k).__name__, repr(k), 'value', v)
def __delitem__(self, k):
print(' __delitem__ got', type(k).__name__, repr(k))
列表的赋值切片有个反直觉特性:步长 1 的切片赋值可以改变列表长度。
x = list(range(6))
x[1:3] = [90, 91, 92]
print(x) # [0, 90, 91, 92, 3, 4, 5] ← 从 6 个变 7 个
y = list(range(6))
y[::2] = [7, 8, 9]
print(y) # [7, 1, 8, 3, 9, 5]
z = list(range(6))
z[::2] = [1, 2] # ValueError
# attempt to assign sequence of size 2 to extended slice of size 3
规则:步长不等于 1 的「扩展切片」赋值,右侧长度必须与切片选中的位置数严格相等,因为没法靠挪动其它元素来腾位置。这个 ValueError 是排查批量替换逻辑时的高频报错。
islice 则是给任意迭代器做惰性切片的版本,不会先把整个迭代器吃掉:
import itertools
print(list(itertools.islice(range(10), 2, 8, 2))) # [2, 4, 6]
五、自己写一个「视图」类
把上面几块拼起来,一个支持完整切片语义(含负步长、越界夹取)的最小实现长这样:
class View:
def __init__(self, data):
self.data = data
def __getitem__(self, k):
if isinstance(k, slice):
start, stop, step = k.indices(len(self.data))
return [self.data[i] for i in range(start, stop, step)]
if isinstance(k, tuple): # 多维切片
return [self.__getitem__(one) for one in k]
return self.data[k]
def __len__(self):
return len(self.data)
v = View(list(range(10)))
print(v[7:1:-2]) # [7, 5, 3]
print(v[1:3]) # [1, 2]
print(v[3]) # 3
要点只有一句:isinstance(k, slice) + k.indices(len(...))。NumPy 的多维索引、Pandas 的 .iloc,往下拆都是这套协议,区别只在于收到 slice 之后是返回拷贝还是视图。
六、六条排错清单
a[::-1]反转后遍历不到索引 0? 检查你手写的 stop 是不是写了 0;正确翻译是indices()给出的-1。- 改了切出来的列表,原列表也变了? 浅拷贝,嵌套对象共享;要
copy.deepcopy。 s[:] is s返回 True? 字符串整串切片允许复用原对象,这是实现优化,不是 bug。a[::2] = [...]报 ValueError? 扩展切片赋值要求长度严格相等,先len(a[::2])对一下。memoryview切片改了原数据? 本来就共享 buffer,零拷贝是它的设计目的。- 自定义类切片不支持负数? 别自己算
k.start % len,改用k.indices(len(self))。 - 给迭代器切片报 TypeError? 迭代器没有
__getitem__,换itertools.islice。
小结
切片语法糖拆开只有三件事:编译期 BUILD_SLICE 造对象 → BINARY_SUBSCR 交给 __getitem__(或 __setitem__ / __delitem__)→ 由接收方用 indices() 把边界翻译成具体的索引序列。
「返回拷贝还是视图」「长度能不能变」「支不支持多维」,全都不是语言规定的,而是那个对象自己决定的。理解这一点之后,再去看 list、str、memoryview、NumPy 的差异,就不再是一堆要背的特例了。