切片协议与自定义切片

Python进阶编程专题 · 深入理解Python的切片机制

专题:Python进阶编程系统学习

关键词:Python, 切片, slice, __getitem__, 切片协议, Ellipsis, 多维切片, 自定义切片

一、概述

Python切片(Slicing)是一种从序列类型中提取子序列的强大机制。它通过简洁的 start:stop:step 语法,允许开发者快速获取列表、元组、字符串等序列中的部分元素。然而,切片的真正威力远不止于此——在Python的底层,切片背后是 slice 对象和 __getitem__ 协议的协作。深入理解这一机制,可以让开发者为自定义类实现优雅的切片接口,甚至构建出像NumPy数组那样支持多维切片的复杂数据结构。

Python的切片设计语言体现了"约定优于配置"的思想。看似简单的 lst[1:10:2] 语法,实际上Python解释器会在编译时将其转换为 lst.__getitem__(slice(1, 10, 2)) 调用。这种设计将语法糖与底层协议分离,使开发者可以在自定义类型中完全控制切片行为。

本文将从切片的基本语法出发,逐步深入到slice对象、indices方法、自定义切片类、多维切片(元组索引)、Ellipsis省略号,以及在数据框和矩阵中的高级应用,帮助读者建立起完整的切片知识体系。

二、切片基本语法:start:stop:step

切片语法是Python最常用的特性之一。其完整形式为 seq[start:stop:step],三个参数的含义如下:

基本用法示例

# 基本列表切片 nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 基本形式:取索引 2 到 5(不包含5) print(nums[2:5]) # [2, 3, 4] # 省略start:从开头开始 print(nums[:5]) # [0, 1, 2, 3, 4] # 省略stop:直到末尾 print(nums[5:]) # [5, 6, 7, 8, 9] # 省略所有:完整副本 print(nums[:]) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 指定步进:每隔一个取一个 print(nums[::2]) # [0, 2, 4, 6, 8] # 负步进:反向切片 print(nums[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] # 负步进取子集 print(nums[7:2:-1]) # [7, 6, 5, 4, 3]

负索引的边界行为

Python支持负索引,从序列末尾开始计数,-1表示最后一个元素。当负索引与切片结合使用时,需要特别注意边界行为:

nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 负索引切片 print(nums[-5:-1]) # [5, 6, 7, 8] (从倒数第5到倒数第1,不包含) print(nums[-5:]) # [5, 6, 7, 8, 9] (从倒数第5到末尾) print(nums[:-3]) # [0, 1, 2, 3, 4, 5, 6] (从开头到倒数第3,不包含) # 负索引 + 负步进 print(nums[-1:-5:-1]) # [9, 8, 7, 6] (从末尾往前取) # 超出边界的处理(Python自动截断) print(nums[-100:5]) # [0, 1, 2, 3, 4] (start自动截断为0) print(nums[5:100]) # [5, 6, 7, 8, 9] (stop自动截断为len)

边界规则:Python的切片会自动处理越界索引——当start或stop超出序列范围时,不会抛出IndexError,而是被自动截断到有效范围内。这一特性使切片代码更加健壮。

字符串和元组切片

切片不仅适用于列表,同样适用于字符串和元组等所有序列类型:

# 字符串切片 text = "Python切片机制" print(text[0:6]) # Python print(text[::-1]) # 制机片切nohtyP print(text[::2]) # Pto切机制 # 元组切片 t = (1, 2, 3, 4, 5) print(t[1:4]) # (2, 3, 4) print(t[::-1]) # (5, 4, 3, 2, 1)

性能提示:列表切片会创建新列表(浅拷贝),时间复杂度O(k),其中k为切片长度。对于大列表的频繁切片操作,可以考虑使用itertools.islice进行惰性求值。

三、slice对象与indices方法

当我们写 obj[1:10:2] 时,Python解释器会创建一个 slice(1, 10, 2) 对象,然后调用 obj.__getitem__(slice(1, 10, 2))。这意味着切片本质上就是对slice对象的操作。slice对象是切片语法的底层表示,理解它对于自定义切片行为至关重要。

slice对象的创建和属性

