Python 的函数和其他语言不一样,它不只是把代码“包起来”重复用这么简单。函数在 Python 里是“头等公民”,可以被赋值、被当作参数传来传去、被作为返回值返回——这门课里你会开始感受到 Python 的灵活和“野路子”。
我们从一个问题出发:为什么学编程没多久,就会反复听到“要写函数”“不要都写在 main 里”?因为一旦代码超过 50 行、100 行,如果没有函数帮你划分边界,调试就变成一场噩梦。你会盯着好几屏代码找 bug,却找不到哪一段出了问题。
这篇内容适合刚学完变量、分支和循环,开始接触函数的初学者,也适合写过一段时间代码但总觉得函数用不顺手的人。我尽量把语法、原理、坑都讲透,最后带你做一个能实际运行的小项目,把这节课的知识点串起来。
1. 函数到底解决了什么问题
1.1 没有函数的世界:代码为什么会失控
先想这样一个场景:你写了一个程序,要在三个不同的地方分别统计一组数字里的最大值、最小值和平均值。如果你不会函数,很可能会把同一段计算逻辑复制粘贴三遍:
- 第一处粘贴,发现统计逻辑要看的是 A 列表;
- 第二处粘贴,要改成处理 B 元组;
- 第三处粘贴,还要顺便修改一下输出格式。
然后产品经理过来说:“平均值那边,我想改成显示两位小数。”你就得从代码里找到三处几乎一样的代码,小心翼翼地各改一遍——改漏一处,程序跑起来的结果就会对不上,而且你还不一定马上能发现。
函数解决的就是这个核心问题:把“一段逻辑”抽出来、命名、定义好输入输出,然后反复调用。修改逻辑只需要改一处,其它地方自动生效。这不是代码风格喜好问题,而是代码能不能长期维护、能不能不把人逼疯的基本功。
1.2 函数是什么:用生活类比拆解概念
如果你用过自动售货机,你就已经理解函数了。售货机有几个要素:
- 它有一个名字,比如“饮料售货机”;
- 它有输入——你投进去的硬币或扫码金额;
- 它有内部逻辑——识别金额够不够、扣款、出货;
- 它有输出——一瓶饮料或一张退款小票。
Python 函数一模一样的:函数名、参数(输入)、函数体(逻辑)、返回值(输出)。
def 饮料机(金额): 价格 = 3 if 金额 >= 价格: 退回 = 金额 - 价格 return "矿泉水", 退回 else: return "余额不足", 金额函数不一定非要接收参数,也不一定非要返回值。比如一个只负责打印提示信息的函数,可以没有参数、没有 return。但在绝大多数情况下,我建议把函数设计成“有明确的输入、有明确的输出”,这样测试和复用都会容易得多。
1.3 什么时候该写函数:三个判断标准
初学者最常问的问题就是:“到底多长的代码才需要提成函数?我写个 10 行的小脚本也要强行拆吗?”我的回答是:不要看行数,看下面三个标准。
标准一:代码是否重复。同样的逻辑出现两次以上,就该考虑抽函数了。复制粘贴三次还无动于衷,后面一定会付出代价。
标准二:逻辑是否太复杂。一个函数体超过 20 行甚至 30 行,读起来就会明显吃力。这时应该把它拆成几个小函数,每个函数只做一件事。
标准三:是否有清晰的概念边界。比如“读取文件”“计算平均分”“把结果保存成 CSV”,每句话本身就是一个明确的任务,可以直接命名成函数。
一个函数最好只干一件事,干完就返回。复杂任务不是靠一个巨无霸函数完成,而是靠多个小函数互相调用。
2. 定义一个像样的函数:从语法到习惯
2.1 定义与调用的最小骨架
Python 定义函数的关键字是def,语法是这个样子:
def greet(name): """向指定的名字打招呼""" return f"你好,{name}!" print(greet("小明"))这里有几个容易被忽略的细节:
def和函数名之间有一个空格,函数名后面必须跟一对圆括号,圆括号里放参数列表,冒号结束第一行;- 函数体必须缩进,Python 靠缩进划分代码块,不能混用 Tab 和空格;
- 函数体里第一行可以写一个字符串——这叫文档字符串(docstring),它不只是注释,很多开发工具和自动文档生成都会读取它。养成给关键函数写 docstring 的习惯,半年后你再回来看这段代码就会感谢自己。
调用函数的时候,函数名后面的圆括号不能省略。greet只代表函数对象本身,greet("小明")才是真正去执行它。这个区别一开始很抽象,但后面讲“函数是一等公民”时会变得非常重要。
2.2 参数的五种形态:必选、默认、可变、关键字、命名关键字
所有编程语言都有参数,但 Python 的参数系统是我见过的最灵活的那一类。初学者不需要一次记全,但知道全貌能避免很多困惑。
1. 位置参数(必选参数):
def add(a, b): return a + b add(1, 2) # 按位置传入2. 默认参数(可选参数):
def greet(name, greeting="你好"): return f"{greeting},{name}!" greet("小明") # 使用默认值 greet("小红", "早上好") # 覆盖默认值默认参数大大提升了函数的灵活性,API 设计里经常用来保持“向后兼容”——老代码不用改,新代码可以使用额外参数。
3. 可变参数*args:
当你不知道调用者会传入多少个位置参数时,用*args:
def total(*numbers): return sum(numbers) total(1, 2, 3) # 6 total(1, 2, 3, 4, 5) # 15*numbers会把所有多余的位置参数打包成一个元组。
4. 关键字参数**kwargs:
def show_info(**info): for key, value in info.items(): print(f"{key}: {value}") show_info(name="小明", age=18, city="上海")**kwargs会把所有多余的关键字参数打包成一个字典。
5. 命名关键字参数:
def register(name, *, age, city): print(name, age, city) register("小明", age=18, city="上海") # 正确 register("小明", 18, "上海") # 报错,* 后面的参数必须用关键字传入星号*后面的参数强制要求调用时用关键字=值的形式,这能防止调用者搞错参数顺序。
每种参数形态都有它存在的意义,初学者最容易犯的错误是:一看到*args和**kwargs就觉得高深,到处滥用。其实大多数函数用普通的必选参数加几个默认参数就够了。什么时候用可变参数?当你真的不确定会有多少个值时,比如写一个求和的工具、注册一个事件处理器。
如果你的函数同时混合这些参数,顺序是固定的:位置参数、默认参数、*args、命名关键字参数、**kwargs。违背这个顺序,Python 直接报语法错误。
2.3 return 到底该不该写:返回值的讲究
Python 函数如果没有 return,默认返回None。这个看似简单的规则,其实是初学者最容易迷糊的地方。
比如,你写了一个函数来修改列表,修改完成后没有写 return,调用者可能会下意识地问“函数执行完了吗?我的列表改了吗?”——因为结果是直接体现在传入的列表上的,你看不到返回值,就有一种“没反应”的感觉。
另一个极端是“每个分支都要有 return”。有一个很常见的 bug:函数里某些分支 return 了值,某些分支没有 return,最后拿到None参与后续的运算,报出奇怪的错误:
def get_grade(score): if score >= 90: return "A" elif score >= 60: return "B" # 漏掉了 score < 60 的 return,实际上会返回 None要养成一个好习惯:要么函数所有路径都返回明确的值,要么明确地让函数无返回值;不要有的分支返回、有的分支隐式返回None。
另外,return还可以一次返回多个值,Python 会自动打包成一个元组:
def compute(a, b): return a + b, a - b s, d = compute(10, 3)看起来像返回了两个东西,实际上返回的还是一个元组对象,只是被解包了而已。这是 Python 非常优雅的写法,写数据处理小程序时很常用。
3. 参数传递的底层逻辑与常见认知偏差
3.1 值传递还是引用传递?结论是:看对象
几乎每一个学 Python 函数的人都会在某一天问:“参数是传值还是传引用?”网上的答案五花八门,让人越看越糊涂。
直接给结论:Python 的参数传递方式是“对象引用传递”——函数接收到的是对象本身,而不是对象的副本或对象的地址,但变量本身是引用。
听起来还是很绕,我们用一个容易理解的版本:
- 如果传入的是不可变对象(整数、浮点数、字符串、元组),函数里怎么改都不会影响外面的变量,因为对不可变对象的“修改”本质上是在创建一个新对象,然后让局部变量指向它;
- 如果传入的是可变对象(列表、字典、集合),函数里对对象内容进行修改,外面的变量会同步看到变化,因为它们引用的是同一个对象。
def modify_list(lst): lst.append(100) # 会影响外部,因为修改的是同一个列表对象 def reassign_list(lst): lst = [1, 2, 3] # 不会影响外部,因为只是让局部变量指向了新对象 data = [1, 2] modify_list(data) print(data) # [1, 2, 100] reassign_list(data) print(data) # [1, 2, 100]这个例子里两个函数的区别一定要分清:append是修改对象内部状态,= [1, 2, 3]是让局部名字重新绑定到新对象。前者影响外部引用,后者不影响。
3.2 可变默认参数陷阱
Python 函数定义的时候,默认参数就已经创建了。如果默认参数是可变对象,这个对象会被所有调用者共享,就会产生一个很经典的坑:
def append_item(item, result=[]): result.append(item) return result print(append_item(1)) # [1] print(append_item(2)) # [1, 2],第二次调用把上一次的结果带出来了第二次调用结果是[1, 2],而不是[2],因为result指向的是同一个列表对象,它在函数定义时就被创建了,后面调用时如果没有传入参数,用的还是同一个对象。
正确的写法是用None作为默认参数,然后在函数体内创建新变量:
def append_item(item, result=None): if result is None: result = [] result.append(item) return result这个陷阱在面试题里出场率极高,但在实际项目里也很容易踩中——尤其是你重构代码、给函数加默认参数的时候。
3.3 用对象引用理解参数,避免最常见的坑
基于上面的原理,我在实际写代码时有一套自己的判断习惯:
- 函数需要“读取”一个列表来做统计,不修改它,直接传就行;
- 函数需要“填充”一个列表,也可以直接传,但要意识到这会改变外部变量,最好在 docstring 里写清楚副作用;
- 函数需要“基于一个列表生成一个新列表”,更推荐用返回值来传递结果,而不要把原列表改得面目全非。
从软件架构角度看,函数尽量减少对外部数据的“偷偷修改”,是降低代码耦合度、减少调试痛苦的重要原则。Python 本身不强制你这样做,但如果你在一个大型项目里看到几十个函数互相修改同一个列表,你一定会怀念这个原则。
4. 变量的作用域与生命周期
4.1 局部变量、全局变量与 global 关键字
在函数内部赋值的变量,叫局部变量;在函数外部定义的变量,叫全局变量。局部变量在函数调用结束后就销毁了,外部访问不到。
x = 10 # 全局变量 def func(): y = 5 # 局部变量 print(x) # 函数内部可以读取全局变量 func() print(y) # 报错:name 'y' is not defined函数内部可以读取全局变量,这点很自然。但如果你在函数内部试图给全局变量赋值,会得到一个UnboundLocalError:
x = 10 def func(): x = x + 1 # 报错:local variable 'x' referenced before assignment func()原因很简单:函数内部只要有x = ...这样的赋值,Python 就把x视为局部变量。那么x + 1里的x也在局部作用域里查找,此时它还没有赋值,于是报错。
如果你真的要修改全局变量,需要先声明global x:
x = 10 def func(): global x x = x + 1 func() print(x) # 11但我要认真建议:不要轻易用global。它会让函数依赖外部状态,函数就不再是“给定输入得到输出”的黑盒了,代码一旦变复杂,你很难弄清楚一个全局变量到底被谁改过。更好的做法是把变量作为参数传进去,或作为返回值传出来。
4.2 闭包:函数记住了外部的变量
Python 允许你在函数内部再定义函数,内部函数会记住外部函数的变量——这个机制叫闭包(closure)。
def make_adder(n): def adder(x): return x + n return adder add_5 = make_adder(5) print(add_5(10)) # 15 print(add_5(20)) # 25adder这个内部函数,在make_adder返回之后仍然可以访问n的值,这就是闭包。它不像局部变量一样随函数调用结束而销毁,而是被捕捉在内部函数的环境里。
闭包在很多场景里很有用,比如给多个函数附加相同的配置、实现简单的计数器。但初学者不用深究,知道存在这个机制就够了。等你读到装饰器的时候,再回头看闭包就豁然开朗了。
4.3 作用域查找规则:LEGB
Python 里名字解析的顺序,是一个叫 LEGB 的规则:
- L(Local):当前函数内部的局部作用域
- E(Enclosing):外层嵌套函数的局部作用域(闭包场景)
- G(Global):模块的全局作用域
- B(Built-in):Python 内置作用域,比如
print、len就住在这里
举个例子帮助你理解:
x = "global" def outer(): x = "outer local" def inner(): x = "inner local" print(x) # 优先找到 inner 里的 x inner() outer()如果你在inner里想修改outer的变量,要用nonlocal;想修改全局变量,要用global。这两个关键字的区别,一定要在真实代码里踩几次坑才能真正记住。
5. 三个实战例子:把函数真正用起来
5.1 例一:考试成绩统计小工具
现在把本课所有核心概念串起来,写一个考试分数统计的小工具。需求如下:
- 输入一个包含若干学生成绩的列表;
- 计算出平均分、最高分、最低分;
- 按成绩划分等级;
- 把统计结果格式化输出。
代码如下:
def average(scores): """计算平均分""" if not scores: return 0 return sum(scores) / len(scores) def max_and_min(scores): """返回最高分和最低分""" if not scores: return 0, 0 return max(scores), min(scores) def get_level(score): """根据分数返回等级""" if score >= 90: return "A" elif score >= 75: return "B" elif score >= 60: return "C" else: return "D" def analyze(scores): """综合分析成绩列表""" avg = average(scores) high, low = max_and_min(scores) levels = [get_level(s) for s in scores] return avg, high, low, levels scores = [88, 92, 79, 63, 54, 100, 67] avg, high, low, levels = analyze(scores) print(f"平均分: {avg:.2f}") print(f"最高分: {high}, 最低分: {low}") print(f"等级分布: {levels}")这个例子里:
average、max_and_min、get_level都只做一件事,职责非常清晰;analyze负责调用其它函数,组合出最终结果;- 每个函数都可单独测试、单独复用。
如果你把几段逻辑压在一个函数里,也能跑,但一旦你要单独测“平均分的计算逻辑”,或者想在另一个脚本里复用“等级判定”,就只能重新复制代码。拆分成小函数的价值就在这里。
5.2 例二:一个支持默认参数的计算器
第二个例子我们做一个更贴近日常需求的工具——一个支持“可选精度”的计算器函数:
def calculate(a, b, operator="+", precision=2): """基础计算器,支持四则运算""" if operator == "+": result = a + b elif operator == "-": result = a - b elif operator == "*": result = a * b elif operator == "/": if b == 0: raise ValueError("除数不能为 0") result = a / b else: raise ValueError(f"不支持的运算符: {operator}") return round(result, precision) print(calculate(3, 4)) print(calculate(3, 4, "*")) print(calculate(10, 3, "/", 4)) print(calculate(10, 3, operator="/", precision=6))这个例子演示了默认参数的价值:调用者可以只传必要参数,其它用默认值;也可以选择性覆盖某些参数。加上参数名校验后,代码的可读性和容错性都更好。
5.3 例三:文本词频分析
最后一个例子更接近真实数据处理场景。需求是给一段英文文本,统计每个单词出现的次数,忽略大小写和标点符号,输出频率最高的 5 个词:
import re from collections import Counter def clean_text(text): """把文本转小写并去掉标点""" text = text.lower() return re.findall(r"[a-z']+", text) def get_word_frequency(text): """统计词频并返回 Counter 对象""" words = clean_text(text) return Counter(words) def top_n_words(counter, n=5): """返回出现频率前 n 个单词""" return counter.most_common(n) sample_text = """ Python is great. Python is easy to learn! Learning Python takes time, but it is worth it. """ freq_counter = get_word_frequency(sample_text) print(top_n_words(freq_counter, 5))这个例子体现了“函数组合”的思维:每个函数只处理流水线中的一环,数据从clean_text流向get_word_frequency,再流向top_n_words。如果你想换一种分词方式,只需要修改clean_text,其它函数完全不用动。这就是抽象边界带来的好处。
6. lambda、函数对象与装饰器初体验
6.1 lambda 匿名函数:一句话函数的适用场景
Python 还有一种定义函数的简写方式——lambda,它专门用来表达“一句话就能写完的简单逻辑”:
add = lambda a, b: a + b print(add(3, 4)) # 7注意,这个例子里我只是为了演示而把它赋值给变量,实际使用中不推荐这样写。lambda 最常见的场景是作为参数传给其它函数,尤其是配合sorted、filter、map这些内置函数:
students = [ {"name": "小明", "age": 18}, {"name": "小红", "age": 20}, {"name": "小刚", "age": 17}, ] students.sort(key=lambda s: s["age"]) print(students)如果你需要签名的函数逻辑超过一行,或者它有比较复杂的分支判断,就不要用 lambda 了,老老实实定义一个普通函数。一个需要仔细读的逻辑如果被塞在 lambda 里,可读性会非常差。
6.2 函数是一等公民:作为参数和返回值
函数在 Python 里可以被当作普通的值来传递,这意味着:
- 函数可以赋值给变量;
- 函数可以作为参数传给另一个函数;
- 函数可以作为另一个函数的返回值。
def say_hello(): return "Hello!" def call_twice(func): return [func(), func()] print(call_twice(say_hello)) # ['Hello!', 'Hello!']这种“函数作为参数”的用法,在高阶函数里极其常见。当你看到map(func, data)、filter(func, data)这种代码时,本质上就是逻辑的传递——你把“怎么处理这条数据”的逻辑交给另一个函数去调度。
6.3 装饰器:不修改原函数却能增强功能
装饰器是函数的“进阶形态”,本质上是闭包+函数对象的实际应用。它让你在不修改原函数代码的情况下,给函数附加新的行为。
def log(func): def wrapper(*args, **kwargs): print(f"调用函数 {func.__name__},参数: {args} {kwargs}") result = func(*args, **kwargs) print(f"函数返回: {result}") return result return wrapper @log def add(a, b): return a + b print(add(3, 5))输出会显示函数调用前后的日志。这里@log只是语法糖,本质上等价于add = log(add)。
初学者不理解装饰器完全正常,函数、闭包、函数对象这几个概念都是环环相扣的,只有实际写过、用错过,才能真正内化。建议你先把前面几节的基础打牢,再回头来看装饰器。
7. 我踩过的坑和一些独家建议
7.1 常见报错和问题排查
问题 1:UnboundLocalError: local variable 'x' referenced before assignment
场景:函数内有x = x + 1,但x在函数外定义过。原因是我前面讲的 Python 作用域规则:函数内只要出现同名赋值,就会把它当作局部变量。解决:用global x,或把这行逻辑改成参数传入、返回值传出。
问题 2:函数返回值是 None,程序后续报TypeError: 'NoneType' object is not subscriptable
场景:调用了一个漏掉 return 的函数,拿到None后继续做索引或运算。排查方式:在调用处打印返回值,看是不是None;检查函数里是否存在“有的分支 return、有的分支没有 return”的情况。
问题 3:函数改了外部列表,造成意外副作用
场景:函数里调用list.append()或list.sort(),导致调用者的列表被改写。如果你不想让外部受影响,可以在函数内部先复制一份:
def process(data): data = data.copy() # 先复制,修改不安全 data.append("x") return data7.2 初学者练习函数的三个层次
我见过太多人看教程时“全都看懂了”,一动手就不知道从哪写起。给你一个可以直接照着练的路径:
层次一:能写、能调。从最简单的“输入两个数返回和”“输入半径返回面积”开始,一直到你闭着眼睛能写出def、return、调用、打印的完整流程。
层次二:能拆、能想边界。把一个 20 行的统计脚本拆成三四个小函数,并且想清楚“空列表怎么办”“除数为零怎么办”“数据量太大怎么办”。
层次三:能组合、能设计。用多个函数拼出一个完整小项目,并且开始考虑函数之间如何传参、哪些函数该放哪一层。
每做一个练习,都要问自己:这个函数如果不写,代码会乱到什么程度?只有你真正遇到“不写函数根本改不动”的时刻,函数的意义才深入骨髓。
7.3 写函数的几个习惯建议
最后分享几个我在实际开发中沉淀下来的习惯:
习惯一:函数命名用动词开头。get_average、validate_email、save_to_file一眼能看出函数在干什么;不要用process_data这种含糊的名字,除非它真的就是“处理一切”。
习惯二:一个函数只写一层抽象。函数里的代码既不要全都挤在几行里,也不要嵌套七八层条件。如果函数体里出现“为了更好的阅读,这里需要一个小标题”的冲动,就把它拆出去。
习惯三:用默认参数增强健壮性,但不要设计过长的参数表。超过三个参数时,考虑把参数打包成一个字典或对象,或者重新审视函数边界是否合理。
习惯四:不要害怕写小函数。在我评审过的很多初学代码里,最大的问题不是函数写太多,而是根本不写函数,一个几十行的脚本从头跑到尾。小函数虽然看起来“琐碎”,但测试、查错、改名字、调整逻辑都容易得多。
这几条习惯不是我一开始就有的。前几年写代码我也爱把一切都塞进一个函数里,觉得“这样看起来效率高”,结果就是排错时人麻了。后来我慢慢学会把逻辑拆细、给每个函数取一个准确的名字、为每个函数想清楚它依赖什么、返回什么,代码质量才真的上了一个台阶。
函数这一课,表面上是学一个语法,实际上是学怎么把一个复杂问题切分成小块,再把这些小块组织成一个整体。你现在写的每一个函数,都是未来几千行代码项目里的一小块砖。把这一课练扎实,后面讲模块、讲面向对象、讲各种框架的时候,你会轻松很多。