
1. 先把Python函数这件事重新捋一遍写了几年Python之后再回头看Python函数这个语法点其实是分水岭。刚入门的人觉得它就是个def加缩进能把一段代码包起来重复调用写过一两个中型项目的人会发现函数的设计质量几乎决定了整个代码库能不能维护下去。我见过太多项目单个文件八百行里面塞了三个巨型函数每个函数干着解析、校验、写库、发消息四件事改一个字段要在里面翻十几分钟。这类代码不是不能跑而是没人敢动。函数解决的核心问题很朴素把一段逻辑命名、隔离、复用。但Python在这一点上给的东西远比多数人用到的多——位置参数、关键字参数、默认值、可变位置参数、可变关键字参数、仅限关键字参数、仅限位置参数、参数解包、闭包、装饰器、生成器、类型注解。这些机制单拎出来都不难难的是知道在什么场景下该用哪一个以及哪些组合会埋雷。这篇内容适合三类人刚学完基础语法、写函数还在靠猜的初学者工作一两年、能写但总觉得代码哪里不对的开发者以及需要给团队定编码规范、想找一份函数设计参考的人。我会从设计取舍讲到参数细节再到闭包装饰器这类进阶用法最后是排查实录中间穿插我自己踩过的坑和实际项目里的处理方式。文中涉及的项目场景都做过脱敏处理只保留技术结构。1.1 函数在Python里到底是什么很多人下意识把函数理解成一段代码块这个理解在C语言里基本成立但在Python里会漏掉最关键的一层函数是一个对象。它可以被赋值给变量、放进列表、当参数传给另一个函数、当返回值返回、挂属性、被装饰。你在模块顶层写def foo(): ...本质上是创建了一个函数对象然后把这个对象绑定到名字foo上跟x 1创建整数对象绑定到x是同一种动作。这个认知一旦建立很多奇怪的写法就顺了。比如把函数当字典的值做分发def handle_create(payload): return {action: create, data: payload} def handle_delete(payload): return {action: delete, data: payload} ROUTES { create: handle_create, delete: handle_delete, } def dispatch(action, payload): handler ROUTES.get(action) if handler is None: raise ValueError(f不支持的操作: {action}) return handler(payload)这段代码的价值在于把if action create: ... elif action delete: ...这种会长到失控的分支结构换成了查表。新增一种操作只需要往ROUTES里加一行不用碰dispatch本身。这是开放封闭原则在函数层面的最轻量实现不需要类不需要继承。函数对象的属性也经常被忽略。foo.__name__、foo.__doc__、foo.__module__、foo.__defaults__、foo.__code__.co_varnames这些都是真实存在的调试和写框架时会用到。尤其是__name__装饰器如果不处理好它日志里全是wrapper排查问题时会让人抓狂后面第3节会专门讲。1.2 参数设计的取舍为什么Python给了这么多参数形式Python的参数形式看起来繁杂其实每一种都对应一个真实痛点。位置参数解决按顺序传值关键字参数解决参数多了记不住顺序默认值解决大部分情况用同一个值*args解决数量不定**kwargs解决键名不定仅限关键字参数解决防止调用方按位置传错仅限位置参数解决参数名属于实现细节不想暴露给调用方。设计函数签名时我一般的判断顺序是这样的第一这个参数是不是永远必须传如果是不给默认值让它必须显式出现。给一个None当默认值然后在函数体里判断等于把错误从调用时推迟到运行时是典型的坏味道。第二这个参数调用方是否需要知道名字如果名字本身有业务含义比如timeout、retries应该做成关键字参数可读性远好于位置。第三参数名是否属于实现细节、以后可能改名如果是用/把它划到仅限位置参数里这样改名不会破坏调用方。有一个很实际的经验参数超过四个的函数调用点几乎一定会出错。不是因为程序员不细心而是因为人脑对无序集合的记忆容量有限。遇到这种情况优先考虑把相关参数聚成一个数据类或者字典而不是硬加。比如send_mail(smtp_host, smtp_port, username, password, to, subject, body)这种签名实际用起来每次都要数一遍参数位置。改成send_mail(config, message)两个参数语义清楚也不容易错。1.3 返回值与副作用函数该不该修改外部状态函数按行为可以粗略分成两类纯函数只依赖入参只通过返回值输出和有副作用的函数写文件、改全局变量、发网络请求、打印日志。Python不强制区分但写代码的人心里最好有条线。纯函数的好处是可测试、可缓存、可并行。给定相同输入永远得到相同输出这种函数写单元测试就是一行断言不需要mock任何东西。而带副作用的函数测试成本高得多你得准备临时目录、假的服务端、清理数据库。我的实际做法是核心业务计算尽量写成纯函数副作用集中在最外层。比如一个订单折扣计算把根据用户等级和商品类型算出折后价写成纯函数把读数据库拿用户等级把结果写回订单表放在调用它的那层。这样折扣规则变了只需要改纯函数并跑它的测试数据库换了只改外层。混在一起写的话验证一个折扣规则可能要连数据库一起起。这条原则也不是绝对的。有些操作天然带副作用硬要拆成纯函数反而绕。判断标准是如果拆开之后中间产生了一堆只为了传参的临时数据结构说明拆过头了。2. 参数机制的核心细节与实操要点参数是函数里最容易出问题的地方也是面试和代码review里高频出现的点。这一节把几个必须弄清楚的机制拆开讲都是我在实际代码里见过、或者自己写错过的东西。2.1 默认参数的可变对象陷阱这是Python最经典的坑没有之一def append_item(item, bucket[]): bucket.append(item) return bucket print(append_item(1)) # [1] print(append_item(2)) # [1, 2] - 不是 [2]原因是默认值只在函数定义时求值一次之后存在append_item.__defaults__里被反复复用。列表是可变对象append改的就是这个共享的默认值。同样的坑还有def f(cache{})、def f(conncreate_connection())——后者更隐蔽函数一定义就建立了连接后面所有调用共用同一个连接对象并发下必然出问题。正确写法是默认值用None占位函数体内创建def append_item(item, bucketNone): if bucket is None: bucket [] bucket.append(item) return bucket这里要强调的是用is None而不是 None或者not bucket。not bucket会把空列表、空字符串、0都当成没传语义就变了 None在某些自定义类重载了__eq__之后会返回意外结果。注意不是只有列表字典会中招自定义类的实例、datetime、正则编译结果都一样。判断标准是这个默认值是不是不可变对象。int、str、float、bool、None、tuple且元素都不可变、frozenset这些当默认值是安全的。还有一种情况值得单独提默认值是函数调用比如def f(nowdatetime.now())。这不会每次都取当前时间而是取定义时刻的时间之后永远不变。想要每次调用取当前时间只能在函数体里取。2.2*args和**kwargs到底该怎么用*args收集多余的位置参数成元组**kwargs收集多余的关键字参数成字典。名字里的args和kwargs只是惯例*things、**options完全合法但为了可读性别乱改。它们最常见的正确用途是包装和透传。写装饰器的时候你不可能预知被装饰函数有什么参数只能def wrapper(*args, **kwargs)接住再原样转发。写中间层适配代码时也一样。最常见的错误用途是把函数签名做得过于模糊。见过这样的代码def process(*args, **kwargs): # 里面翻 kwargs.get(mode)、kwargs.get(level)...调用方根本不知道要传什么只能去看源码。这种写法把一个本来可以靠签名自解释的接口变成了黑盒。**kwargs适合透传不适合当参数清单用。另一个细节是*args和**kwargs的收集行为。如果你在函数定义里写了def f(a, *args, b1, **kwargs)调用f(1, 2, 3, b5, c6)之后a1args(2, 3)b5kwargs{c: 6}。注意b被关键字参数吃掉了不会进kwargs。这个规则的完整顺序是位置参数 → 默认参数 →*args→ 仅限关键字参数 →**kwargs。顺带说一个性能相关的点**kwargs会构造一个新字典在每秒调用几十万次的热路径上是有成本的。但别为了这点开销牺牲可读性除非你真的测出来它是瓶颈。我做过一次profile一个被调用两百万次的函数把**kwargs改成固定三个参数整体耗时降了不到4%。为了4%把接口写死不值。2.3 仅限关键字参数与仅限位置参数Python 3.0 引入了仅限关键字参数用单独的*做分隔def transfer(amount, *, from_account, to_account, dry_runFalse): ...调用时from_account和to_account必须写成关键字。这个设计解决了什么问题防止参数顺序传错。转账场景里把from_account和to_account写反后果是灾难性的而两个参数都是字符串位置传错了Python不会报任何错。强制关键字之后写反的概率归零。我现在的习惯是凡是同类型、相邻、语义容易混淆的参数一律用*隔开强制关键字。比如copy(src, dst)、move(old_path, new_path)、compare(expected, actual)这类函数被位置调用时几乎没法从代码上看出谁是谁。Python 3.8 又加了仅限位置参数用/做分隔def pow(base, exp, /, *, modNone): .../前面的参数只能用位置传。这个主要给标准库和底层API用目的是让参数名变成实现细节以后能自由改名而不破坏兼容性。日常业务代码里用得少但你读len()、range()的文档或者看某些库的签名时会遇到比如dict.get(key, defaultNone, /)知道这个语法就不会一头雾水。2.4 调用时的星号解包定义端的*是收集调用端的*是展开两者是相反动作def add(a, b, c): return a b c nums [1, 2, 3] print(add(*nums)) # 6 params {a: 1, b: 2, c: 3} print(add(**params)) # 6这个能力在实际代码里非常好用。比如你要把一整个配置字典传给一个函数而函数只关心其中几个键配合仅限关键字参数就很自然。再比如日志里拼接参数def call_with_log(func, *args, **kwargs): print(f调用 {func.__name__}, 位置参数{args}, 关键字参数{kwargs}) return func(*args, **kwargs)有个小技巧print(*items, sep\n)比print(\n.join(map(str, items)))简洁尤其在元素类型混杂、不想先转字符串的时候。但如果元素数量极大join的内存表现会更好因为print的*展开会先生成一个元组。还有一个容易忽略的*解包的次数和位置。add(*[1, 2], 3)是合法的等价于add(1, 2, 3)add(1, *[2, 3])也合法。但同一个参数不能既位置传又关键字传否则报got multiple values for argument。这个错误在动态调用的代码里很常见第5节会讲怎么定位。3. 作用域、闭包与装饰器把函数当一等公民用起来参数玩熟了下一个台阶是作用域和闭包。很多这段代码明明看着对跑起来结果不对的问题根子都在这里。装饰器是闭包最主流的应用用好了能大幅减少重复代码用不好会让人查半天不知道逻辑在哪。3.1 LEGB规则与nonlocal、globalPython查变量名的顺序是LEGBLocal函数内部→ Enclosing外层嵌套函数→ Global模块级→ Builtin内置。这个顺序决定了同名变量谁优先。最常见的坑是UnboundLocalErrorcount 0 def increase(): count count 1 # UnboundLocalError increase()原因是只要函数体内出现对count的赋值语句Python在编译阶段就把count标记为局部变量整个函数体内它都是局部的。count 1执行时局部变量还没赋值报错。这不是bug是设计——如果不这样任何函数里随手写个赋值都可能意外改掉全局变量那才是灾难。要改全局变量得显式声明global count要改外层嵌套函数的变量得用nonlocal count。我的建议是业务代码里尽量不写global。需要跨函数共享的状态用参数传、用类实例保存、用模块级容器比如一个字典承载都比global可控。global一旦写多了函数就不再是看签名就能懂的东西因为它偷偷读写外部状态。nonlocal的场景更窄一些基本只出现在闭包和装饰器里。比如一个计数器工厂def make_counter(): n 0 def counter(): nonlocal n n 1 return n return counter这里必须用nonlocal否则n 1会触发和上面一样的UnboundLocalError。如果只需要读不需要写比如return n则不需要nonlocal直接读外层变量是允许的。3.2 闭包的内存视图与晚绑定问题闭包指的是内层函数引用了外层函数的变量并且外层函数返回了内层函数。返回之后外层函数的局部变量并没有销毁而是被内层函数抱住了这是Python垃圾回收机制里的引用计数在起作用。理解这一点就能理解一个经典陷阱——晚绑定funcs [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2]结果全是2而不是0、1、2。原因是这三个lambda闭包捕获的是变量i本身而不是i当时的值。循环结束后i是2调用时才去读读到的自然是2。修复方式有两种。第一种用默认参数绑定当前值[lambda ii: i for i in range(3)]利用默认参数在定义时求值的特性。第二种用functools.partial语义更清晰from functools import partial def make_func(i): return partial(lambda x: x, i)同样的问题在信号槽、回调注册、异步任务提交里都出现过。曾经排查过一个异步任务批次错乱的问题根子就是提交任务时用闭包捕获了循环变量最后所有任务都用了最后一条数据。提示凡是循环里创建函数并延迟执行先怀疑晚绑定。如果循环变量是对象引用问题会更隐蔽——你以为捕获的是值其实捕获的是引用对象后续被修改了闭包读到的也是改后的。3.3 装饰器从能用到好用装饰器本质是一个接收函数、返回函数的可调用对象。最简形态import functools import time def timed(func): functools.wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() try: return func(*args, **kwargs) finally: cost time.perf_counter() - start print(f{func.__name__} 耗时 {cost * 1000:.2f} ms) return wrapper这里有三个细节必须注意。第一functools.wraps不能省。不加的话被装饰函数的__name__会变成wrapper__doc__会丢日志、调试、自动生成文档全乱套而且inspect.signature拿不到原始签名。第二try/finally而不是简单的顺序执行。如果被装饰函数抛异常finally保证耗时照样打印出来这对排查慢异常路径很关键。第三用time.perf_counter()而不是time.time()。前者是单调时钟不受系统时间调整影响。带参数的装饰器要多套一层def retry(times3, delay0.5, exceptions(Exception,)): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): last_err None for attempt in range(times): try: return func(*args, **kwargs) except exceptions as err: last_err err if attempt times - 1: time.sleep(delay) raise last_err return wrapper return decorator retry(times3, delay1.0, exceptions(TimeoutError,)) def fetch_data(url): ...这个重试装饰器我改过好几版才稳定。最初的版本没区分异常类型任何异常都重试结果代码逻辑错误也会重试三次白白拉长故障时间。后来限定只对网络超时类异常重试。又发现固定延迟在服务端限流时不合适于是加了指数退避的选项。真正上过生产的工具函数细节都是在故障里补出来的。再提醒一个装饰器会改变函数的调用栈和性能特征。给一个每请求调用上千次的函数加装饰器要评估一下wrapper那一层的开销。很轻的装饰器比如只做一次属性检查影响可以忽略带锁、带日志格式化、带JSON序列化的就未必了。3.4 装饰器的三个高频落地场景除了计时和重试实际项目里用得最多的还有缓存。functools.lru_cache直接可用from functools import lru_cache lru_cache(maxsize1024) def parse_config_text(text): ...用它之前必须确认三件事参数必须是可哈希的list、dict、set不行得先转tuple或字符串函数必须是纯函数有副作用的话被缓存会出大问题maxsize要设不设等于无限缓存长时间运行的服务会内存泄漏。需要主动清理时调用parse_config_text.cache_clear()策略变更之后不清缓存会读到旧结果——这个坑我在配置热更新时踩过。第二个场景是权限校验。把当前用户有没有权限执行这个操作抽成装饰器业务函数里就不用重复写判断也避免了漏写。第三个场景是统一异常转换。比如把底层的数据库异常统一包装成业务异常让上层只需要catch一种类型。这类装饰器要注意别把原始堆栈吃掉用raise BusinessError(...) from err保留__cause__排查时还能顺着链条看到根因。4. 从能写到写得好函数层面的工程化落地到这一层语法已经不是问题了难的是判断力什么时候该拆函数、注解怎么写才有用、递归和迭代怎么选、性能该优化在哪。这些判断没有标准答案但有一些可参照的经验线。4.1 函数粒度怎么判断该拆了一个流传很广的经验是函数不超过一屏。这个标准粗糙但有用因为屏幕大小本质上对应了人的短期记忆容量。更可操作的判断标准我认为有三个。第一函数名里出现了和。validate_and_save_user这种名字说明它承担了两个职责应该拆成validate_user和save_user再用一个编排函数串起来。第二函数体里出现了多个抽象层级。比如同一个函数里既有遍历列表、累加计数这种细节又有生成报告并发送这种业务动作层级混杂会让人读起来跳跃。第三改动时经常要改同一个函数的不同部分。如果两次不相关的需求都动到了同一个函数说明它在两个方向上都在变化该分开了。反过来说拆得太碎也有代价。见过把三行代码拆成三个函数的代码库读一个流程要在七八个文件之间跳转goto definition按到手酸。我的经验线是一个函数干一件事这件事能用一句话说清楚且这句话里没有然后。4.2 类型注解与文档字符串写给三个月后的自己类型注解在运行时不会强制校验但它带来的价值远超给IDE看from typing import Optional def find_user(user_id: int) - Optional[dict]: ...首先它让函数签名自解释。- Optional[dict]直接告诉调用方可能返回None调用方就会记得判空而不用去翻实现。其次配合静态检查工具能在提交前发现一批类型错误。第三它让重构变安全改参数类型时检查工具会指出所有受影响的调用点。有几个实践细节值得说。参数注解写具体类型别图省事写AnyAny等于没注解。可空的地方一定要用Optional或者X | None明确表达。返回类型哪怕函数只是return None也写上空着容易被误读成忘了写。注解里别放有副作用的表达式因为注解在实际运行中可能被求值。文档字符串的重点不是写多少而是写对位置。我的习惯是至少写三段一句话说明做什么、参数说明、异常说明。返回值如果从名字看不出来也写一句。业务规则、边界条件、为什么这么设计这些才是最有价值的内容——代码本身已经说明了怎么做注释应该回答为什么。4.3 递归、生成器、lambda的取舍递归在Python里要谨慎。CPython没有尾递归优化默认递归深度限制是1000层超过就抛RecursionError。处理树形结构、嵌套JSON这类天然递归的数据用递归最直观但处理线性结构的累加用循环明显更合适。真要递归处理深层结构要么改成显式栈的迭代写法要么评估数据深度上限。生成器是处理大数据集的利器。yield让函数变成惰性求值一次只产出一个元素内存占用跟数据总量无关def read_lines(path): with open(path, encodingutf-8) as f: for line in f: yield line.rstrip(\n)这个函数处理10GB文件也不会把内存撑爆因为文件是逐行读的。如果写成return f.readlines()几GB的文件直接OOM。生成器还有个好处是能表达无限序列比如轮询任务。代价是它只能遍历一次用完就空需要重复使用的话得重新创建或者先list()物化——但物化就失去了惰性的意义。lambda的限制很明确只能写单个表达式不能有语句不能有注解。适合当key、当短小的回调、当一次性传参。我的态度是lambda超过一行能写完就该换成def。给一个复杂表达式起个名字比在sorted(items, keylambda x: ...)里塞一段看不懂的逻辑要好得多。4.4 性能函数调用到底贵不贵Python函数调用开销大这个说法流传很广但需要分场景看。现代Python版本对简单函数调用做了不少优化一次调用的开销在纳秒到微秒级。真正的问题不在于单次开销而在于在热循环里做了不必要的工作。常见的可控优化点有三个。第一循环内不要重复做能移到外面的计算。比如for item in items: if item.key in build_key_set():build_key_set()每次循环都重建集合移到外面性能可能差出几十倍。第二循环内避免属性链式查找。for x in data: obj.config.settings.timeout会重复走属性解析可以先t obj.config.settings.timeout绑定到局部变量。局部变量查找走的是快速路径比属性访问快。第三考虑用内置函数替代显式循环。sum、any、all、max、min、sorted这些是C层实现比Python层循环快代码也更短。但必须强调先测量再优化。没有profile数据的性能优化基本靠猜而猜错的概率极高。我见过为了性能把清晰的三行函数改成一行复杂生成器表达式结果跑分没变化可读性掉了一截。用cProfile或者timeit跑一遍往往发现瓶颈在IO或者某个第三方库调用跟你的函数写法毫无关系。5. 常见问题与排查实录这一节是我这些年攒下来的错误速查表加上几个印象深刻的排查过程。多数问题其实都有固定的排查路径关键是认识错误信息在说什么。5.1 函数相关报错速查报错信息真实原因处理方式missing 1 required positional argument少传了必填参数类方法忘了self检查调用点参数个数确认方法定义有selftakes 0 positional arguments but 1 was given实例方法定义时漏了self补上self或改用staticmethodgot an unexpected keyword argument关键字名写错或者目标函数不接受该参数核对签名注意**kwargs是否被透传got multiple values for argument x同一参数既按位置传又按关键字传统一成一种传法常见于*args与关键字混用UnboundLocalError函数内对变量赋值导致它被视为局部变量加nonlocal/global或改用别的变量名non-default argument follows default argument参数顺序违规有默认值的在前无默认值的在后调整顺序或用*分隔positional argument follows keyword argument调用时关键字参数后面又跟了位置参数全部位置参数放前面或全用关键字RecursionError: maximum recursion depth exceeded递归层数超过限制或递归没有正确终止条件检查终止条件改迭代或评估深度合理性表格里最需要展开的是UnboundLocalError。这个错误的迷惑性在于变量明明在模块顶层定义了。判断方法很简单看函数体内有没有对同名变量赋值有就是它。修复不一定要用global多数情况下把变量改成参数传进来、或者换个局部变量名反而更清晰。另外got multiple values在动态调用里特别难查。比如obj.method(*args, **kwargs)args里已经包含了本该由关键字传的那个参数。定位办法是把args和kwargs在调用前打出来一眼就能看出重复。5.2 三个印象深刻的排查过程第一个是函数返回值莫名其妙变了。某次上线后发现一个计算价格纯函数的返回值跟测试环境不一致同样的输入算出不同的折扣。查了很久发现函数内部引用了一个模块级的配置字典而另一个初始化函数在启动时根据环境修改了这个字典。函数本身没变但它的隐式输入变了。教训是函数的依赖如果在签名里看不出来它就不是纯函数。后来把所有隐式依赖都改成显式参数传进来这类问题再没出现过。第二个是日志里函数名全是wrapper。一个项目里给十几个函数加了装饰器出问题查日志时发现所有耗时记录都显示wrapper完全分不清是哪个函数慢。回头补functools.wraps(func)才恢复。更麻烦的是inspect.signature拿不到原始参数某个依赖签名自省的框架直接报错。这件事之后我把装饰器必须加wraps写进了团队review清单。第三个是缓存导致的诡异数据。给一个配置解析函数加了lru_cache本意是避免重复解析。结果配置热更新之后某些请求还是走旧配置。原因很直接缓存没清。修复方式是在配置更新的入口显式调用cache_clear()并且把缓存装饰器只加在真正无副作用、参数可哈希的函数上。还要注意被缓存函数的参数是字典时压根没法缓存——TypeError: unhashable type: dict必须先把字典转成排序后的元组或者JSON字符串。5.3 几条不成文但很有用的经验函数超过五十行就强制自己重读一遍问三个问题能不能靠返回值替代修改外部状态有没有参数只是为了串联中间结果异常路径有没有被遗漏这三个问题能发现大部分设计问题。参数校验放在函数开头别散落在中间。这样读代码的人一眼就知道哪些输入是不被接受的也避免了校验之前就产生了副作用。公共函数更应该校验if not isinstance(x, int): raise TypeError(...)这种代码看着啰嗦但在被几十处调用之后它会替你省下大量排查时间。不要用可变对象做函数属性的命名空间。见过func.cache {}这种写法虽然可行但一旦并发访问就出问题而且没有任何文档提示这个函数有状态。需要状态的场景老老实实写类。还有一点关于异常处理except Exception: pass是函数里最危险的写法之一。它会把参数传错、类型不匹配、拼写错误全部吞掉让bug变成偶尔结果不对这种更难查的形态。要捕获就捕获明确的类型捕获了就得做点什么——记录、转换、或者重新抛出。最后分享一个我自己一直在用的习惯。每次写完一个稍微复杂的函数我会立刻在下面写三五行调用示例用最典型的输入和最极端的输入各跑一遍注释掉或者放进if __name__ __main__:里。这个动作花不了一分钟但能当场抓住参数顺序错、返回值类型不符、空输入没处理这几类问题。等真出了线上故障再回头补成本就是几十倍了。