# 显式创建slice对象 s1 = slice(5) # 相当于 [:5] s2 = slice(2, 8) # 相当于 [2:8] s3 = slice(1, 9, 2) # 相当于 [1:9:2] print(s1.start, s1.stop, s1.step) # None 5 None print(s2.start, s2.stop, s2.step) # 2 8 None print(s3.start, s3.stop, s3.step) # 1 9 2 # slice对象的字符串表示 print(repr(s3)) # slice(1, 9, 2)

indices方法:计算实际索引

slice.indices(length) 方法是理解切片行为的关键。它接收序列的长度作为参数,返回一个三元组 (start, stop, step),表示在给定长度的序列上,该切片实际映射到的索引范围。所有的负索引和边界截断都在这个方法中得到处理:

# indices 方法:将切片映射到具体索引 s = slice(-5, -1, 1) # 相当于 [-5:-1:1] print(s.indices(10)) # (5, 9, 1) s2 = slice(-1, -5, -1) # 相当于 [-1:-5:-1] print(s2.indices(10)) # (9, 5, -1) # 超出边界的切片 s3 = slice(-100, 100) print(s3.indices(10)) # (0, 10, 1) ——start被截断为0,stop截断为10 # 步进为负时的边界 s4 = slice(100, -100, -1) print(s4.indices(10)) # (9, -1, -1) ——start截断为9,stop截断为-1

理解indices方法的返回值至关重要。正步进时,start和stop都被截断到 [0, length] 区间;负步进时,start截断为 length - 1,stop截断为 -1。这使得我们在实现自定义切片时,可以统一使用indices方法来计算实际遍历范围,而无需自行处理各种边界情况。

手动模拟切片行为

def manual_slice(seq, s): """手动模拟切片行为""" length = len(seq) start, stop, step = s.indices(length) result = [] i = start while (step > 0 and i < stop) or (step < 0 and i > stop): result.append(seq[i]) i += step return result # 测试 nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] print(manual_slice(nums, slice(2, 8, 2))) # [2, 4, 6] print(manual_slice(nums, slice(-5, -1))) # [5, 6, 7, 8] print(manual_slice(nums, slice(None, None, -1))) # [9,8,7,6,5,4,3,2,1,0]

核心理解:indices方法是连接切片语法和实际数据访问的桥梁。无论切片表达式中使用的是正索引、负索引还是省略形式,indices方法总能计算出统一的、与序列长度适配的实际索引范围。自定义切片类时,务必善用这个方法。

四、__getitem__接收slice参数实现自定义切片

Python的切片协议核心就是 __getitem__ 方法。当我们写 obj[key] 时,Python会根据 key 的类型(整数、slice、元组等)将其传递给 __getitem__。如果希望自定义类支持切片,就需要在 __getitem__ 中处理slice类型的参数。

基础知识:__getitem__的单键和切片分发

class SimpleList: def __init__(self, data): self.data = list(data) def __getitem__(self, key): if isinstance(key, slice): # 处理切片访问 start, stop, step = key.indices(len(self.data)) return [self.data[i] for i in range(start, stop, step)] elif isinstance(key, int): # 处理整数索引 return self.data[key] else: raise TypeError(f"不支持的索引类型: {type(key)}") # 测试 sl = SimpleList(range(10)) print(sl[2]) # 2 (整数索引) print(sl[2:7:2]) # [2, 4, 6] (切片) print(sl[:5]) # [0, 1, 2, 3, 4]

支持切片赋值:__setitem__

仅实现 __getitem__ 只能实现读取切片。要实现切片赋值(如 obj[1:3] = [10, 20]),还需要实现 __setitem__

