让你的 Python 对象像列表一样优雅:自定义对象切片操作实战指南

在 Python 里,我们几乎每天都会使用切片:

nums = [10, 20, 30, 40, 50]

print(nums[1:4])   # [20, 30, 40]
print(nums[::-1])  # [50, 40, 30, 20, 10]

切片是 Python 最迷人的语法之一。它简洁、直观、表达力极强。你不需要写复杂的循环,也不需要手动计算边界,只要一个 start:stop:step,就能完成数据截取、反转、跳跃访问等操作。

但很多开发者在写自定义类时,往往只实现了普通索引访问,却没有让对象支持切片。比如你写了一个 Playlist 音乐播放列表类,或者一个 TimeSeries 时间序列类,用户自然会期待它能像列表一样使用:

playlist[0]
playlist[1:5]
playlist[::-1]

如果你的对象也能支持这种操作,它的可用性、可读性和 Pythonic 程度都会明显提升。

这篇文章就带你从基础到实战,系统掌握:如何让自定义对象支持切片操作。


一、切片背后的本质:__getitem__

Python 中的索引访问和切片访问,本质上都依赖一个特殊方法:

__getitem__(self, key)

当你写:

obj[0]

Python 实际会调用:

obj.__getitem__(0)

当你写:

obj[1:5:2]

Python 实际会调用:

obj.__getitem__(slice(1, 5, 2))

也就是说,切片语法并不神秘。它只是把 1:5:2 包装成了一个 slice 对象。

我们可以验证一下:

s = slice(1, 5, 2)

print(s.start)  # 1
print(s.stop)   # 5
print(s.step)   # 2

所以,想让自定义对象支持切片,核心思路就是:

__getitem__ 中判断传入的是整数索引还是 slice 对象,然后分别处理。


二、从一个最简单的自定义容器开始

假设我们要实现一个简单的书架类 Bookshelf,内部保存一组书名。

class Bookshelf:
    def __init__(self, books):
        self._books = list(books)

    def __getitem__(self, index):
        return self._books[index]

现在它已经支持普通索引和切片了:

shelf = Bookshelf([
    "Python 入门",
    "流畅的 Python",
    "Effective Python",
    "Python Cookbook",
    "Django 实战"
])

print(shelf[0])
print(shelf[1:4])

输出:

Python 入门
['流畅的 Python', 'Effective Python', 'Python Cookbook']

为什么这么简单?

因为我们直接把 index 转交给了内部列表 _books。而列表本身就支持整数索引和切片,所以这是一种最省力的实现方式。

不过,这种写法有一个问题:切片返回的是普通列表,而不是 Bookshelf 对象。

part = shelf[1:4]

print(type(part))  # <class 'list'>

如果我们希望 shelf[1:4] 返回的仍然是一个书架对象,就需要自己判断类型。


三、让切片返回同类型对象

一个设计良好的自定义容器,通常应该让切片结果仍然保持原有抽象。

例如:

new_shelf = shelf[1:4]

从语义上看,它仍然是一组书,只是原书架的一个子集。因此,返回 Bookshelf 会比返回 list 更自然。

改造如下:

class Bookshelf:
    def __init__(self, books):
        self._books = list(books)

    def __getitem__(self, key):
        if isinstance(key, slice):
            return Bookshelf(self._books[key])
        elif isinstance(key, int):
            return self._books[key]
        else:
            raise TypeError("索引必须是整数或切片")

    def __repr__(self):
        return f"Bookshelf({self._books!r})"

测试:

shelf = Bookshelf([
    "Python 入门",
    "流畅的 Python",
    "Effective Python",
    "Python Cookbook",
    "Django 实战"
])

print(shelf[1])
print(shelf[1:4])
print(shelf[::-1])

输出:

流畅的 Python
Bookshelf(['流畅的 Python', 'Effective Python', 'Python Cookbook'])
Bookshelf(['Django 实战', 'Python Cookbook', 'Effective Python', '流畅的 Python', 'Python 入门'])

这就是自定义对象支持切片的第一种成熟写法。


