ARTICLE DETAIL

资讯详情

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

深入理解Python魔术方法:掌握__init__/__str__/__getitem__等高频方法

深入理解Python魔术方法:掌握__init__/__str__/__getitem__等高频方法 1. 魔术方法到底是什么——先搞清楚底层逻辑我见过太多人学Python学完函数、类、继承之后突然卡在一个地方明明两个对象看起来一模一样但直接用加号拼接就报错打印出来还是一堆看不懂的__main__.Foo object at 0x7f8d5c。这时候有人就会说“你得定义魔术方法啊。”但为什么必须定义不定义会怎样今天我把这层窗户纸彻底捅破。魔术方法Magic Methods也叫双下划线方法Dunder Methods因为它们的名字格式统一是__名字__这样的双下划线开头和结尾。Python官方文档里其实管它们叫“Special Methods”也就是特殊方法。你不需要手动调用它们——比如a b能执行背后其实是Python帮你调用了a.__add__(b)print(obj)能显示内容背后是调用了obj.__str__()len(obj)能算出长度背后是obj.__len__()。关键在于魔术方法定义了“Python语法与你的自定义对象之间的接口”。内置类型为什么1 1能直接算出2因为int类内部已经实现了__add__。为什么列表可以直接用in判断元素是否存在因为list内部实现了__contains__。你自定义的类如果没实现这些接口Python语法层面的操作就无法识别你的对象。我把这个逻辑用一个生活类比拆解一下魔术方法就好像电器上的国标插头。市电插座是标准化的任何电器要想通电必须具备对应的插头规格。你的自定义类就是一个新生产的电器你要想让它能插进Python语法这个通用插座比如支持加号、支持打印、支持迭代就必须自己造一个标准插头这个插头就是魔术方法。内置类型出厂自带插头所以直接用就行而你的类默认是“裸的”不实现任何魔术方法它就只能做最基础的两件事赋值属性和被引用。这篇文章面向的读者是从零基础开始学Python、学到类和对象之后想继续往前走的人。学会魔术方法之后你能写出看起来“像内置类型一样丝滑”的自定义类这也是进阶到写框架、写库、做面向对象设计的必经之路。我的建议是先别死记硬背所有方法名先理解“语法背后其实是函数调用”这个心智模型然后带着问题逐个掌握高频的几个。2. 构造与表示伴随对象一生的两个关键钩子2.1init__不是构造函数真正的“出生”是__new先说一个很多人不知道的细节__init__虽然天天写但它其实不是真正的构造函数。对象真正被创建出来的那一瞬间执行的是__new__这个类方法而__init__只是“对象创建好之后对对象进行初始化的回调函数”。class Person: def __new__(cls, name, age): print(f__new__被调用了正在创建{name}的对象) obj super().__new__(cls) # 真正向内存申请空间、分配对象 return obj def __init__(self, name, age): print(f__init__被调用了正在初始化{name}) self.name name self.age age p Person(张三, 25)运行这段代码你可以非常清楚地看到先后顺序__new__先被调用返回一个对象然后Python自动把这个对象传给__init__完成属性初始化。__new__在什么时候会用到最常见的是实现单例模式、实现一些不可变类型的子类时。比如你想写一个自定义的元组子类__init__里赋值属性会报错因为元组是不可变对象你必须在__new__阶段准备好所有数据再传进去。另一个高频场景是封装第三方类想拦截创建过程对参数做预处理时。对于日常写业务代码的人__init__是真正高频的入口。我建议你记住一句话__init__里只做“对象的初始化”不要在里头做重活也别在里头写复杂的业务逻辑。这样后续调试时能省很多事。2.2 __str__与__repr__的区别面试也爱问这两个方法很容易混淆我自己带的学员几乎全踩过这个坑。一句话说清楚__str__的职责是“给用户看”追求可读性目标是让人看得懂。__repr__的职责是“给开发者看”追求精确性目标是尽量做到“把这个字符串丢回eval里能重新构建出这个对象”。class Point: def __init__(self, x, y): self.x x self.y y def __str__(self): return f点({self.x}, {self.y}) def __repr__(self): return fPoint({self.x}, {self.y}) p Point(3, 4) print(p) # 输出点(3, 4)调用的是__str__ print(repr(p)) # 输出Point(3, 4)调用的是__repr__注意一个细节如果类里只实现了__repr__而没有__str__那么print(obj)也会退回去用__repr__因为__str__默认会委托给__repr__。所以最省事的做法是先写一个准确的__repr__如果有展示需求再补__str__。我在实际开发中的习惯是所有自定义类都会写一个__repr__。这个习惯在调试时救了我无数次——日志里打印出来的不是object at 0x...而是能直接看出对象状态的内容排查问题的速度快很多。注意如果你在交互式终端直接输入变量名并按回车显示的永远都是__repr__的结果即使你定义了__str__也一样。这不是bug是Python的设计。3. 比较与运算让你的对象支持加号、等号和大小判断3.1eq、__lt__等比较方法的实现套路为什么1 1不用写代码因为int实现了__eq__。那为什么你自己定义的类两个对象即使属性完全相同判断也是False因为默认的__eq__逻辑是“是不是同一个对象”即对象内存地址是否相同等价于is。有时候这是对的尤其是不希望两个看起来一样的对象被当成相等时。但如果你需要根据属性判断相等就得自己实现。class Book: def __init__(self, isbn, title): self.isbn isbn self.title title def __eq__(self, other): if not isinstance(other, Book): return NotImplemented return self.isbn other.isbn def __hash__(self): return hash(self.isbn)这里我要重点说一个隐蔽的坑定义__eq__之后这个对象会变成不可哈希的也就是没法放进set集合里也没法作为dict的key。因为Python约定如果一个对象的相等性是基于内容而不是身份那么它的哈希值也必须对应地基于内容来计算。你实现了__eq__又没实现__hash__Python就直接把__hash__设为None了。解决办法就像上面代码里那样显式实现__hash__并且保证两个相等的对象哈希值必须相等。反过来哈希值相等的对象不一定相等哈希碰撞这是正常现象。再来说排序相关的比较。Python 3.8之后如果用functools.total_ordering装饰器你只需实现__eq__和任意一个排序方法比如__lt__它会自动补齐其他四个比较方法、、。但我的建议是能用total_ordering省事情但排序频繁且对性能敏感的场景不如把__lt____eq__都写全少一层装饰器的函数包装开销。3.2 数值运算add、__mul__让对象支持加减乘除数值运算魔术方法的应用场景非常典型自定义向量类、分数类、金额类、矩阵类。我想用一个复数的简化版来讲透这个逻辑因为复数在Python里本身就是内置实现的但你完全可以自己再造一遍来加深理解。class CustomComplex: def __init__(self, real, imag): self.real real self.imag imag def __add__(self, other): if isinstance(other, CustomComplex): return CustomComplex(self.real other.real, self.imag other.imag) elif isinstance(other, (int, float)): return CustomComplex(self.real other, self.imag) return NotImplemented def __radd__(self, other): return self.__add__(other) def __repr__(self): return f{self.real} {self.imag}i这里有个很多人不知道的知识点__add__是“左加”也就是a b时根据左操作数a的类型调用a.__add__(b)。那反过来如果a类型没有实现或返回了NotImplementedPython会尝试调用右操作数b.__radd__(a)。这就回答了为什么2 c这种“数字加自定义对象”的表达式也能工作——int的__add__发现不会处理CustomComplex就返回NotImplemented然后Python转向调用CustomComplex里的__radd__。不理解这个机制的人经常会卡在“明明定义了__add__为什么加号还是报错”这个问题上。还有一个细节__add__不要返回True或False这种奇怪的值也不要直接改原对象的状态应该像内置类型的加法一样返回一个新对象。不加也不行要是你返回了None却没报错后续代码会以诡异的方式失败特别难排查。3.3 类型转换也是魔术方法__bool__和__len__的关系if obj:这种判断Python内部会调用bool(obj)进而查找__bool__方法。如果没定义__bool__Python会退回去调用__len__长度非零就是True为零就是False。如果两个都没定义那默认都是True。class TaskList: def __init__(self): self.tasks [] def add(self, task): self.tasks.append(task) def __len__(self): return len(self.tasks) def __bool__(self): return any(not t.done for t in self.tasks)这个例子很能说明问题len是给len(obj)用的返回元素个数而bool是给条件判断用的这里返回“是否存在未完成的任务”。两个方法可以有不同的语义完全看业务需要。不过要小心一个反直觉陷阱实现了__len__之后空对象在if判断里会被当作False。这在很多场景下很自然但如果你根本没想过这件事某天突然发现一个非空对象被判成了False排查半天才发现是__len__返回0导致的那就很浪费时间了。4. 容器协议把你的类变成字典和列表的平替4.1 只读序列与映射实现__getitem__、__len__就能用len、切片和遍历Python对“容器”是有协议概念的。你的类只要实现了__getitem__和__len__它就已经满足“序列协议”的最低要求可以直接被for循环迭代、被in关键字判断成员、被len(obj)测长、被下标取值。不需要专门去注册什么类型也不需要继承list或者dict。class Playlist: def __init__(self, songs): self._songs songs def __getitem__(self, index): return self._songs[index] def __len__(self): return len(self._songs) pl Playlist([晴天, 七里香, 简单爱]) print(pl[0]) # 晴天 print(pl[1:]) # [七里香, 简单爱] print(晴天 in pl) # True for song in pl: print(song)为什么这串代码能跑因为Python在for item in obj的循环里会自动调用iter(obj)而iter()在发现对象没有__iter__方法时会退而求其次调用__getitem__从下标0开始不断尝试取值直到捕获到IndexError才结束循环。切片操作pl[1:]能工作是因为Python把切片对象slice(1, None)传给了__getitem__。你甚至可以在自定义类里根据传入的key不同同时支持整数下标、切片和字符串键名的路由逻辑。我建议初学者先按上面代码的顺序做一遍亲眼确认“不用继承任何容器基类也能用这些语法”这样你对协议的理解会非常牢。4.2 可变容器__setitem__与__delitem__的配合只读还不够的话你还可以让你的对象支持赋值和删除操作。实现__setitem__之后obj[key] value就能工作实现__delitem__之后del obj[key]也就能用了。class ConfigDict: def __init__(self): self._data {} def __getitem__(self, key): return self._data[key] def __setitem__(self, key, value): if not isinstance(key, str): raise TypeError(配置项的键必须是字符串) self._data[key] value def __delitem__(self, key): if key not in self._data: raise KeyError(f配置项{key}不存在) del self._data[key] def __len__(self): return len(self._data)这是很常见的“带校验的字典”的实现。赋值入口在__setitem__里统一做类型校验就能防止后面任何地方写入脏数据。代价是你的类不再是原生dict没法直接用.get()、.items()这些内置方法需要自己补齐或者继承collections.UserDict来做进一步扩展。实际项目中究竟选哪种取决于你更看重“写起来像dict”还是“严格保持自己的接口”。注意__getitem__的索引值不一定是整数。传入字符串就让对象支持obj[name]这种按键取值传入slice就让对象支持切片。Python不在乎你内部怎么解释它只负责把对应的key原样传给你。4.3iter容器类的迭代性能关键点如果只实现__getitem__for循环也能跑但性能不好。原因在于每次迭代都要走一次__getitem__的整段逻辑而且切片、索引的前置判断都会消耗额外时间。更好的方式是单独实现__iter__返回一个迭代器对象。class Countdown: def __init__(self, start): self.start start def __iter__(self): n self.start while n 0: yield n n - 1 for num in Countdown(5): print(num) # 5 4 3 2 1这里用了生成器yield关键字来配合__iter__这是实现迭代最省事的写法__iter__本身是一个生成器函数每次for循环请求下一个值时执行到yield就把值抛出来下次从yield的下一行继续执行。如果你想实现更像list那样可以反复迭代的对象需要区分好“返回迭代器”的__iter__和“迭代器本体”的__next__——最简单的解决方案是__iter__内部每次都用iter(self._data)返回一个新的迭代器而不是返回自身self。5. 属性访问控制__getattr__与__setattr__的高级玩法5.1 处理不存在的属性__getattr__与__getattribute__的区别这里有个高频混淆点__getattr__和__getattribute__名字很像但触发时机完全不同。简单说__getattr__只在正常查找失败时才触发。如果你访问的属性根本不存在Python才会调用__getattr__。__getattribute__无论属性是否存在每次属性访问都先执行它相当于所有属性访问的总入口。实际开发里__getattr__的用途比__getattribute__常见得多可以做一个轻量级的动态代理class ProxySettings: def __init__(self, data): object.__setattr__(self, _data, data) def __getattr__(self, name): if name in self._data: return self._data[name] raise AttributeError(f没有配置项: {name}) settings ProxySettings({timeout: 3, retries: 5}) print(settings.timeout) # 3 print(settings.retries) # 5这种做法在读取配置、对接外部API时很爽可以直接用settings.timeout替代settings[timeout]。但注意一个风险__getattr__必须对“不存在的属性”抛AttributeError否则会让所有属性查找都返回奇怪的值。很多人踩过这个坑把拼写错误隐藏成了一个莫名其妙的返回值bug非常隐蔽。5.2 拦截赋值__setattr__的正确姿势实现__setattr__可以拦截所有“实例属性赋值”的操作。很适合做“只读属性”或者“带类型校验的属性”。比如你不想让某个属性在创建后被修改就可以抛异常。但这里有一个非常经典的坑我先说答案class ImmutablePoint: def __init__(self, x, y): object.__setattr__(self, x, x) object.__setattr__(self, y, y) def __setattr__(self, name, value): raise AttributeError(这是不可变对象不允许修改属性)关键在于__init__内部给实例设置属性时也会触发__setattr__。如果你在__init__里写的是self.x x那就会无限递归或者直接被拦截报错。正确姿势是绕过__setattr__直接调用object.__setattr__(self, x, x)。这个坑我见过太多次了几乎每个学员都会在这里卡一下。如果你只是想“附加一些逻辑再继续默认赋值”那就在__setattr__最后调用super().__setattr__(name, value)而不是self.__dict__[name] value——后者在某些场合也行但不够通用。之所以推荐super的方式是因为它能继续触发property、描述符等其他机制不会绕过后面的逻辑。5.3call让实例像函数一样被调用实现了__call__之后这个类的实例就可以直接用obj(...)的语法调用。这在两种场景下特别有用一是写简单的策略类把一段业务逻辑封装成可调用对象二是配合装饰器或回调函数让对象带上状态还能直接当函数用。class DelayLogger: def __init__(self, prefix): self.prefix prefix self.call_count 0 def __call__(self, message): self.call_count 1 print(f[{self.prefix}] 第{self.call_count}次调用: {message}) logger DelayLogger(系统) logger(启动) logger(运行中)与普通函数相比它是一个“有对象”的函数能保存状态、能附带方法、还能被继承。这也是很多Python框架内部大量使用__call__的原因。不过我个人建议能用普通函数解决的事情不要非得定义成一个类__call__的正确使用场景是“你需要同时拥有状态和可调用行为”而不是“我想用类封装一个简单函数”。6. 上下文管理与其他高频魔术方法6.1 with語句背后的__enter__与__exit__with open(...) as f这种写法大家都很熟但背后的接口你可能没认真理解过。with语句会按顺序依次调用__enter__(self)和__exit__(self, exc_type, exc_value, traceback)两个魔术方法。__enter__的返回值会被赋给as后面的变量__exit__负责清理资源并且根据是否返回True来决定是否吞掉异常。class Timer: def __enter__(self): self.start time.perf_counter() return self def __exit__(self, exc_type, exc_value, traceback): self.elapsed time.perf_counter() - self.start print(f耗时 {self.elapsed:.4f} 秒) return False # 不吞掉异常 with Timer() as t: total sum(range(1000000))设计自己的上下文管理器有三个关键点__exit__的三个参数分别对应异常类型、异常实例、回溯对象。如果with代码块里没有异常发生这三个参数都是None。返回True表示“这个异常我处理了不让它继续抛出去”返回False则异常会被Python继续抛出。绝大多数时候应该返回False别乱吞异常。如果你想在__enter__阶段做一些准备工作并且可能失败一定要注意资源清理逻辑的对称性。如果你懒得写类标准库还提供了contextlib.contextmanager装饰器可以让你用生成器函数一行写出上下文管理器。不过我个人在实际项目里倾向于短暂的一次性逻辑用contextlib需要维护复杂状态的资源管理器才用类实现__enter__/__exit__。6.2enter__的核心之外的__aenter__与__aexit如果写异步代码你会需要__aenter__和__aexit__。用法和同步版本完全一致只是返回的都是awaitable对象。奇怪的是Python没有直接把这两对魔术方法统一所以很多异步框架库都要同时实现两套。这一对方法的重要性在异步爬虫、异步数据库连接的场景里会非常明显。class AsyncConnection: async def __aenter__(self): print(连接建立) return self async def __aexit__(self, exc_type, exc_val, traceback): print(连接关闭) return False6.3 __del__的陷阱析构方法确实存在但别依赖它不少面向对象语言都有析构函数Python里对应的是__del__。它确实会在对象被垃圾回收前执行但它不可预测、不可靠。原因在于Python的垃圾回收时机取决于引用计数和GC循环收集你并不能确定程序什么时候释放这个对象。如果依赖__del__去关闭文件、释放数据库连接你的程序可能运行到一半就积累了大量的未释放资源或者干脆在你不想释放的时候被释放掉。我一直以来的建议是显式关闭优于隐式析构。用完一个对象就手工调用close()或配合with语法这才是可控的做法。__del__最多拿来打印一些调试日志别拿它做关键的清理工作。7. 常见问题与排查技巧实录7.1 为什么打印对象还是显示的地址问这个问题的人十有八九是只实现了__str__没实现__repr__而日志系统、容器打印、列表打印等场合调用的是__repr__。比如print([obj])打印一个列表时列表内部的元素显示会调用元素的__repr__不会用__str__。解决办法就是两个都实现或者只写__repr__也够然后别指望__str__在其他场合被调用。7.2 列表排序时类对象报错说“不支持类型比较”如果列表里是你的自定义类对象调用list.sort()时报错 not supported between instances...说明你的类没有实现__lt__而Python的排序是基于比较来完成的。解决办法是实现__lt__或者用key参数指定一个可排序的取数函数sorted(books, keylambda b: b.publish_year)用key方式改排序字段只需要改一处lambda比写一堆比较魔术方法灵活。但如果你希望这个类天然支持排序那实现__lt__更合适。7.3 修改了__getattr__之后连self.attr都报错如果你在__getattr__内部尝试访问self._data而_data又不存在就会陷入无限递归。解决办法有两个方向一是在__init__时就通过object.__setattr__把_data设置好让它真正存在于__dict__里这样__getattr__永远不会被触发来获取它二是在__getattr__内部也使用object.__getattribute__去取内部属性切断递归链条。前者更推荐。7.4 检查某个类实现了哪些魔术方法的快速方法调试的时候我想快速确认一个对象到底支持哪些魔术方法会直接用dir(obj)然后把名字里带双下划线的过滤出来。更精准的判断可以采用hasattr(obj, __len__) # 或者 callable(getattr(type(obj), __len__, None))第二个判断方法更严格因为它检查的是类上有没有定义这个方法而不是实例的属性。这里有一个常见误区hasattr(obj, __len__)只能说明这个名字出现了如果实例属性恰好叫这个名字也会返回True并不代表它真的支持len()。7.5 用vars(obj)看__dict__调试属性赋值逻辑定位__setattr__相关问题最直接的手段就是打印对象的__dict__。我发现很多学员在写自定义__setattr__时赋值根本就没落到self.__dict__里导致后续取值全是None。在__setattr__里加一行print(self.__dict__)立刻能看到问题。执行顺序上有个小技巧先调用super().__setattr__再打印看到的才是更新后的状态。8. 实操总结与工程建议魔术方法这套东西你不需要一次性全部记住但要建立一个“看到语法就想到对应方法”的索引。我把日常开发中最常用的排个优先级__init__、__repr__、__str__、__eq__、__lt__、__len__、__getitem__、__iter__、__enter__/__exit__、__call__、__hash__。先把这组用熟剩下的用到再查。工程上有几个建议我想重点强调第一实现魔术方法时注意保持语义一致。实现了__eq__就一定记得实现__hash__否则对象放进集合时会出现诡异行为。第二不要过度魔术化。我接手的项目里出现过一种“滥用”虽然合法但很痛苦的情况——类里塞满了__getattr__动态生成属性整个对象的状态完全不可预测代码完全没法静态分析维护起来极其困难。魔术方法是为了让代码更符合直觉不是用来炫技的。第三凡是涉及资源管理的场景优先做显式清理别指望析构。我自己踩过不少关于魔术方法的坑。最典型的是刚学__getattr__和__setattr__那阵子做一个“自动保存配置”的类赋值一个属性就自动写一次硬盘文件。结果某天发现程序跑着跑着突然抛出递归深度错误。原因就是配置文件的写回操作内部又触发了__setattr__而__setattr__里又想保存到文件形成无限循环。后来总结出的规则是你重写了__setattr__就要对你所有内部属性的赋值方式都保持高度敏感能用super().__setattr__就不直接用self.xxx yyy。再补充一个小技巧Python在copy模块的浅拷贝和深拷过程中会用到__reduce_ex__和__copy__/__deepcopy__这些魔术方法。如果你的类内部有文件句柄、线程对象、网络连接这些无法被直接序列化的状态同时你又希望这个类的实例能被拷贝那最好显式实现并控制拷贝行为。这在写配置类、缓存类时真的有用。希望这篇内容对你有帮助。掌握了魔术方法你的Python水平就真正从“会用类封装属性”往前走了一大步到了“能用类与Python语法互动”的层次。这也是我们后续写迭代器、装饰器、元类、上下文管理器这些进阶内容的基础。如果有哪一块没看明白多敲几遍示例代码自己在交互式环境里验证一遍比背任何文档都来得快。
返回列表