让对象拥有“容器感”:__getitem____setitem____delitem__ 场景解析与实战指南

在 Python 编程中,有些语法看起来非常自然:

user = users[0]
config["debug"] = True
del cache["expired_token"]

它们像呼吸一样平常,甚至让人忘了背后其实有一套精巧的对象协议在支撑。

当你写下 obj[key] 时,Python 会调用对象的 __getitem__;当你写下 obj[key] = value 时,Python 会调用 __setitem__;当你写下 del obj[key] 时,Python 会调用 __delitem__

这三个特殊方法,是 Python 自定义容器对象的核心入口。它们让你的类可以像列表、字典、缓存、配置中心、数据表、查询结果集一样被使用。

很多初学者第一次接触它们时,会觉得这只是“魔法方法”的语法糖。但在真实工程里,它们远不只是语法糖。它们决定了一个对象是否符合 Python 使用者的直觉,是否足够优雅,是否能在复杂业务里保持清晰边界。

这篇文章将从基础语法讲起,结合多个真实项目场景,系统说明:__getitem____setitem____delitem__ 分别适合什么场景,以及如何把它们用得安全、自然、可维护。


一、先理解三者分别做什么

这三个方法分别对应三类操作。

obj[key]          # 调用 obj.__getitem__(key)
obj[key] = value  # 调用 obj.__setitem__(key, value)
del obj[key]      # 调用 obj.__delitem__(key)

可以把它们理解成:

方法 对应语法 核心职责
__getitem__ obj[key] 读取元素
__setitem__ obj[key] = value 设置或更新元素
__delitem__ del obj[key] 删除元素

它们最适合用在“对象表现得像一个容器”的场景中。所谓容器,可以是列表、字典、队列、缓存、配置对象、二维表、分页结果、时间序列、模型字段集合等。

只要你的对象内部管理了一组数据,并且用户有理由通过某个 key、index、field、slice 来访问这些数据,就可以考虑实现它们。


二、__getitem__:适合“读取型访问”

__getitem__ 是三者中最常用的一个。它让对象支持方括号读取:

value = obj[key]

1. 封装列表:让对象支持索引访问

假设我们在写一个播放列表类:

class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

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

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

    def __repr__(self):
        return f"Playlist({self._songs!r})"

使用方式:

playlist = Playlist(["夜曲", "晴天", "七里香"])

print(playlist[0])   # 夜曲
print(playlist[-1])  # 七里香

这个例子中,Playlist 内部是列表,外部也自然希望按位置读取歌曲。因此实现 __getitem__ 是非常合适的。

更进一步,因为内部列表本身支持切片,所以这个类也天然支持切片:

print(playlist[1:])  # ['晴天', '七里香']

如果希望切片后仍然返回 Playlist 对象,可以判断 slice

class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

    def __getitem__(self, key):
        if isinstance(key, slice):
            return Playlist(self._songs[key])
        if isinstance(key, int):
            return self._songs[key]
        raise TypeError("Playlist 只支持整数索引或切片")

这就是 __getitem__ 的第一个典型场景:
你的对象本质上是一个序列,用户需要按位置读取它。


2. 封装字典:让对象支持字段访问

__getitem__ 也适用于类字典对象。

比如我们写一个配置类:

class Config:
    def __init__(self, data):
        self._data = dict(data)

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

使用:

config = Config({
    "debug": True,
    "host": "127.0.0.1",
    "port": 8000
})

print(config["debug"])  # True

这种设计适合配置管理、请求参数、JSON 数据包装、模型字段访问等场景。

不过要注意,__getitem__ 通常应该在 key 不存在时抛出 KeyError,这与字典行为一致。如果你希望提供默认值,更推荐额外提供 get() 方法:

class Config:
    def __init__(self, data):
        self._data = dict(data)

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

    def get(self, key, default=None):
        return self._data.get(key, default)

这样用户可以清晰地区分“必须存在”和“允许缺省”两种语义。


3. 计算型访问:不一定真的存储数据

__getitem__ 并不要求对象内部真的存储所有元素。

例如我们实现一个平方数序列:

class Squares:
    def __getitem__(self, index):
        if index < 0:
            raise IndexError("不支持负索引")
        return index * index

使用:

s = Squares()

print(s[3])   # 9
print(s[10])  # 100

这里并没有保存 [0, 1, 4, 9, ...],而是在访问时动态计算。

这类设计适用于惰性序列、数学序列、大型数据视图、日志流、分页数据等场景。


三、__setitem__:适合“可变容器”的更新操作

如果说 __getitem__ 表示“我允许你读取”,那么 __setitem__ 表示“我允许你修改”。

它对应的语法是:

obj[key] = value

1. 可变配置对象

比如我们希望配置可以被更新:

class Config:
    def __init__(self, data=None):
        self._data = dict(data or {})

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

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

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

使用:

config = Config({"debug": False})

config["debug"] = True
config["port"] = 8000