class MutableList: def __init__(self, data=None): self.data = list(data) if data else [] def __getitem__(self, key): if isinstance(key, slice): start, stop, step = key.indices(len(self.data)) if step != 1: # 步进不为1时,返回对应元素列表 return [self.data[i] for i in range(start, stop, step)] return self.data[start:stop] elif isinstance(key, int): return self.data[key] raise TypeError(f"不支持的索引类型: {type(key)}") def __setitem__(self, key, value): if isinstance(key, slice): start, stop, step = key.indices(len(self.data)) if step != 1: # 步进不为1时,逐元素赋值 indices = list(range(start, stop, step)) if len(indices) != len(value): raise ValueError( f"切片元素数量({len(indices)})与赋值数量({len(value)})不匹配" ) for i, v in zip(indices, value): self.data[i] = v else: # 步进为1时,替换整个切片 self.data[start:stop] = value elif isinstance(key, int): self.data[key] = value else: raise TypeError(f"不支持的索引类型: {type(key)}") def __delitem__(self, key): if isinstance(key, slice): start, stop, step = key.indices(len(self.data)) if step != 1: # 步进不为1时,按逆序删除以避免索引错位 indices = sorted(range(start, stop, step), reverse=True) for i in indices: del self.data[i] else: del self.data[start:stop] elif isinstance(key, int): del self.data[key] else: raise TypeError(f"不支持的索引类型: {type(key)}") def __repr__(self): return repr(self.data) # 测试切片赋值 ml = MutableList(range(10)) print(ml) # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] ml[2:5] = [100, 200, 300] print(ml) # [0, 1, 100, 200, 300, 5, 6, 7, 8, 9] # 测试步进切片赋值 ml2 = MutableList(range(10)) ml2[1:8:2] = [10, 20, 30, 40] print(ml2) # [0, 10, 2, 20, 4, 30, 6, 40, 8, 9] # 测试删除切片 del ml2[1:8:2] print(ml2) # [0, 2, 4, 6, 8, 9]

注意:实现步进删除时,必须逆序删除目标索引,否则前面的删除操作会导致后续索引错位。这是实现可变序列协议时极易忽略的细节。

浅拷贝与深拷贝的切片语义

对于可变序列,切片创建副本(浅拷贝);对于不可变序列,切片返回原对象的引用(优化)。理解这一点对自定义容器类很重要:

# 列表切片是浅拷贝 original = [[1, 2], [3, 4], [5, 6]] sliced = original[:2] sliced[0][0] = 999 print(original) # [[999, 2], [3, 4], [5, 6]] (内部元素共享) # 字符串切片不创建副本(不可变对象的优化) s = "hello" * 1000 s2 = s[:] print(s2 is s) # True (Python优化:不可变对象切片返回自身)

五、一维切片到多维切片:元组索引

Python的内置序列类型只支持一维切片。但通过在 __getitem__ 中处理tuple类型的key,我们可以实现多维切片——这正是NumPy数组的核心机制。

多维切片的原理

当使用 obj[1:5, 2:8] 这样的语法时,Python解释器会将逗号分隔的索引项打包成一个元组,等价于 obj.__getitem__((slice(1, 5), slice(2, 8)))

# Python如何解析多维索引 import dis def test(): x = [1, 2, 3] y = x[1:3, 2:5] # 语法上可行,但普通列表会报错 # 查看字节码 # dis.dis(test) # 实际上会编译为:y = x.__getitem__((slice(1,3), slice(2,5)))

实现一个支持多维切片的矩阵类

