我刚开始学Python那会儿,函数练习是我又爱又恨的一块内容。爱的是自己写完一个函数、一行调用出结果,那种成就感真挺上头的;恨的是报错永远比预想来得快,参数漏传、作用域搞混、返回值没有按预期返回,光是看控制台那一串英文提示就能耗掉一晚上。后来靠着大量动手练习和反复拆解别人写的函数,才慢慢把这块硬骨头啃下来。回头看,Python函数练习就是入门路上的一道分水岭,它不只是语法问题,更是思维方式的转折点。这篇文章我想把自己练函数时用过的题目、踩过的坑、总结出来的方法论整理出来,适合刚学完循环和条件判断、正准备啃函数这块内容的朋友,也适合那些学了函数但只会照着抄、一让自己写就发懵的自学者。
1. 函数练习为什么是入门路上的分水岭
1.1 函数在项目中的真实分量
先别把函数当成语法书里的一个章节。任何一个能跑起来的Python项目,往底层拆,最后都会拆成若干个函数。发请求的是一个函数,清洗数据的是一个函数,解析结果的是一个函数,保存文件又是一个函数,主程序不过是在合适的时间把这些函数按顺序叫出来执行。函数就像是机器里的一个个齿轮,单个齿轮设计得好,整台机器才转得动。
我见过不少朋友,列表、字典、字符串操作都玩得挺溜,一到自己写函数就发怵。症状很统一:动手写一个复杂点的逻辑时,所有代码都堆在一个大块里,用几十行print去观察每一步的值,改一个局部功能会带出一串连锁问题。这种情况的根源,就是函数练习没跟上。函数最直接的价值是隔离复杂度,把一个大问题切成一个个小块,每一块用一个函数表达。你不练这个,后面学类、学装饰器、学各种设计模式,全都是空中楼阁。
1.2 练习函数到底在练什么
练习函数看上去是在练语法,实际上是在练三样东西。
首先是语法熟练度。def怎么带参数、参数默认值怎么设置、怎么收集任意多个参数、return在什么位置生效,这些不练到肌肉记忆,写代码的时候脑子就一直卡在语法细节上,根本没有余力去思考逻辑设计。其次是设计思路。拿到一个需求,能不能把它拆成几个小函数,每个函数只做一件事,并且这件事足够简单、足够纯粹,这是区分新手和熟练工的关键。最后是调试能力。函数写出来后运行结果不对,是参数传错、逻辑写错、还是返回值没接住,这个排查过程本身就是在练内功。
记住一个原则:看得懂和写得出来是两码事,写得出来和写得对又是另一码事。很多人看教程时觉得函数很简单,真到自己动手,才发现理解是有错觉的。
2. 动笔之前先理清参数、返回和作用域
2.1 参数体系:位置参数、默认参数、可变参数
参数这块是函数练习里最容易“半懂不懂”的知识点。很多初学者一开始只知道def add(a, b): return a + b,等遇到复杂一点的调用场景就懵了。Python的参数体系其实很清晰,我建议把它当成一张表来记。
def print_info(name, age=18, *hobbies, **others): print(f"姓名:{name},年龄:{age}") if hobbies: print("爱好:", hobbies) if others: print("其他:", others) # 位置参数 print_info("A同学") # 位置参数 + 默认参数覆盖 print_info("B同学", 20) # 加上任意多个爱好 print_info("C同学", 22, "阅读", "跑步") # 再加上关键字参数 print_info("D同学", 23, "编程", 城市="某地")这里name是必填的位置参数,age带默认值可以不传;*hobbies会把调用时多传的位置参数收进一个元组;**others会把多余的关键字参数收进一个字典。这个设计让函数既能处理固定信息,又能处理不确定数量的扩展信息,在真实项目中非常常用。
要特别注意参数的排列顺序:默认参数要放在位置参数后面,*args要放在默认参数后面,**kwargs要放在最后面。顺序写错了,解释器直接报语法错误,这是新手特别容易踩的坑。
2.2 return与None的分界线
函数练习里还有一个很经典的错误,就是搞不清返回值到底是什么。我见过不少朋友写代码,函数里用print把结果打出来,看着控制台有输出,于是放心拿去参与计算,结果拿到一个None,一脸茫然。
def square(x): return x * x def nothing(): # 没有 return,函数默认返回 None pass result = nothing() print(result) # NonePython有个规则需要刻进脑子里:如果一个函数没有return语句,或者return后面没有接任何值,那么函数调用的结果就是None。None不是0也不是空字符串,它是独立的特殊值。有些代码里你看到调用没有报错,但结果始终不对,大概率就是拿到了None,然后又拿None去做了加减法或字符串拼接。
另外要注意,return的作用是结束函数执行,return后面的代码永远不会跑。多返回值实际上是返回一个元组,调用时用a, b = foo()这种写法来拆开,这在练习里很常见。
2.3 作用域和那个高频报错
作用域是函数练习里抽象感比较强的一块,但它又直接影响你能不能写出正确代码。简单来说,函数内部定义的变量,默认是局部变量,出了函数就找不到了;函数内部想修改全局变量,需要加global声明;嵌套函数里想修改外层函数的局部变量,需要加nonlocal声明。
count = 0 def increase(): global count count += 1 increase() print(count) # 1如果代码里去掉global,运行就会报UnboundLocalError: local variable 'count' referenced before assignment。这句报错在练习中出现频率极高,看到它基本就能锁定是作用域问题:你在函数内给某个变量赋值了,但在赋值之前又读取了它,Python认为这个变量是局部的,可它还没被定义。解决办法很简单,要么加global声明,要么把变量放进列表或字典里传进去,要么干脆换个设计思路。
3. 一套递进式函数练习方案,直接照着做
3.1 从热身题到综合题的分层清单
练习函数最怕没有章法,想到什么写什么。我自己练的时候总结了一套分层递进的题目清单,从最简单的热身题开始,逐步增加难度,你可以直接照着做。
第一层是数学小函数,目标是练基本语法。比如写一个函数max_of_two(a, b),返回两个数中较大的;写一个is_even(n),判断奇偶;写一个area_of_circle(r),计算圆面积。这些题目一眼就能看懂,适合用来建立手感。
第二层是字符串处理,目标是练循环和条件的组合。比如count_vowels(s)统计字符串里元音字母的个数,reverse_str(s)返回反转后的字符串。这类题目会逼你处理字符串的每一个字符,是很好的综合练习。
第三层是容器处理,目标是练列表、集合的综合运用。比如unique_list(lst)去掉重复元素且保持原有顺序,flatten(nested_lst)把二维列表打平成一维列表。这里有个很实用的小技巧:集合可以去重但不保序,所以用set记已经出现过的元素,用list维护最终结果顺序。
def unique_list(lst): seen = set() result = [] for item in lst: if item not in seen: seen.add(item) result.append(item) return result print(unique_list([3, 1, 3, 2, 1, 4])) # [3, 1, 2, 4]第四层是综合分析题。比如analyze_text(text),输入一段文本,返回大写字母数、小写字母数、数字数、其他字符数的字典。这种题目把字符串遍历、条件判断、字典操作都串起来了,做完会很有成就感。
3.2 用递归题逼自己跳出循环思维
递归是函数练习里绕不过去的一道坎。很多人第一次接触递归时会觉得它绕,其实递归并不复杂,它的核心规则只有三条:有明确的基线条件让递归停止;有递归表达式把大问题化简成小问题;每次递归都在向基线条件靠近。
def factorial(n): if n <= 1: return 1 return n * factorial(n - 1)这是最经典的阶乘递归。n等于1时返回1,这是基线;否则用n乘以n-1的阶乘,这是递推。逻辑很简单,但它把“函数自己调用自己”这个核心概念讲清楚了。
进阶一点可以试试斐波那契数列。直接写递归版非常简单,但n稍微大一点就会发现运行越来越慢。原因很好理解:同样的子问题被重复计算了无数次。解决方法是加一个缓存。
from functools import lru_cache @lru_cache(maxsize=None) def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) print(fib(50))加了缓存之后,fib(50)计算速度飞快。这个练习能让人直观地理解算法优化中“空间换时间”的思路。另外要记住,Python默认的递归深度上限大约是一千层,递归层数太深会报RecursionError,所以递归练习要选对题目,适合分治类的问题,不适合深度超限的场景。
3.3 用迷你项目练函数拆解
练到后期,只做单独的函数题已经不够过瘾了,可以尝试给自己安排一个迷你项目。我以前练过一个迷你记账本,虽然只有几十行代码,但函数拆解的体验非常到位。
def add_record(records, kind, amount, note): records.append({"kind": kind, "amount": amount, "note": note}) def total_income(records): return sum(r["amount"] for r in records if r["kind"] == "income") def total_expense(records): return sum(r["amount"] for r in records if r["kind"] == "expense") def balance(records): return total_income(records) - total_expense(records) def show_records(records): for r in records: print(f"{r['kind']}:{r['amount']}({r['note']})")主程序里的循环只负责接收输入,然后调用对应函数。每个函数都很短小,功能单一。这样做的好处是,想改打印格式只动show_records,想改统计逻辑只动total_income和total_expense,互不影响。这个练习的真正目的不是记账,而是体会“一个函数只做一件事”这个设计原则在真实场景中的价值。等你练到能很自然地把项目拆成函数,你的组织能力就已经上一个台阶了。
4. 练习中高频出现的错误与排查实录
4.1 参数传递导致的TypeError
函数练习中碰到最多的报错类型就是TypeError,而且报错信息往往已经把答案写在脸上了。
- TypeError: f() missing 1 required positional argument: 'b',意思是调用时少传了必填参数。
- TypeError: f() got an unexpected keyword argument 'b',意思是函数里根本没有这个参数名,很可能是关键字参数拼错了。
遇到这类报错别慌,先把函数定义的参数列表和调用处的参数列表对照一遍,大多数情况下立刻就能定位问题。一个小习惯很有效:写调用代码之前,先在注释里写出函数的签名,也就是函数名、参数、返回值,然后再填调用语句,能少走很多弯路。
4.2 作用域导致的UnboundLocalError
前面提到过的UnboundLocalError是作用域问题的典型症状。它出现时,代码里多半存在“函数内部某个变量既被读取又被赋值”的情况。我一开始看到这个报错也一头雾水,后来才明白,Python在编译函数的时候会扫描整个函数体的赋值语句,只要某个变量名在函数内部被赋值过,它就认定这个变量是局部的,所以函数体前面即使引用了同名全局变量,也会被视为引用未定义的局部变量。
解决思路有两种。如果确实需要修改全局变量,就用global声明;但更好的思路是想办法避免这种依赖,把全局变量的值作为参数传进函数,再把新值用return带出来。后一种方式更清晰,也更利于测试。
4.3 递归与默认参数两类隐蔽坑
递归类错误最常见的就是RecursionError: maximum recursion depth exceeded。这通常意味着基线条件没写对,或者递归过程没有收敛。排查时可以加一条print打印当前参数,观察递归过程是不是在逼近终点。如果无限循环,很快就能看出来。
默认参数的坑更隐蔽。很多初学者写过类似下面这种代码:
def add_item(item, items=[]): items.append(item) return items print(add_item("a")) # ['a'] print(add_item("b")) # ['a', 'b'],这里就出问题了第二次调用时,列表里莫名其妙多出了第一次调用留下的元素。原因是默认参数在函数定义时就被创建了,之后每次调用只要没有显式传items,用的都是同一个列表。正确写法是把默认值设为None,在函数内部创建新列表。
def add_item(item, items=None): if items is None: items = [] items.append(item) return items这类问题在真实项目中出现时特别磨人,因为代码逻辑本身没有错,纯粹是默认参数共享了同一个可变对象。遇到这个坑,赶紧自查一下有没有把列表、字典这类可变对象放在默认参数里。
我把这些问题整理成一个速查表,方便你遇到报错时直接对照。
| 报错信息 | 常见原因 | 排查与解决办法 |
|---|---|---|
| missing positional argument | 调用时少传了必填参数 | 对照函数签名补齐参数 |
| unexpected keyword argument | 写了不存在的参数名 | 检查关键字参数拼写 |
| UnboundLocalError | 函数内变量既被读取又被赋值 | 加global,或改成参数传递加return |
| RecursionError | 递归没有基线或层数太深 | 检查基线条件,改用循环或缓存 |
| 结果全部为None | 函数缺return,或分支没有覆盖全 | 检查每个分支是否都有return |
5. 函数练习之后,我的几点进阶体会
5.1 先写函数签名再写实现
我练到后面发现一个效率提升很大的习惯:动手编码之前,先在纸上写下函数签名,也就是函数名、参数列表、返回值的含义,然后再写实现。这个过程和先画图纸再施工很像,看起来多花了一分钟,实际上能避免大量返工。
比如说,需求是“统计一段文本中每个单词出现的次数”,我会先写下来:函数名count_words,参数text,返回值dict,然后才去写代码。签名一旦清晰了,实现起来思路就顺了。很多新手容易犯的错是拿到需求就直接开写,写到一半发现函数要复用但接口设计不对,又推倒重来。先设计签名,就是最好的预防。
5.2 给函数写docstring和简单测试
写函数的第一行,我用三引号写文档字符串,说明这个函数干什么、参数是什么、返回什么。这个习惯一开始觉得啰嗦,但过一个月再回来看自己的代码,才知道当初的注释有多重要。更进阶一点的做法是给函数写测试用例,用assert语句验证输入输出是否符合预期。
def max_of_two(a, b): """返回两个数中较大的那个""" return a if a > b else b assert max_of_two(1, 2) == 2 assert max_of_two(5, 5) == 5 assert max_of_two(-1, -10) == -1这个练习看起来简单,作用却很大。写测试的过程会逼你重新思考函数的所有边界情况,比如空字符串、负数、重复元素、超大数字。很多函数练习时觉得写对了,一推边界情况就出问题,测试能帮你把这些漏洞挖出来。
5.3 每周挑一个生活场景做函数化改造
函数练习最重要的还是保持手感。我自己有段时间规定自己每周找一个生活场景,用函数去组织代码。比如计算购物小票总价、统计一段文字里的词频、把一堆文件名按照规则重命名。这些场景都不需要很大的代码量,但能持续锻炼“拆解问题”的思维。
做这种练习时,如果发现自己写出的函数超过二十行,就会停下来看看能不能拆得更细。拆不出来的时候,说明对问题的理解还不够充分。这种练习没有标准答案,但每做一次,对函数的理解都会更扎实一些。
说到最后,我想起自己练函数时最后悔的一件事:早期觉得函数练习题太简单,跳过了大量的基础训练,直接去看高级用法,结果基础不牢,后面越学越虚。后来老老实实把基础题刷了一遍,才感觉到什么叫“手顺”。这个内容后续还可以扩展的方向很多,但最核心的还是那句朴素的话:多写、多拆、多测。函数能不能练好,从来不是智商问题,是用功方法的问题。