我第一次觉得自己真正迈入Python门槛,不是学会列表推导式的那个下午,也不是跑通第一个爬虫的深夜,而是某天加班时把一段重复了七八遍的代码改成函数的那一刻。在那之前,“函数”这两个字对我而言只是教程里的一个章节名字,我以为它不过是把代码“包起来”再“叫出来”而已。等我真的开始用函数重构代码,才发现这件事远比语法本身深刻得多——它改变了我拆解问题的方式。
这一课的内容,就是Python函数。它适合所有刚学完变量、数据类型、分支和循环,正准备往“写真正可维护代码”方向走的入门读者。我们先搞清楚函数解决的核心痛点,再逐个拆解定义、参数、返回值、作用域这些重难点,最后我会把这几年来教新手写函数时反复强调的几条实战建议一并分享出来。这一课读完,你写出的代码会和之前有一个肉眼可见的分水岭。
1. 函数到底解决了什么问题:从重复代码到“提货单”思维
先别急着写def。在动手之前,我想用一个特别接地气的场景,讲清楚函数存在的意义。
假设你现在要写一段程序,计算三个矩形的面积。最原始的做法是什么?长乘宽,算三次:
width1, height1 = 4, 5 area1 = width1 * height1 print(f"第一个矩形面积: {area1}") width2, height2 = 7, 9 area2 = width2 * height2 print(f"第二个矩形面积: {area2}") width3, height3 = 10, 12 area3 = width3 * height3 print(f"第三个矩形面积: {area3}")这段代码能跑,但它有一个非常隐蔽、却会持续放大的问题:如果有一天你发现“面积”的计算规则变了,比如要四舍五入到两位小数,你就得找到所有算面积的地方,一处一处去改。三处还不算要命,那三十处呢?三百处呢?你改到第五处的时候,大概率会漏掉一处,然后程序带着一个隐蔽的错误运行很久。
函数的第一个价值,就是复用。把“长乘宽”这一步逻辑写进一个叫calc_area的函数里,以后所有需要算面积的地方都调用它。将来规则变了,只改函数内部一行代码,所有调用它的地方全部同步生效。这就是为什么你会听到“不要重复自己”这句程序员老话——重复是滋生bug最肥沃的土壤。
再看一个更贴近生活的类比。你走进咖啡店,从来不会对店员说“请先研磨咖啡豆、再用92度的水萃取、打出奶泡、倒入杯中再加一份糖浆”对吗?你只会说“来一杯拿铁”。“拿铁”这个词,就是把一整套复杂工序打包成了一个名字,店里的操作手册就是它的实现细节。函数就是代码世界里的“提货单”——你把一系列操作打包,取一个名字,之后只要说出这个名字,Python就会帮你执行完里面的所有步骤。
函数的第二个价值,是抽象。当你把“画一个用户登录框”的20行代码封装成draw_login_box()之后,再去看主程序逻辑时,视线里只有一行干净的名字,不会被20行的细节淹没。写代码读代码,本质上是在管理自己的注意力带宽。抽象得好的人,看代码像看目录,一层层下去才看得到细节;不会抽象的人,看代码像翻一本没有目录、没有章节的流水账。
那具体什么时候该把代码拆成函数呢?我给自己定过三条触发条件,分享给你参考:
- 同一段逻辑在项目里出现两次以上,且未来有大概率再出现第三次。
- 一段代码的长度超过了你的“一次性理解范围”——对新手来说,差不多10到15行还读不出它想干什么的,就值得命名封装。
- 你发现给一段代码取个名字很难,这通常说明这段代码混杂了太多职责,反而提醒你该把它拆成多个函数。
记住一个很反常识的点:函数不只是为了“少写代码”,更多是为了“让代码更好读、更好改”。就算一段代码只用一次,只要它让主线逻辑变得清爽,照样值得封装。
2. 定义函数的完整姿势与新手最容易忽略的三个细节
2.1 最基础的定义与调用流程
Python定义函数的语法,大概是整个语言里最像自然语言的写法了:
def 函数名(参数列表): """可选的文档字符串""" 函数体代码 return 返回值看一个完整的最小例子:
def greet(name): """向指定的人打招呼""" message = f"你好,{name}!" return message result = greet("张三") print(result) # 输出: 你好,张三!def是define的缩写,意思就是“定义”。Python读到def关键字时,并不会立刻执行函数内部的代码,它只是把这段代码“登记”下来,记住函数名叫greet、参数是name、函数体是什么。真正执行要等到后面你写出greet("张三")这行调用语句的时候,程序才会跳进函数内部,把"张三"交给参数name,运行函数体,最后把return后面的值交还给你。
新手最容易在“函数定义之后必须手动调用才生效”这件事上犯迷糊。你可以把函数定义理解成录制了一段自动应答系统的语音,不按下播放键,它永远不会自己开口说话。
2.2 函数命名的约定:给代码一点“自解释”的机会
给函数起名字,不是随便起的。虽然Python允许你写def a()、def f1(),但等你三天后再回来看这段代码,大概率会对着f1发呆。行业内约定俗成的做法是:
- 小写字母加下划线分隔,比如
calculate_total_price,这叫snake_case。 - 名字最好以动词开头,让人一眼看出“这个函数做什么”,比如
get_user_info、is_valid_email、save_file。 - 名称要具体,避免
do_stuff、process_data这种说了等于没说的名字。
我见过不少初学者觉得“给名字想半天”是浪费时间,事实恰恰相反,命名是在强迫你理清函数职责。一个名字都起得含糊的函数,十有八九内部逻辑也是含糊的。
2.3 三个必须养成肌肉记忆的细节
细节一:缩进就是Python的语法边界。其它很多语言用花括号{}来划分代码块,Python用缩进。函数体必须相对def行缩进,通常四个空格。缩进错了,或者该缩进的没缩进,直接报IndentationError: expected an indented block。这个错误几乎每个Python入门者都遇到过,解决方式也简单:编辑器里统一用空格,不要混用Tab和空格,很多免费编辑器都自带“把Tab转为空格”的设置。
细节二:文档字符串不是注释那么简单。函数定义第一行下面如果跟着一个三引号字符串,就称为docstring,也就是文档字符串。它不仅仅是给人看的注释,还会被Python自动保存。你在交互环境里敲help(你的函数名),看到的内容就是它。对内行来说,一个没有docstring的公共函数就像一本没有使用说明的电器,能用,但不敢放心用。
细节三:没有return的函数,返回的是None。这是后续所有困惑的起点。哪怕函数体内只干了一件事而没有写return,Python也会在函数结束时隐式地返回一个None。None在Python里是“什么都没有”的专属值,很多新手在这上面栽跟头——他们调用了函数,却发现拿到的结果无法继续计算,原因就在这里。这一条我们在第4章会详细展开。
3. 参数传递的完整地图:从顺序匹配到*args和**kwargs
参数是函数里最容易让新手头晕的部分,但也是最值得花时间的部分。原因很简单:函数是生产车间,参数就是送进来的原材料。材料种类、数量、交接方式没搞清楚,后面全是混乱。
3.1 位置参数与关键字参数:两种“对号入座”的方式
先看一段最普通的代码:
def introduce(name, age, city): print(f"{name}今年{age}岁,来自{city}") # 按位置传参:必须严格对号入座 introduce("李雷", 18, "北京") # 输出: 李雷今年18岁,来自北京 # 按关键字传参:顺序无所谓,名字对上就行 introduce(city="上海", name="韩梅梅", age=20) # 输出: 韩梅梅今年20岁,来自上海按位置传参是最直观的,但有个致命弱点——顺序一旦记错,数据就串位了。比如你写成introduce("李雷", "北京", 18),程序不会报错,但年龄和城市就互换了,这种bug特别隐蔽。按关键字传参则完全没有这个顾虑,只认参数名。实际开发中,尤其当参数比较多、或者有些参数含义相近时,我强烈建议至少给关键的几个参数使用关键字传参。
还可以混着用,位置参数在前,关键字参数在后:
introduce("李雷", city="北京", age=18)但要注意,一旦某个参数用了关键字形式,它后面所有的参数都得用关键字,这是语法硬性规定。
3.2 默认参数:给函数提供“免选套餐”
默认参数的意思是,定义函数的时候直接给某个参数指定默认值,调用时可以省略它:
def make_drink(name, size="中杯", sugar="标准"): print(f"制作{size}{name},甜度{sugar}") make_drink("拿铁") # 输出: 制作中杯拿铁,甜度标准 make_drink("美式", size="大杯", sugar="无糖") # 输出: 制作大杯美式,甜度无糖默认参数的价值在于:调用方大多数时候只需要关心必填信息,低频选项不用每次都填。
但这个特性背后藏着一个整个Python入门阶段最经典的坑——默认参数不能用可变对象(列表、字典这类)。看这段代码:
def add_item(item, container=[]): container.append(item) return container print(add_item("苹果")) # 输出: ['苹果'] print(add_item("香蕉")) # 输出: ['苹果', '香蕉'] ← 意外! println(add_item("橘子")) # 输出: ['苹果', '香蕉', '橘子'] ← 更意外!第二次调用时,你期望container是空列表,结果里面居然还留着上一次的“苹果”。原因是:默认参数[]只在函数定义那一刻被创建一次,之后所有调用共享同一个列表对象,谁都往里塞东西,它就越来越长。修复方式是用None占位:
def add_item(item, container=None): if container is None: container = [] container.append(item) return container这个坑让我帮不少新手排查过,每个人都是一脸震惊“怎么还能这样”。记住结论就行:默认参数只用不可变类型,比如None、整数、字符串、元组。
3.3 收集多余参数:*args与**kwargs
有时候你并不知道调用方会传多少个参数。比如写一个函数计算任意多个数字的总和,参数数量完全不确定。这时候就需要“收集参数”:
def total(*args): """把传入的多个位置参数打包成一个元组""" return sum(args) print(total(1, 2, 3)) # 输出: 6 print(total(10, 20, 30, 40)) # 输出: 100*args里的星号表示“把这里所有多余的位置参数打包成一个元组,名字叫args”。同理,**kwargs把多余的关键字参数打包成一个字典:
def show_info(**kwargs): for key, value in kwargs.items(): print(f"{key}: {value}") show_info(name="张三", age=25, job="工程师") # 输出: # name: 张三 # age: 25 # job: 工程师args和kwargs这两个名字本身没有魔法,只是Python社区约定俗成的惯用名。真正有魔法的是*和**这两个符号。与之对应,还有反向操作——把列表“展开”后传给函数:
def calculate(a, b, c): return a + b + c nums = [1, 2, 3] print(calculate(*nums)) # 相当于 calculate(1, 2, 3),输出: 6参数定义的顺序有一个硬性规则:普通位置参数、默认参数、*args、**kwargs,按这个顺序排列。大致写法是def func(a, b=1, *args, **kwargs),顺序乱了会直接报语法错误。
4. return的隐藏逻辑:为什么你的函数总是返回None
这一章我要把return彻底讲透,因为**“函数返回None”是我在入门阶段收到最多的问题之一**,而且这个问题几乎完全由误解引起。
4.1 print和return,一个给别人看,一个给自己用
很多新手的第一个困惑是:“我明明在函数里用print打印出了结果,为什么外面拿不到?”。看这个例子:
def add(a, b): print(a + b) result = add(1, 2) print(result) # 输出: None第一行print(3)是函数内部的行为,它把结果输出到了控制台屏幕,但那只是“展示”。函数运行结束后,外面接到的result是None,因为这个函数从头到尾没有return过任何东西。print是“展示给别人看”,return是“把结果交给调用方使用”。展示得再多,也交不出来。
什么时候需要return?只要外面的代码还需要接着用这个结果做进一步计算,就必须return。比如:
def add(a, b): return a + b total = add(1, 2) final_result = total * 10 # 这里能继续运算,就是因为拿到了返回值 print(final_result) # 输出: 30如果你只是想看一个中间过程,那用print没毛病。但如果你打算把这个函数的产出当作另一个函数的输入,或者存进变量后续处理,就绝对离不开return。我个人的习惯是:一个函数要么以return交出结果,要么做完整件事后什么都不返回,这两种定位在写之前就要想清楚。
4.2 return的三种隐藏形态
形态一:没有return,等于末尾隐藏了一个return None。Python处理得很“温柔”——你没写,它帮你补上。代价就是这样容易让初学者误以为“函数没有返回值”,实际上它返回了一个None,只是你没意识到。
形态二:return后面不跟任何值,也是返回None。常见用法是提前结束函数。比如检查参数不合法就直接return,后面的逻辑不再执行。这有点像循环里的break,只不过退出的是整个函数。
形态三:同时返回多个值。有人会问“Python函数是不是只能返回一个值”?从语法优化程度看,确实只返回一个对象,但你可以返回一个元组,外面用多个变量同时接住:
def get_min_max(nums): return min(nums), max(nums) # 自动打包成一个元组 low, high = get_min_max([3, 9, 1, 7]) print(low, high) # 输出: 1 9low, high = ...这种写法叫“元组解包”,两边的数量必须一致,不一致会报ValueError。这个技巧在Python里非常常用,你会经常看到函数一口气返回好几个结果,这是语言设计上的特色,不是bug。
4.3 一个“返回值型”与“动作型”函数的使用分寸
我见过有人把一切逻辑全写进函数,而且每个函数都强行写return,结果代码读起来像把所有功能塞进了一个没有出口的迷宫。其实写一个只做动作、不返回结果的函数完全正常,比如“保存文件”“发送消息”“打印日志”。这类函数突出的是“我已经干完了这件事”,返回值对调用方没有价值。
反过来,如果你需要把一个计算结果传给下一段逻辑,就一定要保证每个分支都有return。许多逻辑错误来自于“某个分支忘记写return”,导致程序时而拿到数值、时而拿到None。排查这类问题有个笨却有效的办法:在调用的地方打印一下返回值的类型和内容,立刻就能看出问题出在哪个分支。
5. 变量作用域:为什么函数里改了全局变量,外面纹丝不动
函数内部能访问外面的变量吗?能。函数内部能修改外面的普通变量吗?这个问题是很多新手的认知分水岭,也是Python新手最密集出错的地方之一。
先看第一段代码:
count = 10 def show_count(): print(count) # 能读到外部变量 show_count() # 输出: 10没问题,函数内可以读取全局变量。再看这段:
count = 10 def update_count(): count = count + 1 # 报错! update_count()直接报UnboundLocalError: local variable 'count' referenced before assignment。这是怎么回事?Python有一条规则:只要函数内部对某个变量做了赋值操作,Python就把这个变量视为局部变量,在赋值之前引用它就会报错。上面的count = count + 1里,右边的count此刻被判定为“尚未赋值的局部变量”,于是直接崩溃。
就算避开赋值,改写成下面这样,结果也让你意外:
count = 10 def update_count(): count = 20 # 这个count是全新的局部变量 update_count() print(count) # 输出: 10,外部count纹丝未动原因是一样的——函数内的count = 20创建的是一个局部变量,它和外面的全局变量只是恰好同名,实际上是两个完全不同的东西。函数结束后,局部变量就被销毁了,外部的count从未被碰过。
那到底怎么让函数修改全局变量呢?用global声明:
count = 10 def update_count(): global count count = 20 update_count() print(count) # 输出: 20global count告诉Python:“函数里这个count,请把它当作全局那个count。”听起来挺方便的对吧?但我要说一句经验之谈:全局变量能少用就少用,能不用就不用。你想象一个项目里有十几个函数都在读写同一个全局变量,任何一个函数都可能悄悄改掉它的值,排查bug时,你就得把这些函数全部看一遍才知道是谁动的手。这几乎是所有程序员的噩梦。更健康的做法是:函数通过参数接收输入,通过return返回结果,不依赖外部状态,也不改写外部状态。这样的函数好比一台独立运行的加工机,喂什么材料出去什么成品,中间不掺私货。
Python在查找变量时遵循一套简明的规则,缩写是LEGB,按优先级排序:
- Local:当前函数内部的局部变量
- Enclosing:外层嵌套函数里的变量
- Global:模块级别的全局变量
- Built-in:Python内置的名字,比如
print、len
查找顺序从上到下,找到就停。这套规则你现在只需要混个眼熟,等你之后学到闭包再回头理解,会顺滑很多。
6. lambda、纯函数:这些名词背后的真功夫
6.1 lambda:一句话就能定义的临时小函数
Python里有一种用lambda关键字创建的匿名函数,它可以在一行内完成定义:
add = lambda x, y: x + y print(add(3, 5)) # 输出: 8这段代码等价于:
def add(x, y): return x + ylambda的语法是lambda 参数: 表达式,表达式的计算结果就是函数的返回值。显然,它能干的事很有限——只适合写一句话能表达清楚的简单逻辑。比如排序时指定排序依据:
students = [ {"name": "张三", "score": 82}, {"name": "李四", "score": 95}, {"name": "王五", "score": 76}, ] # 按成绩从高到低排序 students.sort(key=lambda s: s["score"], reverse=True)这个lambda s: s["score"]就是“取每个字典里score字段的值”,比专门定义一个具名函数再传进去要简洁得多。类似的场景还有map、filter这些一次性遍历函数,如果你的逻辑只需要一行表达式,用lambda很合适;需要多行、有分支、有循环,就老老实实用def写具名函数。把复杂逻辑塞进lambda,会让代码变成一行谁也看不懂的“天书”,那就得不偿失了。
6.2 纯函数:写代码时给自己留一条好走的路
“纯函数”这个词听起来有点学院派,其实意思非常直白:同样的输入,永远得到同样的输出,并且函数执行过程中不修改任何外部状态。换句话说,这种函数就像一台黑箱计算器,不会偷偷改变别人的东西,也不会依赖什么“时有时无”的环境变量。
纯函数为什么好?因为好测试、好排查。你想验证一个纯函数对不对,只需要准备一组输入,写上期望输出,跑一遍对一下结果就够了。函数里没有外部状态参与,就不会出现“昨天好好的,今天突然不行了”的玄学问题。第5章里讲的“少用全局变量”,从设计角度看,本质上就是往纯函数的方向靠。
作为入门者,你在写函数时可以先只认一条:如果这个函数的外部世界中没有一个变量因为调用它而发生变化,它大概率就是标准的纯函数。写多了之后你会发现,用纯函数拼装出来的程序,读起来像一条干净的流水线,每一道工序都清清楚楚。
7. 写给刚起步的你:我踩过的那几个函数相关的坑
最后这部分不讲语法了,讲点真正来自实操的经验。这几年我陆续带过几位零基础的朋友入门Python,第一个月里他们问得最多、最容易反复犯错的,都集中在下面几件事上,提前知道能帮你省下不少查bug的时间。
第一件事:函数写好之后,记得在某处真的“调用”一次。这话听起来像废话,但真的有很多人定义了函数,运行程序,看到没有输出就一脸茫然。def只是登记函数,不会执行它。我建议你每写完一个函数,马上用几行测试数据调用它并打印结果,确认行为符合预期再继续往下写。这种“边写边验”的习惯,比写完一大段再统一调试高效得多。
第二件事:一个函数只做一件事。如果你的函数名叫process_data,里面又读文件、又算统计、又画图、又打印,我基本可以确定,不到一周你自己都读不懂它。把函数拆成load_data、compute_stats、plot_chart这样的细粒度小函数,每个都短小、清晰、可单独测试,组合起来反而更强大。判断标准很简单:如果你发现自己需要用“然后”“还有”这样的词来描述一个函数的行为,它多半该拆了。
第三件事:参数超过三个就该警惕了。一个函数如果需要传五六个参数,那调用时很容易弄错顺序。与其硬传,不如考虑把相关参数打包成字典、结构体或之后会学到的数据类。第3章讲的关键字参数在这个问题上能提供很大缓解,但仍然不能替代清晰的参数设计。
第四件事:不要一上来就过度封装。有些新手听完函数的好处后进入另一个极端,看什么都像函数,三行代码也包一层。我的态度是:先用最直接的方式把功能写出来,等代码真的出现重复、或者主线变得难读时,再回头抽函数。函数是重构出来的,不是设计出来的,这句话对入门者尤其适用。
第五件事:多读别人的函数。Python自带的标准库,以及你身边任何一份写得工整的项目代码,都是免费的学习材料。读到别人的函数时,先猜它做什么,再对照文档字符串验证,然后注意别人是怎么处理边界条件和默认参数的。这种“仿写式”学习,比单纯背语法快得多,也扎实得多。
函数这条路,走得慢一点没关系,关键是每个概念都亲手写一遍、每个报错都亲手解决一次。这一课的内容够你消化一阵子,等你发现自己开始不自觉地拆函数、起名字、理参数顺序的时候,恭喜你,Python的大门你已经真正走进来了。