class Matrix: """支持多维切片的矩阵类""" def __init__(self, rows, cols, data=None): self.rows = rows self.cols = cols if data: self._data = [list(row) for row in data] else: self._data = [[0] * cols for _ in range(rows)] def __getitem__(self, key): if isinstance(key, tuple): # 多维索引:key 是包含多个索引项的元组 row_key, col_key = key # 处理行索引 if isinstance(row_key, slice): row_indices = range(*row_key.indices(self.rows)) else: row_indices = [row_key] if isinstance(row_key, int) else list(row_key) # 处理列索引 if isinstance(col_key, slice): col_indices = range(*col_key.indices(self.cols)) else: col_indices = [col_key] if isinstance(col_key, int) else list(col_key) # 提取子矩阵 result_data = [] for r in row_indices: row = [] for c in col_indices: row.append(self._data[r][c]) result_data.append(row) if isinstance(row_key, int) and isinstance(col_key, int): return result_data[0][0] # 返回标量 elif isinstance(row_key, int): return result_data[0] # 返回行向量 else: return Matrix(len(result_data), len(result_data[0]), result_data) elif isinstance(key, slice): # 一维切片:按行切片 row_indices = range(*key.indices(self.rows)) result_data = [self._data[r][:] for r in row_indices] return Matrix(len(result_data), self.cols, result_data) elif isinstance(key, int): return self._data[key][:] raise TypeError(f"不支持的索引类型: {type(key)}") def __setitem__(self, key, value): if isinstance(key, tuple): row_key, col_key = key if isinstance(row_key, int) and isinstance(col_key, int): self._data[row_key][col_key] = value return if isinstance(row_key, slice): row_indices = list(range(*row_key.indices(self.rows))) else: row_indices = [row_key] if isinstance(row_key, int) else list(row_key) if isinstance(col_key, slice): col_indices = list(range(*col_key.indices(self.cols))) else: col_indices = [col_key] if isinstance(col_key, int) else list(col_key) if isinstance(value, Matrix): value_data = value._data elif isinstance(value, (list, tuple)): value_data = value else: value_data = [[value] * len(col_indices)] * len(row_indices) for i, r in enumerate(row_indices): for j, c in enumerate(col_indices): self._data[r][c] = value_data[i][j] if isinstance(value_data[i], (list, tuple)) else value_data[i] else: raise TypeError("多维矩阵仅支持元组索引") def __repr__(self): rows_str = [] for row in self._data: rows_str.append(" " + str(row)) return "Matrix([\n" + ",\n".join(rows_str) + "\n])" # 测试多维切片 m = Matrix(5, 5, [ [1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15], [16, 17, 18, 19, 20], [21, 22, 23, 24, 25] ]) print("原始矩阵:") print(m) print() print("m[1:3, 1:4]:") print(m[1:3, 1:4]) print() print("m[2, :]:") print(m[2, :]) print() print("m[1:4:2, :3]:") print(m[1:4:2, :3]) print() # 多维切片赋值 m[1:3, 1:3] = Matrix(2, 2, [[99, 99], [99, 99]]) print("赋值后的矩阵:") print(m)

设计要点:实现多维切片时,关键在于正确处理key为元组的情况,将每个维度的索引项独立解析(支持int和slice),然后从底层数据结构中提取对应的子集。返回值的类型也需要根据索引的维度进行判断:全部为整数索引时返回标量,部分为切片时返回子矩阵。

六、Ellipsis(...)省略号在切片中的应用

Ellipsis是Python的内置常量,写作三个点 ...,其类型为 ellipsis。在多维切片中,Ellipsis(省略号)表示"填充剩余维度",相当于在对应位置插入所需的全部 : 切片。这是NumPy等科学计算库中的核心语法糖。

Ellipsis的基本用法

# Ellipsis 在多维切片中的语义 import numpy as np arr = np.arange(24).reshape(2, 3, 4) print("形状:", arr.shape) # (2, 3, 4) # 以下两种写法等价: print(arr[0, ..., 0]) # 取第0个批次的第0列全部行 print(arr[0, :, 0]) # 同上 # 以下两种写法等价: print(arr[..., 0]) # 所有批次所有行的第0列 print(arr[:, :, 0]) # 同上 # Ellipsis 可以出现在任意位置 print(arr[0, ...]) # 等价于 arr[0, :, :] print(arr[..., 1:3]) # 等价于 arr[:, :, 1:3]

自定义类中处理Ellipsis

