函数是Python里最值得花时间练透的基础知识点之一,但很多人学到函数就卡住了——定义能看懂,调用也能照抄,一旦脱离教程自己写,就不知道怎么把一段业务逻辑拆成函数,更不知道参数到底该传什么、返回值该怎么接。这篇“Python函数定义与使用练习题”,我按多年写代码的实际经验,把函数拆成定义语法、参数传递、返回值、作用域、递归和综合应用六个层面,每一层都配了可运行的练习和易错点说明,适合刚学完函数语法想巩固的新手,也适合准备面试前快速捡起函数细节的开发者。
1. 为什么函数容易“学了个寂寞”:先搞清楚它到底解决什么问题
很多初学者觉得函数难,其实不是语法难,而是没想清楚函数在代码里到底是干嘛的。我见过太多人写代码是把所有逻辑从头到尾堆在同一个脚本里,一步一步顺序执行,等代码超过两百行就开始晕了——改一个需求要满文件找哪里引用了同一个变量,稍微复杂一点的数据处理逻辑贴着业务代码,想调试都不知道从哪里下手。
函数的核心价值就一句话:把一段可以被重复使用的逻辑封装起来,给它取个名字,需要的时候按名字调用。这句话听起来简单,但真正贯彻到写代码的习惯里,需要反复练习才能形成肌肉记忆。
1.1 函数定义的三段式拆解
先看最基本的定义语法:
def 函数名(参数1, 参数2, ...): 函数体 return 返回值这里有三个关键部分,每一部分都有容易踩的坑:
- def关键字:声明这是一个函数定义,后面必须跟函数名和一对括号,括号里写参数,冒号结尾。很多人漏掉冒号,这是最常见的语法错误。
- 函数体:必须缩进,Python靠缩进识别代码块,同一个函数体内的代码缩进层级必须一致,混用空格和Tab会直接报错。
- return语句:负责把结果返回给调用者。如果函数没有写return,调用后得到的是None,这一点下面会详细展开。
我给你出一道入门级练习,先把这三个部分用实。
练习1:定义一个名为greet的函数,接收一个参数name,函数体内部打印一行欢迎语,不返回任何值。然后调用这个函数三次,分别传入三个不同的名字。
def greet(name): print(f"你好,{name}!欢迎来到Python函数练习。") greet("Alice") greet("Bob") greet("Charlie")这道题考察的是最基本的定义和调用流程。我在实际教学里发现,很多新手会忘记在调用时加括号,写成greet而不是greet("Alice")——不写括号其实不是调用函数,只是把函数对象本身赋值给了一个变量,打印出来会是一段内存地址,这个现象理解了,你就顺便明白了“函数是一等对象”这个概念。
1.2 函数命名的“说的清”原则
函数名不是随便起的,我见过一版代码里出现f(),func2(),test()这种命名,半年后连作者自己都看不懂哪个是干什么的。好的函数名是“动词+名词”结构,能让人一眼看出函数做什么。
| 函数名写法 | 可读性 | 评价 |
|---|---|---|
calc(a, b) | 差 | 计算什么?完全看不出来 |
sum_two_numbers(a, b) | 好 | 加法求和,一目了然 |
handle_data(d) | 差 | 处理什么数据?怎么处理的? |
clean_user_input(raw_str) | 好 | 清洗用户输入,职责明确 |
命名这事看起来跟“练习题”没关系,但函数题做多了,代码风格会直接影响你后续找工作、参与团队协作时的代码审查。我建议从第一道练习开始就养成好习惯——先想这个名字能不能让一个陌生人看懂,再动手写def。
练习2:不用def关键字,直接把下面这段“函数定义”的伪代码翻译成真正的Python语法,并测试调用:
定义一个函数,名字叫calculate_area,接收两个参数:length和width,返回length*width的结果参考答案很简单:
def calculate_area(length, width): return length * width这道题本质上是把中文需求翻译成Python定义。很多新手在这里卡住,是因为把需求里的“接收两个参数”理解成了“必须要有两个输入框”,其实参数就是在调用函数时放进括号里的值。搞清楚了这一步,函数定义的基本语法就过了一关。
2. 参数传递的花样玩法:从必需参数到万能参数
参数是函数练习题里最容易出变化的部分,也是面试常考点。很多初学者只知道一种最基础的写法——调用时顺序传值,但Python的参数机制其实丰富得多:默认参数、关键字参数、可变参数、关键字收集参数,每一种都有真实的应用场景。
2.1 必需参数与默认参数的分工
先看一组对比:
# 必需参数:不传就报错 def create_profile(name, age): return f"姓名:{name},年龄:{age}"# 默认参数:有默认值,调用时可以省略 def create_profile(name, age, city="未知"): return f"姓名:{name},年龄:{age},城市:{city}"第二段代码里,city有了默认值,调用create_profile("Alice", 20)也能正常执行,输出城市为“未知”;如果你想指定城市,可以写create_profile("Alice", 20, city="上海")。这里有个经验之谈:默认参数要放在必需参数后面,否则Python解释器会直接报语法错误。原因是解释器在给参数匹配位置时,如果允许默认参数在必需参数前,调用语句省略了某一个位置参数,后面的参数怎么对齐就无法判断了。
练习3:设计一个打印商品信息的函数print_product_info,参数包括商品名、价格、库存量,其中库存量有默认值0,价格保留两位小数输出。分别测试传全部参数、省略库存量、只传商品名这三种调用方式,观察报错情况,并思考为什么第三种调用方式会报错。
def print_product_info(name, price, stock=0): print(f"商品:{name},价格:{price:.2f}元,库存:{stock}件") print_product_info("手机", 1999.5) print_product_info("手机", 1999.5, 30) print_product_info("手机") # 该行报错:缺少参数price有基础的人秒懂,但新手常常被最后一行误导,以为“库存量有默认值,那是不是商品名一个参数就够了?”其实不是,价格是必需参数,没有默认值,调用时不能不传。这告诉你一个非常实用的小技巧:调试函数报错时,看提示信息里写的是哪个参数missing,再回来看函数定义,就能快速定位问题。
2.2 关键字参数:让调用代码自己解释自己
位置参数有个致命缺点:调用时参数一多,根本没几个人记得第几个位置对应什么意思。比如一个函数接收五个参数,别人调用时得翻函数定义才知道哪个是哪个。关键字参数就是来治这个毛病的:
def export_report(file_name, format, compress=False, notify=True): # 假装导出报表 print(f"导出{file_name},格式{format},压缩={compress},通知={notify}") # 位置参数写法:顺序不能乱 export_report("六月报表", "pdf", True, False) # 关键字参数写法:顺序随便写,可读性极高 export_report(file_name="六月报表", format="pdf", notify=False, compress=True)我在实际项目里强烈推荐多参数函数用关键字参数调用。一个函数如果参数超过三个,后文写调用代码的人根本记不住位置,强行按位置传参就是给自己埋雷。
练习4:定义一个函数register_user(username, email, age, gender="未知"),要求调用时必须使用关键字参数,且不填写gender,最后打印注册信息的完整内容。这道题考察的是“关键字参数+默认参数”的组合使用,代码如下:
def register_user(username, email, age, gender="未知"): print(f"注册成功:用户名={username},邮箱={email},年龄={age},性别={gender}") register_user(username="小明", email="xiaoming@example.com", age=18)这里的易错点是:如果你混合使用位置参数和关键字参数,位置参数必须放在关键字参数前面,否则会抛语法错误。这也是Python函数定义和调用的一个隐含规则,多练几次就记住了。
2.3 *args和**kwargs:处理“不知道会传多少个”的情况
再进阶一层,有时候函数需要接收不定数量的参数。比如写一个求和函数,调用者可能传3个数,也可能传10个数,这时候*args就派上了用场:
def flexible_sum(*args): total = 0 for num in args: total += num return total print(flexible_sum(1, 2, 3)) # 6 print(flexible_sum(1, 2, 3, 4, 5)) # 15*args的本质是:把所有多余的位置参数打包成一个元组传给函数体,你在函数内部可以随便遍历这个元组。同样地,**kwargs把所有多余的关键字参数打包成一个字典传给函数:
def print_config(**kwargs): for key, value in kwargs.items(): print(f"{key} = {value}") print_config(host="10.0.0.1", port=8080, debug=True)练习5:写一个函数make_sentence(*words),接收任意多个字符串,把它们用空格连接成一个完整句子并返回;再写一个函数show_books(**books),接收若干本书名和对应的作者,以“书名——作者”的格式逐行打印。
def make_sentence(*words): return " ".join(words) def show_books(**books): for name, author in books.items(): print(f"{name}——{author}") print(make_sentence("Python", "函数", "练习题")) show_books(三体="刘慈欣", 活着="余华")args和**kwargs这两个名字里的args和kwargs其实只是约定俗成的名字,Python真正认的是那个星号。你可以换成numbers、**pairs都没问题。很多新手在这里纠结“为什么必须叫args”,其实不是必须,只是大家看惯了这种写法,可读性最好。
3. return返回值:最容易翻车的细节都藏在这里
函数题目里,返回值相关的问题占比极高。因为返回值直接关联到“函数这个黑盒子到底给你吐出来什么”这一核心问题。我在实际代码审查里看过太多奇奇怪怪的BUG,追根溯源都是返回值没处理好。
3.1 没有return,函数返回什么
先明确一个事实:如果函数体里没有写return,或者说写了return但后面没跟值,这个函数执行完返回的都是None。
def print_sum(a, b): result = a + b print(result) x = print_sum(3, 4) print(x) # 输出None上面print_sum内部确实算出了7,也打印了7,但函数本身作为“一个表达式”来看,它的求值结果是None,所以赋给x之后,x的值是None。新手特别容易把print和return搞混——print只是把值输出到屏幕上,函数该返回None还是返回None;return才是真正把计算结果交给调用者。
有个很容易验证的方法:在交互式环境里,直接执行print(print_sum(3, 4)),你会看到先打印7,接着打印None,两行输出全看清楚,就理解了。
练习6:定义一个函数square(n),计算n的平方并返回结果;再定义一个函数show_square(n),计算n的平方但只打印不返回。分别把两个函数的返回值赋值给变量并打印,对比差异。
def square(n): return n * n def show_square(n): result = n * n print(result) a = square(5) # a被赋值为25 b = show_square(5) # 打印25,但b被赋值为None这道练习的课堂效果一直很好。做完这道题,你会对“函数是表达式”这句话产生真正的体感:函数调用本身可以放在赋值号右边,它的值就是return后面的东西;没有return就是None。
3.2 返回多个值其实是返回元组
Python里一个函数可以拿到多个结果,语法非常方便:
def get_min_max(numbers): return min(numbers), max(numbers) result = get_min_max([3, 7, 2, 9, 5]) print(result) # (2, 9) low, high = get_min_max([3, 7, 2, 9, 5]) print(low, high) # 2 9这里真正发生的事情是:函数返回了一个元组(2, 9),第一行代码把这个元组整体赋给了result,第二行代码用元组解包的方式把两个值分别赋给low和high。很多教程会把这种写法叫做“多返回值”,本质上是被返回值的便利性给“骗”了——语言层面还是只返回了一个对象,只是这个对象刚好是个元组而已。
练习7:写一个函数analyze_string(text),接收一个字符串,返回它的长度、大写形式、以及单词数量(假设单词之间用空格分隔)三个结果。用元组解包的方式接收三个返回值并分别打印。
def analyze_string(text): length = len(text) upper_text = text.upper() word_count = len(text.split()) return length, upper_text, word_count length, upper_text, word_count = analyze_string("hello python world") print(length, upper_text, word_count) # 17 HELLO PYTHON WORLD 3这里有一个很实用的小技巧:如果用不上某个返回值,可以用下划线占位,比如length, _, count = analyze_string("a b"),提醒自己和读者“这个位置的返回值我不关心”,既保持了元组解包的简洁,又避免了PEP8对未使用变量的警告。
3.3 提前return:控制函数流程的“中途退出”
除了正常返回结果,return还有一个隐藏用途——提前结束函数执行。这是很多复杂函数里用得飞起的一个技巧:
def check_age(age): if age < 0: return "年龄不能为负数" if age < 18: return "未成年" return "成年"函数执行过程中只要遇到return,函数立刻终止,后面的语句不会再执行。这种写法比用一个超大if-else嵌套清爽得多,适合用来做防御式编程——先检查非法输入,不合格直接返回错误信息,然后才继续处理正常逻辑。
我把这个思路应用到更实际的场景里:比如设计一个购票系统的年龄判断函数,小于3岁免票、3岁到12岁半价、大于等于12岁全价,用提前return写出来一目了然:
def ticket_price(age): if age < 0: return "年龄输入有误" if age < 3: return "免票" if age < 12: return "半价票" return "全价票"练习8:设计一个函数validate_password(password),按以下规则校验密码并返回对应的中文提示信息:长度小于6返回“密码过短”,长度超过20返回“密码过长”,不包含数字返回“建议包含数字”,以上条件都不触发返回“密码可用”。要求用提前return实现,每个判断写成独立分支。
def validate_password(password): if len(password) < 6: return "密码过短" if len(password) > 20: return "密码过长" if not any(ch.isdigit() for ch in password): return "建议包含数字" return "密码可用" print(validate_password("abc")) print(validate_password("abcdefghijklmnopqrstuvwxyz")) print(validate_password("abcdef")) print(validate_password("abc123"))这道题练完,你对return的认知基本就到位了:return既能交结果,又能挡错误,还能提前结束流程,熟练之后写代码会干净很多。
4. 作用域与递归:两个容易劝退的山头
函数练习题做到这个阶段,很多人会开始遇到抽象的坎——函数内部和外部的变量为什么有时候通、有时候不通?递归这种“自己调用自己”的写法为什么看着头晕?这两个问题其实是函数理解的试金石,绕不过去。
4.1 局部变量和全局变量的边界感
先看一个经典场景:
total = 0 # 全局变量 def add_to_total(value): total = total + value # 出错了! add_to_total(5)这段代码会报错,提示你total在赋值前被引用了。原因很简单:函数内部的total = total + value这一句,Python解释器认为你在函数作用域内定义了一个新的局部变量total,右边的total引用的是这个还没赋值的局部变量,于是报错。
这个问题的本质是赋值语句的“声明效应”:只要变量在函数体内被赋值,它在整个函数体内就是局部变量,哪怕是函数第一个语句才给它赋值,整个函数作用域里这个变量都已经被标记为局部了。很多有C语言背景的同学也会被这个坑绊一跤。
如果你确实想在函数内部修改全局变量,要先用global声明:
total = 0 def add_to_total(value): global total total = total + value add_to_total(5) print(total) # 5但我个人的实际看法是:全局变量能少用就少用。函数设计的初衷是封装逻辑、隔离状态,频繁改动全局变量会让代码变得很难排查。你可以在练习题里尝试global的写法,但真实项目中更推荐把状态作为参数传入、把结果作为返回值带出。
练习9:编写两个函数,一个叫increase_global(),内部用global声明修改全局变量counter;另一个叫make_local(),内部创建同名局部变量counter并返回局部值。分别调用并用print打印调用前后的全局counter,观察两者的区别。
counter = 10 def increase_global(): global counter counter += 5 def make_local(): counter = 100 return counter print(counter) # 10 increase_global() print(counter) # 15 result = make_local() print(result) # 100 print(counter) # 15,全局counter没有变这道题能很好地帮你建立作用域的边界感:函数内部能读全局变量,但是要重新赋值就得global声明;局部变量无论怎么折腾,都影响不到外面的同名变量。有了这个意识,后面看闭包、装饰器这些概念都会轻松很多。
4.2 递归函数:三步法看清“自己调用自己”
递归是函数题里最高频的拦路虎。它本质上是一种“用函数自己的定义来解决更小规模问题”的思路。我用三个步骤帮大家拆解:
- 确定终止条件:递归不能无限调用自己,必须有一个情况可以直接返回结果,不再调用自身。
- 缩小问题规模:在函数体内调用自身时,传入的参数必须比当前问题更小,最终能碰到终止条件。
- 找到递推公式:把当前规模的问题,拆成“对更小规模问题的调用”加上“当前这一层要做的处理”。
以阶乘为例,n! = n * (n-1)!,终止条件是0! = 1:
def factorial(n): if n == 0: return 1 return n * factorial(n - 1) print(factorial(5)) # 120调用factorial(5)时,函数先问自己:n等于0吗?不是,于是计算5 * factorial(4);而factorial(4)又要计算4 * factorial(3),层层往下递,直到factorial(0)直接返回1,再一层一层乘回去,得到120。这个过程里,每一层函数调用都会在内存里分配一个栈帧,所以递归过深会导致栈溢出,这是递归的天然短板。
练习10:分别用递归实现三个函数:计算斐波那契数列第n项、反转一个字符串、用递归求1+2+...+n的和。
def fib(n): if n <= 1: return n return fib(n - 1) + fib(n - 2) def reverse_string(s): if len(s) <= 1: return s return reverse_string(s[1:]) + s[0] def sum_to_n(n): if n == 1: return 1 return n + sum_to_n(n - 1) print(fib(10)) # 55 print(reverse_string("python")) # nohtyp print(sum_to_n(100)) # 5050说到斐波那契,这里额外提醒一句:用上面的写法计算fib(40)就会很慢,因为大量重复计算。如果你在练习题里想进阶,可以尝试用字典做“记忆化”,把已经算过的结果存起来,这是很多动态规划题目的雏形。不过那是后话了,初学阶段先把递归的结构看懂。
5. 综合实战:把函数拆进真实场景里
前面几节都是单个函数的小练习,这一节我设计两道稍微综合一点的场景题,让你体会“把业务逻辑拆成多个函数,再用函数组合完成整体任务”的感觉。这才是工作中真正每天都在做的事。
5.1 员工薪资计算器:拆分计算逻辑
需求是这样的:某公司按小时计酬,正常工作时间按固定时薪计算,超过40小时的部分算加班,加班费率是正常时薪的1.5倍。公司要从一份工时记录里算出每个员工的应发薪资。
我建议拆成三个函数:一个计算单个员工的应发工资,一个计算多名员工的工资,再用一个主流程把它们串起来。
def calculate_single_salary(hourly_rate, hours_worked): if hours_worked <= 40: return hourly_rate * hours_worked normal_pay = hourly_rate * 40 overtime_hours = hours_worked - 40 overtime_pay = overtime_hours * hourly_rate * 1.5 return normal_pay + overtime_pay def calculate_all_salaries(employees): result = {} for name, rate, hours in employees: result[name] = calculate_single_salary(rate, hours) return result employees = [ ("张三", 25, 35), ("李四", 30, 45), ("王五", 20, 50), ] salaries = calculate_all_salaries(employees) for name, salary in salaries.items(): print(f"{name}:{salary:.2f}元")这道综合题里用到了默认的if分支判断、函数组合调用、以及字典返回,一步到位复习了前面所有内容。你可以试着改需求:如果某天法定节假日上班,加班费率变成2倍,看看需要改哪里——这能帮助你理解“把逻辑拆成小函数”的好处:改得越局部越好,不需要动整个主流程。
5.2 简易文本统计工具:处理真实字符串
再出一道处理字符串的综合题。目标是从一段英文文本中统计:总字符数、单词总数、每个单词出现的次数,并且把出现次数最多的前三个单词打印出来。
def clean_text(text): # 去掉常见标点,统一小写 import re return re.sub(r'[^\w\s]', '', text).lower() def count_words(text): word_list = clean_text(text).split() frequency = {} for word in word_list: frequency[word] = frequency.get(word, 0) + 1 return frequency def show_top_words(frequency, top_n=3): sorted_words = sorted(frequency.items(), key=lambda item: item[1], reverse=True) for word, count in sorted_words[:top_n]: print(f"{word}: {count}次") sample_text = "Python is great. Python is easy. Learning Python is fun!" count_result = count_words(sample_text) show_top_words(count_result)这段代码里用了str.get(word, 0)来给不存在的键设置默认计数,比先判断键在不在要简洁得多;排序时用了key=lambda item: item[1],指定按字典的值排序,这也是后面学更复杂数据处理时的常用套路。你可以把统计工具扩展成支持中文分词版本,但由于中文分词需要引入第三方库,这里就不展开了。当前这版作为函数综合练习已经完全够用。
6. 做题过程中的高频翻车点:每个人的代码都可能遇到
最后这一部分不写新题,我专门把批改练习题时最常看到的问题列一遍,并附上解决思路。这些错误很多不是语法错误,而是概念理解不到位导致的逻辑错误,看了能省很多调试时间。
6.1 参数顺序混乱:位置参数和关键字参数混用的规则
混用时经常有人把关键字参数写在位置参数前面,比如:
def func(a, b, c): return a + b + c func(a=1, 2, 3) # 语法错误规则只有一条:当一个调用里同时出现位置参数和关键字参数时,位置参数必须全部放在前面。这个规则背后的理由是,Python解释器按顺序给位置参数分配位置,如果中间插入关键字参数,后面的位置参数就不知道对应函数定义里的哪个槽位了。建议写多参数函数时优先考虑全用关键字参数,减少这种问题。
6.2 函数命名但忘了调用
还有一类代码,函数定义写得挺好,打印结果却是空的。原因是只写了def,没有在下面写调用语句。函数定义只是“声明了一种能力”,不会自己执行。你在脚本里写完def后,必须显式调用一次或多次,函数体才会运行。新手最容易在final模块里忘了调用,结果输出一片空白,还以为是函数写错了。
6.3 可变默认参数这个经典陷阱
如果你用可变对象作为函数的默认参数,比如列表、字典,且函数内部会修改这个参数,就会踩中Python里非常著名的坑:
def add_item(item, cart=[]): cart.append(item) return cart print(add_item("苹果")) # ['苹果'] print(add_item("香蕉")) # ['苹果', '香蕉'],第二次调用时默认列表已经被改了!这里的默认列表[]是在函数定义时就被创建并绑定到函数对象的,之后每次调用如果没有显式传入cart,用的都是同一个列表对象,所以上次调用的修改会保留下来。稳妥的写法是把默认值设为None,在函数内部新建列表:
def add_item(item, cart=None): if cart is None: cart = [] cart.append(item) return cart这道题常作为面试考察点,建议自己动手跑一遍,印象会非常深。
6.4 递归函数忘记终止条件导致栈溢出
递归的终止条件没写,或者写错了,函数就会无限调用自己,最终抛RecursionError。调试时可以先手动模拟一到两层调用,看每次递归参数是不是在变小,距离终止条件是不是越来越近。比如反转字符串的递归版本,终止条件是len(s) <= 1,你调用reverse_string("ab")时,传入的字符串每次去掉首字符,长度递减,最终一定会到1,这样就不会死循环。写任何递归前,先问自己“这次调用的规模比上次小在哪?”,把小在哪写在注释里,能少踩很多坑。
我在实际教学中还发现过一个共性问题:很多人在做完函数练习后,依然不会主动用函数去重构自己的旧代码。我特别建议你把自己之前写过的一个两百行左右的脚本拿出来,尝试用函数重写一遍:把“采集数据”“清洗数据”“计算指标”“打印结果”各拆成一个函数,再用主逻辑串起来。做完这个重构动作,你对函数的理解会比做十道题都深。函数的最高境界不是会做题,而是遇到任何一段逻辑,都能下意识地思考“这段逻辑能不能独立成一个函数、它的输入输出是什么、它和其他部分的边界在哪里”。沿着这个方向练,函数这块基本就算真正过关了。