Python 工匠:做一个精通规则的玩家——用集合差集与魔法方法写出更地道的 Python 代码
发布时间:2026/9/28 2:29:42来源:尧图网络
技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载本文是开源仓库《Python 工匠》one-python-craftsman系列文章的第 10 篇。它以“旅游客户筛选”这一真实业务案例为主线演示了从 O(n*m) 蛮力循环到 O(nm) 哈希索引、再到基于__hash__/__eq__自定义对象与集合差集的一行式解法并进一步讲解__format__、__len__/__getitem__等魔法方法如何帮你写出更符合 Python 惯例的 API。读完本文你将掌握“把业务问题抽象成语言规则能直接求解的形态”这一核心思维并能在自己的数据建模与容器类设计中立刻落地。前言编程其实是一场“精通规则”的游戏编程和玩电子游戏有一些相似之处。玩不同的游戏前你需要先学习每个游戏各自的规则只有熟悉并灵活运用游戏规则才更有可能在游戏中获胜。编程也是一样不同编程语言有着不一样的“规则”——大到是否支持面向对象小到是否可以定义常量编程语言的规则比绝大多数电子游戏的规则要复杂得多。当我们编程时如果直接拿一种语言的经验套用到另一种语言上很多时候并不能取得最佳结果。这就好像一个 CS反恐精英高手在不了解规则的情况下去玩 PUBG绝地求生虽然他的枪法可能万中无一但极有可能在发现第一个敌人之前就倒在某个窝在草丛里的敌人的伏击下。Python 里的规则Python 是一门初见简单、深入后愈觉复杂的语言。拿 Python 里最重要的“对象”概念来说Python 为其定义了多到让你记不全的规则比如定义了__str__方法的对象就可以使用str()函数来返回可读名称定义了__iter__和__next__方法的对象就可以被循环迭代定义了__bool__方法的对象在进行布尔判断时就会使用自定义的逻辑……熟悉规则并让自己的代码适应这些规则可以帮助我们写出更地道的代码事半功倍地完成工作。下面让我们来看一个有关适应规则的真实故事。案例从两份旅游数据中获取人员名单某日在一个主打新西兰出境游的旅游公司里商务同事突然兴冲冲地跑过来找我说他从某合作伙伴那里要到了两份重要的数据所有去过“泰国普吉岛”的人员及联系方式所有去过“新西兰”的人员及联系方式数据采用 JSON 格式如下所示# 去过普吉岛的人员数据 users_visited_phuket [ {first_name: Sirena, last_name: Gross, phone_number: 650-568-0388, date_visited: 2018-03-14}, {first_name: James, last_name: Ashcraft, phone_number: 412-334-4380, date_visited: 2014-09-16}, ... ... ] # 去过新西兰的人员数据 users_visited_nz [ {first_name: Justin, last_name: Malcom, phone_number: 267-282-1964, date_visited: 2011-03-13}, {first_name: Albert, last_name: Potter, phone_number: 702-249-3714, date_visited: 2013-09-11}, ... ... ]每份数据里都有着姓、名、手机号码、旅游时间四个字段。基于这份数据商务同学提出了一个听上去毫无道理的假设“去过普吉岛的人应该对去新西兰旅游也很有兴趣。我们需要从这份数据里找出那些去过普吉岛但没有去过新西兰的人针对性地卖产品给他们。”第一次蛮力尝试有了原始数据和明确的需求接下来的问题就是如何写代码了。依靠蛮力我很快就写出了第一个方案def find_potential_customers_v1(): 找到去过普吉岛但是没去过新西兰的人 for phuket_record in users_visited_phuket: is_potential True for nz_record in users_visited_nz: if phuket_record[first_name] nz_record[first_name] and \ phuket_record[last_name] nz_record[last_name] and \ phuket_record[phone_number] nz_record[phone_number]: is_potential False break if is_potential: yield phuket_record因为原始数据里没有“用户 ID”之类的唯一标示所以我们只能把“姓名和电话号码完全相同”作为判断是不是同一个人的标准。find_potential_customers_v1函数通过循环的方式先遍历所有去过普吉岛的人再遍历所有去过新西兰的人如果在新西兰的记录中找不到完全匹配的记录就把它当作“潜在客户”返回这里用yield写成生成器函数逐条产出结果避免一次性构造大列表。这个函数虽然可以完成任务但它有着非常严重的性能问题。对于每一条去过普吉岛的记录我们都需要遍历全部新西兰访问记录尝试找到匹配。整个算法的时间复杂度是可怕的O(n*m)——如果新西兰的访问条目数很多的话执行它将耗费非常长的时间。为了优化内层循环我们需要减少线性查找匹配部分的开销。尝试使用集合优化函数如果你对 Python 有所了解那么你肯定知道Python 里的字典和集合对象都是基于**哈希表Hash Table**实现的判断一个元素是否在集合里的平均时间复杂度是O(1)非常快。这一点在系列第 4 篇《容器的门道》里有专门讨论——当需要判断成员是否存在于某个容器时用集合比列表更合适因为item in [...]是O(n)而item in {...}是O(1)。所以对于上面的函数我们可以先针对新西兰访问记录初始化一个集合之后的查找匹配部分就能变得很快函数整体时间复杂度变为O(nm)def find_potential_customers_v2(): 找到去过普吉岛但是没去过新西兰的人性能改进版 # 首先遍历所有新西兰访问记录创建查找索引 nz_records_idx { (rec[first_name], rec[last_name], rec[phone_number]) for rec in users_visited_nz } for rec in users_visited_phuket: key (rec[first_name], rec[last_name], rec[phone_number]) if key not in nz_records_idx: yield rec这里的关键技巧是把每一条新西兰记录的三个关键字段打包成一个元组作为集合元素。因为元组是 Immutable不可变且可哈希的所以可以直接放进集合从而为姓名电话三元组建立起 O(1) 的查找索引。使用了集合对象后新函数在速度上相比旧版本有了飞跃性的突破。但是对这个问题优化到此为止了吗当然不是——不然文章标题就应该改成“如何使用集合提高程序性能”了。对问题的重新思考让我们尝试重新抽象思考一下问题的本质。首先我们有一份装了很多东西的容器 A普吉岛访问记录然后给我们另一个装了很多东西的容器 B新西兰访问记录之后定义相等规则“姓名与电话一致”。最后基于这个相等规则求 A 和 B 之间的“差集”。如果你对 Python 里的集合不是特别熟悉这里稍微多介绍一点假如我们拥有两个集合 A 和 B那么可以直接使用A - B这样的数学运算表达式来计算二者之间的差集 a {1, 3, 5, 7} b {3, 5, 8} # 产生新集合所有在 a 但是不在 b 里的元素 a - b {1, 7}所以计算“所有去过普吉岛但没去过新西兰的人”其实本质上就是一次集合的求差值操作。那么要怎么做才能把我们的问题套入到集合的游戏规则里去呢利用集合的游戏规则在 Python 中如果要把某个东西装到集合或字典里一定要满足一个基本条件“这个东西必须是可以被哈希Hashable的”。什么是 Hashable举个例子Python 里面的所有可变对象比如字典就不是Hashable 的。当你尝试把字典放入集合中时会发生这样的错误 s set() s.add({foo: bar}) Traceback (most recent call last): File stdin, line 1, in module TypeError: unhashable type: dict所以如果要利用集合解决我们的问题就首先得定义我们自己的 Hashable 对象VisitRecord。而要让一个自定义对象变得 Hashable唯一要做的事情就是定义对象的__hash__方法。class VisitRecord: 旅游记录 def __init__(self, first_name, last_name, phone_number, date_visited): self.first_name first_name self.last_name last_name self.phone_number phone_number self.date_visited date_visited一个好的哈希算法应该让不同对象之间的值尽可能唯一这样可以最大程度减少“哈希碰撞”发生的概率。默认情况下所有 Python 对象的哈希值来自它的内存地址。在这个问题里我们需要自定义对象的__hash__方法让它利用姓名电话元组作为VisitRecord类的哈希值来源def __hash__(self): return hash( (self.first_name, self.last_name, self.phone_number) )自定义完__hash__方法后VisitRecord实例就可以正常被放入集合中了。但这还不够——为了让前面提到的求差值算法正常工作我们还需要实现__eq__特殊方法。__eq__是 Python 在判断两个对象是否相等时调用的特殊方法。默认情况下它只有在两个对象的内存地址完全一致时才会返回True。但在这里我们复用了VisitRecord对象的哈希值当二者哈希值相等时就认为它们一样def __eq__(self, other): # 当两条访问记录的名字与电话号相等时判定二者相等。 if isinstance(other, VisitRecord) and hash(other) hash(self): return True return False需要特别留意的一点Python 的数据模型规定一个类如果定义了__eq__却没有定义__hash__那么它的__hash__会被自动设为None实例将因此变得不可哈希也就无法放进集合。所以自定义“相等语义”时务必像这里一样成对实现__hash__与__eq__。完成了恰当的数据建模后之后的求差值运算便算是水到渠成了。新版本的函数只需要一行代码就能完成操作def find_potential_customers_v3(): return set(VisitRecord(**r) for r in users_visited_phuket) - \ set(VisitRecord(**r) for r in users_visited_nz)Hint如果你使用的是 Python 2那么除了__eq__方法外你还需要为自定义类定义__ne__判断不相等时使用方法。使用 dataclass 简化代码故事到这里并没有结束。在上面的代码里我们手动定义了自己的数据类VisitRecord实现了__init__、__eq__等初始化方法。但其实还有更简单的做法。因为定义数据类这种需求在 Python 中实在太常见了所以在 3.7 版本中标准库中新增了dataclasses模块专门帮你简化这类工作。如果使用 dataclasses 提供的特性我们的代码可以最终简化成下面这样dataclass(unsafe_hashTrue) class VisitRecordDC: first_name: str last_name: str phone_number: str # 跳过“访问时间”字段不作为任何对比条件 date_visited: str field(hashFalse, compareFalse) def find_potential_customers_v4(): return set(VisitRecordDC(**r) for r in users_visited_phuket) - \ set(VisitRecordDC(**r) for r in users_visited_nz)不用干任何脏活累活只要不到十行代码就完成了工作。这里有两个关键的声明参数值得展开说明unsafe_hashTrue告诉 dataclass 为你自动生成基于字段的__hash__方法默认情况下定义了__eq__的 dataclass 会把__hash__置为None导致实例不可哈希unsafe_hashTrue正是为了启用哈希能力而设计date_visited: str field(hashFalse, compareFalse)将“访问时间”字段从哈希来源和相等比较中双双排除——这与手写版中__hash__和__eq__只依赖姓名电话的逻辑完全一致保证了 v4 与 v3 的筛选结果等价。适用前提dataclasses是 Python 3.7 加入标准库的特性运行上述代码需要 Python 3.7VisitRecord(**r)这种字典解包构造依赖**对字典的展开能力Python 3.5 均已支持。案例总结问题解决以后让我们再做一点小小的总结。在处理这个问题时我们一共使用了三种方案使用普通的两层循环筛选符合规则的结果集利用哈希表结构set 对象创建索引提升处理效率将数据转换为自定义对象利用规则直接使用集合运算为什么第三种方式会比前面两种好呢首先第一个方案的性能问题过于明显所以很快就会被放弃。那么第二个方案呢仔细想想看方案二其实并没有什么明显的缺点——甚至和第三个方案相比因为少了自定义对象的过程它在性能与内存占用上甚至有可能会微微强于后者。但请再思考一下如果你把方案二的代码换成另外一种语言比如 Java它是不是基本可以做到 1:1 的完全翻译换句话说它虽然效率高、代码直接但它没有完全利用好 Python 世界提供的规则最大化地从中受益。如果要具体化这个问题里的“规则”那就是“Python 拥有内置结构集合集合之间可以进行差值等四则运算”这个事实本身。匹配规则后编写的方案三代码拥有下面这些优势为数据建模后可以更方便地定义其他方法如果需求变更做反向差值运算、求交集运算都很简单理解集合与 dataclasses 逻辑后代码远比其他版本更简洁清晰如果要修改相等规则比如“只拥有相同姓的记录就算作一样”只需要继承VisitRecord并覆盖__eq__方法即可其他规则如何影响我们在前面我们花了很大的篇幅讲如何利用“集合的规则”来编写事半功倍的代码。除此之外Python 世界中还有着很多其他规则。如果能熟练掌握这些规则就可以设计出符合 Python 惯例的 API让代码更简洁精炼。下面是两个具体的例子。使用__format__做对象字符串格式化如果你的自定义对象需要定义多种字符串表示方式就像下面这样class Student: def __init__(self, name, age): self.name name self.age age def get_simple_display(self): return f{self.name}({self.age}) def get_long_display(self): return f{self.name} is {self.age} years old. piglei Student(piglei, 18) # OUTPUT: piglei(18) print(piglei.get_simple_display()) # OUTPUT: piglei is 18 years old. print(piglei.get_long_display())那么除了增加这种get_xxx_display()额外方法外你还可以尝试自定义Student类的__format__方法因为那才是将对象变为字符串的标准规则class Student: def __init__(self, name, age): self.name name self.age age def __format__(self, format_spec): if format_spec long: return f{self.name} is {self.age} years old. elif format_spec simple: return f{self.name}({self.age}) raise ValueError(invalid format spec) piglei Student(piglei, 18) print({0:simple}.format(piglei)) print({0:long}.format(piglei))__format__(self, format_spec)接收的format_spec正是格式化字符串中冒号后面的部分例如{0:simple}.format(piglei)中的simple。实现它之后format(piglei, long)、f{piglei:simple}等所有 Python 原生的字符串格式化入口都会自动走这套逻辑调用方不再需要知道get_simple_display/get_long_display这类自定义方法名。使用__len__/__getitem__定义对象切片操作如果你要设计某个可以装东西的容器类型那么你很可能会为它定义“是否为空”“获取第 N 个对象”等方法class Events: def __init__(self, events): self.events events def is_empty(self): return not bool(self.events) def list_events_by_range(self, start, end): return self.events[start:end] events Events([ computer started, os launched, docker started, os stopped, ]) # 判断是否有内容打印第二个和第三个对象 if not events.is_empty(): print(events.list_events_by_range(1, 3))但是这样并非最好的做法。因为 Python 已经为我们提供了一套对象规则所以我们不需要像写其他语言的面向对象代码那样去自己定义额外方法。我们有更好的选择class Events: def __init__(self, events): self.events events def __len__(self): 自定义长度将会被用来做布尔判断 return len(self.events) def __getitem__(self, index): 自定义切片方法 # 直接将 slice 切片对象透传给 events 处理 return self.events[index] # 判断是否有内容打印第二个和第三个对象 if events: print(events[1:3])这里有两个隐藏的“规则红利”值得拆开来看if events直接可用由于Events没有定义__bool__Python 会退而求其次调用__len__用长度是否为 0 决定实例的布尔真假——这一规则与系列第 2 篇《编写条件分支代码的技巧》中“自定义对象的布尔真假”一节完全一致__bool__与__len__是控制自定义类布尔值的两条标准通道events[1:3]切片透传__getitem__接收到的index实际上是一个slice对象直接把它转交给内部列表self.events[index]Python 会按列表的切片规则返回子序列天然支持1:3这种范围写法。新的写法相比旧代码更能适配进 Python 世界的规则API 也更为简洁。总结Python 世界有着一套非常复杂的规则这些规则的涵盖范围包括“对象与对象是否相等”“对象与对象谁大谁小”等等它们大部分都需要通过重新定义“双下划线方法__xxx__”去实现。如果熟悉这些规则并在日常编码中活用它们有助于我们更高效地解决问题、设计出更符合 Python 哲学的 API。下面是本文的一些要点总结永远记得对原始需求做抽象分析比如问题是否能用集合求差集解决如果要把对象放入集合需要自定义对象的__hash__与__eq__方法__hash__方法决定性能碰撞出现概率__eq__决定对象间的相等逻辑使用 dataclasses 模块可以让你少写很多代码使用__format__方法替代自己定义的字符串格式化方法在容器类对象上使用__len__、__getitem__方法而不是自己实现在仓库中继续阅读本文所属的《Python 工匠》系列全部收录于当前仓库 README.md 的“文章列表”中可按需翻阅与本文“集合/哈希表/容器接口”直接相关的主题见系列第 4 篇《容器的门道》与本文“__bool__/__len__自定义布尔真假”相关的分支技巧见系列第 2 篇《编写条件分支代码的技巧》上一篇《一个关于模块的小故事》下一篇《高效操作文件的三个建议》。另外更全面的 Python 对象模型规则可以在官方数据模型文档中找到有点难读但值得一读。如果希望进一步理解“适配规则、写出更好代码”的实践系列作者也推荐观看 Raymond Hettinger 在 PyCon 2015 上的演讲“Beyond PEP8 - Best practices for beautiful intelligible code”。赞分享技术博客教程文档【免费下载链接】one-python-craftsman来自一位 Pythonista 的编程经验分享内容涵盖编码技巧、最佳实践与思维模式等方面。项目地址https://gitcode.com/gh_mirrors/on/one-python-craftsman点击查看免费下载相关推荐Toggl Desktop跨平台开发揭秘C核心库与多UI架构Toggl Desktop跨平台开发揭秘C核心库与多UI架构 Toggl Desktop是一款支持Windows、Mac和Linux的跨平台时间跟踪应用如何用skills.sh一条命令快速加载claude-skills全部67个技能完整入门教程如何用skills.sh一条命令快速加载claude skills全部67个技能完整入门教程 claude skills 是一个为 Claude Code 打AI 技能AI 插件后端前端DevOpsPython一行式编程技巧简化代码的实用方法合集Python一行式编程技巧简化代码的实用方法合集 想要快速提升Python编程效率吗掌握一行式编程技巧能让你写出更简洁优雅的代码 这些实用的方法不仅能教程文档教育上一篇2025最新Firefox iOS如何用Rust重构账户系统从架构到扫码登录全解析下一篇GIF编码技术革新基于libimagequant的高质量GIF生成方案创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网