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(conn=create_connection())——后者更隐蔽,函数一定义就建立了连接,后面所有调用共用同一个连接对象,并发下必然出问题。
正确写法是默认值用None占位,函数体内创建:
def append_item(item, bucket=None): 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(now=datetime.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, b=1, **kwargs),调用f(1, 2, 3, b=5, c=6)之后,a=1,args=(2, 3),b=5,kwargs={"c": 6}。注意b被关键字参数吃掉了,不会进kwargs。这个规则的完整顺序是:位置参数 → 默认参数 →*args→ 仅限关键字参数 →**kwargs。
顺带说一个性能相关的点:**kwargs会构造一个新字典,在每秒调用几十万次的热路径上是有成本的。但别为了这点开销牺牲可读性,除非你真的测出来它是瓶颈。我做过一次profile,一个被调用两百万次的函数,把**kwargs改成固定三个参数,整体耗时降了不到4%。为了4%把接口写死,不值。
2.3 仅限关键字参数与仅限位置参数
Python 3.0 引入了仅限关键字参数,用单独的*做分隔:
def transfer(amount, *, from_account, to_account, dry_run=False): ...调用时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, /, *, mod=None): .../前面的参数只能用位置传。这个主要给标准库和底层API用,目的是让参数名变成实现细节,以后能自由改名而不破坏兼容性。日常业务代码里用得少,但你读len()、range()的文档或者看某些库的签名时会遇到,比如dict.get(key, default=None, /),知道这个语法就不会一头雾水。
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、global
Python查变量名的顺序是LEGB:Local(函数内部)→ Enclosing(外层嵌套函数)→ Global(模块级)→ Builtin(内置)。这个顺序决定了同名变量谁优先。
最常见的坑是UnboundLocalError:
count = 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 i=i: 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(times=3, delay=0.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(times=3, delay=1.0, exceptions=(TimeoutError,)) def fetch_data(url): ...这个重试装饰器我改过好几版才稳定。最初的版本没区分异常类型,任何异常都重试,结果代码逻辑错误也会重试三次,白白拉长故障时间。后来限定只对网络超时类异常重试。又发现固定延迟在服务端限流时不合适,于是加了指数退避的选项。真正上过生产的工具函数,细节都是在故障里补出来的。
再提醒一个:装饰器会改变函数的调用栈和性能特征。给一个每请求调用上千次的函数加装饰器,要评估一下wrapper那一层的开销。很轻的装饰器(比如只做一次属性检查)影响可以忽略,带锁、带日志格式化、带JSON序列化的就未必了。
3.4 装饰器的三个高频落地场景
除了计时和重试,实际项目里用得最多的还有缓存。functools.lru_cache直接可用:
from functools import lru_cache @lru_cache(maxsize=1024) 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",调用方就会记得判空,而不用去翻实现。其次,配合静态检查工具能在提交前发现一批类型错误。第三,它让重构变安全,改参数类型时检查工具会指出所有受影响的调用点。
有几个实践细节值得说。参数注解写具体类型,别图省事写Any,Any等于没注解。可空的地方一定要用Optional或者X | None明确表达。返回类型哪怕函数只是return None也写上,空着容易被误读成"忘了写"。注解里别放有副作用的表达式,因为注解在实际运行中可能被求值。
文档字符串的重点不是写多少,而是写对位置。我的习惯是至少写三段:一句话说明做什么、参数说明、异常说明。返回值如果从名字看不出来,也写一句。业务规则、边界条件、为什么这么设计,这些才是最有价值的内容——代码本身已经说明了"怎么做",注释应该回答"为什么"。
4.3 递归、生成器、lambda的取舍
递归在Python里要谨慎。CPython没有尾递归优化,默认递归深度限制是1000层,超过就抛RecursionError。处理树形结构、嵌套JSON这类天然递归的数据,用递归最直观;但处理线性结构的累加,用循环明显更合适。真要递归处理深层结构,要么改成显式栈的迭代写法,要么评估数据深度上限。
生成器是处理大数据集的利器。yield让函数变成惰性求值,一次只产出一个元素,内存占用跟数据总量无关:
def read_lines(path): with open(path, encoding="utf-8") as f: for line in f: yield line.rstrip("\n")这个函数处理10GB文件也不会把内存撑爆,因为文件是逐行读的。如果写成return f.readlines(),几GB的文件直接OOM。生成器还有个好处是能表达无限序列,比如轮询任务。代价是它只能遍历一次,用完就空,需要重复使用的话得重新创建或者先list()物化——但物化就失去了惰性的意义。
lambda的限制很明确:只能写单个表达式,不能有语句,不能有注解。适合当key、当短小的回调、当一次性传参。我的态度是:lambda超过一行能写完就该换成def。给一个复杂表达式起个名字,比在sorted(items, key=lambda 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 | 检查调用点参数个数;确认方法定义有self |
takes 0 positional arguments but 1 was given | 实例方法定义时漏了self | 补上self,或改用@staticmethod |
got 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__":里。这个动作花不了一分钟,但能当场抓住参数顺序错、返回值类型不符、空输入没处理这几类问题。等真出了线上故障再回头补,成本就是几十倍了。