class Tensor: """自定义张量类,支持Ellipsis切片""" def __init__(self, shape, data=None): self.shape = shape self._dims = len(shape) if data is not None: self._data = data else: self._data = [0] * self._product(shape) self._strides = self._compute_strides(shape) def _product(self, shape): result = 1 for s in shape: result *= s return result def _compute_strides(self, shape): strides = [1] for s in reversed(shape[1:]): strides.insert(0, strides[0] * s) return strides def _normalize_key(self, key): """将包含Ellipsis的索引标准化为完整元组""" if not isinstance(key, tuple): key = (key,) # 统计普通维度和Ellipsis ellipsis_count = sum(1 for k in key if k is Ellipsis) if ellipsis_count > 1: raise IndexError("索引中只能有一个省略号(...)") if ellipsis_count == 1: # 计算需要补充的 : 数量 explicit = [k for k in key if k is not Ellipsis] remaining = self._dims - len(explicit) # 展开省略号 new_key = [] seen_ellipsis = False for k in key: if k is Ellipsis and not seen_ellipsis: new_key.extend([slice(None)] * remaining) seen_ellipsis = True elif k is not Ellipsis: new_key.append(k) # 如果没有省略号,补充完整的 : key = tuple(new_key) elif len(key) < self._dims: # 没有Ellipsis但维度不足:补充后面的维度 key = tuple(key) + tuple([slice(None)] * (self._dims - len(key))) return key def __getitem__(self, key): key = self._normalize_key(key) if not isinstance(key, tuple): key = (key,) # 计算每个维度的索引范围 ranges = [] for k, dim_size in zip(key, self.shape): if isinstance(k, slice): ranges.append(list(range(*k.indices(dim_size)))) elif isinstance(k, int): ranges.append([k]) else: raise TypeError(f"不支持的索引类型: {type(k)}") # 提取数据 result = [] self._extract_recursive(ranges, 0, 0, result) # 确定返回形状 result_shape = tuple(len(r) for r, k in zip(ranges, key) if isinstance(k, slice)) if not result_shape: # 全整数索引,返回标量 return result[0] if result else 0 elif len(result_shape) == 1: return result else: return Tensor(result_shape, result) def _extract_recursive(self, ranges, dim, offset, result): if dim == len(ranges): result.append(self._data[offset]) return for i in ranges[dim]: self._extract_recursive(ranges, dim + 1, offset + i * self._strides[dim], result) def __repr__(self): return f"Tensor(shape={self.shape}, data={self._data})" # 测试Ellipsis t = Tensor((2, 3, 4), list(range(24))) print("原始形状:", t.shape) # 使用Ellipsis result1 = t[0, ...] # 等价于 t[0, :, :] print("t[0, ...]:", result1) result2 = t[..., 0] # 等价于 t[:, :, 0] print("t[..., 0]:", result2) result3 = t[0, ..., 1:3] # 等价于 t[0, :, 1:3] print("t[0, ..., 1:3]:", result3)

理解Ellipsis:Ellipsis本质上是一个语法填充器,它在多维切片中自动展开为所需数量的 : 切片。当维度较多时(例如一个4维或5维张量),使用Ellipsis可以大幅简化索引表达式,避免书写大量冒号。

七、自定义切片类实现

除了利用 __getitem__ 处理slice参数外,我们还可以自定义切片类来扩展Python的切片能力。通过继承 slice 或实现 __index__ 协议,可以创建带有额外元数据的切片对象。

带标签的切片类

class LabeledSlice: """带有标签和元数据的切片""" def __init__(self, start=None, stop=None, step=None, label=None): self._slice = slice(start, stop, step) self.label = label or "" @property def start(self): return self._slice.start @property def stop(self): return self._slice.stop @property def step(self): return self._slice.step def indices(self, length): return self._slice.indices(length) def __repr__(self): return (f"LabeledSlice({self.start}, {self.stop}, {self.step}, " f"label={self.label!r})") class NamedSlice: """支持按名称切片的容器""" def __init__(self, data, names): self.data = data self.names = names # 名称到索引的映射 def __getitem__(self, key): if isinstance(key, slice): return NamedSlice(self.data[key], self.names) elif isinstance(key, str): # 支持通过名称获取 idx = self.names[key] if isinstance(idx, slice): return self.data[idx] return self.data[idx] elif isinstance(key, (list, tuple)): return [self.data[i] for i in key] return self.data[key] def __repr__(self): return f"NamedSlice({self.data})" # 测试命名切片 ns = NamedSlice( [10, 20, 30, 40, 50, 60, 70, 80, 90, 100], {"前三": slice(0, 3), "后五": slice(5, 10), "中段": slice(2, 8)} ) print(ns["前三"]) # NamedSlice([10, 20, 30]) print(ns["后五"]) # NamedSlice([50, 60, 70, 80, 90, 100]) print(ns["中段"]) # NamedSlice([30, 40, 50, 60, 70, 80])

切片工厂:基于谓词的智能切片

