ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python函数:def定义、参数体系、返回值与作用域

Python函数:def定义、参数体系、返回值与作用域

函数是把一个流程打包成块的操作, 这个包是能够被多次重复使用的。

更新时间是 2026-09-04。在上一篇的时候,你已经掌握了关于流程控制的知识点, 具体来说, 那就是分支的情况以及循环的情况。

如果发现某一段业务逻辑需要被重复地去进行使用, 或者是这段逻辑本身已经复杂到让人觉得有必要把它拆分成为多个更小的一些部分的话, 那么就应该采取一种办法, 就是将它打包成一个函数的形式呈现出来。

这篇文章把def这个定义说清楚了, 然后也解释了参数是怎么传递的, 其中包括位置参数, 关键字参数, 默认参数, *args, 还有, 接着讲了返回值, 同时也说明了函数里面的变量作用是干什么的, 最后呢, 又从函数调用的开销这个角度, 还有局部变量为什么更快的这两个视角, 把本站的性能主线给接上了。

相关的5个例子是在demos/-/这个目录下面的, 所有的输出都是在7或者3.6.8的版本里头真正运行测试过的, 结果是等于0的。

一、定义与调用函数

就是用 def 这个关键字来给一段逻辑操作起一个名字, 这样做的话以后就可以反复去调用它了, 具体的例子就像 demo 中展示的那样。

def greet(name): # def 函数名(参数): 下一行缩进是函数体

return f"你好, {name}" # return 把结果交回调用处

print(greet("世界")) # 你好, 世界

print(greet("alice")) # 你好, alice

要点:

f = greet # 赋值(不加括号,拿到函数本身)

print(f("bob")) # 你好, bob

def apply_twice(func, x): # 函数当参数传进去

return func(func(x))

print(apply_twice(greet, "嵌套")) # 你好, 你好, 嵌套

「函数可以当作参数来传递」这个事实, 正是因为后面关于装饰器的基础内容——你要先记住这样的一个直觉感觉才行。

二、参数, 它这个内容包含了那个位置方面的事, 还有关键字, 同时也包括了所谓的默认值。

在demo这个例子里面, 参数的传递方式是相当灵活的。

def sub(a, b):

return a - b

sub(10, 3) # 位置参数:按顺序,a=10, b=3 → 7

sub(b=3, a=10) # 关键字参数:按名字传,顺序可乱 → 7

def power(base, exp=2): # exp 有默认值

return base ** exp

power(5) # 不传 exp,用默认 2 → 25

power(5, 3) # 传了就用 3 → 125

最大的一个陷阱是, 千万不要使用那些能够被修改的对象作为默认的参数。

def add_bad(item, lst=[]): # [] 在函数定义时只创建一次!

lst.append(item)

return lst

print(add_bad(1)) # [1]

print(add_bad(2)) # [1, 2] —— 两次调用共享同一个列表!

这个默认值是在函数定义的时候就被创建好的, 然后在接下来每次调用的时候, 都是去复用同一个对象。如果把可变对象, 也就是列表或者字典当作默认值来用, 那么在多次调用的过程中, 它们之间会出现互相影响、互相污染的情况。

正确的写法应该是先把默认值设成“无”或者说是没有具体东西的空状态符号None, 然后再进入这个函数体内部之后再去进行新的创建操作:

def add_good(item, lst=None):

if lst is None:

lst = []

lst.append(item)

return lst

print(add_good(1)) # [1]

print(add_good(2)) # [2] —— 各自独立

三、任意数量参数:*args 与 **

如果想让函数的接受的参数数量是不固定的, 那么就可以使用星号(*)以及双星号(), 具体的演示代码在下面。

def total(*nums): # *nums 把多余的位置参数收集成一个元组

print(nums) # (1, 2, 3)

s = 0

for n in nums:

s += n

return s

total(1, 2, 3) # 6

total(10, 20) # 30

def show_info(name, **extra): # **extra 把多余的关键字参数收集成字典

print(name, extra)

show_info("alice", age=30, city="北京") # alice {'age': 30, 'city': '北京'}

反过来, 在调用的那个时候呢, 也是能够把双星号这个符号给用上, 通过这个双星号, 就可以把那个列表啊或者是字典呢给拆开来, 以展开的方式传递进去去的。

