先问一个很多Python新手都会卡住的问题:一个普通的类,没有继承list,内部也没有任何和列表沾边的东西,为什么只要定义了__getitem__和__len__,就能直接放进for循环,能取长度,甚至能解包、切片、做成员判断?我当年第一次意识到这一点是在一个数据封装需求里——需要把一个内部管理多项子记录的类伪装成一个只读列表,当时感觉Python在背后推了我一把。这篇就专门聊聊__getitem__和__len__的实际使用场景,适合那些已经会写类、但总觉得自己的类设计得不够“Pythonic”的人,也适合准备开始用类做业务封装的读者。
1. 为什么说__getitem__和__len__是容器类的“入场券”
1.1 魔术方法不是语法糖,而是解释器与类的契约
很多刚入门的人会把__getitem__当成一种“重载运算符”的写法,觉得它只是让你能写obj[0]而已。这个理解不能说错,但太浅了。这两个方法真正的价值在于:它们是Python解释器给所有容器类定义的接口契约。
你写obj[index],Python实际执行的是type(obj).__getitem__(obj, index);你写len(obj),实际执行的是type(obj).__len__(obj)。注意这里的关键点:特殊方法是在类层面查找的,不是在实例属性里查找的。这意味着你在某个实例上临时塞一个__getitem__属性,不会影响obj[0]的行为,因为它压根不会查实例字典。这个区别在你做动态属性注入、mock对象的时候特别容易踩坑。
所以不要把这些方法当成“魔法”,它们本质上就是契约——你的类只要提供了这个能力,解释器和内建函数就会认账。这套机制和我们常说的鸭子类型完全一致:Python不检查你这个对象是什么类型,只检查你能不能做某件事。
1.2 for循环的后备通道:iter()如何依赖__getitem__
这是最容易被忽视、也最实用的一点。一个对象能被for item in obj遍历,不要求它必须是list或tuple,只要求它是“可迭代的”。而可迭代的判定方式是:先看有没有__iter__,如果没有,再看有没有__getitem__。
当对象没有__iter__时,iter()会退回使用__getitem__,从0开始,依次用obj[0]、obj[1]、obj[2]……直到捕获IndexError,遍历才结束。我用一个最简单的例子演示一下:
class Demo: def __getitem__(self, index): if index >= 3: raise IndexError("stop") return index * 10 for value in Demo(): print(value) # 输出 0, 10, 20这个输出你可能觉得平平无奇,但注意Demo类没有任何和“列表”相关的东西,也没有__iter__方法,它只是提供了__getitem__,for循环就自动工作了。这就是Python迭代协议的后备通道。理解了这一点,你就明白为什么很多第三方库的类只实现__getitem__不实现__iter__也能被遍历——它们是在利用这个机制。
1.3 len()之外:__len__还悄悄参与了bool判断
__len__的明面作用是配合len()返回长度,但还有一个隐性行为值得注意:当一个类没有定义__bool__时,bool(obj)会去调用__len__,根据长度是否为0来决定真假。
举个例子,如果你写了一个“任务队列”类,顺手实现了__len__返回队列中任务数量,那么if queue:就会在队列为空时变成False。这通常是你想要的。但如果你实现__len__只是为了“让这个类也有长度”,而你的业务对象根本没有“空”和“非空”的概念,那就要小心了——一旦__len__返回0,对象在if判断里就是False,这个隐性行为可能带来很隐蔽的bug。
这个关联关系在网上资料里通常不会重点提,但实际编码中非常容易碰到。我自己的习惯是:如果类的语义不是“容器”,就不要轻易实现__len__;如果一定要实现,同时又想保证所有实例都是True,那就显式加一个__bool__返回True,把控制权拿回来。
2. 场景一:把数据藏起来,让对象自己支持遍历和索引
2.1 最小实现:两个方法换回一整套餐列表能力
最典型的使用场景,是你的业务类内部管理着一组有序数据,你希望外部能够像操作列表一样操作这个对象,但又不希望暴露内部数据结构。我用一个书架的例子来说,这个例子最简单,也最能说明问题:
class Bookshelf: def __init__(self, books): self._books = list(books) def __len__(self): return len(self._books) def __getitem__(self, index): return self._books[index]就这么点代码,这个Bookshelf实例立刻拥有了一大堆“免费”的能力:
shelf = Bookshelf(["深入理解计算机系统", "流畅的Python", "代码整洁之道"]) len(shelf) # 3 shelf[0] # "深入理解计算机系统" shelf[-1] # "代码整洁之道",负索引直接支持 shelf[1:3] # 切片也支持,返回列表 for book in shelf: print(book) "流畅的Python" in shelf # True,成员判断可用 first, *rest = shelf # 解包可用 reversed_shelf = reversed(shelf) # 反向遍历可用为什么这些能力都自动有了?因为for循环、in运算符、解包这些操作,本质上都建立在迭代协议之上;而迭代协议在缺少__iter__时会回退到__getitem__。reversed()则需要__len__和__getitem__配合,先拿长度再从后往前索引。切片能工作则是因为self._books[index]在index是slice对象时,list自己会处理切片逻辑。你看,两个方法换来这么一大堆能力,这就是协议设计的杠杆效应。
2.2 调用方代码会变成什么样
如果你在一个项目里见过类似这样的代码:
for student in room.students: ... total = len(room.students) first_student = room.students[0]你会不会觉得有点啰嗦?room本身就是一个“容纳学生的对象”,为什么还要管room.students?当你在类里实现__getitem__和__len__之后,调用方的代码就变成:
for student in room: ... total = len(room) first_student = room[0]这个变化看起来不大,但它把“内部数据结构”彻底封装起来了。你以后把self._students从list换成tuple,换成数据库游标,换成生成器,调用方代码一行都不用改。这种封装带来的可维护性提升,在项目里会随着使用范围的扩大越来越明显。
实际操作中,我见过好多类似的类:订单类内部有明细行列表、播放器类内部有播放列表、班级类内部有学生列表。它们的共同点是:类本身的语义就是一个容器,但内部又不止有一个数据结构。这个时候,让类本身支持序列操作是最自然的设计。
2.3 为什么组合+协议比直接继承list更靠谱
有人可能会问:直接继承list不就行了吗,还不用自己写这两个方法。这话听着省事,但实际是个很大的坑。
先看一个反面例子:
class ShoppingCart(list): def append(self, item): if item.price <= 0: raise ValueError("price must be positive") super().append(item)看起来你重写了append,做了校验。但问题是:cart[0:2]返回的是一个普通list,不是ShoppingCart;cart + other_list返回的也是普通list;cart == [1, 2, 3]照样成立。数据可以通过切片、加法、extend等一堆通道绕过你重写的append,你的校验形同虚设。最后你不得不再重写extend、__add__、__iadd__、__setitem__等等,工作量反而更大。
而组合加协议的方式就干净得多:内部用self._items持有真正的列表,对外只暴露__getitem__和__len__这两个只读接口。外部想改内容?改不了,因为没有__setitem__,也没有append方法。你想在读取时加权限校验、加日志、做格式转换,只需要在__getitem__里写几行逻辑,所有读取入口全部被拦住。
我现在的默认选择就是:想给一个类加列表行为,先考虑组合,再考虑继承;继承list只在极少数“我真的需要一个增强版list”的场景才值得。
3. 场景二:惰性加载的分页数据集,索引时再去拉数据
3.1 场景模型:分页API结果集
第二个场景来自一个实际得过且过的需求:对接一个分页接口,服务端一次最多返回100条,但业务方需要“按索引随机拿某一条数据”,还需要知道总条数。如果一上来就把全部数据拉下来,分页好几千条数据,网络开销和内存都扛不住;但如果每次只拉一条,又浪费请求次数。
这个场景下,__getitem__和__len__的组合可以很好地解决问题:__len__直接返回接口告知的总数,不触发任何网络请求;__getitem__按索引算出它属于哪一页,按页拉取并缓存,后续再访问同页数据就直接走缓存。
3.2 完整实现:__len__先返回总数,__getitem__按页缓存
class PageResultSet: PAGE_SIZE = 100 def __init__(self, fetch_page, total): self._fetch_page = fetch_page # 一个函数,接收页码,返回该页列表 self._total = total self._page_cache = {} def _page(self, page_no): if page_no not in self._page_cache: self._page_cache[page_no] = self._fetch_page(page_no) return self._page_cache[page_no] def __len__(self): return self._total def __getitem__(self, index): if isinstance(index, slice): return [self[i] for i in range(*index.indices(len(self)))] if index < 0: index += len(self) if not 0 <= index < len(self): raise IndexError("index out of range") page_no, offset = divmod(index, self.PAGE_SIZE) return self._page(page_no)[offset]这段代码有几个值得说的细节。
第一,__len__里没有任何网络IO,len(rs)瞬间返回总数,调用方不需要等待。这个体验在写报表、写统计逻辑时非常重要。
第二,__getitem__里先判断负索引。因为self._page是按页码组织的,你不做负索引处理的话,rs[-1]会算出一个负数页码,直接崩掉。标准list自带负索引支持,但你自己实现索引映射时,必须手动把负数转成正数。这里用的是index += len(self),很经典。
第三,越界检查必须主动做,不能依赖内部逻辑。一旦越界,应该抛IndexError,因为迭代器协议是靠IndexError来判定“迭代结束”的,稍后踩坑部分我会详细说。
第四,切片处理用到了slice.indices(len(self))这个内置方法,它能把切片参数统一转换成(start, stop, step),还会自动处理负数和越界,比你自己写一堆if index.start is None的判断干净得多。
3.3 扩展讨论:切片处理与遍历性能优化
上面代码里切片走的是“逐条索引”的路线,也就是rs[100:150]会依次调用self[100]、self[101]……每50条数据可能触发多次分页请求。如果切片范围很大,性能会有一点浪费。但好处是实现简单、逻辑清晰,而且有缓存兜底,同页数据只请求一次。如果切片的频率很高、范围很大,你可以单独写一个吸收整页的逻辑去优化,这个项目里按需来就行。
另一个值得注意的点是:这个类没有实现__iter__,所以for item in rs走的还是__getitem__逐条索引路线。每条数据都要经过divmod、缓存查询、下标计算等,性能虽然不差,但如果你确定需要频繁遍历整个结果集,实现一个__iter__直接按页迭代会更高效:
def __iter__(self): for page_no in range((self._total + self.PAGE_SIZE - 1) // self.PAGE_SIZE): for item in self._page(page_no): yield item有了这个__iter__,for循环会优先走它,一次拉一整页再逐条yield,减少了Python层反复计算索引的开销。这两种方式可以同时存在:遍历走__iter__,随机访问走[],互不干扰。
我在实际项目中用这个模式包装过一个远程配置服务:配置项按命名空间分页存储在服务端,客户端代码直接写configs["user.profile"]、len(configs),完全没有感知到背后有网络请求和分页缓存。这个封装带来的收益,远超那几十行代码的成本。
4. 场景三:像操作字典一样操作自定义配置对象
4.1 用ConfigNode实现cfg["db"]["host"]
第三种常见场景是类本身更像一个字典而不是列表。比如配置对象、环境变量对象、带命名的数据分组等等。当你希望obj["key"]能正常工作、len(obj)返回键的数量时,__getitem__和__len__配合起来可以做一个很好用的只读配置节点:
class ConfigNode: def __init__(self, data): self._data = data def __getitem__(self, key): value = self._data[key] if isinstance(value, dict): return ConfigNode(value) return value def __len__(self): return len(self._data)然后就能这样用:
cfg = ConfigNode({ "database": { "host": "127.0.0.1", "port": 5432 }, "debug": True }) cfg["database"]["host"] # "127.0.0.1" len(cfg) # 2关键的设计点是:当取出来的值仍然是个dict时,把它包成新的ConfigNode返回。这样调用方可以一路用[]往下访问,不用自己关心“什么时候取到的是普通值,什么时候取到的是嵌套字典”。
4.2 让__getitem__同时支持tuple键
还有一个很实用的扩展:让cfg["database", "host"]也能工作。这样多级访问就变成了一次索引:
def __getitem__(self, key): if isinstance(key, tuple): node = self for k in key: node = node[k] return node value = self._data[key] if isinstance(value, dict): return ConfigNode(value) return valuecfg["database", "host"]本质上就是传入了一个("database", "host")的tuple,然后逐级取值。这个写法的好处是:你在代码里能非常直观地表达“我要取数据库配置里的host字段”,尤其适合用在配置校验、模板渲染这类需要大量读取嵌套字段的场景。
4.3 只读设计:没有__setitem__时的安全感
注意这个ConfigNode类没有实现__setitem__,所以外部如果写cfg["database"] = {...},Python会直接抛TypeError:'ConfigNode' object does not support item assignment。这恰恰是我想要的效果——配置在加载完成后就应该只读。
如果你真的需要支持修改,可以单独实现__setitem__,并在里面做类型校验。但以我的经验,配置类最好是只读的,任何修改动作都通过显式方法(比如update)来完成,这样变更历史、日志、校验都集中在少数几个方法里,而不是分散在业务代码的各种赋值语句中。
5. 实测中绕不开的边界条件与递归陷阱
5.1 在__len__里调用len(self)会怎样
这个坑我见过不止一次。你以为“我取这个对象的长度”,于是写了:
def __len__(self): return len(self)看起来逻辑合理?实际上这是一条通向RecursionError的路。因为len(self)的执行过程就是调用self.__len__(),你等于在__len__里调自己,无限递归,直到Python栈溢出。
正确的写法是委托给内部的数据结构:
def __len__(self): return len(self._items)这个错误调试起来还挺迷惑,因为报错信息是RecursionError: maximum recursion depth exceeded,如果你没意识到len()和__len__的关系,可能会怀疑是代码别的地方出问题。
5.2 越界不抛IndexError的后果
第二个边界问题更隐蔽。很多人不知道,for循环在没有__iter__、回退到__getitem__时,是靠IndexError来判断遍历结束的。所以你的__getitem__在越界时“善意地”返回一个None,以为程序会更宽容,结果反而是灾难:
class BadList: def __getitem__(self, index): if index > 3: return None return index for item in BadList(): print(item)这段代码会无限循环下去,或者直到外部条件才停,因为Python在0、1、2、3取完之后,继续调用obj[4]得到None——它不知道你已经“没有数据”了,只会继续问obj[5]、obj[6]……所以,实现__getitem__时,索引越界必须抛IndexError,这是和迭代器之间的一个明确约定,不是你服务端开发时那种“尽量返回空值”的风格。
5.3 负索引和切片应该如何委派
如果你内部持有的是一个真正的list,最省事的做法是直接把index透传给list,list自己会处理负索引和切片。但如果你内部不是list,那就得自己处理这两件事。
负索引的做法是先加len(self)转正,然后再进入真正的索引逻辑:
if index < 0: index += len(self)切片则要留意index的类型是slice。最稳妥的做法是用slice.indices(len(self))把切片展开成具体的起始、结束和步长,再按需处理。我自己写容器类时,如果切片场景不复杂,就直接返回一个用列表推导构建的新列表,逻辑清晰,调试起来也不费劲。
5.4 继承dict/list后重写方法的失效问题
这个坑我踩得比较深,值得单独说。有人希望给dict加日志,于是写:
class LoggedDict(dict): def __getitem__(self, key): print("loading", key) return super().__getitem__(key)测试d["x"]时,日志是有的;但当他用d.get("x")、"x" in d、甚至for key in d时,发现日志完全没有被触发。这是因为dict的很多方法在CPython里走的是C层面的优化路径,并不会逐一回调你重写的Python方法。你以为是“重写一个方法拦住所有读取”,实际上只是“拦住了一小部分入口”,剩下的漏网之鱼会让人困扰很久。
所以我现在看到同事写class MyDict(dict),都会多看两眼,并多问一句:这里是真的需要“字典能力”,还是只是想要一个“能装键值对的对象”?如果是后者,组合优先,内部持有dict,外部通过__getitem__统一接管,反而更可控。
6. 进阶:从只读容器走向完整协议
6.1iter、contains、__setitem__和__delitem__怎么配合
当你已经会用__getitem__和__len__之后,下一步就是补齐完整协议。它们之间的配合逻辑是这样的:
__iter__:负责提供迭代器。实现了它,for循环优先走这个方法,不再走__getitem__回退路线,性能更好,语义也更清晰。__contains__:实现后in运算符可以走专门逻辑。不实现的话,in会退化为遍历容器逐项比较,时间复杂度是O(n)。__setitem__和__delitem__:分别对应obj[key] = value和del obj[key]。实现了它们,你的容器就从只读变成了可变容器。__len__依然负责长度信息,是序列协议的基础。
这几者的关系不是互斥的,而是可以共存的。比如既实现__getitem__实现随机访问,又实现__iter__优化遍历批量加载,再实现__contains__让成员判断更高效。
6.2 用collections.abc校验你的类
Python的collections.abc模块是一个很好的“协议检查器”。值得一说的是,collections.abc.Sequence这类抽象基类在做isinstance()判断时,不是看继承关系,而是看结构——它通过__subclasshook__检查类里面有没有对应的协议方法。
所以你只实现了__len__和__getitem__,用isinstance(obj, collections.abc.Sequence)判断时,结果可能是True。这在Python 3里是标准行为。也就是说,标准库直接认可了你的类“是”一个序列,尽管你压根没有继承任何序列类型。这对类型标注、接口设计都有意义。
6.3 实战:带权限校验的只读映射类
最后写一个小而完整的例子。假设你要做一个配置读取服务,有些键只允许特定角色访问,你当然可以在业务代码里到处写判断,但更优雅的做法是把规则收进容器协议里:
class SecureMapping: def __init__(self, data, permitted_keys): self._data = dict(data) self._permitted = set(permitted_keys) def __getitem__(self, key): if key not in self._permitted: raise PermissionError(f"key '{key}' is not permitted") return self._data[key] def __len__(self): return len(self._data) def __contains__(self, key): if key not in self._permitted: return False return key in self._data def __iter__(self): return (key for key in self._data if key in self._permitted)只读映射类,内部是普通dict,外部通过[]读取时强制做权限校验,len()返回数据条目数,in运算也过滤了无权限的键,遍历更是只产出允许访问的键。整个类的对外接口和dict非常像,但它完全没有继承dict,所有行为都清清楚楚地由这几个方法定义。
使用效果:
sm = SecureMapping( {"api_key": "secret", "name": "demo"}, permitted_keys={"name"} ) sm["name"] # "demo" sm["api_key"] # PermissionError "api_key" in sm # False这个例子充分展示了__getitem__和__len__这类协议方法的设计哲学:它们不是用来炫技的,而是让你把一个类的行为和语义收拢到少数几个入口上,调用方写得自然,实现方控得住边界。
这两个方法我用了很多年,最大的体会是:协议比类型重要。你不需要继承list,只要满足了list的接口约定,调用方就会把你当成list用;你也不需要把内部结构暴露出去,只要在__getitem__里做校验和计算,外面拿到的始终是你想给的数据。设计类的时候先别急着写一堆普通方法,想一想:这个对象在概念上是不是一个容器?如果是,就老老实实实现__len__和__getitem__,收益远比你想象的大——我最近改的一个连接池模块,就是靠这两个方法让调用方可以直接conn[0]拿到连接,删掉了一堆原本的get_connection()调用,整份代码清爽了很多。