Python 的切片到底切了什么:拆开 slice 对象、负步长和 __getitem__

一句话结论 :a[1:5:2] 里的 1:5:2 不是语法里的一段特殊记号,Python 会先在栈上造出一个 slice 对象,再把它整个交给 __getitem__。所以「切片怎么工作」这个问题,本质是「一个对象收到 slice 之后做了什么」。

本文所有输出均来自本机 CPython 3.13.14 实测,代码可直接粘贴运行。

一、先看 __getitem__ 到底收到了什么

写个只负责打印参数的类,把各种切片语法都喂给它:

python 复制代码
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]

本机输出:

python 复制代码
  __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))

四个结论一次给全:

  1. 写全的切片 → slice(1, 5, 2);
  2. 省略的位置是 None,不是 0 也不是 len;
  3. a[:] 也会造 slice(None, None, None),并不是「什么都没传」;
  4. 多维切片(NumPy 那种写法)传进去的是一个 tuple,里面每个元素是一个 slice------语言层面根本没有「多维」概念,只是把多个对象打包成元组。

反汇编能验证这确实是两步:

python 复制代码
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):

python 复制代码
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。这是手写反转循环最容易错的地方。第三行则演示越界自动夹到合法区间。

对应到真实序列:

python 复制代码
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 是(但共享缓冲区) 零拷贝
python 复制代码
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 对象:

python 复制代码
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 的切片赋值可以改变列表长度。

python 复制代码
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 则是给任意迭代器做惰性切片的版本,不会先把整个迭代器吃掉:

python 复制代码
import itertools
print(list(itertools.islice(range(10), 2, 8, 2)))   # [2, 4, 6]

五、自己写一个「视图」类

把上面几块拼起来,一个支持完整切片语义(含负步长、越界夹取)的最小实现长这样:

python 复制代码
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 之后是返回拷贝还是视图。

六、六条排错清单

  1. a[::-1] 反转后遍历不到索引 0? 检查你手写的 stop 是不是写了 0;正确翻译是 indices() 给出的 -1。
  2. 改了切出来的列表,原列表也变了? 浅拷贝,嵌套对象共享;要 copy.deepcopy。
  3. s[:] is s 返回 True? 字符串整串切片允许复用原对象,这是实现优化,不是 bug。
  4. a[::2] = [...] 报 ValueError? 扩展切片赋值要求长度严格相等,先 len(a[::2]) 对一下。
  5. memoryview 切片改了原数据? 本来就共享 buffer,零拷贝是它的设计目的。
  6. 自定义类切片不支持负数? 别自己算 k.start % len,改用 k.indices(len(self))。
  7. 给迭代器切片报 TypeError? 迭代器没有 __getitem__,换 itertools.islice。

小结

切片语法糖拆开只有三件事:编译期 BUILD_SLICE 造对象 → BINARY_SUBSCR 交给 __getitem__(或 __setitem__ / __delitem__)→ 由接收方用 indices() 把边界翻译成具体的索引序列。

「返回拷贝还是视图」「长度能不能变」「支不支持多维」,全都不是语言规定的,而是那个对象自己决定的。理解这一点之后,再去看 list、str、memoryview、NumPy 的差异,就不再是一堆要背的特例了。

相关推荐
用户9562562542915 小时前
跟做:Firecrawl 单页转 Markdown 再丢进沙箱总结
python·api
威哥爱编程16 小时前
模拟面试智能体开发实战 02:回答等半分钟,谁还愿意练?把服务端和流式输出搭起来
python·agent·harmonyos
威哥爱编程16 小时前
模拟面试智能体开发实战 01:面试挂了都不知道挂在哪?用 Python 给自己造一个随叫随到的 AI 面试官
python·agent·harmonyos
2601_9679801116 小时前
Python行情API量化 02:顺序拉 20 要 2 秒?并发批量把时间压到 0.5 秒
python·api·限流·并发·量化
伞伞悦读16 小时前
【第81期】特征缩放怎么选:标准化、MinMax 与 Robust 的原理、泄漏对照和完整实验
人工智能·python·深度学习·机器学习
新*17 小时前
从一段 WebSocket 代码聊开:bytes 不是 zlib、binaryType 决定一切,15MB 大消息的零拷贝优化
javascript·python·websocket·性能优化·blob·zlib·arraybuffer
卷无止境19 小时前
mathmodel-figure-templates 以及两个上手案例
后端·python
锋行天下19 小时前
服务器 Python 2.7 装不了 langgraph-cli?用本地生成 Dockerfile 曲线救国
人工智能·后端·python
一条大祥脚19 小时前
深入理解 PyTorch FlexAttention:从 Score Mod 到 Mask Mod 的稀疏加速之道
人工智能·pytorch·python·注意力机制·flex attention·可编程注意力