1. 为什么写到这里,我会劝你先搞懂函数
如果你正在学Python,大概率已经经历了“变量赋值→条件判断→for/while循环”这条路。走到这一步,你会发现一个很现实的问题:代码越来越长,重复的东西越来越多。比如你要计算三批商品的平均价,第一反应可能是把同样的代码复制三遍,改改变量名。这能跑,但维护起来非常痛苦——一旦计算逻辑要改,你得记得去改三处地方,漏一处就出bug。
函数就是为了解决这个痛点而生的。它把一段逻辑封装成一个“小工具”,起个名字,定义好输入和输出,之后想用多少次就调用多少次。爬虫里的请求头构造、量化交易里的均线计算、数据处理里的清洗逻辑,这些东西在真实项目里几乎全都会用函数包起来。所以你会发现,学函数不只是学一个语法,而是思维方式从“写一段代码”变成“设计一个可复用的组件”。
这篇笔记适合谁?一是刚学完基础语法、正在往函数过渡的人,二是已经写了些脚本但代码总是又长又乱、想重构的人。我不打算面面俱到地复述官方文档,而是把函数声明、参数传递、作用域、闭包、内置函数这些我实际用过并且踩过坑的点,掰开揉碎讲清楚。每段代码都是可以在本地直接跑的完整示例,你跟着敲一遍,再自己改一改,效果比只看不练好得多。
2. def到return——一次把函数声明讲透
2.1 最小可用函数与“隐藏”的None
先看最基础的样子:
def greet(): print("你好,Python") greet() # 输出:你好,Python这个函数做了三件事:用def关键字告诉解释器“我要定义一个函数”,greet是函数名,括号里面是参数列表(这里为空),冒号下面是函数体。函数体必须缩进,通常4个空格,Python完全靠缩进来划分代码块,这一点的严格程度让很多从其他语言转过来的人不适应——在C语言里你写不写大括号都行,只要能对上,在Python里缩进错了直接报错。
但这里有个新手很容易忽略的细节:这个函数有没有返回值?你可能会说“它打印了一句话,这不就是返回值吗?”并不是。打印到屏幕上和返回给调用者是两回事。你可以这样验证:
result = greet() print(result) # 输出:Nonegreet()执行完,result拿到的是None。因为当一个函数没有写return语句时,Python会自动返回None。我见过不少初学者把这个概念搞混,以为print就等于返回,结果在后续计算中拿到None,一加一减就报TypeError: unsupported operand type(s)。
真正的返回是显式的:
def add(a, b): return a + b total = add(3, 5) print(total) # 输出:8return的作用有两个:一是把计算结果交还给调用方,二是一旦执行到它,函数立刻结束,后面的代码不会再跑。这个“立刻结束”的特性,经常被用来做提前退出:
def check_age(age): if age < 0: return "年龄不能为负数" if age < 18: return "未成年" return "成年人"很多新手喜欢在函数里写一堆if...else嵌套,最后统一返回,逻辑绕来绕去。其实用多个return做“早退”,代码会清晰很多。这个习惯在真实项目里非常常见,面试官也会比较认可这种写法。
2.2 参数、函数体、文档字符串,一个都不能少
参数是函数对外界的接口。定义一个函数时,括号里写的叫形式参数(形参),调用时传进去的值叫实际参数(实参)。这个概念听起来有点绕,但实际上就是你给函数“预留的变量名”。
def calculate_bmi(weight, height): """根据体重(kg)和身高(m)计算BMI指数""" return weight / (height ** 2) print(calculate_bmi(70, 1.75)) # 输出:22.86...看到函数第一行那句用三引号包起来的字符串了吗?那叫文档字符串(docstring)。它会被Python自动存进函数的__doc__属性里,help()函数能把它显示出来。很多人在自己的脚本里从来不写,但如果你打算把代码给同事用,或者在GitHub上开源,文档字符串就是最简单的“使用说明书”。我不建议写那种“这个函数是计算BMI的”废话,而是要写清楚参数是什么单位、返回什么结果、有没有特殊约定。比如:
def fetch_article(url, timeout=5): """ 抓取指定URL的HTML内容。 参数: url: 完整链接,如 https://example.com/article/1 timeout: 请求超时时间,单位秒,默认5秒 返回: 请求成功返回HTML字符串,失败返回None """函数名本身要“见名知意”。Python官方推荐的命名风格是lowercase_with_underscores,也就是小写字母加下划线。像getUserInfo这种驼峰式在Java里很常见,但Python社区更习惯get_user_info。这不是强制规范,但写出来一眼就能看出你是“老手”还是“新人”。
还有一个容易忽略的点:函数体的代码行数。如果某个函数超过50行甚至上百行,往往说明它干了太多事,应该拆分成几个小函数。这个判断标准不是绝对的,但我自己的经验是,一个函数最好只做一件事,做完了就return。我以前写数据处理脚本,经常一个函数从读文件干到画图表,后来想复用其中一段逻辑,只能复制粘贴,非常痛苦。把函数拆小之后,组合起来反而更灵活。
3. 参数传递这门学问,新手最容易在这里翻车
3.1 位置参数、关键字参数、默认参数怎么搭配
函数参数看起来只是“定义时写几个变量”,但真到用的时候,组合方式还挺多的。最基础的是位置参数,调用时按顺序传:
def introduce(name, age, city): print(f"我叫{name},今年{age}岁,来自{city}") introduce("张三", 25, "杭州")这样传参的缺点是:调用的人必须记住每个位置的顺序,一旦写错,函数也不会报错,只是结果变成“我叫25,今年张三岁”。这种bug很难排查。所以Python提供了关键字参数的写法:
introduce(age=25, city="杭州", name="张三")用参数名=值的格式传参,顺序完全无所谓,代码的自解释性也更强。我自己在写对外调用的函数时,只要有三个以上参数,基本都会用关键字参数来调用,宁可多敲几个字。
默认参数则是给形参一个“保底值”:
def introduce(name, age, city="未填写"): print(f"我叫{name},今年{age}岁,来自{city}") introduce("张三", 25) # 城市取默认值 introduce("李四", 30, city="北京") # 覆盖默认值默认参数适合那些“大多数时候用同一个值,但偶尔能改”的场景。比如HTTP请求的超时时间、爬虫的User-Agent、导出的文件编码,这些都可以设置默认值。但这里藏着一个很多教程没强调的规则:默认参数必须放在没有默认值的参数后面。写def f(a=1, b)会直接报SyntaxError,因为Python没法判断你在缺省一个参数时,到底想传给a还是b。
3.2 *args和**kwargs,收下任意多的参数
有时候你提前不知道调用方会传多少个参数,比如写一个求和函数,今天求5个数,明天可能求10个数。用固定参数显然不行,这时就该*args上场了:
def sum_all(*args): total = 0 for num in args: total += num return total print(sum_all(1, 2, 3)) # 6 print(sum_all(1, 2, 3, 4, 5)) # 15*args会把所有传入的位置参数打包成一个元组(tuple),你在函数内部可以正常遍历它。与之对应的是**kwargs,它把所有关键字参数打包成一个字典:
def print_info(**kwargs): for key, value in kwargs.items(): print(f"{key}: {value}") print_info(name="张三", age=25, city="杭州")args和kwargs不是语法规定死的名字,但全世界的Python程序员都这么写,已经成为一种约定俗成的惯例。*和**才是关键符号。它们还有另外一个用处:在调用函数时,把列表或字典“展开”传给参数。比如:
def introduce(name, age, city): print(f"{name}, {age}, {city}") info = ["张三", 25, "杭州"] introduce(*info) # 把列表拆成三个位置参数这个技巧在处理“数据从外部来,需要批量调用函数”的场景特别有用。比如你从Excel读了一行数据,它是一个列表,配合*直接展开传入,省得一个个取下标。**同理,可以把{"name": "张三", "age": 25}这种字典展开成关键字参数。不过我不建议在简单的函数上硬用这个技巧,它更适合写装饰器、框架代码、或者参数特别灵活的工具函数,普通业务逻辑里过度使用反而让人看不懂。
3.3 默认参数千万别写可变对象——一个真实的bug经过
这是我早期写Python时踩过的坑,印象非常深。我当时写了一个向列表里添加商品的功能:
def add_item(item, cart=[]): cart.append(item) return cart看起来没问题:第一次调用,cart默认是空列表,添加一个商品,返回列表。第二次调用,不传cart,按理说应该还是空列表。但实际结果让我当场懵了:
print(add_item("苹果")) # ['苹果'] print(add_item("香蕉")) # ['苹果', '香蕉']第二次调用把上一次的“苹果”也带上了,默认列表居然“继承记忆”。原因是:在Python里,默认参数值只在函数定义时被计算一次,生成的那个列表对象被反复使用,而不是每次调用都重新创建。这就是“可变对象作为默认参数”的经典陷阱。
解决办法是用None作为占位,在函数内部创建新列表:
def add_item(item, cart=None): if cart is None: cart = [] cart.append(item) return cart这个写法在真实项目里到处都是,你要看到哪个函数定义成def f(x, lst=[]),基本可以断定是个潜在bug。判断一个对象是否“可变”,最简单的方式是:数字、字符串、元组是不可变的,列表、字典、集合是可变的。不可变对象做默认参数没有这个问题,但为了统一习惯,我所有默认参数如果是容器类,一律用None占位,内部再初始化。
4. 作用域和闭包:函数内部到底怎么“看见”变量
4.1 LEGB规则:变量查找的优先级
函数内部能访问哪些变量?这个问题的答案叫LEGB规则。它是四个英文单词的缩写:Local(局部)、Enclosing(外层函数的局部)、Global(模块全局)、Built-in(Python内置名字)。当你在一段代码里引用一个变量名时,Python会按这个顺序去找。
x = 100 # 全局变量 def outer(): x = 50 # 外层函数的局部变量 def inner(): x = 10 # 内层函数的局部变量 print(x) # 10 inner() outer() print(x) # 100内层函数里打印x,会优先用自己的局部x=10;如果注释掉这行,就会去外层找x=50;再注释掉,就会用全局的100;如果全都没有,就去内置命名空间里找print、len这些函数。我见过初学者在函数里写print = "hello",结果后面所有print()调用全炸了,就是因为print这个内置名字被局部变量覆盖了。所以在给变量起名时,尽量不要覆盖内建函数名,list、dict、str这类也是,虽然能跑,但代码读起来非常混乱。
4.2 global与nonlocal到底该怎么用
正常情况下,函数内部对全局变量是“只读”的。你在函数里写:
count = 0 def increment(): count = count + 1 # 这里会报错Python解释器会在编译函数时看到count =,把它判定为局部变量,于是函数体内的count和全局的count就断了联系,执行count + 1时发现局部count还没有赋值,直接报UnboundLocalError。
要修改全局变量,需要显式声明global:
count = 0 def increment(): global count count += 1 increment() print(count) # 1但说实话,在业务代码里我很少用global。它会让函数产生“隐藏的副作用”——调用者在函数外根本看不出它会修改某个全局状态,调试时非常痛苦。更推荐的模式是:函数用参数接收需要修改的对象,用返回值把结果交还回来。
那nonlocal呢?它用在嵌套函数里,用来修改“外层函数的局部变量”。比如你想写一个计数器:
def create_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment counter = create_counter() print(counter()) # 1 print(counter()) # 2 print(counter()) # 3这里nonlocal count的意思是:increment里的count不是它自己的局部变量,而是引用外层函数create_counter里的count。如果不加这个声明,count += 1同样会报错,原理和global是一样的。
4.3 闭包:让函数记住环境的办事员
上面这个计数器例子,其实已经是一个典型的闭包(closure)。闭包的定义有点绕:内层函数引用了外层函数的变量,并且外层函数把内层函数作为返回值,就构成了闭包。更直观的理解是:内层函数像是一个“办事员”,它不只是自己一个人在工作,还随身携带了一个“办事手册”——外层函数里的那些变量。即使外层函数已经执行完毕、局部变量按常规早该销毁了,但这些被内层函数引用的变量并不会消失,而是被保存在内层函数的__closure__属性里。
闭包这种特性有什么实际用处?我举一个爬虫里的场景。你要抓一个电商网站,不同品类有不同页面的解析规则,你希望根据品类返回一个专门的解析函数:
def make_parser(category): def parse(html): print(f"用{category}的规则解析页面") # 这里实际解析HTML return parse book_parser = make_parser("图书") phone_parser = make_parser("手机") book_parser("<html>...") # 用图书规则 phone_parser("<html>...") # 用手机规则book_parser和phone_parser虽然是同一个工厂函数造出来的,但各自记忆了不同的category,互不干扰。这种模式在配置化、插件化的代码里很常用。当然,闭包也不全是优点,如果外层变量被内层函数意外持有,可能导致内存不释放,但在Python普通的脚本场景里,这个影响基本可以忽略。
5. 函数也是一等公民,Python函数式三板斧
5.1 函数可以赋值、传参、返回——这代表什么
在很多编程语言里,函数就是一个“过程”,不能像变量一样传来传去。但Python不一样,你可以在运行时把函数赋给一个变量,可以把它作为参数传给另一个函数,也可以把它作为返回值。这就是所谓的“函数是一等公民”。
举个例子:
def square(x): return x * x f = square # 把函数赋给变量 print(f(4)) # 16这里的f不是调用square()的结果,它和square指向的是同一个函数对象。你可以把这种机制理解为“把工具装进工具箱,然后再决定什么时候用”。更进一步,函数还能作为参数:
def apply_twice(func, value): return func(func(value)) print(apply_twice(square, 3)) # 81,先3*3=9,再9*9=81这种“把函数传来传去”的写法,在数据处理里非常常见。比如你有一个列表,想对每个元素做不同处理,可以用一个通用的处理函数,接收“处理方式”作为参数。我第一次看懂这种代码时,最大的感受是:原来代码也可以像积木一样自由组合。这也是装饰器能够存在的基础,因为一个函数能接收另一个函数并返回一个新函数,才有了后面那种优雅的语法糖。
5.2 lambda:临时的小函数工具
lambda是Python创建小匿名函数的语法。所谓“匿名”,就是不需要用def给它起名字。它的基本形式是:
square = lambda x: x * x print(square(5)) # 25其实def定义一个函数也可以达到同样效果,lambda的定位是“临时用一下,不值得专门起名”。最常见的场景是配合sorted等内置函数使用:
students = [ {"name": "张三", "score": 85}, {"name": "李四", "score": 92}, {"name": "王五", "score": 76}, ] students.sort(key=lambda s: s["score"], reverse=True) print(students)这个排序的例子,如果用def单独写一个取分的函数,逻辑上也没问题,但代码会显得“小题大做”。lambda把“就取这个字典里的score字段”这个意图直接写在key参数里,读起来非常顺。
不过lambda也有它的限制:函数体只能有一个表达式,不能有多条语句,也不能用=做赋值。所以你看到有人用lambda写特别复杂的逻辑,比如里面塞一个三元表达式再套嵌套,可读性就下来了。我的习惯是:逻辑超过一行,就老老实实写def,别硬用lambda炫技。你写的代码最后是给同事看的,不是给解释器看的。
5.3 map/filter/reduce配合函数使用的组合拳
这三个内置函数()是函数式编程的经典组合。map(func, iterable)把函数依次作用于序列的每个元素,返回一个迭代器:
numbers = [1, 2, 3, 4, 5] squared = map(lambda x: x ** 2, numbers) print(list(squared)) # [1, 4, 9, 16, 25]注意,map返回的是迭代器,直接print只能看到<map object at ...>,需要list()转成列表才能直观看到结果。filter(func, iterable)则按照函数返回的真假值,筛掉为False的元素:
numbers = [1, 2, 3, 4, 5, 6] evens = filter(lambda x: x % 2 == 0, numbers) print(list(evens)) # [2, 4, 6]reduce不在内置函数里,它在functools模块中。它做的事情是把序列“累积”成一个值。比如求累乘:
from functools import reduce numbers = [1, 2, 3, 4, 5] product = reduce(lambda x, y: x * y, numbers) print(product) # 120在实际工作中,我发现filter和map经常可以用列表推导式替代,而且列表推导式往往更直观:
squared = [x ** 2 for x in numbers] evens = [x for x in numbers if x % 2 == 0]列表推导式的性能通常也更好。那map/filter是不是就没用了?也不是,当你处理的是生成器、内存里不想一次性生成整个列表的时候,它们就很合适。我的建议是:初学阶段两种写法都练一练,理解它们各自适合的场景。面试时被问“Python函数式编程有哪些手段”这类问题,你能把lambda、map、filter、reduce、列表推导式、生成器表达式之间的联系和区别讲清楚,比单纯背概念有用得多。
6. 内置函数速查与常见报错排查
6.1 我平时最常用的内置函数清单
Python内置函数有三四十个,但真正每天高频使用的大概是下面这十几类。我把它们按用途分组,方便你查阅:
类型转换与数学计算类
| 函数 | 作用 | 示例 |
|---|---|---|
int() | 转整数 | int("42")→42 |
float() | 转浮点数 | float("3.14")→3.14 |
str() | 转字符串 | str(42)→"42" |
bool() | 转布尔值 | bool(0)→False,bool([])→False |
abs() | 绝对值 | abs(-5)→5 |
round() | 四舍五入 | round(3.14159, 2)→3.14 |
sum() | 求和 | sum([1,2,3])→6 |
max()/min() | 最大/最小值 | max(3, 5, 2)→5 |
divmod() | 商和余数 | divmod(10, 3)→(3, 1) |
int()能直接把字符串数字转成整数,但要注意int("3.14")是会报错的,应该先转float再转int。bool()的规则有点反直觉:bool("")是False,但bool(" ")是True,因为空格字符串非空。还有bool([])、bool({})都是False,这是Python把“空容器”视为假值的约定。
序列处理类
| 函数 | 作用 | 示例 |
|---|---|---|
len() | 获取长度 | len("hello")→5 |
enumerate() | 同时取索引和值 | list(enumerate(["a","b"]))→[(0,'a'),(1,'b')] |
zip() | 并行打包序列 | list(zip([1,2],["a","b"]))→[(1,'a'),(2,'b')] |
sorted() | 排序 | sorted([3,1,2])→[1,2,3] |
reversed() | 反向迭代 | list(reversed([1,2,3]))→[3,2,1] |
这里我要特别说一下enumerate。新手遍历列表想同时拿下标,经常写:
for i in range(len(fruits)): print(i, fruits[i])用enumerate可以优雅得多:
for i, fruit in enumerate(fruits, start=1): print(i, fruit)start=1可以自定义起始下标,在处理“从第1条开始编号”的业务逻辑时很实用。zip则常用于把多个列表按位置配对,比如把名字列表和成绩列表合并成一个个元组,再交给循环处理。
6.2 报错信息怎么看:从NameError到TypeError
写函数过程中最常见的报错,我按出现频率排个序,并附上真实原因:
NameError: name 'xxx' is not defined这个报错的意思是:解释器在LEGB链条的每一层都找不到xxx这个变量。常见原因是变量名拼写错误,或者函数内部访问了还没定义的变量。还有一种情况很隐蔽:你在函数里用了global声明之后才赋值,但赋值语句写在了调用之后,执行顺序导致没走到赋值那一步。排查思路很简单——看报错那一行,往前找变量是从哪里来的,是不是先赋值再使用。
TypeError: xxx() takes 2 positional arguments but 3 were given这个报错说明参数数量不匹配。你定义了一个函数只需要两个参数,调用时却传了三个。也可能是你调用内置函数时少传了参数,比如round(3.14)没问题,但round()就会报这个错。解决方式:数一数函数定义的形参个数和调用传入的实参个数。如果你想支持不固定数量的参数,就用前面讲过的*args。
IndentationError: unexpected indent缩进错误。可能是某一行多了或者少了空格,也可能是混用了Tab和空格。Python官方推荐用4个空格缩进,并且永远不要混用Tab。现代编辑器比如VS Code、PyCharm基本都默认把Tab转成4个空格,但如果你拿文本编辑器改代码,就要特别注意。
SyntaxError: invalid syntax语法错误,解释器完全看不懂你写的代码。常见于中英文符号混用,比如把中文逗号“,”写在代码里,或者漏了冒号、括号没闭合。这种报错最大的问题是它指向的行不一定真的是错误行,因为解释器检查到最后才发现“这个表达式不完整”。排查时可以先看这一行,再看上一行,通常问题在上一行。
6.3 环境类报错:命令找不到怎么办
热搜词里有一组很典型的问题:“claude: 无法将‘claude’项识别为 cmdlet、函数、脚本文件或可运行程序的名称”,还有opencode、npm、pnpm、git报同样的错。这类问题虽然不是Python函数本身的报错,但我在教别人配环境时遇到得很多。核心原因是:你在命令行敲了一个命令,但系统在环境变量PATH对应的目录里找不到这个程序的可执行文件。
如果你装完Python之后,在命令行敲python也报“无法识别”或者"not recognized",一般就两个原因:
- Python安装时没有勾选“Add Python to PATH”选项。Windows安装包在第一个界面底部有那个复选框,很多人没注意。解决办法是重新安装一次,或者手动把Python安装目录(比如
C:\Users\你的用户名\AppData\Local\Programs\Python\Python312\)和它的Scripts子目录加到系统环境变量里。 - 安装成功后装了一些带命令行工具的第三方包,这些工具所在目录(通常是
Scripts)没进PATH。很多包都会在Scripts下生成一个.exe入口,报错信息和上面完全一样。
遇到这类问题,先在命令行执行:
where python或者Windows PowerShell下用:
Get-Command python | Format-List *看看Python到底装到哪了,然后去检查环境变量。排查过程本身不复杂,但对新手来说,最难的是意识到“问题出在环境变量,而不是我的代码写错了”。我见过太多人报错后反复检查函数代码,结果问题根本不在那。先用where确认命令能被找到,再开始怀疑代码逻辑,这个顺序能省下大量时间。
至于“要安装缺失的节点,请先在你的Python环境中运行pip install”这类提示,则说明你运行的程序需要某些第三方库,但环境里没装。操作方式是在终端切换到对应的Python环境,执行pip install 包名。切记不要在错误的Python环境里安装,尤其当你电脑上同时存在多个Python版本时,要用python -m pip install 包名而不是直接pip install,这样能确保装到当前python对应的环境里。
6.4 把数学函数用对:sqrt背后的模块意识
再补充一个热搜词里出现过的细节:平方根函数sqrt。很多初学者一上来就用sqrt(16),结果报NameError,因为sqrt并不是内置函数,它在math模块里。正确的做法是先导入:
import math print(math.sqrt(16)) # 4.0也可以只导入这个函数:
from math import sqrt print(sqrt(16)) # 4.0这个例子看起来简单,但它背后是一个重要的模块化思维:Python把常用数学运算、随机数、时间处理、文件操作等功能分散在不同模块里,用多少就导入多少。内置函数是随时可用的,但绝大多数专业功能都封装在模块和包里。写函数时也是这样,一个函数不应该依赖那些“恰好存在”的全局变量,它应该通过参数把需要的东西传进来,干净清爽。如果某个计算逻辑比较复杂,就把相关函数组织在一个模块里,互相调用,需要时再import,这样代码结构才会越来越清晰。
我在实际使用中发现,学函数最有效的路径不是背语法,而是带着“这段代码我要复用”的意识去写。每次你想复制粘贴一段逻辑时,停下来想想:如果把这个逻辑抽成一个函数,参数是什么,返回值是什么?坚持两周,你会发现代码的可读性和复用率会有质的提升。函数学到这里,下一步再去看生成器、装饰器、类这些概念,你会轻松很多,因为它们全都是围绕“函数”这个基础组件展开的。