
1. 为什么这两个魔术方法值得单独拿出来讲如果你写过一段时间的Python大概率见过类里面那些双下划线开头和结尾的方法比如__init__、__str__这些。但__getitem__和__len__这两个平时自己写业务代码时用得不算多可一旦接触到框架源码、数据处理库或者工具类封装就会发现它们几乎是“协议级”的存在。先说个最直观的场景。你用len()去获取一个对象的长度用obj[key]或者obj[index]去取里面的元素这几乎是每一天都在做的事情。但你想过没有为什么列表、字典、字符串这些内置类型可以这么用而你自己写的类默认不行答案就在这两个魔术方法里。__len__定义了len()函数的行为__getitem__定义了obj[key]这种下标访问的行为。一旦你在自己的类里实现了它们你的类就获得了和内置容器类型一样的“待遇”——可以被len()计算长度、可以通过下标取值、可以被for循环迭代、可以配合in做成员判断甚至可以直接用isinstance(obj, collections.abc.Sequence)来标记为序列类型。这背后是Python一个非常重要的设计哲学协议Protocol。Python不要求你继承某个具体的基类才能具备某种能力只要你把该实现的方法实现出来解释器就认你。这种“鸭子类型”的设计让__getitem__和__len__成了自定义容器类最核心的两块基石。本文会从原理到实战把这两个方法的使用场景、边界情况和踩坑经验完整过一遍。内容适合已经掌握了Python基础语法、想深入了解类的高级用法、或者准备阅读源码的读者。我会尽量用真实项目里会遇到的需求来讲解而不是概念的空转。2. 先搞清楚底层原理协议机制的来龙去脉2.1 特殊方法魔术方法在Python中的地位Python解释器在执行语法操作时会去查找对应的特殊方法。比如你写a b解释器会尝试调用a.__add__(b)你写str(obj)会调用obj.__str__()你写len(obj)会调用obj.__len__()。这个机制不是“约定”那么简单它是CPython解释器层面的硬性规定。len()之所以能这么高效正是因为它在内部直接通过PyObject_Size函数访问了__len__的槽位而不是像普通方法那样做属性查找。这也是为什么len()比某些自己写的.size()方法要快的一个原因。2.2__getitem__到底承载了哪些功能__getitem__这个方法的名称看起来只是“按下标取值”但实际上它的职责远比这宽泛下标访问obj[0]、obj[key]这样的操作会调用obj.__getitem__(0)或obj.__getitem__(key)。切片操作obj[1:5]会把一个slice对象传给__getitem__而不是把1:5计算成一个元组。所以你要自己处理slice类型的传入。迭代支持在没有定义__iter__的情况下Python的迭代协议会退而求其次通过不断调用__getitem__(0)、__getitem__(1)、__getitem__(2)……直到抛出IndexError为止才算迭代结束。这一点很多初学者完全不知道后面我用案例展示。成员判断x in obj在没有__contains__的情况下会尝试通过索引遍历来逐个比对。解包操作a, b, c obj能实现的前提也是迭代协议能正常工作。这么一看就明白了__getitem__撑起了Python容器操作的半壁江山。2.3__len__的行为约定与隐性影响__len__要求返回一个非负整数逻辑上代表“元素的数量”。它的直接影响是len(obj)的结果。但间接影响更值得注意当一个对象定义了__len__且返回值为0时这个对象在布尔上下文中会被判定为False也就是bool(obj)返回False。这是Python的__bool__协议的特殊处理如果类没有定义__bool__解释器就回退到__len__如果len为0则视为假。__len__是否实现会影响collections.abc.Sized的判断。自定义类的实例能否在for循环中被正确遍历、能否被list()转换均间接依赖__len__的一致性。但需要注意__len__和__getitem__之间并没有强制的一致性约束。换句话说你可以让len(obj)返回8但obj[7]抛IndexError。这样做不会导致解释器报错但会让你的类行为变得不一致容易被别人骂。良好的实现应当保证0 index len(obj)范围内的所有索引都能正常访问。2.4 内置函数与协议的对应关系这里整理一张表方便对照理解语法/函数实际调用的特殊方法说明len(obj)obj.__len__()要求返回非负整数obj[key]obj.__getitem__(key)key可以是整数、字符串或sliceobj[1:5]obj.__getitem__(slice(1, 5))切片传入的是slice对象for x in obj优先obj.__iter__()否则退化为obj.__getitem__()只要__getitem__能按递增索引访问并最终抛IndexErrorx in obj优先obj.__contains__(x)否则通过迭代逐项比较需要__getitem__或__iter__支持bool(obj)优先obj.__bool__()否则回退到obj.__len__() ! 0没实现__bool__时len为0即False从这个表能看出来__getitem__和__len__不只是两个孤立的方法它们撑起的是整个“容器行为”的默认协议链。3. 实战案例一一个可切片的数据集封装类3.1 需求背景实际项目中我们经常需要封装一个数据集。比如说从Excel、CSV或数据库里加载了一批记录每条记录是一个字典形如{name: 张三, age: 28, city: 北京}。你最希望的是既能用len()知道有多少条又能用data[0]取第一条还能用data[1:10]拿到一个子集如果能顺手支持for record in data遍历就更好了。3.2 基础版实现__len__和__getitem__来看这个类的完整实现class Dataset: 一个简单的数据集封装类支持长度查询、索引访问、切片和迭代。 def __init__(self, records): # 这里做一层防御性拷贝避免外部修改影响内部数据 self._records list(records) def __len__(self): return len(self._records) def __getitem__(self, index): return self._records[index]就这么几行代码这个类立刻获得了一连串能力data Dataset([ {name: 张三, age: 28, city: 北京}, {name: 李四, age: 32, city: 上海}, {name: 王五, age: 25, city: 广州}, {name: 赵六, age: 30, city: 深圳}, ]) print(len(data)) # 4 print(data[0]) # {name: 张三, age: 28, city: 北京} print(data[1:3]) # 后两个记录组成的列表 total 0 for record in data: total record[age] print(total) # 115注意切片这里有个细节data[1:3]返回的是self._records[index]的结果也就是一个list切片类型是list不是Dataset。这在实际使用中够用但如果你希望切片返回的还是Dataset对象方便链式操作就需要额外处理。3.3 升级版处理切片返回同类型改进一下__getitem__判断传入的是不是sliceclass Dataset: def __init__(self, records): self._records list(records) def __len__(self): return len(self._records) def __getitem__(self, index): if isinstance(index, slice): return Dataset(self._records[index]) return self._records[index]这样data[1:3]返回的还是一个Dataset对象你就可以继续调用len()、继续切片、继续迭代封装性更好。3.4 为什么for record in data能工作这里就有意思了。Dataset并没有实现__iter__为什么for循环还能跑答案是前面提到的迭代协议回退机制。当for循环发现对象没有__iter__时它会退回到“下标迭代”从obj[0]开始依次obj[1]、obj[2]……每次调用__getitem__直到遇到IndexError异常才终止。也就是说Python用一个“不断尝试取下一个索引”的策略配合一个“异常即结束”的信号硬是让只有__getitem__的对象也能被迭代。这个设计在Python的早期版本里尤其常见——很多老代码没有实现__iter__但照样能for循环就是因为这个机制。3.5 实操心得性能与边界这个回退机制虽然好用但性能上不如有__iter__的实现。因为每次都要做一次属性查找和方法调用而且循环的次数完全取决于__getitem__内部的逻辑。如果记录数量很大比如几万条往上建议还是补一个__iter__。顺手就能做def __iter__(self): return iter(self._records)另外要处理好IndexError的边界。如果不小心在__getitem__里把索引越界误判成了正常返回比如返回None而不是抛IndexError那么for循环会变成无限循环。这是个很隐蔽的坑。4. 实战案例二实现一个自动分页工具4.1 需求背景分页大概是Web后端最常见的需求了。把一批数据按每页page_size条切分成若干页前端通过页码参数来访问某一页。常见的做法是写一个函数paginate(data, page, page_size)。但如果数据量大、翻阅频繁不如把分页逻辑封装成一个类让它本身就像列表一样支持索引访问——索引是页码取到的是该页的数据。4.2 实现一个Paginator类class Paginator: 将数据按页封装支持通过页码索引获取数据。 def __init__(self, data, page_size10): self._data list(data) self.page_size page_size def __len__(self): # 总页数 总条数除以每页条数向上取整 import math return math.ceil(len(self._data) / self.page_size) def __getitem__(self, page): # 页码从1开始更符合人类习惯 if isinstance(page, slice): start_page page.start or 1 stop_page page.stop step page.step or 1 pages range(start_page, stop_page, step) return [self[p] for p in pages] if page 1: raise IndexError(页码必须从1开始) start (page - 1) * self.page_size end start self.page_size if start len(self._data): raise IndexError(页码超出范围) return self._data[start:end]使用效果records list(range(1, 101)) # 模拟100条数据 pager Paginator(records, page_size10) print(len(pager)) # 10页 print(pager[1]) # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] print(pager[10]) # [91, 92, 93, 94, 95, 96, 97, 98, 99, 100]这里我把__getitem__的入参page当成了“页码”而不是传统意义上的“位置索引”这是一个很典型的“语义重定义”的使用方式。__getitem__的形参名虽然叫index但它的实际含义完全由你定义。4.3 为什么这样设计更优雅传统分页函数用法是paginate(data, page, page_size)每次都要传参数。而用类的方案一次封装到处访问代码的可读性更高而且天然支持len()查看总页数配合for page in pager还能遍历每一页语义非常完整。这种“把规则封装进容器类”的思路在写数据处理管道、配置管理模块、SQL查询结果集封装的时候非常实用。本质上是用Python的协议机制让自定义类的使用体验无限接近内置类型。4.4 实操心得页码越界与切片语义在__getitem__里抛IndexError要特别注意一个细节如果你在for page in pager这种循环中使用这个类一旦页码越界for循环会把它当作迭代终止的正常信号。所以如果你不希望“越界即终止”而是希望外部调用方明确感知错误就要在抛异常时多加一层封装或者限制使用方式。再者切片支持最好不要省略。用户在拿到一个分页器对象后很容易想当然地去用pager[2:5]取“第2页到第5页”的数据。如果不实现会直接抛TypeError体验很差。5. 进阶用法让__getitem__和__len__协同发挥威力5.1 场景一键值对存储的配置对象除了像列表那样按整数索引取值__getitem__还完全支持字符串键。这其实更像是字典的行为。举个例子把环境配置封装成一个只读配置对象class Config: 一个支持属性访问和字典式访问的配置对象。 def __init__(self, config_dict): self._config dict(config_dict) def __getitem__(self, key): # 支持通过 config[host] 方式访问 if key not in self._config: raise KeyError(f配置项不存在: {key}) return self._config[key] def __len__(self): return len(self._config) def __iter__(self): return iter(self._config.items())使用conf Config({host: 127.0.0.1, port: 8080}) print(conf[host]) # 127.0.0.1 print(len(conf)) # 2 for k, v in conf: print(k, v) # host 127.0.0.1 / port 8080注意这里我刻意让__iter__返回的是items()的迭代器不是keys()。这与字典本身的迭代语义不同是自定义行为。正因为自定义了外部调用者必须知道这个类在迭代时产出的是“键值对”而不是“键”。所以当你在类里改变这些协议的默认语义时一定要写清楚文档字符串避免误导。5.2 场景二惰性加载的数据集合假设你要封装一个数据查询接口真实数据存储在数据库或远端API里。你当然不希望初始化时就把所有数据拉到内存里而是希望等到真正访问某一项时才去查询。这时候__getitem__可以做成惰性加载class RemoteDataView: 远程数据视图按需加载支持索引访问和长度感知。 def __init__(self, fetcher, total_count): self._fetcher fetcher self._total total_count self._cache {} def __len__(self): return self._total def __getitem__(self, index): if index 0 or index self._total: raise IndexError(索引超出范围) if index not in self._cache: self._cache[index] self._fetcher(index) return self._cache[index]这个设计的价值在于你仍然可以像一个普通列表一样使用它len()、下标访问、for循环但底层数据的获取被延迟到真正需要的时候还带上了缓存。这在处理大文件、远程接口、昂贵计算时特别有用。5.3 场景三二维矩阵或表格数据处理表格场景下二维结构非常普遍。我们可以让obj[i, j]这样的写法成立只需要在__getitem__里接收一个元组。class Table: 简单的二维表格数据访问类支持 table[i][j] 和 table[i, j] 两种方式。 def __init__(self, rows): self._rows rows def __len__(self): return len(self._rows) def __getitem__(self, key): if isinstance(key, tuple): i, j key return self._rows[i][j] return self._rows[key]使用t Table([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) print(t[1, 2]) # 6 print(t[1][2]) # 6这里的关键是__getitem__收到了一个(1, 2)元组因为Python语法obj[i, j]本身就是把逗号分隔的东西打包成元组传进去。不理解这一点你会百思不得其解为什么__getitem__的参数是个元组。5.4__len__为0时对布尔值的影响前面原理部分提到过没有定义__bool__的类布尔值回退到len。这个特性有时候是好事有时候是坑。好的方面你的容器类可以自动获得“空即False”的正确语义比如if not table:就不需要你额外写if len(table) 0:。坑的方面假设你的类本身有业务上的“真假”含义比如一个订单类订单金额为0可能是合法的但因为有__len__且返回0bool(order)会得到False于是在if order:的判断中走了错误的分支。这时候你需要补一个__bool__方法覆盖默认行为。def __bool__(self): # 业务上只要订单存在就为真不管金额是否为0 return True这种细节在写框架、轮子、公共库的时候特别容易踩到值得多留个心眼。6. 常见错误、坑与排查技巧6.1__len__返回了非整数len()要求返回整数。如果你在__len__里返回3.14或者字符串解释器不会等你反应过来直接在调用len(obj)时抛TypeError。排查思路很简单检查__len__的返回值类型确保是int。但有一种隐蔽情况容易忽略——__len__内部调用了某个外部函数外部函数在异常时返回了None造成返回类型不对。所以写__len__尽量保持逻辑简洁最好直接对已有数据计算长度。6.2__getitem__在for循环中导致无限循环这是一个经典的坑。class BadList: def __init__(self, items): self.items items def __getitem__(self, index): if index len(self.items): return None # 错误应该抛IndexError return self.items[index]然后写for item in BadList([1, 2, 3]): print(item)执行后程序不会终止因为迭代协议认为只要没抛IndexError就说明还有下一个元素。你返回None它照样当成一个正常元素继续往前取。正确的做法是在越界时抛出IndexErrordef __getitem__(self, index): if index len(self.items): raise IndexError(索引超出范围) return self.items[index]判断这个过程是否正常可以直接在Python解释器里手动调用obj[0]、obj[1]、obj[2]、obj[3]一直试到抛异常为止。如果一直不抛那就是有问题。6.3 负索引的处理Python的内置序列支持负索引比如data[-1]取最后一个元素。如果自定义类也想支持负索引有两种选择直接委托给内部存储对象让它处理负索引像前面的Dataset直接返回self._records[index]那样。自己在__getitem__里手动解析例如把负数映射为len(self) index。第一种方案省事但要注意当你处理的容器不是标准列表而是自定义逻辑时外部传入负索引很容易造成你内部取值的错乱。比如在一个“页码从1开始”的分页器里pager[-1]是什么意思如果没想清楚最好显式抛IndexError而不是让负索引产生一个不可预期的结果。6.4 切片与step参数不一致当用户调用obj[::2]时传入的slice对象有三个属性start、stop、step。初学者最容易忽略step的存在只处理start和stop。正确做法是如果你要自己实现切片逻辑必须把三种情况都考虑进去def __getitem__(self, key): if isinstance(key, slice): start key.start if key.start is not None else 0 stop key.stop if key.stop is not None else len(self._records) step key.step if key.step is not None else 1 indices range(start, stop, step) return [self._records[i] for i in indices]如果step没处理好遇到obj[1:10:2]这样的调用返回的数据就可能完全错误。6.5 可变对象与不可变对象的行为差异自定义类是否允许修改内部数据也会影响到协议实现的细致程度。如果是只读对象__getitem__和__len__都按固定逻辑返回即可。但如果你允许通过obj[i] value来修改内容也就是实现__setitem__就必须额外小心修改操作是否会影响__len__的结果索引边界是否要同步更新不要求一并实现__setitem__但如果你实现了必须确保它和__getitem____len__的逻辑保持一致。否则就会出现“能存进去但读不出来”或者“长度对不上”的尴尬情况。6.6 值得关注的排查工具遇到协议相关的问题可以用内置的isinstance检查对象是否满足标准容器接口便于快速定位from collections.abc import Container, Iterable, Sequence, Sized print(isinstance(data, Sequence)) # True 如果__len__和__getitem__都实现了 print(isinstance(data, Container)) # True 如果有__contains__或可迭代 print(isinstance(data, Sized)) # True 如果有__len__ print(isinstance(data, Iterable)) # True 如果有__iter__或可迭代Sequence要求同时实现__len__和__getitem__所以这个检查能在一定程度上帮你验证类的协议完备程度。但要注意isinstance检查的是类是否实现了相关协议方法并不保证行为一定正确实际效果还得靠测试覆盖。7. 结合其他热门的Python库看这两个方法7.1 在pandas中的体现pandas的Series和DataFrame都是重度的__getitem__用户。df[column]、df[df[age] 25]这些操作本质上都走了__getitem__这条路径。不同之处在于pandas把“键”的类型从普通的整数和字符串扩展到了布尔序列、切片、列表、条件表达式等形成了一个极具表现力的索引系统。理解__getitem__再看pandas的某些行为就清晰得多。比如为什么说df[age]和df.age在列名为“age”时等价、在列名和已有属性冲突时不等价——因为属性访问走的是__getattr__下标访问走的是__getitem__“协议不同入口不同”结果自然可能不一致。7.2 在Django ORM中的体现Django的QuerySet是另一个典型实现。它对__len__做了缓存优化第一次计算长度后会把结果缓存起来后续len(qs)不再访问数据库。对__getitem__则支持整数索引、切片还额外支持切片后再取长度的优化逻辑。这背后用到的就是和本文完全一致的协议机制。如果你想深入理解ORM框架先看懂__getitem__和__len__是个很好的切入点。很多看似“魔法”的功能其实就是把这两个协议方法发挥到了极致。7.3 在astropy等科学计算库中的体现astropy这类科学计算库里的自定义数组、星表对象Table同样依赖这些协议来实现类似NumPy ndarray的访问习惯。比如table[0]取第0行table[col_name]取某列len(table)返回行数。面向用户呈现出统一的“容器操作体验”底层就是协议方法的灵活运用。8. 实操总结怎么设计一个好的容器类写了这么多案例最后聊一点我自己的经验。设计一个带__getitem__和__len__的类有几个原则最好守住。原则一语义要一致。len()说什么__getitem__就要认什么。0 index len(obj)的范围应该保证能访问到有效元素。如果某个索引超出了这个范围该抛IndexError就抛IndexError不要吞掉异常不要返回None更不要返回一个默认值。保持“越界即异常”的语义才符合Python的惯例。原则二能委托尽量委托。如果你的内部数据就是一个list或dict直接把__getitem__的实现委托给它就行不要自己手写索引计算。手写多了边界情况处理不完整很容易出负索引、切片步长之类的bug。当然像分页器这种需要对索引做“语义重定义”的特殊场景委托反而不合适。原则三性能要心里有数。考虑一个类经常被for迭代吗那最好同时实现__iter__不要在关键路径上依赖__getitem__的回退机制。考虑一个类经常被in判断吗那可以考虑实现__contains__如果成员判断逻辑比较复杂单独实现的性能优势会很明显。考虑一个类需要布尔判断吗别忘了__len__为0时对象为False的这个特性必要时通过__bool__覆盖。原则四文档要说清楚。当你在类里改变这些方法的“默认语义”时比如让obj[0]代表“第一页数据”而不再代表“第一个元素”时一定要在这个类的docstring里写清楚。协议给你了自由度但使用者靠的是“惯例”和“直觉”破除惯例必须给出替代的明确指引。这一点我自己吃亏过好几次。原则五配合抽象基类做类型标注。从Python 3.9开始collections.abc里的泛型功能更完善了。你可以在定义类时直接继承Sequence、MutableSequence等抽象基类这样既能强制约束自己实现哪些方法又能获得混合类方法比如index、count的免费实现还能让类型检查工具更好地推断类型一举三得。9. 写在最后的两个小技巧再分享两个我自己在代码里常用的处理细节。第一个是关于负索引的“委托派发”技巧。如果内部存储是列表但你的__getitem__需要覆盖部分语义可以这样处理def __getitem__(self, index): # 只有负索引才委托给内部列表处理其余情况走自定义逻辑 if isinstance(index, int) and index 0: return self._records[index] # 其他情况……这种做法比“一刀切委托”要灵活也比“一刀切自定义”更安全兼顾了Python负索引的惯用语义和自定义逻辑的独立性。第二个是关于slice对象的“空默认值”问题。很多人在处理obj[1:]、obj[:5]这些切片时直接用key.start去参与计算。但要注意省略掉的维度和显式传入None在slice里都表现为None。如果你的内部逻辑需要把None转换成具体值务必区分“用户没传”和“用户传了None”本质上是一样的统一用if key.start is not None这种写法来处理。把这两个方法玩熟了你对Python“协议驱动”这一套设计哲学的理解会一下子深很多。以后再看那些框架源码里“为什么这样写就能用”的问题会少很多困惑。