用数据结构思考,而不是用控制流苦熬
很多人拿到一个需求,第一反应是写循环。把列表遍历一遍,判断条件,塞进新列表。写出来倒也没错,但那是C语言的声调在Python的嗓子里唱。你不需要用循环来构建一个列表,你需要的是列表推导式。
# 繁琐的 result = [] for x in numbers: if x % 2 == 0: result.append(x x)
# 简洁的 result = [x x for x in numbers if x % 2 == 0]
列表推导式不是语法糖,它把“创建容器、迭代、过滤、变换”这四个动作压缩成一个声明,读起来像一句英文:“给我所有偶数平方”。同样道理,构建字典、集合、生成器,都可以用推导式完成。如果你发现自己写了三行以上的代码只是为了填满一个容器,那你应该警惕自己是否在跟Python打架。生成器表达式又是一个升级版:它不一次性分配整个列表,而是惰性求值。处理几百万条日志时,用生成器表达式替代列表推导式,内存占用从几百兆降到几字节。别小看这一层括号的区别,把“现在就要全部结果”改成“用的时候再算”,往往就是生产环境稳定与崩溃的分界线。
再进一步,把条件判断交给内置函数。比如筛选数据,filter(lambda x: x > 0, data)不如推导式直观;filter(None, data)却能干净地去除假值。但最该记住的还是:能用字典查表解决的问题,绝不用一连串的if/elif。状态机、配置映射、事件分发,字典就是一张活生生的跳转表。
让内置函数替你打工
Python的标准库和内置函数里,藏着一大群免费劳动力。sum、max、min、any、all,这些名字本身就说明了一切。你或许没意识到,它们配合生成器时威力更大——any(x > 10 for x in data),一行完成本来要循环加标志位的工作。别自己手写计数器了,尊重语言内置的抽象,是简洁的前提。
enumerate和zip是遍历场景下的无名英雄。需要同时知道索引和元素?for i, value in enumerate(items)。需要把两个列表按位置配对?for a, b in zip(list_a, list_b)。很多人还在用range(len(items))去取索引,然后访问items[i]——这不仅冗长,还容易错。直接解包迭代变量,Python解释器已经替你把脏活干完了,你偏要抢回来做,这不是勤奋,是自我惩罚。
collections模块里的Counter和defaultdict也常在关键时刻救命。统计词频,一个Counter(text.split())搞定,比自己写字典累加干净得多。defaultdict(list)让你免去“键不存在先初始化”的重复劳动。deque则是双向队列,从两端增删都是O(1)。正确选择数据结构,能让你的代码短一半,同时快十倍。
解包的艺术:把逗号变成杠杆
Python里最被低估的语法,就是多元赋值和解包。交换两个变量,a, b = b, a,一行完成,不用中间变量。函数返回多个值时,x, y = get_point(),不拖泥带水。然而解包的能力远不止这些。
星号表达式是处理不定长序列的利器。first, middle, last = data,把首尾拆出来,中间全塞进一个列表。这在处理分割逻辑时极其好用。比如解析一行文本,只要第一个和后面所有:head, tail = line.split()。你再也不用为“第一个元素和剩余部分”写slice和索引了。解包迫使你从整体上描述数据的形状,而不是碎碎念地逐个取出。
嵌套解包也值得掌握。一个元组列表,每个元组里有三个元素,你要分别处理:for a, (b, c) in items,结构直接对应数据。字典的items()方法天然支持for key, value in d.items()。配合下划线占位符,for _ in range(10),明确告诉读者“我不关心这个值”。下划线不是随意命名的偷懒,它是给未来读者的一封信:这位置没有含义,别费神。
函数参数中的args和kwargs也是解包的另一种形态。它们让你把“数量不确定的参数”打包成一个元组或字典,然后在函数体内解包使用。调用时也可以用f(args)把列表展开成多个位置参数。这种“打包-解包”的对称美,让函数接口变得异常灵活——简洁不是写更少的字,而是让接口适应变化,而不是让调用方迁就固定签名。
with语句:把资源管理的脏活交给上下文
打开文件要关闭,获取锁要释放,连接数据库要断开。这些“事后清理”动作,如果全靠try/finally来写,代码里会铺满重复的样板。Python用with语句把这一切封装成了上下文管理器。
# 繁琐的 f = open("data.txt") try: content = f.read() finally: f.close()
# 简洁的 with open("data.txt") as f: content = f.read()
with块结束,文件自动关闭,无论中途是否抛异常。你节省的不只是三行缩进,而是把“正确处理异常”这件事从人脑的负担清单中删除了。人脑是最不可靠的组件,尤其在处理会失败的资源操作时。你可能会忘记close,而with不会。
更厉害的是,你还能借助contextlib模块自定义上下文管理器。用@contextmanager装饰一个生成器函数,yield之前的代码在进入时执行,之后的代码在退出时执行。想临时改环境变量,想测一段代码的执行时间,想临时改变目录路径——用这个配方,几行就能写出一个干净的小工具。上下文管理器让“进入-处理-退出”成为一种可以复用的语言级模式,而不是散落在每个函数里的手工抄写。
装饰器:把横切关注点从业务逻辑中剥离
日志记录、权限校验、性能计时、缓存结果、重试机制——这些逻辑跟核心业务无关,但又必须存在。把它们塞进每个函数里,代码就会变成千层蛋糕,每一层都是重复。装饰器正是为解决这个问题而生:它让你在不改动原函数代码的前提下,给函数“穿上外套”。
import time def timed(func): def wrapper(args, kwargs): start = time.perf_counter() result = func(args, kwargs) print(f"{func.__name__}: {time.perf_counter() - start:.6f}s") return result return wrapper @timed def work(): ...
有了这个@timed装饰器,任何函数只要在定义前加一行,执行时就会自动打印耗时。你不需要去修改work内部,也不需要复制粘贴计时代码。装饰器是声明式编程的典范——你不再写“先做什么再做什么”,而是直接标出“函数应该具备什么能力”。
Python内置的functools.lru_cache也是装饰器的绝妙案例。面对一个递归计算斐波那契数列的函数,只需加@lru_cache(maxsize=None),重复计算就被自动缓存,复杂度从指数级降到线性级。一行装饰器,胜过百行手工缓存逻辑。而functools.wraps则是写装饰器时的必备工具,它能保留原函数的__name__和__doc__,避免调试时连函数名都搞不清楚。写装饰器时忘了@wraps,就像换了件外套却把名字牌也换掉了,别人认不出你是谁。
数据类:让类定义诚实且简短
定义一个简单的数据类,传统写法需要__init__里一一行绑定属性,还要重写__repr__、__eq__。认真写起来动辄几十行,于是很多人干脆用字典代替。字典虽然灵活,但访问属性靠字符串键,拼错了也不报错,类型更是一团混沌。
dataclasses模块改变了这一切。
@dataclass class Point: x: float y: float
短短三行,Point就有了初始化、打印、相等比较、甚至排序(如果加order=True)。你不再写冗长的样板代码,因为类应该只描述数据的长相,而不是描述初始化过程的每一步动作。类型注解在这里不是可选项,而是数据类的本质——它把“这个字段叫什么、是什么类型”直接写进类定义,成为一份可被IDE和静态检查工具读取的契约。
配合frozen=True还能创建不可变对象,天然适合作为字典的键或集合的元素。field(default_factory=list)则能安全地为可变默认值提供服务,避开了那种让人头疼的“默认参数是同一个列表”的陷阱。数据类的简洁,是建立在类型约束、默认值、比较逻辑这些信息被显式声明的基础上的。你省下的是代码量,增加的是可读性。如果项目里还在用手写几个字段一个比一个复杂的__init__,那堆代码应该被关进小黑屋反省。
f-string:把字符串拼接到骨子里
字符串拼接是代码日常的一部分,也是最容易写得杂乱的地方。旧时代用%格式化,或者用str.format,但都不如f-string来得直接。
name = "Ada" age = 37 # 简洁的 print(f"{name} is {age} years old.")
花括号里可以直接写任意Python表达式。f"{price:.2f}"控制小数位,f"{value:>10}"按宽度对齐,f"{date:%Y-%m-%d}"格式化日期。甚至可以在花括号里调用函数、做算术、解包对象。f-string把“格式化的模板”和“运行时的数据”放在同一行,你的视线不需要来回跳到另一个位置寻找变量。
更进阶的是=调试技巧:f"{x = }"会输出x = 42,这比手写"x = " + str(x)简洁太多,也减少了写错变量名的风险。能用f-string解决的事,别用字符串相加——字符串相加会出现大量的引号、加号和空格,读起来像一盘散沙。尤其要避免在循环里用+累加字符串,那会引发可怕的二次时间复杂度;用"".join(parts)则一次成型,干净利落。
itertools:组合迭代的高阶乐趣
itertools是Python标准库中一个被严重低估的宝藏。它提供了一整套用于操作迭代器的工具,每一个都短小精悍,合起来威力无穷。chain可以把多个可迭代对象串成一个;product实现笛卡尔积,替代多层嵌套循环;combinations和permutations一键生成组合与排列;groupby按规则对相邻元素分组。这些工具的妙处在于,它们不会一次性生成全部数据,而是惰性流式处理,内存消耗恒定。
比如要打印三个文件夹里的所有文件,如果写三重循环,就是缩进地狱。用for file in chain(files1, files2, files3):,一个循环搞定。itertools教会我们的不是某个具体函数,而是一种思维:用组合代替嵌套,用流式代替一次性拉取。
再比如zip_longest,当两个列表长度不一致时,它用填充值来配对,正好用来处理对齐的日志行或者表格数据。islice则可以对无限序列进行切片,比如读取一个巨大的生成器,只取前100个元素:for item in islice(big_gen, 100):。简洁不是把代码压缩成一行,而是让每个想表达的逻辑变成一个简单的名词。当你看到“chain”这个词时,你就知道是串联;看到“zip”时,就知道是拉链。这种命名本身就是一种自文档化,比任何注释都清晰。
匹配语句:用结构说话,而不是用分支堆砌
Python 3.10引入的match语句,把模式匹配带进了语言。很多人只当它是加强版switch,其实它远远不止。它能匹配结构、解包数据、绑定变量。
match point: case Point(0, 0): print("Origin") case Point(x, 0): print(f"X-axis at {x}") case Point(0, y): print(f"Y-axis at {y}") case Point(x, y): print(f"Point at ({x}, {y})")
这种写法直接根据数据结构的形状分派逻辑,而不是先判断类型再用if拆包。对于解析AST、处理协议消息、分析配置文件这类场景,match让代码的意图和数据的形状一一对应。当你发现自己写了一大堆isinstance加索引取值时,就该考虑用match做一次模式重写。它让每个分支自己声明“我匹配什么样的数据”,而不是靠一堆条件组合来模拟这个判断。
当然,match不是万能钥匙。简单场景下,字典分发可能更短。但一旦分支条件复杂到需要用嵌套结构来表达,match的简洁优势就会碾压传统的if链。简洁的代码不排斥关键字,关键是让关键字服务于逻辑的清晰度,而不是为了新特性而新特性。模式匹配是那种你用一次就会觉得“这才对”的功能。
敢删,才是简洁的最高境界
前面聊了这么多技巧,其实所有技巧背后有一个共同心理:克制。简洁的最终形态是删代码,而不是写代码。一个函数里有大量临时变量,每个变量只用来传一次值——可以删掉,直接内联。一个条件判断的结果被存到变量里,然后只在下一个if里用一次——可以删掉,直接合并。注释解释了一大段“为什么这么写”,如果代码本身足够清晰,注释就是噪音——可以删掉,把原因写进提交信息。如果你的代码需要一个接一个的“首先、然后、最后”来解释,那不是因为内容复杂,而是因为结构混乱。
试着用“能斩则斩”的眼光去审查自己上个星期写的代码。我敢打赌,至少有三分之一可以删掉而不影响任何行为。那些多余的else(因为return之后无需else)、多余的中间变量(因为可以直接链式调用)、多余的类型转换(因为Python可以自动处理)、多余的空行和括号——每一样都在稀释你想要传递的信号。真正的优雅,不是你写了多少行让人惊叹的魔法,而是让后来的人打开文件时,不出三秒就能说出这个模块在干什么。
从今天开始,写Python时问自己三个问题:这个变量可以省掉吗?这个循环可以用推导式替代吗?这个重复的模式能否提取成装饰器或上下文管理器?答案一旦浮现,就动手改。简洁不是天赋,是持续对代码动刀的习惯。而每一次删减,都是你对“什么才是必要”这个问题的一次重新回答。