class PredicateSlice: """基于谓词(条件函数)的智能切片""" def __init__(self, data): self.data = data def __getitem__(self, predicate): """predicate 是一个函数,返回True的元素被选中""" return [item for item in self.data if predicate(item)] class SmartList: """支持谓词切片的智能列表""" def __init__(self, data): self.data = list(data) @property def where(self): """返回PredicateSlice,支持 lst.where[lambda x: x > 5] 语法""" return PredicateSlice(self.data) def __getitem__(self, key): if callable(key): return [x for x in self.data if key(x)] return self.data[key] def __repr__(self): return repr(self.data) # 测试谓词切片 sl = SmartList(range(20)) print(sl[lambda x: x > 15]) # [16, 17, 18, 19] print(sl[lambda x: x % 3 == 0]) # [0, 3, 6, 9, 12, 15, 18] print(sl[lambda x: 5 <= x <= 10]) # [5, 6, 7, 8, 9, 10] print(sl.where[lambda x: x % 2 == 0]) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

实践模式:谓词切片虽然不是Python切片协议的正式部分,但它利用 __getitem__ 可接受任意类型参数的特性,实现了类似SQL WHERE子句的筛选语义。这种模式在pandas等数据处理库中有广泛应用。

八、切片在数据框/矩阵/时间序列中的高级应用

切片在数据处理和科学计算领域有着极其广泛的应用。本节以pandas数据框和NumPy矩阵为例,展示切片在实际数据分析中的高级用法。

数据框的多维切片(pandas风格)

pandas的DataFrame同时支持基于标签(loc)和基于位置(iloc)的切片,二者有不同的边界语义:

import pandas as pd import numpy as np # 创建示例数据框 df = pd.DataFrame( np.random.randn(10, 4), columns=['A', 'B', 'C', 'D'], index=pd.date_range('2026-01-01', periods=10, freq='D') ) print("原始数据框:") print(df.head()) print() # iloc:基于整数位置的切片 print("iloc切片 (前3行, 第1-2列):") print(df.iloc[:3, :2]) print() # loc:基于标签的切片(注意:loc包含终点) print("loc切片 (从2026-01-01到2026-01-05):") print(df.loc['2026-01-01':'2026-01-05', ['A', 'C']]) print() # 布尔索引(谓词切片) print("A列 > 0 的行:") print(df.loc[df['A'] > 0, :])

loc和iloc的区别体现了切片的两种语义模型:loc遵循"包含终点"的标签语义(label-based),iloc遵循"不包含终点"的位置语义(position-based)。理解这一区别对于正确使用pandas的切片至关重要。

时间序列的切片行为

时间序列切片是切片在金融数据分析中的重要应用。pandas的Series支持基于时间的部分字符串匹配切片:

# 创建时间序列 ts = pd.Series( np.random.randn(100), index=pd.date_range('2026-01-01', periods=100, freq='h') ) print("前5个时间点:") print(ts.head()) print() # 部分字符串切片 print("2026-01-03 全部数据:") print(ts['2026-01-03']) print() # 范围切片(自动填充) print("2026-01-01 12:00 到 2026-01-02 06:00:") print(ts['2026-01-01 12:00':'2026-01-02 06:00'].head()) print() # 高级切片:按月取数据 monthly = ts.resample('D').mean() print("日均值前10天:") print(monthly.head(10))

模拟简化版pandas DataFrame切片

