ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Python函数完全指南:从封装到作用域,解决重复代码与变量管理

Python函数完全指南:从封装到作用域,解决重复代码与变量管理 1. 函数到底解决了什么问题从一段重复代码说起先聊点带新人的体会。这两年我陆陆续续带过一些从零开始学Python的朋友发现大多数人并不是卡在语法本身而是卡在一个更基础的问题上不明白函数这东西到底有什么用。很多人学def的时候觉得哦就是把一段代码包起来但真到自己写项目时几十行脚本平铺下来改一个需求要翻三个地方最后干脆复制粘贴改一处漏一处。最典型的场景就是数据处理。几个朋友最初写爬虫时代码长这样price_text response.find(span, class_price).text price float(price_text.replace(¥, ).replace(,, )) if price 100: print(贵了)这段逻辑如果只在页面上查一次价格没问题。但爬虫通常要爬几十个商品同样的清洗代码就得复制几十遍。等改需求把货币符号从¥换成USD你就得全局搜索替换稍不留神就漏掉一处。这种事情我当年干过不止一次每次都在心里骂自己当初为什么不写个函数。1.1 一段逻辑出现了两次以上就有必要封装这里没有很悬的理论判断标准非常朴素一段逻辑只要出现两次以上就有必要考虑封装成函数。封装之后调用方只需要关心输入输出不关心中间过程。就好比你用洗衣机只需要把衣服放进去、按开始不需要知道里面的电机怎么转、水位传感器怎么判断水位。封装成函数之后上面的价格清洗可以写成这样def parse_price(text): return float(text.replace(¥, ).replace(,, )) price parse_price(response.find(span, class_price).text)看着很简单但好处是实打实的。以后货币符号变了只需要改parse_price一处的逻辑以后格式判断规则复杂了也只需要在函数内部加代码。调用方一行都不用动。1.2 函数封装的三个直接好处第一个好处是消除重复。代码量减少出错面也随之减少这是最直观的收益。第二个好处是隔离复杂度。函数内部随便你怎么写只要保持参数和返回值稳定外面的代码就不会被影响。写的时候脑子里有个边界这个函数只负责把文本变成浮点数不负责判断价格是否合理。边界划清楚了代码就能拆成一块块独立调试。第三个好处是可测试性。有了函数你可以在交互式环境里单独验证它 parse_price(¥1,299.00) 1299.0 parse_price($12.99)这个时候你就能发现——哦美元符号没处理函数抛了ValueError。如果是平铺的脚本你得把整个爬虫跑起来才能发现这个问题。1.3 什么时候不值得抽函数话又说回来不是所有代码都要抽函数。我见过一些朋友为了抽象而抽象三行代码也要包一层函数结果整个项目里全是转发型函数读起来反而费劲。我的经验是三个判断维度重复次数、逻辑复杂度、变化频率。一段逻辑只出现一次、逻辑只有两行、以后基本不变——这种情况完全没必要抽函数。反过来说一段逻辑会出现多次、内部状态多、需求方经常改——这种情况不抽函数就是给自己埋雷。2. def到return函数从定义到调用的完整执行逻辑很多教程讲函数上来就给你看语法什么def、参数、返回值列一遍看起来都认识但自己写就懵。我后来发现真正的问题在于很多人不理解函数的执行流程。函数不是随便写的一段带名字的代码它在Python里有非常明确的执行机制。2.1 def是一个可执行语句不是声明先纠正一个很常见的误解很多人以为def像C语言里的函数声明一样是告诉编译器我要定义一个函数。实际上Python里的def是一条运行时执行的语句。什么意思解释器执行到def那一行的时候会创建一个函数对象然后把这个对象绑定到def后面的名字上。举个直观的例子这段代码能正常运行if True: def greet(): return hello else: def greet(): return world print(greet()) # hellodef写在if分支里完全合法。因为def就是普通语句解释器执行到哪条就创建哪个函数。同理你可以在函数里面再定义函数这也是后面讲闭包的基础。顺便提一句因为def是执行语句函数对象在定义之后才存在。如果你在def之前去调用这个函数必然会得到NameError。这个顺序问题很多新手踩过坑其实理解了def是运行时语句就不会再犯。2.2 调用的三个步骤压栈、绑定参数、执行函数体函数调用在Python里是这样发生的。当解释器执行到greet(张三)这样的表达式时它会在内存中创建一块新的命名空间也就是局部作用域把实参张三绑定到函数定义里的形参name上在这个新的命名空间里逐行执行函数体代码执行完毕后销毁这个命名空间把返回值如果有的话交给调用方整个过程可以想象成你打开一个便签本在便签本上写函数内部的草稿写完撕下来带走结果便签本丢掉。函数内部建立的变量在函数结束那一刻就没了——这就是局部变量的本质。2.3 return真正的含义结束函数并把值送回调用点return的作用有两层。第一层是立即结束函数的执行。函数体里不管后面还有多少代码只要遇到return函数就结束了。第二层是把值传回调用点。有一点需要注意如果函数体里没有写returnPython会隐式返回None。很多新手发现一个函数打印正常、赋值却是None原因就在这里def add(a, b): result a b # 忘了写 return result total add(1, 2) print(total) # None这是新手最容易踩的坑之一而且不太好排查因为看起来函数内部一切正常。解决方案也简单写函数的时候先想清楚这个函数要对外提供什么结果然后确保所有分支都return了。2.4 函数定义的基本骨架一个完整的函数由四部分组成函数名、参数列表、函数体、返回值。函数名要能表达这段代码做了什么参数列表是外部传入的输入函数体是处理逻辑返回值是对外提供的输出。def 函数名(参数列表): 文档字符串说明这个函数的功能 函数体 return 返回值文档字符串这个东西我强烈建议养成习惯。不用写很长两三行就行说明函数做什么、参数是什么、返回什么。回头过一个月再来看自己的代码你就知道这玩意多值钱了。3. 参数传递的演化从位置参数到关键字参数到*args和**kwargs参数是函数和外部交互的窗口这块学扎实了函数写的才能灵活。很多教程把参数类型一口气列出来看的人一头雾水。我的建议是不要背按需求场景去理解。3.1 最简单的场景位置参数最基础的是位置参数调用时按顺序传值def profile(name, age, city): return f{name}{age}岁来自{city} print(profile(李雷, 25, 北京)) # 李雷25岁来自北京这种方式的缺点是调用时必须记住参数顺序。如果profile定义是(name, city, age)调用方写错顺序程序不一定报错但结果就错了。所以当参数数量多、顺序容易混的时候就用关键字参数print(profile(name李雷, city北京, age25))关键字参数的好处是显式写明每个值的含义且不依赖顺序。我给自己定了个习惯函数参数超过三个调用时一律用关键字参数。这不是强制规范但能减少很多低级错误。3.2 给参数一个默认值但不是所有参数都适合默认值参数的场景很容易理解大部分时候这个参数用一个值偶尔需要改。比如def connect(host, port3306): return f连接 {host}:{port}调用时可以只传hostport用默认值也可以两个都传。但要注意默认值参数必须放在非默认参数后面。def connect(port3306, host)是语法错误因为Python没法判断调用connect(127.0.0.1)时传的是哪个参数。3.3 吃过大亏的坑默认值不要用可变对象这是Python函数定义里最有名的一个坑几乎每个写过一段时间Python的人都撞过。看这个例子def add_item(item, items[]): items.append(item) return items你觉得两次调用会返回什么直觉告诉你是[a]和[b]实际上第二次调用返回的是[a, b] add_item(a) [a] add_item(b) [a, b]原因是默认值是在def语句执行时被创建一次之后所有调用共享同一个列表对象。每次调用append都是在同一个列表上操作。解决方案是习惯性的写法——默认值设为None函数内部再创建def add_item(item, itemsNone): if items is None: items [] items.append(item) return items我现在的规则是任何默认值只要是可变对象list、dict、set一律用None内部创建的写法。这不是强迫症是为了不给自己埋雷。3.4 不确定参数个数怎么办*args和**kwargs有时候你确实不知道调用方会传多少个参数。比如实现一个求和函数def sum_all(*args): return sum(args) print(sum_all(1, 2, 3)) # 6 print(sum_all(1, 2, 3, 4, 5)) # 15*args会把所有传入的位置参数收集成一个元组。**kwargs则收集关键字参数成一个字典def print_options(**kwargs): for key, value in kwargs.items(): print(f{key} {value}) print_options(debugTrue, levelINFO)*args和**kwargs最常见的用法是在封装、扩展已有函数时。比如你想给一个现有的函数加日志但不想改它原本的参数签名就可以用这两个。4. return的秘密返回值的类型、多值返回与提前返回return是函数和外部世界的交接棒。很多函数写不好不是因为逻辑复杂而是不明确这个函数到底对外提供什么。理清返回值函数的设计就顺了一半。4.1 函数返回类型要稳定这一点我想放在最前面强调。写函数时最好保证同一个函数在正常情况下返回的类型是一致的。比如一个解析配置的函数要么返回字典要么返回None表示解析失败不要有时候返回字符串有时候返回列表。返回类型不稳定调用方就得写一堆isinstance判断代码质量直线下降。Python虽然没有静态类型检查但习惯上可以在函数签名里标注返回类型def parse_price(text: str) - float: 把价格文本转成浮点数 return float(text.replace(¥, ).replace(,, ))类型标注不会强制检查但对阅读代码的人帮助极大。看到- float调用方就知道这个函数返回浮点数不用去翻函数体。4.2 返回多个值其实是返回一个元组Python的函数能不能返回多个值语法上可以def get_user(): name 李雷 age 25 return name, age user_name, user_age get_user()看起来像返回了两个值本质上是返回了一个元组(李雷, 25)然后调用方用解包的方式分别赋值。这是一种非常Pythonic的写法适合返回少量、彼此相关的值。但如果要返回的值超过三个我的建议是使用命名元组或字典否则调用方很容易搞混顺序from collections import namedtuple User namedtuple(User, [name, age, email]) def get_user(): return User(李雷, 25, lileiexample.com) user get_user() print(user.name) # 比 user[0] 可读性强多了4.3 提前return防御式编程的利器return不仅仅用来返回结果它还是个控制流工具。当一个函数有很多前置条件时用提前return可以让代码层级从深嵌套变成平铺。举个例子下面是“嵌套地狱”的风格def process_data(data): if data is not None: if len(data) 0: result analyze(data) if result is not None: return result return None用提前return重写def process_data(data): if data is None: return None if len(data) 0: return None result analyze(data) if result is None: return None return result两种写法结果一样但第二种明显更容易读懂和修改。每增加一个条件就加一个ifreturn不需要把新逻辑嵌到已有的缩进里去。提前return让非法路径先走合法路径留在最后这是我很推荐的一种风格。4.4 不写return的函数也能影响外部吗有一种特殊情况函数内部不写return但通过修改传入的可变对象来输出结果。这种用法并不少见比如往传入的list里添加元素def add_tags(tags, new_tag): tags.append(new_tag) my_tags [python] add_tags(my_tags, 函数) print(my_tags) # [python, 函数]函数没有返回任何东西但外部变量被改了。这不算错误但隐式的副作用会让代码难排查——调用方很难看出变量是否被修改。我的建议是如果一个函数会修改传入的可变对象名字里最好体现出来比如add_tag_to_list调用方看到名字就知道它动手了。能通过返回值表达的逻辑尽量用返回值少用副作用。5. 全局还是局部作用域规则如何决定变量的命运函数里能访问哪些变量不能访问哪些变量这个规则不搞清楚写出来的代码就是薛定谔的Bug——看起来偶尔正常偶尔报NameError让人摸不着头脑。5.1 LEGB规则Python查找变量的顺序Python在函数内部查找一个变量时按这个顺序去找局部作用域Local→ 嵌套函数的外层作用域Enclosing→ 全局作用域Global→ 内置作用域Built-in。这就是LEGB规则。举个例子x 全局 # 全局作用域 def outer(): x 外层 # Enclosing作用域 def inner(): x 内层 # Local作用域 print(x) inner() outer() # 内层如果inner里没有定义x它会向外层找外层没有再找全局全局没有就去内置作用域找比如len、print这类内置函数。一路找不到就抛NameError。5.2 一个让很多人懵掉的现象局部变量遮蔽全局变量看这段代码count 10 def show_count(): count 20 print(count) show_count() # 20 print(count) # 10函数内的count 20创建了一个新的局部变量和外面的全局count无关。函数结束后局部count销毁外面的count还是10。这就是“遮蔽”——局部变量把全局变量挡住但只在自己这一亩三分地里挡住。很多初学者会误以为函数内修改全局变量全局就变了其实不然。只有在函数内显式声明global才能修改全局变量count 10 def change_count(): global count count 20 change_count() print(count) # 205.3 UnboundLocalError一个极其迷惑的报错比5.2更坑的是这个场景count 10 def show_count(): print(count) # 报错 count 20这行print(count)会抛UnboundLocalError它不去读全局的10。原因是Python在编译函数的时候发现函数体里有count 20这个赋值语句于是把count标记为局部变量。局部变量在赋值之前使用就是未绑定所以报错。解决方式就是5.2里说的用global声明或者换个局部变量名不要和全局变量重名。这个报错的迷惑性在于它不像NameError那样容易理解很多人会想count不是定义在外面了吗为什么不能用理解了Python按赋值语句判断局部性这个机制就不会再被它绊住了。5.4 nonlocal嵌套函数中修改外层变量global解决的是函数内修改全局变量的问题nonlocal解决的是嵌套函数里修改外层函数局部变量的问题def outer(): total 0 def inner(): nonlocal total total 1 return total return inner counter outer() print(counter()) # 1 print(counter()) # 2这里inner通过nonlocal声明表示total是外层函数的变量允许修改。如果不加nonlocaltotal 1会报UnboundLocalError和5.3是一个道理。nonlocal是学习闭包时的关键一环但也别滥用——嵌套层级太深了代码读起来也费劲。6. 函数也是对象嵌套、传递、lambda和高阶函数进入这个部分函数就不只是执行的代码段了它变成了一种可以操作的数据。这个思维转变很重要很多编程技巧都建立在这个基础之上。6.1 函数是一等公民意味着什么在Python里函数和整数、字符串一样是对象。对这个函数对象你可以赋值给变量、放进列表、作为参数传给另一个函数、作为返回值从函数里出来。def add(a, b): return a b my_func add # 赋值不带括号 print(my_func(3, 5)) # 8不带括号的add引用的是函数对象本身带括号的add(3, 5)是在调用这个函数并得到结果。这个区别是理解函数可以当参数的关键。6.2 高阶函数函数作为参数和返回值接收函数作为参数或者返回函数的函数统称高阶函数。最典型的例子是sorted的key参数students [ {name: 李雷, score: 85}, {name: 韩梅梅, score: 92}, ] students.sort(keylambda s: s[score])这里的key接收一个函数sorted内部会对每个学生调用这个函数用返回值作为排序依据。如果不理解函数可以作为参数传递你就没法理解为什么key这里要写一个函数。自己动手写一个高阶函数也很简单def run_twice(func, value): return func(func(value)) def add_one(x): return x 1 print(run_twice(add_one, 5)) # 76.3 lambda一句话写一个匿名函数lambda是一种简化写法适合函数逻辑非常简单、不需要名字的时候。比如上面sort例子里lambda s: s[score]等价于def get_score(s): return s[score]lambda的语法是lambda 参数: 表达式。注意lambda只能写一行表达式不能写多条语句。如果你发现lambda写得太复杂说明这个场景不该用lambda老老实实def一个具名函数可读性更好。6.4 装饰器高阶函数最经典的应用理解了函数可以作为参数和返回值装饰器就顺理成章了。装饰器的本质是给函数增加额外功能但不修改函数本身的代码def my_decorator(func): def wrapper(*args, **kwargs): print(调用前) result func(*args, **kwargs) print(调用后) return result return wrapper my_decorator def say_hello(): print(你好) say_hello()这里的my_decorator只是语法糖它等价于say_hello my_decorator(say_hello)。装饰器接收原函数返回一个新的wrapper函数原来的say_hello名字被重新绑定到wrapper上。调用say_hello时真正执行的是wrapperwrapper内部再调用原函数。写装饰器的时候wrapper用*args和**kwargs接收所有参数就能兼容任何原函数。这是*args和**kwargs使用频率最高的场景之一。7. 从TypeError到NameError把最常见的函数报错一次讲清最后这部分整理一些我在教学和代码评审中反复看到的函数相关报错。这些错误每个都有人踩过把报错信息和原因记住自己能省不少排查时间。7.1 调用时参数数量不匹配TypeErrordef greet(name, age): print(f{name} {age}) greet(李雷) # TypeError: greet() missing 1 required positional argument: age这个报错最好处理要么补齐参数要么给age一个默认值。还有一种情况是传多了greet(李雷, 25, 北京)报错是takes 2 positional arguments but 3 were given。如果你不确定函数签名可以交互式环境里用help(greet)查看。7.2 UnboundLocalError和NameError作用域问题UnboundLocalError在5.3里已经详细讲过了这里补充一个容易混淆的场景函数内访问了一个不存在的全局变量名。比如def show(): print(name) # NameError: name name is not definedNameError是变量从头到尾就没绑定过UnboundLocalError是变量被标记为局部但赋值之前就用了。两个报错看着像根因完全不同。排查方法先看函数体内有没有同名变量的赋值语句。有就是UnboundLocalError没有就是NameError。7.3 返回值使用不当把打印当返回这个错太常见了我再强调一次def add(a, b): print(a b) # 打印不是返回 result add(1, 2) print(result) # None区别在于print是把结果输出到控制台人能看到return是把结果交还给调用方程序能拿到。函数内部可以用print调试但最终对外提供结果必须用return。如果看到函数算得对但拿到的是None优先检查是不是把print当成return了。7.4 函数调试的三个实用建议最后分享三个我实际调试函数时常用的方法都是笨办法但很有效第一步先单独测。在交互式环境里用一个具体输入调用函数看返回值是否符合预期。不要在完整项目里调试函数那会引入太多干扰变量。第二步检查边界。测试数据不要只挑正常情况多试试空字符串、空列表、None、边界数值。很多函数Bug都是边界条件没处理好。第三步加临时打印。在函数入口打印参数、在关键节点打印中间值、在return前打印结果。定位到问题后再把print删掉。虽然现在有各种调试器但临时print依然是最直接的手段之一。我写函数还有一个习惯每个函数尽量控制在一个屏幕能看完的长度。如果一个函数超过三十行通常意味着它做了好几件事可以考虑拆分成几个更小、更专注的函数。这样每个函数都容易理解、容易测试组合起来的系统反而更稳定。这算是这几年写Python、也带人写Python下来最值钱的一条经验了。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表