print(config)

输出:

Config({'debug': True, 'port': 8000})

这个例子非常直接:对象像字典一样可读可写。


2. 在赋值时加入校验逻辑

__setitem__ 的价值不只是“把值放进去”,更重要的是可以在赋值时加入业务规则。

例如端口号必须是整数,且在合法范围内:

class ServerConfig:
    def __init__(self):
        self._data = {}

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

    def __setitem__(self, key, value):
        if key == "port":
            if not isinstance(value, int):
                raise TypeError("port 必须是整数")
            if not (1 <= value <= 65535):
                raise ValueError("port 必须在 1 到 65535 之间")

        self._data[key] = value

使用:

config = ServerConfig()

config["port"] = 8080      # 正常
# config["port"] = 99999   # ValueError
# config["port"] = "8000"  # TypeError

这就是 __setitem__ 在工程中的高价值场景:
把数据写入和业务约束绑定在一起,避免非法状态进入对象内部。


3. 支持切片赋值

对于类列表对象,还可以支持切片赋值:

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

    def __getitem__(self, key):
        return self._tasks[key]

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

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

使用:

tasks = TaskList(["需求分析", "开发", "测试", "上线"])

tasks[1] = "编码实现"
print(tasks)

tasks[1:3] = ["代码审查", "自动化测试"]
print(tasks)

输出:

TaskList(['需求分析', '编码实现', '测试', '上线'])
TaskList(['需求分析', '代码审查', '自动化测试', '上线'])

如果你的对象是任务队列、编辑器缓冲区、数据行集合、命令列表,切片赋值会让它更接近 Python 原生列表体验。


四、__delitem__:适合“显式删除”的容器

__delitem__ 对应:

del obj[key]

它表达的是一种非常明确的语义:删除某个元素、字段、缓存项或资源引用。

1. 删除字典型数据

继续以配置对象为例:

class Config:
    def __init__(self, data=None):
        self._data = dict(data or {})

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

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

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

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

使用:

config = Config({"debug": True, "secret": "abc"})

del config["secret"]

print(config)

输出:

Config({'debug': True})

如果 key 不存在,内部字典会自然抛出 KeyError。这通常是合理的,因为删除一个不存在的 key,本身就是一个值得暴露的问题。


2. 删除缓存项

缓存是 __delitem__ 的经典场景:

class SimpleCache:
    def __init__(self):
        self._data = {}

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

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

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

使用:

cache = SimpleCache()

cache["token"] = "abc123"
print(cache["token"])

del cache["token"]

这比写 cache.remove("token") 更符合 Python 容器直觉。

当然,如果删除涉及更多动作,比如释放文件句柄、关闭连接、清理磁盘文件,就更应该在 __delitem__ 中明确处理。


五、完整实战:实现一个带过期时间的缓存

下面我们实现一个更完整的案例:TTL 缓存。

需求:

  1. 支持 cache[key] 读取;
  2. 支持 cache[key] = value 写入;
  3. 支持 del cache[key] 删除;
  4. 支持数据过期;
  5. 读取过期数据时自动清理并抛出 KeyError

代码如下:

import time


class TTLCache:
    def __init__(self, ttl=60):
        self.ttl = ttl
        self._data = {}

    def __setitem__(self, key, value):
        expire_at = time.time() + self.ttl
        self._data[key] = (value, expire_at)

    def __getitem__(self, key):
        value, expire_at = self._data[key]

        if time.time() > expire_at:
            del self._data[key]
            raise KeyError(f"{key!r} 已过期")

        return value

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

    def __contains__(self, key):
        try:
            self[key]
            return True
        except KeyError:
            return False

    def __repr__(self):
        valid_keys = []
        for key in list(self._data):
            if key in self:
                valid_keys.append(key)
        return f"TTLCache(keys={valid_keys!r}, ttl={self.ttl})"

使用:

cache = TTLCache(ttl=3)

cache["user:1"] = {"name": "Alice"}
print(cache["user:1"])

print("user:1" in cache)

del cache["user:1"]
print("user:1" in cache)

这个例子展示了一个非常重要的思想:
__getitem____setitem____delitem__ 不只是“访问内部字典”的包装,它们可以承载领域规则。

在缓存对象中,读取不是简单读取,而是要判断是否过期;写入不是简单写入,而是要记录过期时间;删除不是简单删除,而是表达“主动清理”。

当一个对象把这些规则封装起来,外部代码就会变得非常干净。


六、什么时候不应该实现它们?

并不是所有类都应该实现这三个方法。

如果你的对象不是容器,就不要强行加方括号语法。

例如:

class EmailSender:
    ...

你通常不会希望用户写:

sender["to"] = "alice@example.com"

这会让对象语义变得奇怪。

判断是否适合实现它们,可以问自己三个问题:

第一,用户是否会自然地把这个对象看作“一组数据”?
第二,是否存在明确的 key、index、field 或 slice?
第三,方括号语法是否比普通方法更清晰?

