写代码写到第6章基本都会撞上“函数”这道坎。前面几章无非是变量、判断、循环写出来的脚本像一条直线从上跑到下能出结果就算赢。等到代码量过了两三百行同样的校验逻辑被复制到第五个地方改一个字段名要全局搜索十几处这时候才意识到Python函数不是为了考试才存在的语法糖它是把一条直线折成一张网的第一根折痕。我见过的多数问题包括参数默认值踩的坑、闭包里变量取到的值不对、装饰器把函数名字改没了都不是“不会写函数”而是“把函数当成代码块没把它当成对象和契约”。这篇内容我会从参数、作用域、返回值、装饰器、递归到调试拆分把自己和身边人实际踩过的坑按顺序摊开讲适合刚学完基础语法、正准备动手写第一个稍大一点项目的人也适合写到中途突然觉得代码开始失控、想回头补课的人。读完你至少能判断什么样的重复值得抽函数、参数该怎么设计才不给未来的自己挖坑、为什么有些bug只有把函数拆开才看得见。1. 函数不是把代码装进盒子它是控制复杂度的最小单位1.1 从一段被我删掉的重复代码说起前阵子接手一个内部用的小工具两千多行代码塞在一个脚本里其中有个用户输入校验的段落连续出现了六次每次长十来行判断字段是否为空、长度是否超限、编号是否符合预期格式。六段代码几乎一模一样唯一区别是第二段多了一句把结果写进日志。改需求的时候那位作者改了其中四处漏了两处于是同样一条非法输入从A入口进会被拒绝从B入口进就直接写进了存储层。这种问题不是水平问题是函数没有被当作隔离带使用。抽函数这件事真正的价值不在于少打几行字而在于制造一道边界边界外面的人只关心“给我输入我还你一个结果”边界里面的实现怎么改、日志怎么写、异常怎么转外面的人不需要知道。六段重复代码抽成一个validate_user_payload(payload)改需求的时候只需要动一处漏改的可能性直接归零。这就是我常说的函数的第一职责是收敛变化点把最容易变的那部分圈起来。但反过来说我见过另一种极端有人把每个if判断都抽成一个只有一行return的函数整个文件变成四十个小函数互相调用读起来的跳转成本比原来还高。函数的粒度应该由“变化的可能性”决定而不是由“代码行数”决定。一段十行的代码如果半年都不会改重复两次也没什么一段三行的代码如果被六个入口依赖那就必须抽。1.2 语法块、对象、命名空间函数的三副面孔多数教程讲函数只讲第一副面孔一个可以传参、可以返回的语法块。真正决定你能不能写好Python函数的是另外两副。第一副是对象。在Python里def语句执行完函数名就绑定到一个函数对象上跟a 1绑定整数没有任何本质区别。这意味着函数可以赋值给变量、放进列表、当参数传给另一个函数、作为返回值返回。装饰器、回调、策略表这些东西之所以能成立全靠这一条。很多人看到装饰器觉得玄其实就是“函数当参数、函数当返回值”两个动作叠在一起。第二副是命名空间。函数体被调用时才产生一个局部命名空间函数返回后这个空间就销毁。函数里的变量默认不会污染外面这既是优点也是坑的来源——你在函数里对一个外层列表做了append外层确实变了因为改的是对象但你在函数里写x x 1外层的x纹丝不动因为只是重新绑定了一个新名字。这两件事看起来像一回事实际差得远第3节会展开。def touch_list(lst): lst.append(1) # 改的是外部那个列表对象本身 def rebind_number(n): n n 1 # 只是把局部名字n重新指向了一个新整数 data [] num 0 touch_list(data) rebind_number(num) print(data, num) # [1] 01.3 什么时候该抽函数什么时候抽了更糟我的判断标准很土但用了很多年当你需要给这段代码写注释解释“它在干什么”的时候这段代码就该有一个名字了。注释是对名字的替代如果这段逻辑有一个准确的名字那就用函数名承担说明职责别让注释散落在正文里。反过来以下几种情况我倾向于不抽或者晚抽只在一个地方用、且逻辑与调用点强耦合、拆开后需要传五六个参数才能工作。这种“参数搬运”说明拆错了位置边界没找对。纯粹为了减少行数而抽比如把print包一层。这种分层没有带来任何信息量读的人还要多点一次跳转。抽出来的函数名字叫process_data、handle_info、do_check这类——名字模糊说明你自己也没想清楚它的职责这种函数迟早会变成新的意大利面。一个实用原则函数名如果是动词加名词且能一句话说清它输入什么、输出什么、副作用是什么才算合格的名字。如果一句话说不完多半是职责太多。真正值得抽的通常是这几种输入校验、格式转换、与外部交互读写文件、发请求、以及那些你已经在脑子里默认“这块是独立的”的逻辑。前三种是变化最频繁的地方最后一种是认知最清晰的地方。2. 参数设计默认值、可变对象与那些星号2.1 默认参数在 def 执行的那一刻就求值了这是Python函数参数里最容易被忽略的机制。很多人的直觉是“每次调用时重新算一遍默认值”实际上默认值是在函数定义被执行时求值一次之后一直复用那个对象。对不可变对象数字、字符串、元组、None没什么影响对可变对象就是灾难。import time def log(msg, tstime.time()): print(f[{ts:.0f}] {msg}) time.sleep(1) log(第一条) time.sleep(1) log(第二条)两条日志的时间戳一模一样。因为time.time()在定义log的时候就算完了后续所有调用都在用这个旧值。想要每次调用取当前时间必须写成tsNone然后在函数体里判断。这个坑和可变默认参数是同一个根源只是表现形式更隐蔽因为它不报错只是数据不对。2.2 可变默认参数每个人都该亲手踩一次的坑def add_item(item, bucket[]): bucket.append(item) return bucket print(add_item(a)) # [a] print(add_item(b)) # [a, b] 期望是 [b]原因很直接[]这个列表在def执行时创建了一次挂在函数对象上所有没用第二个参数的调用共享它。默认值实际上存在add_item.__defaults__里打印一下就看得见。正确写法是双段式这也是PEP 8推荐的惯用法def add_item(item, bucketNone): if bucket is None: bucket [] bucket.append(item) return bucket注意这里必须是is None而不是if not bucket。如果调用方传进来一个空列表not bucket为真你会新建一个列表调用方传进来的那个就不会被修改行为反了。这种“看起来能跑、边界情况下错”的写法比直接报错更难排查。写法是否安全说明def f(x, a[])否所有调用共享同一个列表def f(x, a{})否字典同理且更隐蔽def f(x, aNone) 判空重建是推荐写法def f(x, a())是元组不可变但无法追加元素用途有限def f(x, afrozenset())是需要集合语义时的安全选择有一点值得补充如果你确实想用共享状态做缓存那用可变默认参数反而是个技巧但必须写注释说明这是故意的并且给它一个好名字比如_cache{}。没有注释的共享状态在代码评审里一律按bug处理。2.3 *args 和 **kwargs 什么时候真的需要两个星号很多人学完就忘了或者反过来走到哪都用。它们真正的适用场景其实很窄包装或转发。你要在调用某个已有函数前后加点东西计时、日志、重试但你不想关心它到底收几个参数。这时候def wrapper(*args, **kwargs)是唯一不破坏兼容性的写法。参数数量本身是开放的。比如一个批量执行函数run_all(*tasks)一个配置合并函数merge(**overrides)。给装饰器传参。def retry(times3, delay1.0)返回一个真正的装饰器这层结构必须用嵌套函数。不适合的场景也很明确如果你明知道函数只接收name和age就别写def create(**kwargs)。这种写法会让IDE的补全、类型检查、以及调用方对参数的理解全部失效出错只能在运行时暴露。我评审代码时看到这种签名第一反应是问“你到底想接收什么”。还有一个细节*args拿到的是元组**kwargs拿到的是字典而且顺序在Python 3.7是有保证的按传入顺序。如果你需要把kwargs原样传给下一个函数直接用func(**kwargs)解包不要自己拼。2.4 斜杠和星号把调用方式锁死Python 3.8 之后函数签名里可以出现/和*用来限定参数只能按位置传、或只能按关键字传。def transfer(amount, /, account, *, memo): ...这里的含义是amount只能按位置传account位置或关键字都行memo只能用关键字传。这个语法看起来古怪实际解决的是参数名不能随便改的问题——如果一个参数被设计成仅位置你就可以放心把它改名为amt而不担心有调用方在用amount传参。标准库和很多三方库里大量使用这个技巧做演进。日常写业务代码我的建议是关键参数尽量只允许关键字传递用*隔开。像send_mail(to, subject, body, cc, bcc, retry)这种签名调用时写成send_mail(a, b, c, True, False, 3)半年后没人看得懂。改成def send_mail(to, subject, body, *, ccNone, bccNone, retry3)调用方必须写retry3可读性立刻不一样。3. 作用域与闭包变量到底被谁改了3.1 LEGB 不是背诵口诀是查找顺序局部Local、外层函数Enclosing、全局Global、内置Built-in这个顺序背起来容易用起来出问题的地方在于Python在编译函数体时就决定了每个名字是局部还是全局不是运行时才判断。count 0 def bump(): print(count) # 这里会直接抛 UnboundLocalError count count 1因为函数体里出现了count ...这个赋值编译器把count标记为局部变量。于是第一行print(count)去找局部变量发现还没赋值直接报错。这个错误信息叫UnboundLocalError: local variable count referenced before assignment几乎每个学函数的人都撞过而且看错误信息完全猜不到原因。记住一句话就够了函数里只要对某个名字做过赋值它就是局部的除非你显式声明 global 或 nonlocal。3.2 闭包保存的是绑定不是值闭包指的是内层函数引用了外层函数的局部变量并且外层函数返回后这些变量依然存活。这个概念本身不难难的是它的一个推论闭包捕获的是变量本身不是捕获那一刻的值。变量后来变了闭包里读到的也变。funcs [lambda: i for i in range(3)] print([f() for f in funcs]) # [2, 2, 2]三个lambda都捕获同一个i循环结束时i是2。修复方式有两种第一种是借助默认参数在定义时求值funcs [lambda ii: i for i in range(3)] # [0, 1, 2]第二种是用functools.partial可读性更好一些from functools import partial def make(i): return i funcs [partial(make, i) for i in range(3)]同一个机制在按钮回调、事件绑定、异步任务提交里反复出现症状都是“所有回调都用了最后一个值”。认出这个模式比记住修复方法更重要。顺带说一句闭包在Python里是有实际用处的不是炫技。典型场景是做带状态的计数器或者带配置的函数工厂def make_counter(start0): n start def counter(): nonlocal n n 1 return n return counter这里的nonlocal是必须的否则n 1会让n变成内层函数的局部变量又触发 UnboundLocalError。3.3 global 和 nonlocal 该用到什么程度我的态度比较保守global在业务代码里基本不该出现它意味着你要从一个函数深处改全局状态这种耦合迟早会咬人。真要共享状态用参数传、用返回值接、或者用类封装都比改全局变量好。nonlocal的情况要好一些因为它限制在嵌套函数之间影响范围可控前面那个计数器就是合理的用例。但同样一旦嵌套超过两层nonlocal的追踪成本就上来了我一般会把状态改成一个小的类或者dataclasses实例。经验判断如果你需要写global才能让函数工作先别写回头看看这个变量是不是该作为参数传进去或者该作为返回值带出来。90%的情况能改掉。模块级别的常量全大写那种读是可以的改就免了。4. 返回值设计None、异常还是哨兵值4.1 多返回值是元组解包前先想清楚return a, b看起来像返回两个值实际是返回一个元组。这带来一个常见问题调用方写x, y get_pair()如果函数在某条分支上只return None解包就会抛TypeError: cannot unpack non-iterable NoneType object。这类错误通常出现在异常分支跑正常流程永远测不出来。我的做法是要么所有分支都返回相同结构的元组要么干脆不返回元组。如果某条分支实在没结果返回(None, None)而不是None至少解包不会炸。还有一个语法细节常被误解def f(): return 这个字符串永远不会被返回 def g(): return ( 括号里的内容会被返回 )return后面换行语句就结束了下面的东西是死代码。而括号里的内容可以跨行因为括号构成了隐式的续行。写多返回值时长元组拆行记得用括号包起来。4.2 什么情况下该抛异常而不是返回 None这是函数设计里最影响长期维护的一个决定。我的判断标准只有一条“查不到”和“出错了”是两种不同的事。查询类操作查不到是正常业务结果返回None或空集合。比如find_user(uid)找不到用户None合理。参数不合法、前置条件不满足、外部依赖失败这些属于异常直接raise。千万不要用返回-1、返回ERROR、返回False来表达“出错了”。调用方永远会忘记判断而raise至少会在出错的地方炸掉不会带着错误数据继续往下跑。哨兵值sentinel是第三种选择适用于“None也是一个合法返回值”的场景_MISSING object() def lookup(store, key): if key in store: return store[key] return _MISSING value lookup(data, name) if value is not _MISSING: ...用object()创建一个独一无二的标记比用None或者字符串__missing__安全得多因为不可能有业务数据恰好等于它。4.3 函数签名本身就是文档一个函数对外暴露的信息除了名字就是签名参数名、默认值、类型注解、返回类型。我见过很多函数注释写了五行签名却是def handle(a, b, cNone, dFalse)读者还得读函数体才知道dTrue是什么意思。类型注解不是必须的但在稍大的项目里投入产出比很高from typing import Optional, Sequence from dataclasses import dataclass dataclass class Result: ok: bool message: str items: Sequence[str] () def parse_records(raw: str, *, strict: bool False) - Result: ...这里的注解有三个作用编辑器的补全和跳转能用上调用方不看实现就知道传什么strict前面有个*调用时必须写名字不会被误传。这三条加起来比写十行注释都有用。5. 把函数当对象用装饰器、高阶函数与回调5.1 函数是对象的四个具体表现这句话被说滥了落到实际操作上到底是哪四条第一函数可以赋值给另一个名字alias original两者指向同一个函数对象。第二函数可以作为参数传给另一个函数sorted(items, keylen)里的key就是。第三函数可以作为返回值这是闭包和装饰器的基础。第四函数可以有属性func.tag v2是合法的虽然少用。理解这四条之后很多“黑魔法”就变得平淡了。比如策略表def by_name(item): return item[name] def by_score(item): return -item[score] STRATEGIES { name: by_name, score: by_score, } sorter STRATEGIES.get(sort_key, by_name) items.sort(keysorter)这比写一长串 if/elif 好维护得多新增排序方式只要往字典里加一行不需要改调用逻辑。缺点是如果某个策略需要额外参数比如升序降序纯函数就不够用了需要换成functools.partial或者小对象。5.2 手写一个装饰器以及为什么必须加 wraps装饰器的本质是“接收函数、返回新函数”。最简版本import time def timing(func): def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) cost time.perf_counter() - start print(f{func.__name__} 耗时 {cost*1000:.1f} ms) return result return wrapper timing def slow_job(n): time.sleep(n) return n这段能跑但有个副作用slow_job.__name__变成了wrapper__doc__也没了。用functools.wraps修一下from functools import wraps def timing(func): wraps(func) def wrapper(*args, **kwargs): ... return wrapperwraps(func)会把原函数的__name__、__doc__、__module__、__qualname__、__annotations__和__dict__复制到新函数上。不写这个日志里的函数名全是 wrapper调试时根本分不清是哪个函数打的。带参数的装饰器要多包一层这是很多人第一次写会绕晕的地方def retry(times3, delay0.5): def decorator(func): wraps(func) def wrapper(*args, **kwargs): last None for _ in range(times): try: return func(*args, **kwargs) except Exception as e: last e time.sleep(delay) raise last return wrapper return decorator三层结构记法最外层收装饰器自己的参数中间层收被装饰的函数最内层是真正的替换函数。写的时候数一下括号层级基本不会错。注意装饰器堆叠时执行顺序是“离函数近的先包离函数远的先执行外层逻辑”。两个装饰器写反了行为可能完全不同尤其是涉及异常处理和计时的组合。5.3 lambda 和 map/filter 的适用边界lambda唯一的正当用途是逻辑只有一行、并且只在这里用一次。典型的就是sorted(keylambda x: x[1])、max(items, keylambda r: r.score)。超过一行、或者需要命名就老老实实写def起一个有意义的名字。map和filter在Python里其实不如列表推导式好用因为推导式能直接表达条件、能做嵌套、可读性更好nums [1, 2, 3, 4, 5] # 不推荐 list(map(lambda n: n * 2, filter(lambda n: n % 2 0, nums))) # 推荐 [n * 2 for n in nums if n % 2 0]map真正有价值的地方是配合已有函数用不用写lambdalist(map(str.strip, lines))、list(map(int, parts))。这时候它比推导式短也不损失可读性。至于filter我基本不用[x for x in xs if cond(x)]就够了。6. 递归、调试与把函数拆开排查6.1 递归的两个必要条件与深度限制递归写错无非两种忘了终止条件无限循环直到栈溢出或者终止条件写错结果偏了一位。Python 默认的递归深度限制是 1000可以用sys.getrecursionlimit()查但我不建议随便调大——Python 没有尾递归优化每一层调用都真实占栈帧调到一万层基本就是赌机器不死。def flatten(items): out [] for it in items: if isinstance(it, list): out.extend(flatten(it)) else: out.append(it) return out print(flatten([1, [2, [3, [4]]], 5])) # [1, 2, 3, 4, 5]这个例子里终止条件是“遇到非列表元素就直接收进结果”递归条件是“遇到列表就下去一层”。写递归的时候我习惯先写终止条件再写递归调用顺序反了很容易漏。如果数据层级可能很深比如解析嵌套很深的配置用显式栈的迭代版本更稳def flatten_iter(items): stack list(reversed(items)) out [] while stack: cur stack.pop() if isinstance(cur, list): stack.extend(reversed(cur)) else: out.append(cur) return out或者用生成器加yield from写法更简洁也天然带惰性求值。6.2 用函数拆分把 bug 逼到角落里排查问题的效率很大程度取决于函数切得够不够细。一个两百行的函数出了错你只能一行行读切成八个二十行的函数你可以写个临时脚本单独调用中间那一个输入一份构造好的数据直接看它返回什么。我的排查顺序一般是先确认输入。在函数入口打印或者用breakpoint()停下看参数到底长什么样。很多“逻辑错误”其实是上游传了个空列表或者字符串。再看返回值。在每个return前面打印一次确认走到的是哪条分支。同一个函数有五个return报错时你根本不知道从哪出去的。如果中间状态复杂把可疑的那一段临时抽成独立函数单独测。这一步很关键耦合在长函数里的逻辑无法隔离验证。最后才去怀疑库和外部依赖。顺序反了会浪费大量时间。顺带推荐一个习惯函数里尽量只做一件事尤其不要“边计算边写文件”。把副作用挪到调用方函数本身保持纯输入输出测试成本会低一个数量级。6.3 一组函数相关的报错对照下面这些错误信息学函数这章的时候基本都会撞一遍。能读懂它们调错速度会快很多。报错信息典型原因处理方向NameError: name x is not defined变量没定义、拼写错误、或者定义在另一个函数里检查作用域确认变量在当前位置可见UnboundLocalError函数内某处对变量赋值导致编译器判定为局部变量加global/nonlocal或改名避开TypeError: f() missing 1 required positional argument少传了必填参数检查签名和调用处必要时给默认值TypeError: f() got an unexpected keyword argument关键字参数名拼错或该参数是仅位置的对照def行核对名字TypeError: f() takes 2 positional arguments but 3 were given多传了位置参数通常是把关键字参数写成了位置形式给后面的参数加名字TypeError: cannot unpack non-iterable NoneType object函数某条分支返回了 None调用方按元组解包统一各分支返回结构RecursionError: maximum recursion depth exceeded缺终止条件或递归分支没有收敛补基础情形或改成迭代SyntaxError: non-default argument follows default argument有默认值的参数写在了无默认值参数前面调整参数顺序把带默认值的放后面最后一行那个错误值得单独说一句def f(a1, b)是语法错误但def f(a, b1)没问题。这条规则和调用时的“位置参数必须在关键字参数前”是同源的记住“有默认值的往后靠”就不会错。把这几节的内容串起来看函数这块真正的难点从来不是语法。def、return、*args、装饰器符号这些半小时就能背完。真正花时间的是那些需要写够代码量才会浮现的问题默认值被共享、闭包捕获到最后一个值、异常分支返回了 None 导致解包炸掉、长函数里根本定位不到出错的那一行。我自己写代码这些年最有用的两个习惯都和函数有关——一个是给每个函数写清楚它的输入输出契约宁可在签名上多花五分钟另一个是看到重复就抽但抽之前先问自己“这段逻辑以后会一起变吗”。这两个习惯帮我省下的排查时间比学任何高级语法都多。
阅读完成 · 觉得有帮助?