四、深入理解 slice.indices()

实际项目中,切片并不总是规整的。

用户可能会写:

obj[:]
obj[::2]
obj[::-1]
obj[-5:-1]
obj[100:200]

这时,手动处理 startstopstep 会很容易出错。幸运的是,slice 对象提供了一个非常实用的方法:

slice.indices(length)

它可以根据容器长度,把切片参数规范化为明确的 (start, stop, step)

示例:

s = slice(None, None, -1)

print(s.indices(5))

输出:

(4, -1, -1)

这意味着对于长度为 5 的序列,[::-1] 实际会从索引 4 开始,到 -1 停止,步长为 -1。

再看几个例子:

print(slice(None, None, None).indices(5))  # (0, 5, 1)
print(slice(1, None, 2).indices(5))        # (1, 5, 2)
print(slice(-3, None, 1).indices(5))       # (2, 5, 1)

如果你要自己实现底层数据访问逻辑,而不是简单委托给列表,indices() 非常重要。


五、实战案例:实现一个支持切片的时间序列类

下面我们实现一个更贴近真实业务的案例:时间序列 TimeSeries

它适合用于存储温度、价格、访问量、传感器数据等连续数据。

需求如下:

  1. 支持整数索引访问单个数据点;
  2. 支持切片获取子时间序列;
  3. 切片后仍然返回 TimeSeries 对象;
  4. 支持 len()、迭代和友好的打印;
  5. 保护内部数据,避免外部直接修改。

代码如下:

class TimeSeries:
    def __init__(self, data):
        self._data = tuple(data)

    def __getitem__(self, key):
        if isinstance(key, int):
            return self._data[key]

        if isinstance(key, slice):
            return TimeSeries(self._data[key])

        raise TypeError("TimeSeries 只支持整数索引和切片操作")

    def __len__(self):
        return len(self._data)

    def __iter__(self):
        return iter(self._data)

    def __repr__(self):
        return f"TimeSeries({list(self._data)!r})"

使用示例:

temps = TimeSeries([22.5, 23.0, 24.1, 25.3, 24.8, 23.9])

print(temps[0])
print(temps[-1])
print(temps[1:4])
print(temps[::2])
print(temps[::-1])

输出:

22.5
23.9
TimeSeries([23.0, 24.1, 25.3])
TimeSeries([22.5, 24.1, 24.8])
TimeSeries([23.9, 24.8, 25.3, 24.1, 23.0, 22.5])

这里有一个细节:内部数据使用 tuple 而不是 list

self._data = tuple(data)

这样做有两个好处:

第一,避免外部误修改内部数据;
第二,让 TimeSeries 更接近不可变对象,便于调试和推理。

在工程实践中,可变状态越少,程序通常越稳定。


六、支持切片赋值:实现 __setitem__

如果你的对象是可变容器,还可以进一步支持切片赋值。

例如列表支持:

nums = [1, 2, 3, 4, 5]
nums[1:4] = [20, 30, 40]

print(nums)  # [1, 20, 30, 40, 5]

自定义对象也可以通过 __setitem__ 实现类似能力。

下面实现一个可变的任务列表:

class TaskList:
    def __init__(self, tasks):
        self._tasks = list(tasks)

    def __getitem__(self, key):
        if isinstance(key, slice):
            return TaskList(self._tasks[key])
        if isinstance(key, int):
            return self._tasks[key]
        raise TypeError("索引必须是整数或切片")

    def __setitem__(self, key, value):
        if isinstance(key, slice):
            self._tasks[key] = list(value)
        elif isinstance(key, int):
            self._tasks[key] = value
        else:
            raise TypeError("索引必须是整数或切片")

    def __repr__(self):
        return f"TaskList({self._tasks!r})"

测试:

tasks = TaskList(["写需求", "写代码", "写测试", "上线"])

tasks[1] = "重构代码"
print(tasks)

tasks[1:3] = ["代码审查", "补充单测"]
print(tasks)

输出:

