skip to content
Logo Lostman_Wang的小站

Python函数-函数进阶

一、函数参数传递

  • 在 CPython 中,所有参数传递都是“对象指针(内存地址)的按值拷贝”——官方称为 call-by-object-reference。

1、内存模型总览

调用者栈帧 被调函数栈帧
┌────────┐ ┌────────┐
│ 变量 a │──→ 对象 1001 ←──│ 形参 x │
└────────┘ └────────┘
  • ax 各自保存 同一份对象 地址 1001。
  • 对象本身 的修改,取决于 1001 指向的对象 是否可变

2、不可变类型:重新赋值 = 新建对象

def inc(n):
n += 1 # 新建 int 对象,x 指向新地址
print(id(n)) # 与外部不同
a = 10
inc(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 dis
dis.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 0
LOAD_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(): pass
print(hash(foo)) # 固定值

2、容器用法全景表

容器写法场景
列表[add, sub]回调链 / 策略列表
元组(add, mul)不可变策略表
字典{add: "加法", mul: "乘法"}函数映射表
集合{add, mul}去重函数集合
嵌套[{"op": add, "name": "+"}]配置化插件

3、实战代码:函数列表动态调度

def add(x, y): return x + y
def mul(x, y): return x * y
ops = [add, mul] # 函数列表
for op in ops:
print(op(3, 4)) # 7 12

4、函数映射表(函数做 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()) # 42
print(bar is foo) # True
  • 两个名字共享同一对象,调用成本为 零拷贝。
  • 常用于 回调注册、策略表、装饰器链。

3、给函数名重新赋值(覆盖)

old_foo = foo # 先保存原函数
def new_foo():
return "new"
foo = new_foo # 名字 foo 指向 0x2002
print(foo()) # new
print(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_sqrtmonkey-patch

5、易错点

lambda 闭包陷阱

fs = [lambda x: x + i for i in range(3)]
i = 5
print([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() # 10
bar()
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()) # 1
print(f()) # 2
  • nonlocal cntinnercnt 视为 Enclosing 作用域变量。

6、全局变量与循环引用陷阱

  • 全局变量长期存在,可能被多个模块共享;过度使用会导致 命名冲突 与 测试困难。
  • 用 函数参数 或 类属性 替代全局。
  • 必须用全局时,放在 专用模块(如 config.py)。