让对象拥有“容器感”:`__getitem__`、`__setitem__`、`__delitem__` 场景解析与实战指南
让对象拥有“容器感”:__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 缓存。
需求:
- 支持
cache[key]读取; - 支持
cache[key] = value写入; - 支持
del cache[key]删除; - 支持数据过期;
- 读取过期数据时自动清理并抛出
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 字段访问,也欢迎在评论区分享你的设计经验:你更倾向于使用方括号语法,还是显式方法调用?为什么?
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)