☰
Python函数定义与调用全解析:从参数传递到作用域实战
2026/10/15 5:32:20 网站建设 项目流程

写Python,绕不开函数。不管你是刚摸到语法边的初学者,还是已经能用脚本解决日常问题的爱好者,函数定义与调用这堵墙早晚要翻过去。坦白讲,我见过不少代码写得又臭又长的人,几百行脚本从头写到尾,中间改一个参数要用编辑器的全局替换,想加一个功能就得把一大段逻辑从头复制一遍。代码能跑,但人快崩溃了。函数就是专门治这个毛病的。

函数的价值用一句话说清:把一段有完整逻辑的代码打包起来,给它一个名字,定义好入口(参数)和出口(返回值),之后随时像用积木一样调用它。这篇文章不打算背文档,我会把函数定义与调用里的每个环节都拆开揉碎讲一遍,包括参数传递的各种姿势、作用域、坑位现场,最后用一个完整案例演示怎么把零散脚本重构成一堆干净的函数。适合谁?刚学会if、for,想进入结构化开发阶段的初学者,非常合适;写过一段时间脚本、总觉得代码别扭但不知道怎么改的人,也能从中找到顺手可用的重构思路。

1. 函数到底是什么:从生活例子里理解本质

1.1 用“做蛋炒饭”理解函数三要素

把函数想象成厨房里的一道工序。你不可能每次炒饭都从头翻菜谱、切葱、热锅、烧油,再一步步来一遍;第一次学会之后,你会把整套流程记在脑子里,下次说一句“炒个蛋炒饭”,整个流程就被激活了。函数在Python里干的也是这件事。

一个完整的函数有三个核心要素:名字、入口、出口。名字就是函数名,它让你不必关心内部每条语句在干什么,只要知道“调用它就能完成这个任务”;入口就是参数,外部数据通过参数进入函数,就像你递给厨师一份米饭和鸡蛋;出口就是返回值,函数处理后把结果交还给你,等你端走。

这种抽象能力是编程思维的分水岭。初学者写代码像流水账,一行一行往下排,眼睛盯着屏幕就能知道哪一步在干什么。但代码一旦超过几百行,流水账就会失控:一个数据要在多个地方被修改,改完忘了同步,两台机器上跑出的结果不一样,排查起来简直是灾难。函数把“过程”变成一个“物件”,你关注的焦点从“每一步怎么实现”转移到“这个功能做什么、输入输出是什么”,复杂度立刻降维。

1.2 复用只是函数最表面的好处

很多人一提函数的好处就只说“复用”,其实这只是冰山一角。函数真正改变的是你的组织代码的方式,它逼着你把大问题拆成小问题,每个小问题都有明确边界,这恰恰是工程化开发的核心思维。

举一个我踩过的坑。早年间写爬虫脚本,连续抓十几个页面,每一页都要做同样的清洗、解析、去重。我图省事,直接复制粘贴,每一段微调几个字段名。结果某个页面改了HTML结构,我花了一下午在十几处复制代码里逐一修改,漏了两处,数据第二天全乱。后来拆成fetch_page、parse_page、save_to_db三个函数,改一个数据结构,只需要动一个函数。

除此之外,函数还有两个看不见的好处:可测试性和可组合性。把逻辑拆进函数以后,你可以单独喂测试数据进去,观察返回值对不对,而不用把整个程序跑起来才能验证。两个写好的函数还可以拼出新功能,比如先tokenize(text)再做filter_stopwords(tokens),组合本身就是一种创作。我越来越觉得,写函数的功夫,本质上是在训练自己“拆解问题”的能力,这个收益会跟着你走很远。

2. 函数定义语法:def语句的每一处细节都别糊弄

2.1 基本写法与函数命名的规矩

定义一个函数在Python里非常简单,一个def关键字,一个函数名,一对括号,一个冒号,然后是缩进的函数体:

def greet(name): """根据名字生成一句问候语""" return f"你好,{name}!"