如果答案都是“是”,就可以考虑实现。

反过来,如果行为更像动作,比如发送邮件、提交订单、启动服务、渲染页面,那么普通方法往往更好:

sender.send(email)
order.submit()
server.start()

Pythonic 并不意味着到处使用魔法方法,而是让接口符合对象本身的语义。


七、最佳实践:让行为符合用户预期

1. 像列表就遵守列表习惯

如果你的对象按整数索引访问,建议支持:

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

同时实现:

__len__
__iter__

示例:

class ResultSet:
    def __init__(self, rows):
        self._rows = list(rows)

    def __getitem__(self, key):
        if isinstance(key, slice):
            return ResultSet(self._rows[key])
        return self._rows[key]

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

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

这样 ResultSet 就能自然地参与循环、切片和长度判断。


2. 像字典就遵守字典习惯

如果你的对象按 key 访问,建议:

  • key 不存在时抛出 KeyError
  • 提供 get() 方法处理默认值;
  • 必要时实现 keys()items()values()
  • 不要悄悄吞掉错误。
class FieldMap:
    def __init__(self, fields):
        self._fields = dict(fields)

    def __getitem__(self, key):
        return self._fields[key]

    def get(self, key, default=None):
        return self._fields.get(key, default)

    def keys(self):
        return self._fields.keys()

清晰的失败,比沉默的错误更容易调试。


3. 可变与不可变要分清

如果对象代表不可变数据,就只实现 __getitem__,不要实现 __setitem____delitem__

例如:

class FrozenConfig:
    def __init__(self, data):
        self._data = dict(data)

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

这样用户写:

config["debug"] = True

会直接报错。这是好事,因为它保护了对象的设计边界。

如果对象是可变容器,再实现 __setitem____delitem__


4. 错误类型要准确

常见约定如下:

obj[100]      # 序列索引越界,通常抛 IndexError
obj["name"]   # 映射 key 不存在,通常抛 KeyError
obj[1.5]      # key 类型不支持,通常抛 TypeError

不要把所有问题都写成:

raise Exception("出错了")

准确的异常类型,会让调用者更容易捕获和处理错误。


八、调试与测试建议

这三个方法非常容易被频繁调用,因此一定要写测试。

以列表型对象为例:

def test_result_set_getitem():
    rs = ResultSet([1, 2, 3, 4])

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

以字典型对象为例:

def test_config_items():
    config = Config({"debug": True})

    assert config["debug"] is True

    config["debug"] = False
    assert config["debug"] is False

    del config["debug"]

    try:
        config["debug"]
    except KeyError:
        pass
    else:
        raise AssertionError("应该抛出 KeyError")

测试重点不是“正常路径能跑通”,而是边界行为是否符合预期。

例如:

  • 访问不存在的 key;
  • 删除不存在的 key;
  • 使用错误类型的 key;
  • 切片是否返回正确类型;
  • 修改切片是否影响原对象;
  • 过期缓存是否会被正确清理。

这些细节决定了一个自定义容器是否可靠。


九、三者场景选择速查表

需求 推荐方法
支持 obj[key] 读取 __getitem__
支持 obj[index] 索引访问 __getitem__
支持 obj[start:stop] 切片 __getitem__ 处理 slice
支持 obj[key] = value 更新 __setitem__
写入时需要校验数据 __setitem__
支持 del obj[key] 删除 __delitem__
删除时需要清理资源 __delitem__
对象不可变 只实现 __getitem__
对象像字典 让异常行为接近 dict
对象像列表 让索引、切片行为接近 list

十、总结:魔法方法的意义,是让代码更接近人的直觉

Python 的优雅,并不只来自简洁的语法,也来自它对“对象行为”的尊重。

当一个对象像容器时,我们希望它可以被读取:

value = obj[key]

当它是可变容器时,我们希望它可以被更新:

obj[key] = value

当某个元素不再需要时,我们希望它可以被删除:

del obj[key]

__getitem____setitem____delitem__ 正是把这些直觉连接到对象内部逻辑的桥梁。

它们适合用在自定义序列、映射、缓存、配置、数据表、时间序列、查询结果集等场景中。用得好,可以让你的 Python 实战代码更自然、更优雅、更具表达力;用得过度,则可能让接口变得晦涩。

真正的 Python 最佳实践,不是把所有魔法方法都用一遍,而是在恰当的位置使用恰当的协议。

当你下次设计一个类时,不妨问自己:

这个对象是否像一个容器?
用户是否会自然地想写 obj[key]
它应该允许修改吗?
删除一个元素是否有明确语义?

这些问题的答案,就是你是否应该实现 __getitem____setitem____delitem__ 的最好判断标准。

如果你在项目中实现过配置对象、缓存系统、分页结果、数据集合或 ORM 字段访问,也欢迎在评论区分享你的设计经验:你更倾向于使用方括号语法,还是显式方法调用?为什么?

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