ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python函数进阶:从对象、参数到装饰器与实战踩坑

Python函数进阶:从对象、参数到装饰器与实战踩坑 1. 为什么学了几天还写不好函数先理解“函数即对象”前几天收到一条留言“我按教程写了def hello(): print(hi)然后hello()调用是调用了但为什么要包一层函数直接写 print 不香吗”这个问题问得很实在。函数在最直观的层面是“把一段代码装进盒子里起个名字随时调用”它可以避免复制粘贴、方便改一处全局生效。但在 Python 里这件事比大多数编程语言更彻底——函数本身是对象你能对整数和列表做的操作几乎都能对函数做把它赋值给变量放进列表作为参数传给另一个函数再作为返回值从函数里蹦出来。这一特性决定了 Python 里大量高级玩法装饰器、回调、猴子补丁的底层逻辑。当你写下def时Python 解释器做的事情相当于创建一个 function 类型的对象然后把名字绑定到它上面。验证方式很简单def greet(name): 简单的问候函数 return f你好{name} print(type(greet)) # class function print(greet.__name__) # greet print(greet.__doc__) # 简单的问候函数甚至可以用lambda创造一个没有名字的函数对象随即赋值给变量行为和def几乎一样。有人说“Python 的函数声明”和“JavaScript 的函数声明”很像因为两者都把函数提升到了“一等公民”的地位。但差别也有Python 的lambda只能容纳单个表达式不能写语句JavaScript 的箭头函数虽然也是表达式却在写法上支持块体。下面这段代码是很多人搞混的“箭头函数写法”在 Python 里对应的是lambda x: x * 2double lambda x: x * 2 print(double(21)) # 42不过我个人的建议是能用 def 就不用 lambda除非那个函数真的短到一眼能看完。lambda 的好处是省几行代码坏处是报错时栈信息里显示lambda调试体验很一般。曾有一次我维护项目一长串 map(lambda...) 套 filter(lambda...)排查数据异常时只能挨个拆开打印痛快不起来。后来改成具名函数加文档字符串虽然行数多了但每个环节都能单独测试。既然函数是对象它自然也可以作为参数传递。标准库里到处是这个套路sort(keylen)里的 key 就是一个函数对象functools.reduce(func, iterable)里 func 也是一个函数对象。理解到这一层你再看高阶函数、装饰器就不会觉得“这是魔法”它只是“把函数当普通数据来用”。2. 参数传递默认参数的坑、*args/**kwargs、位置关键字参数新手最容易在参数上翻车而且翻得莫名其妙。先记一条结论Python 的参数传递既不是纯值传递也不是纯引用传递而是“对象引用传递”。传给函数的是对象引用但引用能不能修改到原对象取决于对象本身可变还是不可变。整数、字符串、元组是不可变对象你在函数里重新赋值只会让局部变量指向一个新的对象外面的变量纹丝不动列表、字典是可变对象你在函数里调用append、pop、update这类原地修改的方法外面的数据就会被改动。很多教程会画“传值 vs 传引用”对比图其实只要记住一条判断准则看你在函数里是做“赋值”还是做“修改”。对形参赋值永远影响不到外部变量对可变对象调用修改方法则会影响外部。想清楚这条很多诡异 bug 都能避免。2.1 默认参数别用可变对象某同学写了这个函数第二天发现列表里的数据越攒越多def add_item(item, container[]): container.append(item) return container第一次调用add_item(a)返回[a]第二次再调就变成[a, b]。原因在于默认参数[]在函数定义时只创建一次之后所有没有传入 container 的调用共用同一个列表。正确写法是def add_item(item, containerNone): if container is None: container [] container.append(item) return container这个坑太经典了面试如果问“Python 的坑”十有八九会提到它。我在实际项目里见过比这更隐蔽的版本有人在类方法里用了data{}做默认参数多个实例之间共享同一份字典某天突然发现订单数据串了排查了半天才意识到根本不是并发问题而是默认参数共享。2.2 *args 和 **kwargs 到底在收集什么星号不是在“传递参数”而是在“收集参数”。*args把位置参数打包成一个元组**kwargs把关键字参数打包成一个字典。比如def log(level, *messages, **meta): print(level, messages, meta) log(INFO, 启动成功, 耗时 1s, useralice, envprod) # INFO (启动成功, 耗时 1s) {user: alice, env: prod}反过来调用时也可以加星号把列表或字典拆开data [3, 7, 2] print(max(*data)) # 等价于 max(3, 7, 2) params {sep: -, end: !\n} print(a, b, **params) # a-b!这套“打包/拆包”机制让函数接口变得非常灵活比如写一个装饰器时你根本不知道目标函数到底接收几个参数直接def wrapper(*args, **kwargs)就能原样转发。但要注意**kwargs经常让人把接口设计得太随意。如果一个函数需要接收五六个不确定参数大概率说明函数职责过宽至少应该拆开或改用显式的参数名。2.3 用 / 和 * 限制参数的用法Python 3.8 之后可以在参数里写/和*来声明参数的传递方式。/左边只允许按位置传*右边只允许按关键字传def calc(a, b, /, op, *, verboseFalse): ...calc(1, 2, add)合法calc(1, 2, add, verboseTrue)合法但calc(a1, b2, add)会直接报错。为什么要这么麻烦因为有些库的作者希望保留参数名的改动空间只要用户按位置传未来想给参数改名也不影响兼容性。Python 标准库里到处都能看到类似的签名你写自定义 API 时也可以这样设计。不过普通业务代码里不用太纠结知道有这个语法读源码时不懵就够了。3. 作用域与闭包函数操作外部变量到底怎么才顺手“我写了count 1为什么函数跑完 count 还是 0”这是个出现频率极高的提问。Python 查找变量时遵循LEGB 规则先局部Local再外层函数Enclosing再全局Global最后内置Built-in。函数内部的count 1会被解释器理解成“先读 count再写 count”而在局部作用域里没定义 count于是直接报UnboundLocalError而不是去全局找。要让函数修改全局变量得用global声明要修改外层函数的变量通常在闭包里得用nonlocal。光讲规则很干来看一个现实中常见的“计数器闭包”例子def make_counter(): count 0 def increment(step1): nonlocal count count step return count return increment c make_counter() print(c()) # 1 print(c(5)) # 6increment捕获了外部函数make_counter里的count即使make_counter早就返回了这个变量依然活在闭包里。这有点像给函数随身带了一个“小背包”每次调用都能读到上次的状态。Python 世界里很多框架都用了这个套路比如 Flask 的app.route装饰器、Tkinter 的按钮回调背后的机制都跟闭包有关。闭包有个容易踩的坑叫“延迟绑定”。下面这个代码好多人期待输出[0, 1, 2]实际输出[2, 2, 2]funcs [] for i in range(3): funcs.append(lambda: i) print([f() for f in funcs]) # [2, 2, 2]原因是循环结束后i这个变量还活着三个闭包引用的都是同一个i取值时它已经变成 2。解决办法是两个一是给 lambda 加默认参数lambda ii: i二是改用闭包捕获def make(x): return lambda: x funcs [make(i) for i in range(3)]我在实际代码里很少直接写一堆 lambda一般用列表推导配合具名函数就是为了避开这种“看着是闭包其实是共享变量”的错觉。作用域问题还经常出现在类方法和嵌套函数里。如果你在函数里看到一个global x基本上是在告诉读代码的人这里有一个跨作用域共享的可变状态。能用参数传递、返回值或类属性表达逻辑就尽量别用 global因为全局变量会让函数失去“只靠输入输出就能推理”的能力。测试的时候全局状态还会让用例之间互相污染排查起来非常辛苦。4. 递归、回调与“李白打酒”函数在真实问题里的拆法函数除了“把流程封装起来”还有一种用法是“把自己调自己”也就是递归。很多人一听说递归就头皮发麻但递归解决的是“问题里套着同一种问题”的场景。一个经典的入门题是李白打酒李白出门壶里有一些酒遇到酒店就翻倍遇到花就喝掉一斗经过“店、花、店、花、店、花”三次之后壶中酒刚好喝光问原来有多少酒。这道题正向想很绕反向推却很简单最后一次是遇到花喝完为 0所以见到花之前是 1 斗再往前遇到店店前是现在的一半……用递归写特别自然def reverse_drink(events, wine): if not events: return wine last events[-1] if last 花: return reverse_drink(events[:-1], wine 1) else: # 店 return reverse_drink(events[:-1], wine / 2) events [店, 花, 店, 花, 店, 花] print(reverse_drink(events, 0)) # 0.875递归的写法往往能把复杂的循环逻辑压缩成一个“当前状况 下一步转移”的模型。写递归时你要想清楚三件事终止条件是什么、当前层做什么、子问题怎么缩小。对这道题终止条件是事件序列为空当前层根据最后一个事件反推上一轮的酒量子问题就是去掉最后一个事件。4.1 递归不是越多越好Python 默认递归深度上限大约是 1000 层sys.getrecursionlimit()可以查看一旦超过就会抛RecursionError。我在处理树形菜单、目录结构时经常用递归但遇到可能很深的数据会先考虑改成循环加栈。比如用显式栈做深度优先遍历代码稍微长一点却不受递归深度限制也不容易爆栈。还有一个常见误区是以为“尾递归”更高级其实 Python 解释器并不对尾递归做优化你写成尾递归它照样占栈所以别指望靠这个技巧突破深度限制。4.2 回调函数把“干什么”交给调用方回调函数也是函数对象的一种使用方式常见于事件驱动、异步和框架扩展。简单说函数 A 在合适的时机调用函数 B但 A 不写死 B 的内容而是由调用者把 B 传进来。比如你封装一个批量任务处理器def process_all(items, on_item, on_errorNone): results [] for item in items: try: results.append(on_item(item)) except Exception as e: if on_error: on_error(item, e) return results调用方可以传不同的处理函数复用同一套循环和异常处理逻辑。怎么传比如def square(x): return x * x def report_error(item, e): print(f处理 {item} 时出错{e}) process_all([1, 2, -3, 4], square, report_error)Python 新手可能以为回调是旧时代的习俗其实不然像sorted(key...)、threading.Thread(target...)、asyncio的事件循环底层都在玩同一个概念。理解回调的关键还是那句话函数是对象传参时不带括号被调用时才带括号。5. 内置函数、函数式三板斧和高阶思路搜“Python 函数”搜索栏里往往还会出现“内置函数大全”“函数的作用”这类词。内置函数是 Python 解释器自带的不用导入就能直接用其中有一批跟函数式编程关系特别近map、filter、reduce、sorted、zip、enumerate、any、all。很多人一上来就喜欢用列表推导式替代 map/filter这没有错但理解这些函数背后的思想仍然值得。拿reduce举例它来自functools功能是把序列折叠成一个值from functools import reduce total reduce(lambda acc, x: acc x, [1, 2, 3, 4], 0) print(total) # 10map和filter都是惰性求值的返回的不是列表而是迭代器。配合list()才能看到具体结果。把这个特性和生成器结合起来可以处理超大文件而不需要一次性载入内存words map(str.strip, open(access.log, encodingutf-8, errorsignore)) long_lines filter(lambda line: len(line) 200, words) for line in long_lines: # 逐行处理不把整个文件读进内存 ...5.1 用 zip 同时遍历几个列表写代码时经常遇到“两个列表里对应位置的数据要放在一起处理”最朴素的做法是for i in range(len(names))但更清晰的写法是for name, score in zip(names, scores)。zip会像拉链一样把多个可迭代对象按对应位置组合成元组。如果列表长度不一致默认按最短的截断想严格报错Python 3.10 之后可以用zip(..., strictTrue)。加上enumerate可以同时拿到序号和数据这在生成报表、遍历二维数组、构建邻接矩阵这类任务里非常常用。5.2 内置函数实战批量清洗数据举一个更像真实业务的例子。假设有一串原始数据包含空字符串和带空格的值raw [ apple, , banana , None, cherry] cleaned [s.strip() for s in raw if s and s.strip()]这里的列表推导式比filter(lambda x: x and x.strip(), map(str.strip, raw))更直观。我的经验是列表推导式适合可读性优先的小数据map/filter 适合已经有函数对象、追求操作连贯性的场景没有谁绝对更优。语言特性是工具箱哪个顺手用哪个别为了“函数式”而函数式。内置函数其实也是一张“安全网”。比如isinstance在类型判断时比type更保险因为它支持继承关系getattr(obj, attr, default)可以安全地拿属性any和all在判断列表里是否存在满足条件的元素时比手写循环清爽得多。这些东西看起来零碎但组合起来能大大减少代码里的重复样板。6. 装饰器与生成器从“函数升级”到“框架级思维”到了这一节你已经不是在“用函数”而是在“加工函数”。装饰器的本质就是一个函数接收另一个函数返回一个新函数在中间插入额外逻辑。最常见的需求是给函数记日志、算耗时、做权限校验。6.1 动手写一个计时装饰器import time from functools import wraps def timer(func): wraps(func) def wrapper(*args, **kwargs): start time.perf_counter() result func(*args, **kwargs) elapsed time.perf_counter() - start print(f{func.__name__} 耗时 {elapsed:.4f} 秒) return result return wrapper timer def heavy_job(n): return sum(range(n)) heavy_job(1_000_000)注意那个wraps(func)新手常常会漏掉。如果不加装饰后的heavy_job.__name__会变成wrapper在调试、文档生成、测试框架里都会暴露。wraps会把原始函数的__name__、__doc__等元信息拷贝过来这个细节最能体现你是否真正写过装饰器。装饰器还可以带参数比如制定日志级别做法是在外层再套一层函数def log_with(level): def decorator(func): wraps(func) def wrapper(*args, **kwargs): result func(*args, **kwargs) print(f[{level}] {func.__name__} - {result}) return result return wrapper return decorator log_with(INFO) def add(a, b): return a b第一次读这种“三层嵌套”代码确实有点晕但只要记住最外层接收参数中间层接收函数最内层接收真正的运行时参数就不容易迷路。6.2 生成器让函数“变成”数据流带yield的函数就是生成器函数。调用它不会立刻执行函数体而是返回一个生成器对象每次next()或for循环取值时函数从上次yield的地方继续执行。很多人把yield理解成“return 多次”这个说法不算错但更准确的理解是函数在暂停和恢复之间反复切换而每次暂停时的局部状态都被保留。def fibonacci(limit): a, b 0, 1 while a limit: yield a a, b b, a b for n in fibonacci(100): print(n, end ) # 0 1 1 2 3 5 8 13 21 34 55 89生成器真正的价值是“惰性”它不一次性把所有结果放进内存而是用多少取多少。我处理过几个 GB 的日志靠的就是生成器一层层过滤内存占用稳定在几十 MB。如果一开始就readlines()全部读进来机器直接卡死。这里有个容易混的点普通函数里写yield之后return就不能再返回值了return只能用来结束生成器否则会报SyntaxError。另外一个生成器对象只能迭代一次想重复遍历就得再调用一次生成器函数这在写测试用例时很容易踩到。6.3 async def 算不算函数搜索热词里还经常出现“Python 协程”。async def定义的也是函数但调用它不会执行函数体而是返回一个 coroutine 对象。这个对象必须通过await或事件循环驱动才会真正运行。如果用普通def习惯去调用 async 函数经常会打出coroutine was never awaited的警告。我的建议是没打算写并发代码就先别碰 async把同步函数、装饰器、生成器吃透收益已经很大。7. 踩坑实录那些和“函数”纠缠不清的环境、命名与心智错误最后一节专门聊坑。有几个问题在搜索里高频出现虽然严格说不是“函数写错”但报错信息里偏偏带着“函数”两个字最容易把新手带偏。比如在 Windows 的 PowerShell 里输pip install requests经常弹出一句无法将“pip”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。看到“函数”两个字就以为是自己函数定义有问题其实这是环境变量的问题意思是系统在当前路径下找不到 pip 命令。解决方法通常是两条检查 Python 安装时有没有勾选 Add to PATH或者直接用完整命令python -m pip install requests来跑后者更稳还能避免多个 Python 版本互相抢 pip 的混乱。这个“无法识别”的报错和make、pnpm这类命令找不到本质上是一类问题都不是函数语法层面的事。7.1 命名撞车你的文件别叫 random.py另一个坑是“函数名和内建模块重名”。比如你写了一个random.py放在自己项目里然后import random导入的就不再是标准库的 random而是你自己的文件。此时你调用random.randint(1, 10)如果没有定义randint就会报AttributeError。Python 搜索模块时优先当前目录所以自己项目里任何和标准库重名的文件都会造成替换效果。我给团队同学检查代码时见过把文件命名为json.py、utils.py的都有后者还好前者立刻把标准库干掉了。还有一个常见但隐蔽的问题在函数里用了和外层变量同名的局部变量导致读时序错位。比如items [1, 2, 3] def show(): print(items) # 这里想读全局 items items [4, 5, 6] # 但下面又赋值了于是上面的 items 被解释为局部变量运行会报UnboundLocalError因为 Python 在编译函数时发现items被赋值就把它划为局部变量连前面的print(items)也不会去全局找。想避免这种情况要么改名要么在函数开头声明global items但更好的策略是别在函数里依赖这种“先读后写”的全局变量改成参数传进去。7.2 调试技巧别让 print 成为唯一的日志函数逻辑越来越复杂后print到处打点会淹没信息。我常用的做法是一开始就引入标准的logging模块给不同模块起名字输出带上时间和模块名排查问题时grep一下就定位到了。Python 3.7 之后还有个内置的breakpoint()可以直接进入 PDB 交互调试环境。在函数里放一行breakpoint()运行到那里就会停下来你可以输入变量名查看当前值、单步执行这是 print 完全给不了的体验。我还想提醒一点函数返回值如果忘记写returnPython 会默认返回None。这在链式逻辑里特别容易出问题。比如def get_firstname(user): if user: name user.split()[0] # 忘了 return调用方拿到的是None进一步调用name.lower()就报AttributeError。这种问题不报在函数内部而是报在使用函数的地方排查时绕一大圈才发现是少了个 return所以写完函数先自己打印一次返回值比百般推测都高效。7.3 心智建设函数越小越好但别为了小而小踩过这么多坑之后我最大的经验是函数的第一目的是让人能读懂第二才是让机器能复用。一个动辄上百行的函数就算每一步都对也几乎没法测试和改动。反过来把逻辑拆得七零八落也不是好事。我一般遵循一个朴素标准如果这个函数能用一句话说清楚“输入是什么、输出是什么、做了什么事”那它的职责就是清晰的。给函数起名时动词开头会更容易理解比如get_user_by_id、calculate_total、send_notification。写函数时也别过早优化。我看过有人为了少写两行在函数里塞了五个条件分支结果测试用例怎么都补不齐也有同事坚持每个函数不超过五行最后代码跳来跳去翻页翻到怀疑人生。平衡点靠实践拿捏先把逻辑写直白再考虑浓缩。真到了函数报错时你能一眼看出是哪一层的问题那才是真正把函数用明白了。
返回列表