class SimpleDataFrame: """简化版DataFrame,演示多维切片的实际应用""" def __init__(self, data, columns, index=None): self.columns = list(columns) self.index = list(index) if index else list(range(len(data))) self._data = [list(row) for row in data] self._col_index = {col: i for i, col in enumerate(self.columns)} def iloc(self, row_spec, col_spec=None): """基于整数位置的切片""" # 解析行 if isinstance(row_spec, slice): rows = list(range(*row_spec.indices(len(self._data)))) elif isinstance(row_spec, int): rows = [row_spec] else: rows = list(row_spec) # 解析列 if col_spec is None: cols = list(range(len(self.columns))) elif isinstance(col_spec, slice): cols = list(range(*col_spec.indices(len(self.columns)))) elif isinstance(col_spec, int): cols = [col_spec] else: cols = list(col_spec) result = [] for r in rows: result.append([self._data[r][c] for c in cols]) result_cols = [self.columns[c] for c in cols] result_index = [self.index[r] for r in rows] if len(rows) == 1 and len(cols) == 1: return result[0][0] return SimpleDataFrame(result, result_cols, result_index) def loc(self, row_spec, col_spec=None): """基于标签的切片(包含终点)""" # 解析行标签 if isinstance(row_spec, slice): start_idx = 0 if row_spec.start is None else self.index.index(row_spec.start) stop_idx = len(self.index) - 1 if row_spec.stop is None else self.index.index(row_spec.stop) step = row_spec.step if row_spec.step is not None else 1 if step > 0: rows = list(range(start_idx, stop_idx + 1, step)) else: rows = list(range(start_idx, stop_idx - 1, step)) elif isinstance(row_spec, str): rows = [self.index.index(row_spec)] else: rows = [self.index.index(r) for r in row_spec] # 解析列标签 if col_spec is None: cols = list(range(len(self.columns))) elif isinstance(col_spec, slice): start = 0 if col_spec.start is None else self._col_index[col_spec.start] stop = len(self.columns) - 1 if col_spec.stop is None else self._col_index[col_spec.stop] step = col_spec.step if col_spec.step is not None else 1 if step > 0: cols = list(range(start, stop + 1, step)) else: cols = list(range(start, stop - 1, step)) elif isinstance(col_spec, str): cols = [self._col_index[col_spec]] else: cols = [self._col_index[c] for c in col_spec] result = [] for r in rows: result.append([self._data[r][c] for c in cols]) result_cols = [self.columns[c] for c in cols] result_index = [self.index[r] for r in rows] if len(rows) == 1 and len(cols) == 1: return result[0][0] return SimpleDataFrame(result, result_cols, result_index) def __repr__(self): lines = [] header = "索引\t" + "\t".join(str(c) for c in self.columns) lines.append(header) for idx, row in zip(self.index, self._data): lines.append(f"{idx}\t" + "\t".join(str(v) for v in row)) return "\n".join(lines) # 测试简化DataFrame sdf = SimpleDataFrame( [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]], columns=['A', 'B', 'C', 'D'], index=['a', 'b', 'c', 'd'] ) print("原始DataFrame:") print(sdf) print() print("iloc[:2, :2]:") print(sdf.iloc(slice(None, 2), slice(None, 2))) print() print("loc['b':'d', 'B':'D']:") print(sdf.loc(slice('b', 'd'), slice('B', 'D')))

核心差异:iloc基于整数位置(不含终点),loc基于标签(含终点)。这一差异是pandas等库中切片语义的关键设计决策。实际开发中,实现标签切片时要特别注意"包含终点"的语义要求,这使得实现比位置切片更复杂。

九、切片的边界行为与步进规律总结

切片看似简单,但其边界行为和步进规律存在一些容易混淆的细节。以下是对这些规律的全面总结。

切片边界行为速查表

表达式 结果 说明
nums[:] [0,1,2,3,4,5,6,7,8,9] 完整副本/引用
nums[2:6] [2,3,4,5] 包含start,不包含stop
nums[-5:-1] [5,6,7,8] 负索引映射为正后,仍然不包含stop
nums[:-3] [0,1,2,3,4,5,6] 省略start默认为0
nums[-3:] [7,8,9] 省略stop默认为len
nums[::2] [0,2,4,6,8] 正步进,每隔一个取一个
nums[1::2] [1,3,5,7,9] 正步进,从索引1开始
nums[::-1] [9,8,7,6,5,4,3,2,1,0] 负步进,完整反向
nums[8:2:-1] [8,7,6,5,4,3] 负步进时start>stop
nums[-1:-5:-1] [9,8,7,6] 负索引+负步进
nums[100:200] [] 越界不报错,返回空
nums[100:] [] start越界返回空

步进规律