这里值得注意的细节不少。函数名要小写,单词之间用下划线分隔,这是PEP 8约定俗成的主流风格。比如calculate_area比CalculateArea更常见,get_user_list比getUserList更符合Python社区习惯。虽然Python并不强制,但写出来一眼能看出“这是Python代码”,在大团队协作里尤其重要。

函数名本身要有“动词感”。函数做的是事情,命名最好直接体现动作:parse_html、send_email、load_config。不要用data、handle这类似是而非的名字,我看到过太多叫process_data的函数,看完整个函数体都不知道它到底处理了什么。一个好的函数名加上好的参数名,很多时候比注释还管用。

函数定义的时候,括号里的name叫形参,它是函数内部的变量占位符。等真正调用greet("张三")时,"张三"这个值才被传进去,这叫实参。很多初学者分不清这两个词,其实记住一句话就行:定义时是形参,调用时是实参。后面讲参数传递时会反复用到这组词。

2.2 函数体、docstring和缩进这条底线

Python用缩进制作用域,函数体必须统一缩进,一般用4个空格。刚学的时候总以为缩进只是“好看”,直到我见过把混用Tab和空格的代码丢进运行时报IndentationError,整段代码直接罢工,才明白缩进在Python里不是风格问题,是语法问题。

函数体内部第一行,建议写一个docstring,也就是用三引号括起来的说明文字:

def calculate_area(length, width): """计算矩形面积。 参数: length: 矩形长度。 width: 矩形宽度。 返回: 面积值(float) """ return length * width

docstring有三个实实在在的作用。第一,输入help(calculate_area),解释器会把这段说明打印出来,比翻代码看逻辑快得多。第二,很多文档生成工具会从docstring自动抽取API文档,团队协作时不用额外维护文档,省一大笔功夫。第三,docstring逼着你定义函数时先想清楚“这个函数到底要做什么”,哪怕只写一句话,思路也能清晰很多。

如果某个函数暂时不想实现,可以先用pass占位,这相当于给函数留了个空壳:

def future_feature(): pass

这样代码不会报错,程序还能正常运行,等后面再补实现。我用这个技巧做项目骨架,先把模块间调用关系搭好,再一个函数一个函数去填内容,写代码的节奏感会好很多。

2.3 第一个完整的函数:矩形面积计算

把上面这些串起来,写一个真正可用的计算矩形面积的函数:

def calculate_area(length, width): """计算矩形面积。""" return length * width # 调用它 area = calculate_area(3.5, 4.2) print(f"矩形面积为: {area:.2f}")

运行之后会输出矩形面积为: 14.70。这个例子虽然小,但已经把定义、docstring、return、调用、结果接收全流程走了一遍。后面要做的所有复杂操作,无非是在这几条主线里加更多细节。

注意return后面那个值就是函数的出口。area = calculate_area(...)这一步,等号右边的函数调用会先被计算成结果,再把它赋给area。这就是往返的过程,清晰、确定、可靠。

3. 函数调用:参数传递是从门外到门内的通道

3.1 位置参数与关键字参数:两种玩法各有优劣

写函数的时候定义的是形参,用的时候传的是实参,实参怎么传,Python给了两种主流方式。

位置参数,靠顺序对应。定义def purchase(customer, item, price),调用时purchase("张三", "苹果", 5),三个值按位置依次塞进customer、item、price。这种方式简洁,但有个致命弱点:一旦调用的地方多了,你很容易搞混某个位置到底传的是什么。代码一长,purchase("苹果", 5, "张三")这种错位事故几乎不可避免。

关键字参数在括号里写明形参名:

purchase(customer="张三", item="苹果", price=5)

这样就不用记顺序,可读性也大幅提升。我评价一个函数好不好用,很多时候就看我读调用代码时,能不能不看函数定义就清楚每个参数的意义。关键字参数在这方面贡献极大。

两种混着用时有个硬性规则:位置参数必须在前面,关键字参数必须在后面,否则解释器直接报错。

# 正确的混用 purchase("张三", item="苹果", price=5) # 这里不行,会报SyntaxError purchase(customer="张三", "苹果", 5)

这个规矩并不是Python故意找茬,而是为了避免歧义:如果位置参数跟在关键字参数后面,解释器会拿不准到底该按什么规则匹配。

