ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Python魔术方法__getitem__与__len__:让自定义类秒变容器

Python魔术方法__getitem__与__len__:让自定义类秒变容器 先问一个很多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不是ShoppingCartcart other_list返回的也是普通listcart [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__里没有任何网络IOlen(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会直接抛TypeErrorConfigNode 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透传给listlist自己会处理负索引和切片。但如果你内部不是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(fkey {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()调用整份代码清爽了很多。
返回列表