# 正步进规律总结 nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 步进 = 1:连续子序列 print(nums[2:7:1]) # [2, 3, 4, 5, 6] # 步进 = 2:每隔一个取一个 print(nums[2:7:2]) # [2, 4, 6] # 步进 = 3:每隔两个取一个 print(nums[2:7:3]) # [2, 5] # 负步进:从右向左 print(nums[7:2:-2]) # [7, 5, 3] # 步进为 -1:反向连续 print(nums[7:2:-1]) # [7, 6, 5, 4, 3] # 完全反转 print(nums[::-1]) # [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]

切片赋值行为

# 切片赋值的独特行为 nums = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] # 1. 替换连续子序列 nums[2:5] = [100, 200] print(nums) # [0, 1, 100, 200, 5, 6, 7, 8, 9] # 注意:左右两边元素数量可以不匹配 # 2. 扩展列表(左少右多) nums[2:2] = [10, 20, 30] print(nums) # [0, 1, 10, 20, 30, 100, 200, 5, 6, 7, 8, 9] # 3. 删除切片(赋值为空列表) nums[2:5] = [] print(nums) # [0, 1, 100, 200, 5, 6, 7, 8, 9] # 4. 步进切片赋值(数量必须匹配) nums2 = list(range(10)) nums2[1:8:2] = [10, 20, 30, 40] print(nums2) # [0, 10, 2, 20, 4, 30, 6, 40, 8, 9] # nums2[1:8:2] = [10, 20] # 报错:ValueError(数量不匹配)

切片赋值风险:连续切片(step=1)的赋值不要求左右长度匹配,Python会自动调整列表长度。但步进切片(step≠1)的赋值要求替换数量严格匹配,否则抛出ValueError。这是实现 __setitem__ 时必须处理的重要边界情况。

十、核心要点总结

  • 切片本质:obj[start:stop:step] 语法糖会转换为 obj.__getitem__(slice(start, stop, step)) 调用,理解slice对象是掌握切片协议的关键。
  • indices方法:slice.indices(length) 返回在当前序列长度下实际遍历的 (start, stop, step) 三元组,自动处理负索引和边界截断,是实现自定义切片的核心工具。
  • __getitem__分发:在自定义类中实现切片需要根据key的类型(int、slice、tuple)进行分发处理。tuple类型对应多维切片,Ellipsis类型对应省略号展开。
  • 多维切片:通过处理元组索引实现多维切片,每个维度可以独立使用int或slice。返回值类型由各维度索引方式共同决定(全int→标量,有slice→子容器)。
  • Ellipsis展开:省略号(...)在多维切片中自动展开为对应数量的 : 切片,简化高维数据的索引表达式。
  • 标签vs位置:数据框切片的关键设计决策:loc遵循包含终点的标签语义,iloc遵循不包含终点的位置语义。
  • 边界规则:切片不会抛出IndexError——超界自动截断。正步进时 start >= stop 返回空,负步进时 start <= stop 返回空。
  • 赋值规则:连续切片(step=1)赋值不要求长度匹配;步进切片赋值必须长度严格一致。

十一、进一步思考

Python的切片协议是一个优雅的设计范例:既提供了简洁直观的语法糖,又通过底层协议保持了足够的扩展性。通过深入理解这一机制,开发者不仅可以更好地使用Python的内置序列类型,还可以为自己的自定义数据结构赋予与内置类型一致的切片语义。

扩展方向:

  • 惰性切片:参考NumPy的视图(view)语义,实现不复制数据的惰性切片,父对象数据变化时切片结果也随之变化。
  • 链式切片:设计支持 obj[1:5][:3][::2] 链式调用的容器,每次返回仍支持切片的新对象。
  • 异步切片:在异步编程中实现支持await的切片操作,适用于流式数据的分段读取。
  • 表达式切片:探索使用 >< 等符号重载实现类似 obj[>5, <10] 的领域特定切片语法。

掌握切片协议的深层原理,意味着我们能够在需要时脱离Python内置序列类型的限制,构建出完全自定义的、具有丰富切片语义的数据结构。这对于科学计算、数据分析、机器学习等领域的开发尤为重要——事实上,NumPy、pandas、xarray等核心科学计算库的底层都离不开对切片协议的深入实现。

"切片的优雅之处在于:它用简单的冒号语法,隐藏了复杂的边界计算,让开发者可以专注于'取什么'而不是'怎么取'。"