3.2 默认参数:给函数一个“备选方案”

有些参数在大多数调用时都用同一个值,这时候就可以设置默认参数。比如日志函数,大部分人都记录到INFO级别,极少需要DEBUG:

def log(message, level="INFO"): print(f"[{level}] {message}") log("服务启动") # 默认用INFO log("疑似出现异常", "ERROR") # 手动指定ERROR

默认参数的写法很简单,形参名后面跟等号跟默认值。这里有一个很多老手都会踩的坑:默认参数必须放在所有没有默认值的参数后面。

# 这样写不行 def log(level="INFO", message): pass

为什么?因为Python按位置匹配实参,如果把level放在第一个,调用时log("服务启动")这个字符串会被误认为level,而message没有实参可接,立刻报错。

真正的隐形炸弹在下面这个例子:

def add_item(item, basket=[]): basket.append(item) return basket

第一次调用add_item("苹果")返回["苹果"],第二次调用add_item("香蕉")返回["苹果", "香蕉"]——这好像没错?但如果你在一个循环里反复调用,并且每次都希望从空篮子开始,就会发现问题。

print(add_item("苹果")) # ['苹果'] print(add_item("香蕉")) # ['苹果', '香蕉'],第二次不应该是['香蕉']吗?

问题出在basket=[]这个空列表只被创建了一次。函数被调用多次,用的其实是同一个列表对象。默认参数在定义时就被求值,并一直存在内存里。解决方法是改成默认参数为None,函数体内再新建列表:

def add_item(item, basket=None): if basket is None: basket = [] basket.append(item) return basket

这个坑我见过无数次,包括一些工作好几年的开发者也会中招。记住一条原则:默认参数永远使用不可变类型,比如None、数字、字符串、元组。可变类型的默认值迟早出事。

3.3 可变参数:*args与**kwargs的收集魔法

有时候函数接收的参数数量不确定。比如print函数,你传3个值也行,传10个值也行。这时候就要用到可变参数。

用一个*开头的形参,会把没被匹配的位置参数收集成一个元组:

def total_sum(*numbers): print(numbers) # (1, 2, 3, 4) return sum(numbers) print(total_sum(1, 2, 3, 4)) # 10

用**开头的形参,则把没被匹配的关键字参数收集成一个字典:

def print_info(**kwargs): print(kwargs) # {'name': '张三', 'age': 20} print_info(name="张三", age=20)

*args和**kwargs是社区约定俗成的名字,Python并不强制你必须用这两个词,你可以写*numbers、**options,功能完全一样。但既然大家都这么写,你最好也这么写,别人一看就明白你要收集剩余参数。

反向操作同样存在。如果一个函数接收多个位置参数,而你手里恰好有一个列表,可以在调用时加一个*把它展开:

def add(a, b, c): return a + b + c nums = [1, 2, 3] print(add(*nums)) # 等价于 add(1, 2, 3)

这种方式在从前端传来的数据或配置文件里读取数据时特别好用,数据是一大堆值,函数接收的是固定参数,直接用展开运算符解决,不用手动写nums[0]、nums[1]这种难看代码。

3.4 返回值:return的三种形态

函数有没有return,行为很不一样。第一种形态,没有return,函数执行完自动返回None:

def print_summary(a, b): print(f"a+b={a+b}") result = print_summary(1, 2) print(result) # None

None在Python里代表“什么都没有”。如果函数只是做打印、写入文件这类不产生结果的操作,就不需要return。但如果你把这个返回值赋给变量,变量就是None。

第二种形态,return一个值。最常见,从函数外部接收结果。

第三种形态,return多个值。Python会把这些值打包成一个元组返回:

def get_user(): name = "张三" age = 20 return name, age name, age = get_user() print(name, age) # 张三 20

这里name, age = get_user()是元组解包,把返回的(name, age)一次拆开。我经常用这个特性让函数一次返回多个统计结果,比用全局变量传值干净得多。

还有一种使用技巧:提前return,用于条件分支拦截。比如查数据库时,数据不存在就可以直接返回None退出,而不是硬着头皮走完整个函数:

