首页 / 资讯中心 / 文章详情

6个Python隐藏功能,告别臃肿代码

6个Python隐藏功能,告别臃肿代码 ★ FEATURED ARTICLE
写Python这几年我见过太多“代码看着挺对但就是很累”的情况。不是你不会写而是被自己习惯性的写法绑住了。每天手写构造函数、手工判断字典key有没有存在、用加号拼字符串、循环里套循环去做统计……代码越堆越长功能没多全是模板活。这篇想聊的是Python里6个不算热门但非常提效的隐藏功能。这些写法大多在Python 3.7以后越来越好用只要写过几百行以上的Python把这些技巧放进自己的代码里能明显感觉到“代码瘦了一圈”是什么体验。这篇文章也适合刚学完基础语法、想进一步提升代码质量的读者里面每个例子都可以直接复制跑一遍。1. 代码为什么越写越臃肿病根通常在“重复模板”1.1 新手最容易踩的三类“赘肉”很多刚写Python的朋友代码臃肿不是因为业务复杂而是因为不知道标准库和语法层面已经给了更好的工具。第一类是手写类样板代码。定义一个User类老老实实写__init__、__repr__每个属性都要手动赋值一遍同类的东西写三四次之后代码就开始失控了。第二类是集合操作靠“先判断再动手”。往字典里加计数器先来一句if key not in d:然后初始化再自增。统计词频、分组数据、合并配置到处都是这种三行起步的“安全代码”。它没错但每写一次都多占用你几秒阅读时间。第三类是格式化输出全靠记忆拼接。数字要保留两位小数用%.2f % price字符串要右对齐写 * (10 - len(name)) name调试打印变量名得加上变量名的字符串。如果打印的字段一多这种拼接式写法不仅难读改起来也容易漏。这三类“赘肉”的共同特点就是它们都在重复劳动一旦改用更合适的写法原来十行的功能可以压成两三行。1.2 Python标准库本来就不是“够用就好”说这些功能“隐藏”其实它们并不是秘密只是很多教程默认你已经会了、或者压根没提。Python的官方设计理念是“batteries included”意思就是标准库已经内置了很多趁手的工具。你不需要为了一个计数器去装第三方包collections里就有现成的不需要自己写递归缓存functools.lru_cache装饰器早就等着你不需要为了简化类定义装一堆插件dataclasses从Python 3.7开始就是标准库的一部分。这些工具的目标非常一致让你用更少的代码表达同样的逻辑。代码少不一定代表可读性差关键是“少的这部分”本来就是模板。模板省掉之后剩下的全是业务逻辑反而一眼就能看明白。2. 六个隐藏功能逐个拆解每个都能少写好几行2.1 dataclasses把构造函数模板直接删掉dataclasses模块解决的是“类定义太啰嗦”的问题。以前定义一个数据类你得这么做class User: def __init__(self, name: str, age: int): self.name name self.age age def __repr__(self): return fUser(name{self.name}, age{self.age}) def __eq__(self, other): if not isinstance(other, User): return False return self.name other.name and self.age other.age就这么点功能手写了20行。改成dataclass之后from dataclasses import dataclass dataclass class User: name: str age: int__init__、__repr__、__eq__这些全都自动生成了。日常用来装数据的类基本就是给几个字段命名加两个参数就够了。这个功能还支持一些很实用的配置dataclass(frozenTrue) class Point: x: float y: floatfrozenTrue会让实例变成只读的尝试修改属性会直接抛异常。这在传参和配置对象场景下非常有用数据不会被别的地方偷偷改掉调试的时候少很多头疼事。还有一个经常被踩的坑是可变默认值。你想让一个字段默认是空列表如果直接写tags: list []Python会在类定义时直接报错。正确做法是dataclass class Article: title: str tags: list field(default_factorylist)这里用field(default_factorylist)告诉dataclass每次创建实例时都调用一次list()生成全新的列表。这样不同实例之间的tags就不会互相影响。2.2 解包与扩展解包拆数据不再借助临时变量解包这个语法很多人用的还是最基础的版本a, b 1, 2但它真正的威力在于扩展解包。比如从一个列表里把第一项拿出来其余放进另一个列表first, *rest [10, 20, 30, 40] # first 10, rest [20, 30, 40]这种写法在解析日志、拆分配置、处理函数返回值的时候特别顺手。以前你可能要写first items[0]、rest items[1:]现在一行搞定。字典也有类似的快捷合并方式default_config {host: localhost, port: 8080} user_config {port: 9090} final_config {**default_config, **user_config}两个字典合并user_config里的port会覆盖默认值。如果你自己手写合并逻辑通常要写一个for循环还得分清楚谁覆盖谁。用**展开代码意图直接写在脸上了。解包在函数调用里也一样好用params {name: Tom, age: 18} create_user(**params)一个字典直接作为关键字参数传进去在接口封装、参数透传的场景下能省不少重复代码。不过这里有个使用建议扩展解包固然简洁但嵌套太深就不要硬用了。比如从三层嵌套结构里一次性解包出十几个变量读代码的人要数半天才能对上位置这种时候真实写法反而是先拆一部分再拆一部分可读性更好。2.3 f-string格式化从“写作文”变成“做填空题”f-string是Python 3.6加入的但很多人直到今天格式化输出还是只会写{}.format()或者%s % value。f-string最基础也最常用的写法就是把变量名直接放进去name Tom score 87.5 print(f{name}的分数是{score})真正的效率提升来自那些“隐藏”格式说明。比如数字千分位amount 1234567.891 print(f{amount:,.2f}) # 1,234,567.89对齐和补位rank 3 name Tom print(f{name:10}{rank:3})10表示左对齐占据10个字符宽度3表示右对齐3个字符宽度。做排行榜、报表对齐的时候再也不用自己拼空格了。百分比格式化也很常用rate 0.235 print(f{rate:.1%}) # 23.5%调试输出是这个功能里最值得分享的一个点Python 3.8开始支持在f-string里写直接打印变量名和变量值user_count 1024 print(f{user_count}) # user_count1024不用再写print(user_count , user_count)这种语句了。日志里临时想看一下某个表达式的结果f{len(items)}就完事。要注意的一个限制是Python 3.12以前f-string的表达式里不能包含反斜杠。我以前想在f-string里用\n来换行直接报错后来只能先把结果存进变量再格式化。这个点在3.12已经解决但如果你还在维护老项目遇到类似报错别慌把表达式提前算好就行。2.4 lru_cache重复计算第一次做之后直接缓存lru_cache是functools模块里的一个装饰器专门给“重复调用、结果相同”的函数做缓存。它最有名的演示是斐波那契数列from functools import lru_cache lru_cache(maxsize128) def fib(n): if n 2: return n return fib(n - 1) fib(n - 2)不加缓存fib(35)要跑好几秒加了缓存瞬间出结果。因为每个子问题只计算一次时间复杂度从指数级降到了线性级。这个功能不只是数学题里能用。实际业务中有大量“相同入参、相同结果”的场景。比如根据城市ID查询城市名称如果每次都查数据库N个订单就要查N次。给查询函数加上lru_cache装饰器运行期间相同的城市ID只查一次库后面的直接命中缓存速度和压力都改善不少。用的时候有几个注意事项得记清楚只有参数可哈希的函数才能用lru_cache。参数如果包含list、dict这类可变对象会直接报TypeError。遇到这种情况先把参数转成tuple再传进去。函数必须是纯函数不能依赖外部可变状态不能有随机行为。比如random.random()或者读取当前时间的函数加上缓存就会出现“第一次拿到老结果后面永远拿到老结果”的诡异问题。可以用fib.cache_info()查看命中情况用fib.cache_clear()清空缓存。如果函数参数特别多记得根据实际情况调整maxsize默认128次通常够用设为None则不限大小但内存会持续增长。Python 3.9以后还有一个更轻量的functools.cache等价于lru_cache(maxsizeNone)不需要考虑淘汰策略的简单场景可以直接用它。2.5 defaultdict与Counter统计逻辑不再遍地if统计是Python代码里最容易被写啰嗦的场景。先看一段最典型的“新手统计”word_count {} for word in words: if word not in word_count: word_count[word] 0 word_count[word] 1用defaultdict之后三行变一行核心逻辑from collections import defaultdict word_count defaultdict(int) for word in words: word_count[word] 1defaultdict(int)的意思是访问一个不存在的key时自动调用int()生成默认值0然后继续执行加法。你永远不需要先判断key在不在。分组数据也一样defaultdict(list)自动初始化为空列表groups defaultdict(list) for user in users: groups[user.city].append(user)Counter则是专门为计数设计的工具from collections import Counter c Counter(words) print(c.most_common(5))统计词频后直接拿到出现次数最多的前5个比手动sort或者写一堆比较逻辑方便太多。Counter本身也支持相加、相减、交集、并集这些运算合并多份统计数据的时候可以直接用counter1 counter2。用defaultdict有一个值得记住的小坑只要访问不存在的key它就会主动往里塞一个默认值。如果你只是“读一下看有没有这个key”用d.get(key)或者判断if key in d都不会生成默认值但直接d[key]就会。要是担心统计结束后数据结构被“查询动作”污染可以读完统一转成普通dict或者用if key in d做判断。2.6 contextlib把资源管理做成自己的武器with语句大家都会用with open(...) as f:几乎是每个Python教程的第一课。但很少有人知道with并不只是“文件开关”它是一种通用的资源管理协议而且你可以用contextlib模块轻松自定义。最常用的自定义方式是contextmanagerfrom contextlib import contextmanager import time contextmanager def timer(label): start time.perf_counter() try: yield finally: print(f{label}: {time.perf_counter() - start:.3f}s) with timer(数据处理): result do_something()yield之前的代码在进入with时执行yield之后尤其是finally里的代码在退出时执行。你可以在with块里临时改配置、开连接、记录耗时退出时再统一恢复、关闭、打日志。contextlib.suppress也是一个容易被忽视的好东西。以前忽略某些异常得写try ... except空块try: os.remove(temp.txt) except FileNotFoundError: pass用suppress一行搞定from contextlib import suppress with suppress(FileNotFoundError): os.remove(temp.txt)代码里“删除文件但文件可能不存在”这种场景特别多每次写空except都显得很笨重suppress能把这些“意图明确但没必要处理”的异常收得很干净。还有ExitStack适合需要动态同时进入多个上下文管理器的场景。比如多个文件要同时打开数量不定普通写法只能层层嵌套with用ExitStack可以全部挂进去退出时统一关闭。这个稍微进阶一些但理解了它的原理之后在企业级代码里非常解压。3. 实战对比同一段业务代码重构前后差了整整一半3.1 重构前一份直观但啰嗦的订单统计光讲单个功能可能体会不到组合起来的威力。我在本地模拟了一个非常常见的业务场景读取两个订单文件统计每个客户的消费总额找出消费前5名的客户输出一个排行榜报表。先用最常见的写法实现一遍class Order: def __init__(self, customer, amount, city): self.customer customer self.amount amount self.city city def get_customer(self): return self.customer def get_amount(self): return self.amount def get_city(self): return self.city orders [] for path in [orders1.txt, orders2.txt]: f open(path) for line in f: parts line.strip().split(,) orders.append(Order(parts[0], float(parts[1]), parts[2])) f.close() customer_total {} for order in orders: customer order.get_customer() if customer not in customer_total: customer_total[customer] 0.0 customer_total[customer] order.get_amount() top_customers sorted(customer_total.items(), keylambda x: x[1], reverseTrue)[:5] report_lines [] for i, (customer, total) in enumerate(top_customers): report_lines.append(排名{}: {}消费金额: {:.2f}.format(i 1, customer, total)) report \n.join(report_lines) print(report)这份代码逻辑上完全正确但问题很明显文件打开用的是最原始的openclose万一中间抛异常文件不会被关闭统计消费总额前要先判断key存在格式化输出还在用format()类定义光是__init__就占据了很多行紧接着连续两个for循环才完成统计和排序。3.2 重构后六个功能在同一段代码里汇合用前面聊到的功能重写一版from collections import defaultdict, Counter from contextlib import suppress from dataclasses import dataclass from functools import lru_cache from itertools import chain import os dataclass(frozenTrue) class Order: customer: str amount: float city: str lru_cache(maxsize64) def tax_rate(city: str) - float: # 假设这里查一次外部配置成本很高 city_tax {北京: 0.06, 上海: 0.05} return city_tax.get(city, 0.0) def load_orders(path): with open(path) as f: for line in f: customer, amount, city line.strip().split(,) yield Order(customer, float(amount), city) customer_total defaultdict(float) for order in chain(load_orders(orders1.txt), load_orders(orders2.txt)): customer_total[order.customer] order.amount * (1 tax_rate(order.city)) top_customers Counter(customer_total).most_common(5) report_lines [ f排名{i 1:2}: {customer}消费金额: {total:,.2f} 元 for i, (customer, total) in enumerate(top_customers) ] print(\n.join(report_lines)) with suppress(FileNotFoundError): os.remove(orders.tmp)重构后的版本每一行都更“密”了dataclass(frozenTrue)把Order类压到了四行构造、比较、只读全都有了。文件读取直接放在with open(...)里但又用生成器函数load_orders把读取逻辑封装起来两个文件用itertools.chain串起来遍历文件自动关闭。defaultdict(float)省掉了统计之前的判断逻辑。tax_rate加上lru_cache同一个城市只会计算一次税率。f-string负责排版排名、金额格式都在格式说明符里完成。suppress清理临时文件不用写空except。3.3 对比结果行数变少不代表可读性下降我把两份代码的头部对比放在一起看维度重构前重构后类定义24行4行文件读取手写open/close约10行生成器 with约5行统计逻辑判断key存在 累加defaultdict一行格式化输出format()拼接f-string 格式说明异常忽略手写try/exceptsuppress一行行数几乎砍半。更关键的是重构后的每一行都在表达“业务是什么”而不是表达“面对Python语法我该怎么办”。比如Counter(customer_total).most_common(5)读起来就是“消费额计数的前5名”意图非常直白。也有朋友会担心职能拆成好几个块之后是不是更散了我的观点是一段几百行逻辑全堆在main里的代码才是最难读的。重构后的函数各自只有五六行维护的人不用从头看到尾才知道这段程序在干嘛从load_orders开始就是清晰的流水线。4. 常见问题与排查技巧实录4.1 高频报错和应对速查表这六个功能虽然好用但新上手时总有几个固定的坑。我把最常遇到的问题整理成一个速查表报错或现象原因解决办法TypeError: __init__() missing 1 required positional argument使用dataclass时手写了__init__或是在frozen实例上尝试赋值检查类里是否还有自定义__init__frozenTrue的实例需要只读使用ValueError: too many values to unpack (expected 2)解包时变量个数与实际数据个数不一致用扩展解包first, *rest吸收多余项或先print看下数据结构TypeError: unhashable type: listlru_cache接收了可变参数比如list把list转成tuple再传入或不使用缓存f-string表达式里面写反斜杠报错Python 3.12之前不允许\出现在f-string表达式内提前把含反斜杠的结果存入变量再放进f-stringdefaultdict莫名其妙多出一些key访问不存在的key时它会自动插入默认值判断key是否存在用if key in d或dict.get()另外一个很多人忽略的点是lru_cache是作用于函数级别的它缓存的key是所有参数组成的一个元组。如果参数里有一个是大量数据构成的tuple缓存规模会很大内存占用可能超预期。遇到这种情况可以缩小maxsize也可以在产品设计层面避免把大对象传进被缓存的函数。我之前写过一段处理用户画像的代码参数是用户ID列表转成的tuple结果maxsize128直接吃了几十MB内存后来改成只缓存单个用户ID的查询结果再在外层循环内存问题和重复计算问题一起解决了。4.2 几个我踩过的坑和注意事项先说dataclass和继承。如果你定义一个继承自dataclass的子类父类里有带默认值的字段子类新增字段也有默认值这在Python 3.10之前会有顺序问题。比如dataclass class Base: name: str age: int 0 dataclass class Child(Base): score: int 0这种写法在3.10之前某些版本会报“non-default argument follows default argument”之类的错误。我自己遇到过一次项目还在跑Python 3.8后来干脆把父类里的默认值也显式传一遍问题才解决。如果你在维护老项目遇到类似报错可以先检查dataclass继承链里的默认值顺序。defaultdict的坑还有一个容易被忽略的json.dumps序列化时defaultdict会正常转成普通对象但如果你用defaultdict存配置然后又用get方法去读读的时候不会触发默认值如果直接d[key]读一次字典里就会多一个key。有时候线上调试发现配置字典越来越膨胀很可能就是代码里无意中用下标做了很多次“读取”。suppress也不是万能的。它适合“明确知道这个异常可以忽略”的场景但如果你对一个不确定的异常也用suppress就可能导致真正的错误被静默吞掉。比如文件可能不存在用suppress(FileNotFoundError)是合理的但如果是权限问题抛的PermissionErrorFileNotFoundError的suppress并不会帮忙处理异常还是会抛出来。遇到这种情况要判断是“这个异常一定不会影响业务”还是“我不确定可能出什么错”后者还是老实写显式的except。f-string的格式化看起来简单但嵌套引号有时也会让人卡一下。比如要格式化一个字典里的值user {name: Tom} print(f{user[name]})这里f-string外层用双引号字典key就必须用单引号反过来也一样。如果你在f-string里写JSON模板花括号和引号混在一起很容易看花眼。我的经验是复杂的模板还是提前组装字典再用json.dumps生成不要在f-string里塞太复杂的表达式。还有一个小技巧lru_cache在调试时非常有用。你可以用fib.cache_info()查看缓存命中率如果发现hits一直在0说明你的函数参数在不停变化缓存形同虚设此时要么调整入参设计要么放弃这个装饰器。不要盲目给“看似重复”的函数加缓存先跑一小段数据看cache_info的输出再决定要不要保留。最后分享一点我实际工作中的体会这几个功能我用得最多的是lru_cache和f-string。lru_cache最省事加上之后代码基本不用动性能问题就缓解很多f-string则是每次写报表都离不开我很早之前还在用format()的时候代码里充满了{:.2f}和{}占位符换到f-string之后变量名直接出现在字符串里读代码的时候不用再回头找占位符对应的参数列表非常舒服。关于“隐藏功能”这个说法我想多说一句它们不是魔法也不是炫技Python官方把它们放进标准库就是为了让常见需求有更简洁的表达。你不需要一次性把六个功能全塞进项目里挑一两个目前最困扰你的场景先用起来写几天之后自然会发现代码“变薄”了。下次再写统计、报表、配置管理这类代码时先停下来想一想这个场景有没有现成的标准库工具是不是能少写几行。这个习惯一旦养成了比记住任何单个函数都值钱。
阅读完成 · 觉得有帮助?
咨询建站