def sub2(a, b):

return a - b

args = [3, 5]

sub2(*args) # 等价 sub2(3, 5) → -2

def power2(base, exp=2):

return base ** exp

power2(2, **{"exp": 3}) # 等价 power2(2, exp=3) → 8

四、这个返回值是和函数里面使用的那个变量有关联的。然后是第4.1章节, 讲的是如何一次返回多个值, 这背后其实是一个元组的东西在起作用。

def stats(a, b):

return a + b, a * b # 逗号分隔,自动打包成元组

s, p = stats(3, 4) # 调用处解包

print(s, p) # 7 12

4.其实传的是对象引用, 可变对象会被改。

当函数的参数传入的是对象的引用的时候, 那么如果在函数的内部进行了修改可变对象的动作, 外部是能够看得见的;如果是不可以变对象重新绑定的话, 那么就对外部不起任何影响(demo)

def append_to(lst):

lst.append(99) # 改的是同一个列表对象

data = [1, 2]

append_to(data)

print(data) # [1, 2, 99] —— 外部被改了

def rebind(n):

n = n + 1 # 不可变:局部名 n 指向新对象,外部无感

x = 10

rebind(x)

print(x) # 10

这个情况和对象模型这一篇章节当中所讲述的内容, 也就是关于「+=」这个方法在处理可变类型数据的时候是直接进行原地修改, 而在处理不可变类型数据的时候则会重新建立一个新的对象, 这种情况其实是完全相同的本质。

4.在函数里面进行的赋值操作, 就意味着这是局部变量。

当函数里面给某一个名字赋值的时候, 它默认情况下就是局部变量了, 绝对不会去触动外面的全局变量。

g_counter = 100

def use_local():

g_counter = 999 # 这是新建的局部变量,不改全局

return g_counter

print(use_local(), g_counter) # 999 100

查找名字的规则, 现在的人还是按照 LEGB 这个顺序来理解, 也就是先从局部开始找, 然后找外层的, 接着找全局的, 最后才去内置那里看。

如果确实想在函数内部去修改全局变量的值, 必须在代码里声明一下变量名字, 但是刚开始学编程的朋友们, 最好还是尽量少用全局变量, 多靠参数传递和返回值返回数据, 这样写出来的代码, 读起来会更清晰明白一些。

五、从性能的视角来看, 调用操作是要花费时间成本的, 而使用局部变量其运行速度往往更快。

函数这一手段能够使得代码变得更加清晰, 然而在某些特定的环境或者框架里面, 它并不是那种不需要付出代价就可以获取的东西。

5.当执行函数调用的时候, 其实所做的操作就等同于去创建一个新的函数栈帧。

每次函数调用的时候, 解释器都需要去新建一个栈帧对象, 把参数传进去, 等到执行完了以后再进行销毁操作并且返回结果。这种机制在C语言里面其实对应的是压栈行为, 压栈的开销很轻。但是在这里是在内存堆上面去建立对象, 所以产生的开销是非常明显的。

对于同样的一些计算任务, 如果把它们放到热循环里面去反复调用函数的话, 和直接采用内联展开的方式来进行对比的话, 在实测过程中是可以观察到存在一定差距的, 具体的数据可以参考相关的演示材料进行的200万次的测试统计。

text

结果一致: True

函数调用: 0.482s 内联: 0.326s 调用慢 1.48x

全局查找: 0.201s 局部绑定: 0.153s 局部快 1.32x

请你要清楚, 这不是告诉你去写函数还是不写函数的问题。实际上, 函数能够提供的清晰性、复用性和可测试性, 比那么一点点开销要重要得多。所以, 正确的说法应该是: 绝大多数的代码都可以放心地使用函数。

只有那些真正属于热点内层循环的部分, 也就是已经确认过的极小一段代码, 才是考虑把极小的而且频繁发生的计算过程, 内联处理或者下沉到C语言层面的时候。这个结论也呼应了流程控制篇中提到的关于热循环下沉到C的观点。

5.在计算机里面, 那个局部变量呢, 它是确实比全局变量要快一些这个情况的。