def find_user(user_id): if user_id <= 0: return None # 假设这里是真正的查询逻辑 return f"用户{user_id}"

这种提前返回的写法,能让函数里的分支结构更扁平,减少可怕的嵌套层级。我自己特别偏爱这种风格,它让代码阅读起来像走一条笔直的路,而不是到处拐弯。

4. 函数作用域:变量在哪里出生,归宿就在哪里

4.1 局部变量与全局变量之间的那道界线

函数内部定义的变量叫局部变量,它只在函数执行期间存在,函数结束就被回收。函数外面定义的变量叫全局变量,它从定义处开始一直活着,直到整个程序结束。

total = 0 # 全局变量 def change_total(): total = 100 # 这里定义了一个新的局部变量total,和外面那个total没有关系 change_total() print(total) # 0

初学者经常在这翻车。以为在函数里给total赋值就能改变全局变量,结果函数外面打印还是原来的0。原因是赋值语句让Python把total当成了函数内的新局部变量,跟全局变量只是同名,实际上是两个完全独立的变量。

如果确实想在函数内部修改全局变量,用global关键字声明:

total = 0 def change_total(): global total total = 100 change_total() print(total) # 100

但global这种写法我建议能不用就不用。全局变量被过多函数修改以后,程序的状态就像一盘散沙,你不知道哪个函数在哪一刻动了你的全局值。更优雅的做法是把全局值作为参数传进去、把新结果作为返回值带回来,保持函数对外界的“透明性”。

有一种特殊边界情况要格外注意:函数内部读取全局变量是可以的,不需要global。但同一函数里,如果发生赋值操作,哪怕赋值在读取之后,Python也会因为先看到赋值而认为这个名字是局部变量,从而报错。

count = 10 def demo(): print(count) # 报UnboundLocalError count = 20

这种“障眼法”折磨了无数新手。解决思路很简单:如果函数要修改外部变量,先决定好是主动用global,还是把变量传进函数并返回结果。我选择后者的概率,大概占九成以上。

4.2 LEGB:变量查找的一条顺序链

Python在函数内查找一个名字时,会按“局部变量→嵌套函数作用域→全局变量→内置作用域”这个顺序找,简写就是LEGB规则。

name = "全局" def outer(): name = "外层函数" def inner(): name = "内层函数" print(name) # 内层函数 inner() print(name) # 外层函数 outer() print(name) # 全局

这条规则解释了为什么同样一个名字,在不同位置打印出的结果完全不同。查找总是从最里面一层往外找,找到哪个算哪个,找不到就报NameError。

理解LEGB的最大价值,在于看懂函数嵌套和闭包。当内层函数引用了外层函数的变量,并且内层函数返回出去时,就形成了闭包。闭包在装饰器、回调函数、状态保持等场景里无处不在,如果你现在不打好作用域基础,后面遇到闭包会一头雾水。

我不建议新手一开始就深挖闭包和装饰器,但一定要知道作用域的存在。等你在项目里写过多层抽象以后,会发现LEGB其实就是“就近原则”的编程版——内层的永远是内层的,外层的东西除非有通道(参数、返回值、global),否则进不来,也改不了。

5. 实操案例:把零散脚本重构为可复用函数

5.1 场景:处理多份成绩数据

聊了这么多概念,现在动手把一个真实的零散脚本重构一遍。场景是这样的:我手头有好几份班级成绩文件,每一行是一条成绩,可能有空行、可能有非数字的脏数据。我需要统计每份文件的平均分、最高分和及格率,并生成一行简洁的中文报告。

新手拿到这种任务,通常会写一个平铺直叙的脚本,比如第一份文件的统计流程写上重复三遍,每一份微调文件路径和变量名。代码倒是能跑,但只要文件数量一变多,或者统计逻辑要调整,就会陷入复制粘贴的泥潭。现在用函数来重构。

5.2 第一步:拆解任务,先列出函数清单

我拿到任务后不会立刻写代码,先拆任务。这份需求读下来,可以拆成三个环节:读数据、算统计量、写报告。于是函数清单就是三件套:

  • read_scores(path):从文件读取成绩列表,负责清理空行和脏数据。
  • compute_stats(scores):输入成绩列表,返回平均分、最高分、及格率。
  • format_report(name, stats):把统计结果格式化成一行中文字符串。