TaskList(['写需求', '重构代码', '写测试', '上线'])
TaskList(['写需求', '代码审查', '补充单测', '上线'])

这类能力在工作流系统、编辑器缓冲区、队列管理工具中非常有用。


七、支持删除切片:实现 __delitem__

同理,如果你希望对象支持:

del obj[1:3]

可以实现 __delitem__

class TaskList:
    def __init__(self, tasks):
        self._tasks = list(tasks)

    def __getitem__(self, key):
        if isinstance(key, slice):
            return TaskList(self._tasks[key])
        if isinstance(key, int):
            return self._tasks[key]
        raise TypeError("索引必须是整数或切片")

    def __setitem__(self, key, value):
        self._tasks[key] = value

    def __delitem__(self, key):
        del self._tasks[key]

    def __repr__(self):
        return f"TaskList({self._tasks!r})"

使用:

tasks = TaskList(["设计", "开发", "测试", "部署", "复盘"])

del tasks[1:3]

print(tasks)

输出:

TaskList(['设计', '部署', '复盘'])

如果你的类本质上是一个“序列容器”,那么 __getitem____setitem____delitem____len__ 往往应该一起考虑。


八、一个更高级的例子:惰性切片

普通切片通常会创建新对象或新列表,这在数据量很大时可能会带来内存开销。

例如:

big_data = list(range(10_000_000))
part = big_data[1_000_000:9_000_000]

这会创建一个新的大列表。

如果你在处理日志、大型数据流、科学计算结果时,希望切片只是“视图”,而不是立即复制数据,就可以设计惰性切片。

下面是一个简化版:

class LazyRange:
    def __init__(self, start, stop=None, step=1):
        if stop is None:
            start, stop = 0, start

        self.start = start
        self.stop = stop
        self.step = step

    def __len__(self):
        return len(range(self.start, self.stop, self.step))

    def __getitem__(self, key):
        r = range(self.start, self.stop, self.step)

        if isinstance(key, int):
            return r[key]

        if isinstance(key, slice):
            new_range = r[key]
            return LazyRange(new_range.start, new_range.stop, new_range.step)

        raise TypeError("索引必须是整数或切片")

    def __iter__(self):
        return iter(range(self.start, self.stop, self.step))

    def __repr__(self):
        return f"LazyRange({self.start}, {self.stop}, {self.step})"

使用:

nums = LazyRange(0, 1_000_000_000)

print(nums[10])
print(nums[100:200:10])
print(nums[::-1])

输出类似:

10
LazyRange(100, 200, 10)
LazyRange(999999999, -1, -1)

这个类不会真的创建十亿个整数。它只保存起点、终点和步长。

这就是切片设计中非常重要的思想:
切片不一定意味着复制,也可以意味着视图、范围或计算规则。

这也是 NumPy、Pandas 等数据处理工具中大量使用的设计理念。


九、常见陷阱与最佳实践

1. 不要只支持整数索引

很多人第一次写自定义容器时只写:

def __getitem__(self, index):
    return self._items[index]

如果内部是列表,这通常没问题。但如果你自己实现索引逻辑,就要记得处理 slice

推荐写法:

def __getitem__(self, key):
    if isinstance(key, int):
        ...
    elif isinstance(key, slice):
        ...
    else:
        raise TypeError("索引必须是整数或切片")

2. 切片返回什么类型要慎重

切片返回列表,简单直接;返回同类型对象,抽象更一致。

一般建议:

  • 如果你的类只是列表的轻量包装,返回列表可以接受;
  • 如果你的类表达了明确领域概念,建议返回同类型对象;
  • 如果对象很大,可以考虑返回视图或惰性对象。

例如:

users[1:5]

如果 usersUserCollection,那么返回 UserCollection 往往比返回 list 更合理。

3. 处理负索引和反向切片

Python 用户天然期待这些写法有效:

obj[-1]
obj[::-1]
obj[-5:-1]

如果你把切片委托给内部列表,Python 会自动处理。
如果你自己实现底层逻辑,建议使用:

start, stop, step = key.indices(len(self))