在函数内部的时候, 对于局部的这种变量来说, 使用的是通过下标的方式去直接进行的访问操作, 而如果是关于全局变量的情形, 则是需要去借助字典按照名字来查寻。

因此, 在对于那些频繁出现的热点循环场景进行处理的环节中, 应当把那些被非常反复地使用到的、并且属于那种全局性质的名称(这里面包含了比如数学模块里面的开根号方法 sqrt , 以及一些内置函数的相关名称)先提前绑定到局部变量的这种位置上, 这样一来速度就会变得更加的快速一些, 实际测试出来的结果是说局部变量的访问速度相比于其他方式要快大约 1.32 倍的幅度。

import math

def use_local():

sqrt = math.sqrt # 先绑到局部

tot = 0.0

for i in range(1_000_000):

tot += sqrt(i + 1) # 局部查找

return tot

这正是 01 对象模型篇提到的 LEGB 规则以及所谓的局部绑定微优化措施的实测版本。如果想要从字节码的底层层面去查看相关的区别, 建议大家去阅读一下关于字节码以及求值循环的内容。

六、本部分的概念与咱们这里网站的性能主线之间的关系, 对应着这个主线衔接文档里面的内容。

通过调用函数的方式创建新的堆栈帧时, 会存在成本损耗。

解释器里面的那个栈帧, 还有呢就是C语言原本的那种栈。

字节码和求值循环, 它们是有关联的两件事。

局部变量 比全局快

对名字进行解析, 并且做一点点的微优化。

对象模型, 以及语法的基础部分。

函数是一等对象, 可以把这个函数作为一个参数进行传递。

高阶函数、装饰器

就是那个生成器, 还有那个迭代器, 以及那个装饰器。

传对象引用、可变对象被改

引用语义、别名

上下文管理器 with

七、常见坑 一句话总结

先是用 def 这个关键字把那一套逻辑给打包起来, 然后再弄成一个函数, 接着把这东西的结果给交出去;然后参数可以按照位置来传也可以按照关键字来传还可以带上默认值再去传, 要是参数的个数变得不是固定的话那就用 *args , 这个东西是个元组 , 再用 / args 这种东西去装字典;还有那个函数它本身就是一个一等的对象所以它也能够被当做参数去进行传递;你传递的时候传的实际上是对象的引用那么当你去改变一个可变对象的时候外面的代码是能够看到这种变化的;当你在调用函数的时刻确实是需要构建出一个栈帧的并且会伴随有一些开销, 另外局部变量的读取速度比起全局变量来会更加快速一点;所以说对于那些普通的代码片段你可以尽管放心地去做抽象化的处理至于那些热点部分最后再进行内联操作和下沉的处理。

代码位置: 配套 5 个纯标准库 demo 在 demos/-//(~), 7。3.6.8, 实测等于0, bash .sh。一键复跑。

动手进行练习。① 要求写一个函数, 这个函数的参数是 n, 作用是去判断数字是不是质数, 具体实现的时候需要复用前面流程控制章节里提到的循环 else 结构, 函数最终要返回 True 或者 False。

② 要求写一个叫 avg 的函数, 这个函数带有一个用星号表示的可变数量参数 args, 意思是能接收任意多个数字, 然后要求计算并返回这些数字的平均值。③ 要求写一个函数, 用来接收一个列表类型的参数, 然后返回这个列表里的最大值和最小值, 这里需要注意处理两个返回值解包的问题。

④ 要求故意写一个带有默认参数 x 的 def f 函数定义, 通过两次调用该函数来观察它们之间存在的共享现象, 然后把这个代码改成使用 None 作为默认值的版本进行对比。

继续推进, 接下来的主题是关于字符串以及文本数据的处理技巧, 这里面包含了像f- 格式化这样的高级写法, 还有关于数据不可变性以及与编码相关的知识;如果你迫切想要跳过基础, 直接去学习和掌握可读性更强的生成器、迭代器机制以及装饰器的运用方法, 那么你可以重点去阅读那篇介绍「如何将函数当作参数来传递」的文章, 看看它是怎样一步步进化形成如今的装饰器模式的, 同时也可以了解yield这个关键字是如何让函数具备惰性特征, 从而能够一串串地产生出数值的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进