
1. 写给还在爬坡路上的你第八篇笔记到底要聊什么这是《Python语法进阶笔记》系列的第八篇也是我自己整理得最吃力的一篇。前面几篇基本还在讲“某件东西怎么用”写到这篇的时候我发现真正值钱的东西已经不完全在语法本身了而在语法背后的设计习惯和解法思路。尤其是后台总有朋友留言说自己死磕过 lambda、装饰器、生成器感觉每个字母都认识拼在一起就是看不懂。如果你也有这种感觉这篇笔记应该能帮你把最后几环打通。先说清楚这篇笔记适合谁。要是你刚看完入门教程还在拿print(hello)和for i in range(10)写练习我建议先把基础语法再过一遍不用硬读这篇。这篇更适合已经能写一些小脚本、但一遇到“闭包”“迭代器”“装饰器”就头皮发麻的人。换句话说适合那种想从“写得出”迈向“写得巧”的阶段。很多人在搜“Python语法”相关话题时其实找的就是一份能直接抄作业的进阶清单。所以这篇笔记我不会按官方教程的顺序复述语法规则而是按我这些年实际写代码的顺序把那些让我觉得“原来还能这样”的知识点串起来。每个知识点都会给场景、给原因、给坑尽量让你看完之后不是记住了一堆规则而是理解为什么要这样写。另外多说一句后台搜索记录里经常有人把 Python、SQL、Shell、C 的语法混在一起搜。能理解不同语言的语法确实有交集但混在一起学非常容易拧巴。这篇我们只聚焦 Python其他语言的语法坑以后有机会再单开笔记聊。2. 函数进阶参数机制是Python最容易出彩也最容易翻车的地方函数大概是 Python 里最值得花时间钻研的语法单元。很多进阶技巧本质上都是函数花活。所以这一章我写得特别细尤其是参数机制这里既有惊喜也有惊吓而且多数人第一次踩坑都踩在这。2.1 默认参数同一个坑我踩了三次先看一段特别经典的错误写法def add_item(item, items[]): items.append(item) return items第一次调用add_item(a)返回[a]第二次调用add_item(b)按理说你想得到[b]但实际返回的是[a, b]。原因在于默认参数items[]只会在函数定义时创建一次之后每次调用如果没传items拿到的都是同一个列表对象。换句话说默认列表被所有调用者共享了。正确写法是用None作为占位值在函数体内部再创建新列表def add_item(item, itemsNone): if items is None: items [] items.append(item) return items为什么推荐None因为None是不可变对象没有“内容被修改”的问题每次调用时if会生成一个全新的列表互不干扰。除了列表字典、集合这类可变对象也都有同样的坑。我早期写缓存逻辑时就因为在函数定义里放了个默认真空字典导致多个请求之间数据串来串去排查了大半天才找到问题。从那以后我养成了一个习惯任何可变对象都不直接做默认参数值一律走None加初始化。2.2 *args和**kwargs把不确定性留给调用方*args和**kwargs是提起 Python 参数机制时绕不开的语法。*args用来接收任意数量的位置参数打包成一个元组**kwargs用来接收任意数量的关键字参数打包成一个字典。def log_message(level, *args, **kwargs): print(f[{level}], args, kwargs) log_message(INFO, 用户登录, user_id123, retryTrue)这段代码的输出会告诉你普通参数level先接住“INFO”剩下的位置参数进args剩下的关键字参数进kwargs。这样做最大的价值是你写中间件、装饰器、接口封装时不需要提前列完所有参数调用方能传什么就传什么你这边只需要原样转发。顺带提一个很实用的写法参数列表里单独出现/和*时表示位置参数和关键字参数的边界。def register(name, age, /, email, *, join_timeNone): .../左边的参数只能按位置传*右边的参数只能按关键字传。这个功能从 Python 3.8 开始支持很多人用了好几年 Python 都没发现。它的意义是给 API 定规矩不让调用方自由发挥减少误用。2.3 lambda、sorted和LEGB作用域lambda 是我见过被捧得最高、也用得最歪的一个语法。它本质上就是一行函数适合在需要一个小函数、又不方便单独def的地方用。比如sorted()的key参数就是 lambda 最典型的战场students [(张三, 82), (李四, 95), (王五, 78)] sorted(students, keylambda item: item[1], reverseTrue)不过 lambda 里只适合写简单表达式千万别在里面塞循环和复杂逻辑。如果逻辑一复杂可读性会断崖式下降到时候你宁可写个普通函数再传函数名进去维护起来反而舒服。提到sorted还有个隐藏知识点是itemgetter。operator.itemgetter(1)的效果等价于lambda x: x[1]但执行效率更高代码也更短。做多维排序、字段提取时用itemgetter和attrgetter比手写 lambda 更体面熟记以后能省很多事。关于变量查找顺序Python 遵循 LEGB 规则Local局部→ Enclosing外层→ Global全局→ Built-in内置。很多莫名其妙的 bug本质都是这个顺序没搞清。最常见的翻车现场是你在函数里用了和全局变量同名的变量然后抱怨“为什么外面那个变量没变”。答案很简单函数内的赋值创建的是局部变量。想在函数里修改全局变量要声明global想修改外层函数里的变量要声明nonlocal。这个坑我在下一章讲闭包时还会再提到。3. 迭代器与生成器让代码像流水线一样省内存如果说函数是你随手就能拿起的工具那迭代器和生成器就是让你能够“用小内存跑大任务”的底气。这一章我得重点讲透尤其是迭代协议和生成器的区别很多人到现在还是一笔糊涂账。3.1 迭代协议for循环背后的两个魔法方法每次写for item in something的时候Python 其实做了三件事先调用iter(something)拿到一个迭代器然后不停地调用next()拿下一个元素直到抛出StopIteration为止。所以一个对象能被 for 循环遍历要么它本身实现了__iter__和__next__要么它包含的迭代器实现了这两个方法。class Counter: def __init__(self, limit): self.limit limit self.n 0 def __iter__(self): return self def __next__(self): if self.n self.limit: self.n 1 return self.n raise StopIteration这个Counter就能直接用for i in Counter(5)来遍历。理解协议层面的东西好处是以后看到任何陌生对象你都能判断它能不能被循环解开。比如文件对象、range对象、zip对象它们内部都遵循同一套协议只不过有的惰性、有的非惰性。惰性的意思是不一次性把数据全部加载进内存。文件对象是一行一行读range是一个数一个数地算这些都不会因为你只取前三个元素就把后面的全部提前准备好。这也是生成器被大量使用的根本原因。3.2 生成器yield不是优雅而是省资源生成器函数用yield代替return函数执行到yield时会暂停并把值抛出来下次再调用next()又会从暂停的位置继续执行而不是从头开始。def fibonacci(): a, b 0, 1 while True: yield a a, b b, a b fib fibonacci() for _ in range(10): print(next(fib))这段代码你完全可以理解成一条不会停的流水线需要的时候才取一个零件不需要就挂在那不占额外内存。如果换成列表推导式硬造一堆数数量一上来内存直接爆。除了生成器函数生成器表达式也是一种很常用的写法total sum(x * x for x in range(1000000))表面看sum接收的是一个可迭代对象实际上它得到的是一个生成器每次只算一个平方数而不会先把 100 万个平方数全算出来存进列表。这类写法在处理大文件、大数据流时非常顶用。我一直建议读者在遇到“数据量可能很大”的场景时果断选生成器而不是列表哪怕代码只是多了个括号。生成器还有send()、throw()、close()这些方法。send()可以往生成器内部传值配合yield可以实现双向通信进阶的协程就是从这衍化出来的。不过初学阶段不必死磕这三个方法先理解惰性求值和暂停恢复就够了。3.3 yield from把子生成器“摊平”当你想把一个生成器里的元素全部丢给另一个生成器时最直接的办法是写个循环def nested(): for item in inner_gen(): yield item但更简洁的写法是yield fromdef nested(): yield from inner_gen()它就是把子生成器整个展开既能省掉一层for又保留了惰性求值的特性。像请求多个接口、合并多条数据流时“委托给子生成器”这种写法非常舒服。3.4 让迭代器停在“恰到好处”的位置itertools常用函数如果你写过很多数据处理代码itertools库会是你离不开的伙伴。我只挑几个最常用的islice(iterable, start, stop)对迭代器做切片不用先转成列表。chain(*iterables)把多个迭代器首尾拼接成一个大迭代器。groupby(iterable, keyNone)把相邻的相同元素分到一组注意是“相邻”而不是全局分组用之前记得先排序。有次我处理一个 5GB 的日志文件需要按时间片切出片段统计。用islice一行就搞定了切分逻辑内存占用稳定在几十 MB 以内。这种“迭代器思维”和普通列表思维的区别是你处理海量数据时能不能撑得住的关键。4. 装饰器与闭包写给“不满足于写业务代码”的人装饰器几乎是 Python 进阶的分水岭。写普通业务代码不需要它但是想做框架、做中间件、做工具库装饰器几乎是必备技能。而装饰器之所以让很多人头疼是因为它先要懂闭包然后要理解函数就是对象。4.1 闭包函数记住了外层的变量闭包的意思是内层函数引用了外层函数的变量并且外层函数返回了内层函数的引用于是这个内层函数去到哪都带着外层那个变量的记忆。def make_counter(): count 0 def inc(): nonlocal count count 1 return count return inc counter make_counter() print(counter()) # 1 print(counter()) # 2如果没有nonlocal count在inc里对count赋值会被当成创建局部变量直接报错。用上nonlocal之后这个计数器就能持续记住“上次算到哪了”。闭包的价值在于制造有状态的函数而且这种状态不污染全局。闭包也有一个著名的大坑就是循环变量延迟绑定。看这个例子funcs [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 输出全是 2原因是 lambda 里引用的i是循环结束时最终的值不是每次循环时的值。解决办法是用默认参数lambda ii: i或者用闭包工厂当场把值“锁住”。这种坑一旦碰上新手很容易抓狂因为报错都不报只是结果不对。4.2 手写一个装饰器从打印耗时开始装饰器本质上就是一个“接收函数、返回新函数”的函数。最常见的入门案例是统计函数耗时import time import functools def timer(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) print(f{func.__name__} 耗时 {time.perf_counter() - start:.4f} 秒) return result return wrapper timer def do_work(): time.sleep(0.5)注意那个functools.wraps(func)特别关键。不加它被装饰函数的__name__和文档字符串会被wrapper覆盖掉调试器和日志输出全是wrapper看着想哭。加上了它原函数的信息才会保留下来。如果装饰器需要参数就得在外面再包一层形成三层结构def retry(times3): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for _ in range(times): try: return func(*args, **kwargs) except Exception: continue return None return wrapper return decorator retry(times5) def fetch_data(): ...这个三层结构的理解方式是先把retry(5)执行完得到真正的装饰器decorator然后decorator再作用于下面的函数。手动拆开看一遍比你背十遍“装饰器是闭包”都有用。4.3 装饰器的实际应用场景在我的日常脚本里装饰器最常出现在三类场景日志埋点、重试机制、缓存结果。日志埋点不用多说重试机制适合那些依赖外部接口的功能缓存结果则可以直接用functools.lru_cachelru_cache(maxsize128) def expensive_func(n): ...这个内置装饰器会记住函数在特定参数下的结果参数相同就直接返回缓存不用重新计算。对递归、复杂计算这类场景提速非常明显而且写起来只是一行装饰器。写多个装饰器时执行顺序也容易踩坑。装饰器是从下往上应用的执行时从外往里。比如retry(times3) timer def request(): ...实际效果是retry(timer(request))也就是先计时再重试。每重试一次都会重新计时一次。理解执行顺序才能避免在重试场景里统计出奇怪的时间数据。5. 语法糖与标准库里的“隐藏彩蛋”“语法糖”这个词本身已经被搜索热词带得很火了。不过我始终觉得语法糖不是用来炫技的它是用来让代码读起来更像人话的。真正值得学的语法糖是那种你一看就知道“这个场景我遇到过”的技巧。5.1 推导式一行搞定的快乐列表推导式是多数人最早接触的语法糖squares [x * x for x in range(10) if x % 2 0]字典推导式、集合推导式同理name_len {name: len(name) for name in [Tom, Jerry, Spike]} unique_chars {c for word in [hello, world] for c in word}推导式能大幅压缩代码量但有三条原则我得强调一下一是别在里面写print()这类有副作用的操作因为推导式是用来生成新对象的不是用来干活的二是嵌套层次不要超过两层超过以后可读性会直线下降三是当你只是想循环执行一段逻辑、不需要结果集合时老老实实写for别硬套推导式。还有一个有点争议的语法糖是海象运算符:它允许在表达式中同时赋值并返回这个值if (n : len(items)) 10: print(f列表太长共 {n} 项)这个写法在需要“先算一个值再在同一表达式里用这个值判断”的场景里很好用尤其适合正则匹配、缓存读取等操作。它确实让代码短了但也让不少人觉得可读性差。我的建议是只在明显的局部场景里用别到处秀。5.2 内置函数与语法的小玩具enumerate用来同时拿下标和值zip用来并行打包多个序列map和filter用来做批量转换和过滤。这些内置函数单看都不难难的是组合使用。比如做多个列表的汇总for idx, (name, score) in enumerate(zip(students, scores), start1): print(idx, name, score)f-string 是我推荐每个 Python 使用者都练熟的格式化语法。除了最基本的f{name}它还能直接做对齐、数字分隔、百分比、时间格式甚至嵌套表达式price 1234567.891 print(f{price:,.2f}) print(f{name:10}) print(f{progress:.1%})这些技巧平时看着不起眼真正写报表脚本、日志模板时能帮你省掉大量str.format的挣扎。5.3 两个我常用的“冷门”语法点先说是getattr动态调用。它允许你通过字符串名字去拿对象属性或方法action delete getattr(service, action)(resource_id12)这在写命令分发器、插件系统时特别好用省掉一大串if/elif。不过也要注意别拿着用户输入直接拼方法名那会带来安全隐患所以使用前需要做一层白名单校验。另一个是functools.singledispatch它可以根据第一个参数的类型让同一个函数执行不同的分支。比如你写一个打印函数数字走数字格式字符串走字符串格式列表走列表格式用singledispatch组织起来比堆一堆if type(x) is ...清爽得多。这类语法点不属于“必须背下来”的类型但知道有这个东西关键时候你能想到去翻文档。6. 梳理过的坑比语法本身更值钱最后这一章我想把踩坑清单整理成一份速查表。说真的语法背得再多不如把这些高频翻车现场提前预习一遍省下的都是真实调试时间。6.1 常见问题速查表问题典型表现根本原因解决办法可变默认参数共享多次调用函数结果互相污染函数定义时默认对象只创建一次改用None并在函数体内初始化闭包循环变量被延迟绑定几个闭包输出全是循环结束值内层函数引用的是外层变量最终值默认参数锁定lambda ii: i忘记写return函数返回None函数无返回值确认每个分支都需要return浅拷贝导致意外修改修改副本影响原数据只复制了引用没复制内容用copy.deepcopy生成器被消费完第二次for没输出生成器只能遍历一次需要重用时重新创建生成器生成器只能遍历一次这个点我重点说一下。很多人第一次发现for i in range可以重复跑就以为生成器也可以重复跑。实际上生成器是“跑完即弃”的你第一次for循环耗尽它之后再想遍历就得重新调用生成器函数。如果你拿生成器做了成员判断之类的操作后续想再用里面的数据会发现它已经空了。最简单的方法是把一次性数据存成列表或者把生成器函数重新调用一遍。6.2 我的排查套路与笔记方法遇到这类问题我习惯先做“最小复现”也就是把场景压缩到十几行代码再打印每次变量变化。比如闭包延迟绑定问题你只要把循环里的i打印出来马上就能看出来内层函数记着的不是每次循环的实时值而是循环结束后的最终值。这种靠打印变量的土方法看起来笨但在绝大多数场景里比直接上手调试器更高效。另外我强烈建议不要只靠“看别人博客”学语法。看完这篇笔记最好自己敲一个“命令分发器”的小项目比如让用户输入add、delete、query程序通过getattr或字典分发到不同函数。再做一个小型重试装饰器挂在模拟会报错的函数上。这种“能跑通”的成就感比背一百条笔记都管用。我自己整理系列笔记时还有一个习惯每个语法点都逼自己写一个“为什么会存在这个语法”的理由。写不出来说明我其实还没真正理解它。比如装饰器为什么存在因为要在不改变原函数的前提下增强函数能力。生成器为什么存在因为要解决“大数据一次性载入内存”的问题。有了这层理解语法就不是死规则而是工具箱里能对应场景的工具。最后再分享一套个人经验学 Python 进阶语法时不用追求把每个知识点都一步到位。可以先掌握最常用的那 20%然后通过实际项目把另外 80%慢慢逼出来。我第一次写装饰器的时候笨到连functools.wraps都没加后面查日志发现函数名全变成了wrapper才意识到还有这回事。踩过坑、补上笔记这个知识你就彻底拿到了。这就是我坚持写系列笔记的初衷也是这篇进阶笔记想传递给你的东西。