每个函数只做一件事,参数和返回值都很明确。这就是我前面说的“单一职责”,拆完之后你会瞬间觉得问题清爽很多。

5.3 第二步:逐个实现并测试

先实现读取函数:

def read_scores(path): """从文件读取成绩,返回float列表,忽略空行与无法转换的内容。""" scores = [] with open(path, encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: scores.append(float(line)) except ValueError: # 脏数据直接跳过 continue return scores

注意读取文件用了with open,这是上下文管理器,能自动关闭文件,避免忘记close()。每个成绩读进来后用float(line)转换,转换失败就跳过。这里我也在展示“函数内部处理异常”的好处:调用者拿到的永远是干净的列表,不用关心读取过程那些脏细节。

再实现统计函数:

def compute_stats(scores): """计算平均分、最高分、及格率(按60分及格)。""" total = len(scores) if total == 0: return None avg = sum(scores) / total max_score = max(scores) pass_count = len([s for s in scores if s >= 60]) pass_rate = pass_count / total return avg, max_score, pass_rate

这个函数返回三个值,调用方可以用元组解包接收。如果列表为空,直接返回None,这比在后面算avg时报ZeroDivisionError友好得多。我把None当成“没有有效数据”的信号,上一节讲的提前return在这里派上了用场。

然后是格式化函数:

def format_report(name, stats): """生成一行中文报告文本。""" if stats is None: return f"{name}: 无有效数据" avg, max_score, pass_rate = stats return f"{name}: 平均{avg:.1f}分, 最高{max_score:.1f}分, 及格率{pass_rate:.1%}"

到这里,三个函数都是独立的,可以分别喂数据测试。我每写完一个函数就立刻在交互环境里调一调,而不是攒到全部写完再调试。这个习惯帮我省了大量排查时间,因为单个函数的边界条件少,出错一眼就能定位。

5.4 第三步:主程序调用与扩展空间

最后写一个主程序把这些函数串起来:

def main(): files = ["class_a.txt", "class_b.txt", "class_c.txt"] for path in files: scores = read_scores(path) stats = compute_stats(scores) report = format_report(path, stats) print(report) if __name__ == "__main__": main()

这个写法里有一个重要细节:if __name__ == "__main__"。它的作用是,只有直接运行这个脚本文件时才执行main(),如果这个文件被其他模块import,主程序不会自动跑起来。这样函数部分可以被其他地方安全复用。

重构之后,我再想增加一个统计指标,比如中位数,只需要在compute_stats里加一行返回结果,再改format_report里对应的一行,其他函数完全不用动。想让程序支持一百份文件,无非就是在files列表里加一百个路径。这份从容,就是函数带来的。

6. 常见问题与排查技巧实录

6.1 NameError:函数未定义还是变量拼错

NameError: name 'xxx' is not defined是新手遇到最多的报错。常见原因有几个:一是函数定义在调用之后,Python执行到调用语句时,函数还没有被放进内存,所以找不到;二是函数名或者变量名拼写不一致,前面写cal_area,后面调用calc_area,这属于离谱但常见的错误。

排查思路很简单,先在报错信息里确定“哪个名字未定义”,然后到当前文件的全局范围找有没有这个名字的定义。如果函数定义了却没找到,检查定义和调用谁在前、谁在后。如果变量没找到,很可能在函数内部忘了定义就直接使用。打印dir()也能列出当前作用域的所有名字,能帮你搞清楚Python的“视野”里到底有什么。

6.2 TypeError:参数数量对不上

TypeError: f() missing 1 required positional argument: 'b'这类报错,表示某函数接收的参数数量和你传的不一致。常见原因包括:忘了填某个默认参数;调用时少写一个位置参数;或者把关键字参数放到了位置参数前面。

解决办法先数清楚函数定义有几个形参,再核对调用处传了几个实参。我推荐在调用时多用关键字参数,它能让解释器自动提示缺少哪个参数,报错信息也比纯位置参数更明确。同时注意一个经验:修改函数签名(增加或删减参数)之后,全局搜索这个函数的所有调用点,逐个确认,不能只改定义不改调用。

6.3 函数调用“没结果”的隐形问题

有人在交互环境里调用一个函数,显示输入行结束,却什么都没输出。最常见的解释是函数只有print没有return,或者写了return但没写后面的值。函数返回值是None时,如果用print(result)打印,只会看到None。

还有一种情况是你确实写了return,但把return放在了循环或条件分支的某个分支里,遇到不满足条件的情况时,函数直接走到结束,返回了默认的None。这种问题不是“没结果”,而是“结果丢了”。我建议写函数时,心里始终有一张“流程路线图”:每一条路径结束时,要么返回某个值,要么明确地不返回。我在重构案例里用了if total == 0: return None,就是主动定义了这一条路径的返回结果。

6.4 可变默认参数的“灵异事件”

之前讲过默认参数用可变对象会出问题,这里再说一个现场排查思路。如果你的函数默认参数里有[]、{}、set(),并且发现多次调用后结果越来越“胖”,十有八九就是默认参数被累计复用了。

排查方式可以打印函数名加一串特殊值,看函数内部id(默认参数对象)是否每次都一样。不过我一般直接用is判断默认值是否还是原来的对象。

def demo(items=[]): return id(items)

两次调用打印的id如果一致,就代表默认参数是同一个对象,问题实锤。解决方法是把默认参数改成None,函数体内再初始化,这样每次调用都是新对象,一劳永逸。

6.5 同一个函数里打印全局变量却报错

这就是前面提到的作用域“障眼法”。

limit = 100 def check(): print(limit) limit = 50

执行check()会报UnboundLocalError: local variable 'limit' referenced before assignment。原因是函数体内有limit = 50,Python就会把整个limit当作局部变量,print(limit)碰一个还没赋值的局部变量,自然报错。

遇到这个问题,你要做的不是硬加global,而是先想清楚设计:这个变量是不是真的要被函数修改?如果只是读取,print(limit)完全可以直接工作,前提是函数内不要有同名赋值语句。如果确实要修改,再考虑用global或者在参数与返回值之间传递。我在第四章里已经强调过,尽量让函数对外部世界透明,这是最省心的代码风格。

6.6 定位函数bug的几条独家经验

最后一个小节,说点我调试函数时的实操经验。第一,每个新函数写完,立刻用几组典型的输入值单独测试,包括正常值、边界值(空列表、负数、0)、脏数据。不要等所有函数写完才统一测试,到时候你根本不知道问题出在哪一环。第二,把握“纯函数优先”的原则,尽量让函数的输出只依赖输入参数,不要偷偷依赖全局变量或文件状态。纯函数可预测性极强,测试起来几乎不费劲。第三,遇到难排查的bug,不要只盯着报错信息,在函数入口打印一遍所有参数的值,经常能发现参数顺序错位、数据格式不对这类低级但致命的问题。

我自己的习惯是随手写测试断言,不用什么测试框架,一行就行:

assert read_scores("class_a.txt") == [89.0, 76.0] assert compute_stats([60, 80]) == (70.0, 80.0, 1.0)

这比在代码里加一堆print更放心,因为断言一旦失败,程序立刻停下并告诉你具体哪一行出了问题。函数的设计天然适合这种验证方式,这也是我极力推荐大家多用函数的一个现实原因:可测试性不是加分项,是刚需。

回到开头的比喻,函数像把蛋炒饭的流程固定成了一道菜的配方。配方写好了,同样的味道就能复制无数次,改配方也只需动一处。我在实际项目里写了无数个函数之后越来越确信,函数定义与调用看起来是语法基础,但它决定了一个人写代码的视野和习惯。把函数练扎实,后面接触的模块、库、框架,所有抽象概念都会顺理成章。最后再分享一个小技巧:你可以在写任何一段代码前先问自己“这段逻辑以后会不会被复用或修改”,只要答案是肯定的,就把它塞进一个函数试试。这个习惯,大概率会让你的代码质量上一个台阶。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询