这样可以避免大量边界错误。

4. 注意可变对象的副作用

如果切片返回的是新对象,修改新对象不应意外影响原对象,除非你明确设计为视图。

例如:

part = obj[1:3]
part[0] = "changed"

这是否应该影响 obj

这个问题没有绝对答案,但一定要在设计时想清楚,并在文档中说明。

5. 补齐序列协议

如果你希望自定义对象真正像 Python 内置序列一样好用,除了切片,还可以实现:

__len__
__iter__
__contains__
__repr__
__reversed__

示例:

def __contains__(self, item):
    return item in self._items

这样用户就能写:

if "Python" in shelf:
    print("找到了")

十、用单元测试守住边界

切片最容易出错的地方不是正常情况,而是边界情况。

建议至少测试以下场景:

def test_timeseries_slice():
    ts = TimeSeries([1, 2, 3, 4, 5])

    assert ts[0] == 1
    assert ts[-1] == 5
    assert list(ts[1:3]) == [2, 3]
    assert list(ts[:]) == [1, 2, 3, 4, 5]
    assert list(ts[::2]) == [1, 3, 5]
    assert list(ts[::-1]) == [5, 4, 3, 2, 1]

这些测试看似简单,却能帮你发现大量隐藏 bug。

真实项目中,我非常建议在实现自定义容器时,把切片测试作为基础测试项。因为切片一旦支持不好,用户体验会非常割裂:对象看起来像序列,却不能像序列一样工作。


十一、一个完整可复用模板

最后给出一个通用模板,适合大多数自定义序列对象:

class CustomSequence:
    def __init__(self, items):
        self._items = list(items)

    def __getitem__(self, key):
        if isinstance(key, int):
            return self._items[key]

        if isinstance(key, slice):
            return type(self)(self._items[key])

        raise TypeError(
            f"{type(self).__name__} indices must be integers or slices"
        )

    def __len__(self):
        return len(self._items)

    def __iter__(self):
        return iter(self._items)

    def __contains__(self, item):
        return item in self._items

    def __repr__(self):
        return f"{type(self).__name__}({self._items!r})"

使用:

seq = CustomSequence([10, 20, 30, 40, 50])

print(seq[0])
print(seq[1:4])
print(seq[::-1])
print(30 in seq)

这段代码的关键点在于:

return type(self)(self._items[key])

它让切片结果自动返回当前类的实例。即使以后你继承了 CustomSequence,也能保持较好的扩展性。


十二、总结:让对象更 Pythonic

让自定义对象支持切片操作,本质上不是炫技,而是尊重 Python 用户的直觉。

当一个对象表现得像序列时,用户就会自然地期待它支持:

obj[0]
obj[-1]
obj[1:5]
obj[::-1]

如果你的对象满足这种期待,代码就会更自然,接口就会更优雅,团队协作也会更顺畅。

本文我们从 __getitem__ 入手,理解了切片背后的 slice 对象;然后实现了支持切片的 BookshelfTimeSeriesTaskListLazyRange;最后总结了切片设计中的常见陷阱与最佳实践。

你可以记住这几个核心原则:

  1. 索引和切片都由 __getitem__ 处理;
  2. 切片参数会被包装成 slice 对象;
  3. 简单场景可以委托给内部列表;
  4. 复杂场景建议使用 slice.indices()
  5. 切片返回类型要符合你的领域抽象;
  6. 大数据场景可以考虑惰性切片或视图;
  7. 一定要为边界情况写测试。

Python 的优雅,往往就藏在这些小而精致的协议里。
当你开始理解并运用这些协议时,你写出的就不只是“能跑的代码”,而是更贴近语言精神、更容易被他人理解和使用的代码。

如果你正在设计自己的数据结构、领域模型或工具库,不妨回头看看:你的对象是否也应该支持切片?

也欢迎你在评论区分享:你在项目中有没有实现过类似列表、时间序列、查询结果集、分页对象这样的自定义容器?你希望它们的切片行为是返回新对象,还是返回视图?

更多推荐