Python函数-函数进阶
一、函数参数传递
- 在 CPython 中,所有参数传递都是“对象指针(内存地址)的按值拷贝”——官方称为 call-by-object-reference。
1、内存模型总览
调用者栈帧 被调函数栈帧┌────────┐ ┌────────┐│ 变量 a │──→ 对象 1001 ←──│ 形参 x │└────────┘ └────────┘a和x各自保存 同一份对象 地址 1001。- 对 对象本身 的修改,取决于 1001 指向的对象 是否可变。
2、不可变类型:重新赋值 = 新建对象
def inc(n): n += 1 # 新建 int 对象,x 指向新地址 print(id(n)) # 与外部不同a = 10inc(a)print(a) # 10字节码关键指令
LOAD_FAST 0 (n)LOAD_CONST 1 (1)INPLACE_ADD # 新建 int,赋值回 n- 形参
n指向 新对象,外部a仍指向旧对象 → 值不变。
3、可变类型:原地修改 = 外部可见
def grow(lst): lst.append(1) # 修改现有列表对象a = [10]grow(a)print(a) # [10, 1]- 1001 指向 可变列表对象,原地改内部数组 → 外部同步看到。
4、重新赋值 vs 原地修改对照表
| 场景 | 代码 | 结果 | 原因 |
|---|---|---|---|
| 不可变 + 重新赋值 | x = x + 1 | 外部不变 | 新建对象 |
| 可变 + 原地修改 | x.append(1) | 外部变化 | 同一对象 |
| 可变 + 重新赋值 | x = [99] | 外部不变 | 形参指向新对象 |
5、默认参数为可变类型
在 Python 中,默认参数只在函数定义时创建一次,并把可变对象(如列表、字典、集合)的引用保存下来。
- 无论是否显式传递该参数,只要使用默认参数,就始终共享同一份对象;
- 该对象的 内存地址(id)永不改变;
- 在函数体内原地修改默认对象,会影响后续调用;
- 显式传参会替换默认引用,此时 id 与值按传入对象决定。
5.1、内存图:默认对象只创建一次
函数对象内部┌─────────────────────┐│ 默认参数槽位 │ → 地址 0x1000└─────────────────────┘ ↓ 列表对象 [ ]- 地址 0x1000 在函数 整个生命周期内不变。
- 每次调用只是把 地址 0x1000 塞进形参变量。
5.2、代码实验:id 不变,值递增
def f(lst=[]): print(f"id={id(lst)}, before={lst}") lst.append(1) print(f"id={id(lst)}, after ={lst}") return lst
# ① 省略默认参数(共享)r1 = f() # id=0x1000, before=[], after=[1]
r2 = f() # id=0x1000, before=[1], after=[1,1]
# ② 显式传参(替换引用)r3 = f([99]) # id=不同地址, before=[99], after=[99,1]
# ③ 再次省略(仍用默认,继续累积)r4 = f() # id=0x1000, before=[1,1], after=[1,1,1]5.3、字节码证明:默认值只计算一次
import disdis.dis(f)LOAD_CONST 1 ([]) # 定义时创建一次STORE_DEREF # 存到闭包 cell- 后续每次
LOAD_DEREF都取出 同一地址。
5.4、修复方案:用 None 做哨兵
def safe(lst=None): if lst is None: lst = [] # 每次新建 lst.append(1) return lst- 此时省略默认参数时 id 每次不同,不再累积。
二、函数返回值传递
- 在 CPython 中,“函数返回值” 本质上只干一件事:把 对象在堆上的内存地址(PyObject)复制给调用者。
| 场景 | 修改/重新赋值 | 调用者是否可见 | 原因 |
|---|---|---|---|
| 返回 不可变对象 | 重新绑定 | ❌ | 新对象,地址已变 |
| 返回 可变对象 | 原地修改 | ✅ | 同一地址,内容变 |
| 返回 可变对象 | 重新绑定 | ❌ | 变量指向新地址 |
1、底层机制:返回值 = 地址按值拷贝
调用者栈帧 被调函数栈帧┌────────┐ ┌────────┐│ 变量 r │──→ 对象 1001 ←──│ return │└────────┘ └────────┘return obj把 对象 1001 的地址 复制给调用者变量r。- 无论对象可变或不可变,拷贝的都是 地址值本身。
2、不可变类型:重新绑定 = 新建对象
def inc(): x = 10 x += 1 # 新建 int 对象 return x # 返回新地址
a = inc()print(a) # 11字节码关键指令
LOAD_CONST 1 (10)STORE_FAST 0 (x)LOAD_FAST 0LOAD_CONST 2 (1)BINARY_ADD # 新建 int 对象RETURN_VALUE # 返回新地址- 原对象 10 不变;调用者拿到 新对象 11 的地址。
3、可变类型:原地修改 = 外部可见
def make_list(): lst = [1, 2] lst.append(3) # 原地修改 return lst # 返回同一地址
b = make_list()print(b) # [1, 2, 3]- 地址始终为 1001;内容被修改 → 调用者 同步看到。
4、重新绑定 vs 原地修改对照
| 操作 | 代码 | 结果 | 解释 |
|---|---|---|---|
| 重新绑定 | lst = [99] | 外部不变 | 新地址,原地址弃用 |
| 原地修改 | lst.append(9) | 外部变化 | 同一地址,内容变 |
三、函数做容器元素
在 Python 里,“函数即对象”,因此任何函数都可以被
- 塞进 列表、元组、字典、集合
- 作为 key 或 value
- 做 回调、策略、插件、装饰器链
1、可哈希性:函数能不能当 dict、set 的 key?
- 普通函数 默认可哈希(__hash__ 继承自 object)。
- 只要 函数名相同且内容相同(同一地址),就满足 hash(f) == hash(f)。
- 若自定义类实例函数(如 lambda 捕获外部变量)需注意可变闭包。
def foo(): passprint(hash(foo)) # 固定值2、容器用法全景表
| 容器 | 写法 | 场景 |
|---|---|---|
| 列表 | [add, sub] | 回调链 / 策略列表 |
| 元组 | (add, mul) | 不可变策略表 |
| 字典 | {add: "加法", mul: "乘法"} | 函数映射表 |
| 集合 | {add, mul} | 去重函数集合 |
| 嵌套 | [{"op": add, "name": "+"}] | 配置化插件 |
3、实战代码:函数列表动态调度
def add(x, y): return x + ydef mul(x, y): return x * y
ops = [add, mul] # 函数列表for op in ops: print(op(3, 4)) # 7 124、函数映射表(函数做 key)
registry = { add: "加法", mul: "乘法"}
def describe(func): return registry.get(func, "未知")
print(describe(add)) # 加法5、函数嵌套容器:装饰器链
def timeit(func): import time def wrapper(*args): start = time.perf_counter() ret = func(*args) print(time.perf_counter() - start) return ret return wrapper
pipeline = [timeit, lambda f: f] # 装饰器列表6、易错点 & 提示
lambda 可变捕获:
fs = [lambda x: x + i for i in range(3)]# 全部输出 2,因为 i 是闭包变量,循环结束后 i=2# 修复fs = [lambda x, i=i: x + i for i in range(3)]函数名冲突:同名函数会覆盖旧地址,导致容器里引用失效。
四、函数名赋值
在 Python 里,“函数名”本质上只是一个指向函数对象的变量,因此它服从普通变量的全部规则:
- 可以赋值给其它变量(别名)
- 可以重新绑定到新对象(覆盖原函数)
- 原函数对象不受影响(除非无人引用才会被 GC)
1、函数名即变量:底层模型
def foo(): return 42- 执行时,创建一个函数对象(地址 0x1001)。
- 名字 foo 绑定到该地址:foo -> 0x1001。
- 类型检查:type(foo) → <class ‘function’>。
2、把函数名赋值给其它变量(别名)
bar = foo # bar 与 foo 指向同一块函数对象print(bar()) # 42print(bar is foo) # True- 两个名字共享同一对象,调用成本为 零拷贝。
- 常用于 回调注册、策略表、装饰器链。
3、给函数名重新赋值(覆盖)
old_foo = foo # 先保存原函数def new_foo(): return "new"foo = new_foo # 名字 foo 指向 0x2002print(foo()) # newprint(old_foo()) # 42 原对象仍可用- 原函数对象 0x1001 仍存在;只是名字 foo 不再指向它。
- 若无人引用旧对象,GC 会回收。
4、典型用途
| 场景 | 代码 | 说明 |
|---|---|---|
| 策略表 | ops = [add, sub, mul] | 函数列表 |
| 装饰器链 | f = cache(f) | 层层包装 |
| 插件热插拔 | process = plugin_map[plugin_name] | 运行时切换 |
| 别名回退 | old_sqrt = math.sqrt; math.sqrt = my_sqrt | monkey-patch |
5、易错点
lambda 闭包陷阱
fs = [lambda x: x + i for i in range(3)]i = 5print([f(0) for f in fs]) # [5, 5, 5]- 修复:
lambda x, i=i: x + i
循环引用导致内存泄漏
- 容器里存函数,函数又捕获容器 → 需要
weakref解决。
五、作用域
1、作用域层级速记表(LEGB)
| 名称 | 英文 | 触发场景 | 只读/可写 |
|---|---|---|---|
| Local | 局部 | 当前函数内部 | 默认可写 |
| Enclosing | 闭包 | 外层嵌套函数 | 只读,需 nonlocal 改写 |
| Global | 全局 | 模块顶层 | 只读,需 global 改写 |
| Built-in | 内建 | Python 内置 | 只读 |
2、函数是最小作用域边界
- 函数定义 会创建一个 新的局部命名空间。
- 函数执行 时,所有在函数体内 赋值 的标识符默认落入 Local。
- 函数返回 后,局部命名空间被销毁(闭包除外)。
3、全局变量 vs 局部变量
| 特征 | 全局变量 | 局部变量 |
|---|---|---|
| 定义位置 | 模块顶层 | 函数体内 |
| 生命周期 | 解释器启动-关闭 | 函数调用-返回 |
| 访问规则 | 任何地方可读 | 仅函数体内可读/写 |
| 修改规则 | 函数内 只读(除非 global) | 默认可写 |
4、代码实验:读/写差异
x = 10 # 全局变量
def foo(): print(x) # ① 读取:成功(LEGB 找到 Global)
def bar(): x = 20 # ② 赋值:创建新的 Local x,遮蔽全局
def baz(): global x # ③ 声明:告诉解释器“我要改全局” x = 99
foo() # 10bar()print(x) # 10(全局未被改)baz()print(x) # 99(全局被改)5、nonlocal:修改外层函数局部
def outer(): cnt = 0 def inner(): nonlocal cnt cnt += 1 return cnt return inner
f = outer()print(f()) # 1print(f()) # 2nonlocal cnt让inner把cnt视为 Enclosing 作用域变量。
6、全局变量与循环引用陷阱
- 全局变量长期存在,可能被多个模块共享;过度使用会导致 命名冲突 与 测试困难。
- 用 函数参数 或 类属性 替代全局。
- 必须用全局时,放在 专用模块(如 config.py)。