Python 基础体系 · 第 12/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 函数与参数:位置、关键字、默认值、解包和返回
函数是 Python 中组织可执行逻辑的基本单位。它不仅封装一段代码,还定义了一份调用协议:调用者可以按位置传参、按关键字传参、省略带默认值的参数,也可以通过 * 和 ** 批量传入参数;函数则负责把这些实参绑定到形参,并通过 return 把结果交给调用者。
理解函数,不能只记住 def 的语法。真正需要掌握的是四个连续阶段:
- 执行
def,创建函数对象; - 调用函数,计算实参;
- 按规则把实参绑定到形参;
- 执行函数体,并通过
return结束调用或返回结果。
Python 3.14 的函数定义语法支持位置专用参数、位置或关键字参数、仅限关键字参数、可变位置参数和可变关键字参数。(docs.python.org)
1. 函数是对象,def 本身是可执行语句
最基本的函数定义如下:
def add(a, b):
return a + b
这里有三个不同概念:
add:当前命名空间中的一个名称;a、b:函数定义中的形参;add(a, b):一次函数调用。
执行 def add(a, b): ... 时,Python 会创建一个函数对象,并把名称 add 绑定到这个对象。函数体不会在定义时执行,只有调用 add(...) 时才会执行。函数对象还会记录定义它时所在的全局命名空间。(docs.python.org)
def add(a, b):
print("函数体正在执行")
return a + b
print("定义完成")
result = add(2, 3)
print(result)
输出:
定义完成
函数体正在执行
5
定义阶段只创建函数对象;调用阶段才执行 print("函数体正在执行")。
由于函数是对象,它可以被赋值给其他名称、放入容器、作为参数传递,或者作为返回值返回:
def add(a, b):
return a + b
another_name = add
print(another_name(2, 3)) # 5
print(add is another_name) # True
这也是装饰器、回调函数、闭包和高阶函数的基础。装饰器本质上也是在函数定义完成后,用另一个可调用对象替换原来的名称绑定。
函数对象中还保存了一些与参数和元数据有关的属性:
def scale(value, factor=2, *, rounding=False):
return value * factor
print(scale.__defaults__)
print(scale.__kwdefaults__)
print(scale.__annotations__)
可能输出:
(2,)
{'rounding': False}
{}
其中:
__defaults__保存位置参数和位置或关键字参数的默认值;__kwdefaults__保存仅限关键字参数的默认值;__annotations__保存参数标注和返回值标注;__code__保存编译后的函数代码对象。
这些属性属于函数对象的数据模型;其中默认值确实存储在函数对象上,而不是代码对象上。(docs.python.org)
2. 形参与实参:调用的核心是参数绑定
形参是函数定义中的变量名:
def greet(name, punctuation):
...
这里的 name 和 punctuation 是形参。
实参是调用函数时提供的值:
greet("Alice", "!")
这里的 "Alice" 和 "!" 是实参。
调用函数时,可以把它抽象为一个映射过程:
例如:
def introduce(name, age):
return f"{name} is {age}"
introduce("Alice", 18)
绑定结果相当于:
name -> "Alice"
age -> 18
函数体开始执行时,参数名已经成为当前调用帧中的局部名称。
2.1 参数不是变量的“复制值”
Python 中的参数传递更准确的描述是:
调用者计算出对象,函数调用把形参名称绑定到这些对象。
例如:
def append_item(items):
items.append("new")
values = ["old"]
append_item(values)
print(values)
输出:
['old', 'new']
函数没有把列表复制一份。形参 items 和调用者中的 values 都指向同一个列表对象,因此通过 items.append(...) 修改了对象本身。
但如果在函数内部重新绑定形参,不会改变调用者的名称绑定:
def replace_list(items):
items = ["new"]
values = ["old"]
replace_list(values)
print(values)
输出:
['old']
调用过程可以表示为:
调用前:
values ─────┐
▼
["old"]
进入函数:
items ─────┘
▼
["old"]
执行 items = ["new"] 后:
items ─────────► ["new"]
values ─────────► ["old"]
因此需要区分两件事:
items.append(...):修改对象;items = ...:让局部形参改为指向另一个对象。
这不是“按值传递”和“按引用传递”二选一的简单问题,而是对象引用被赋给局部名称。
3. 参数的五种形式
Python 3.14 中,函数参数可以按调用方式分为五类:
| 参数类型 | 定义位置 | 调用方式 |
|---|---|---|
| 位置专用参数 | / 之前 |
只能按位置 |
| 位置或关键字参数 | / 之后、* 之前 |
位置或关键字均可 |
| 可变位置参数 | *args |
接收多余位置实参 |
| 仅限关键字参数 | * 或 *args 之后 |
只能按关键字 |
| 可变关键字参数 | **kwargs |
接收多余关键字实参 |
例如:
def example(pos_only, /, normal, *args, keyword_only, **kwargs):
print(pos_only)
print(normal)
print(args)
print(keyword_only)
print(kwargs)
参数区域可以分成:
pos_only, /, normal, *args, keyword_only, **kwargs
└──────┘ └──────┘ └─────┘ └──────────┘ └──────┘
位置专用 位置或关键字 多余位置 仅限关键字 多余关键字
/ 和 * 是语法分隔符,不是实际参数名。位置专用参数必须位于 / 之前;仅限关键字参数位于单独的 * 或 *args 之后。(docs.python.org)
4. 位置参数:按参数顺序绑定
最普通的函数参数既可以按位置传递,也可以按关键字传递:
def power(base, exponent):
return base ** exponent
print(power(2, 3))
print(power(base=2, exponent=3))
print(power(2, exponent=3))
三次调用的绑定结果都相同:
base -> 2
exponent -> 3
但位置参数的顺序有意义:
def divide(dividend, divisor):
return dividend / divisor
print(divide(10, 2)) # 5.0
print(divide(2, 10)) # 0.2
如果把位置实参顺序写错,Python 不会根据变量名猜测你的意图,因为位置调用只依据参数顺序绑定。
4.1 位置专用参数
使用 / 可以明确禁止关键字调用:
def distance(x, y, /):
return (x * x + y * y) ** 0.5
print(distance(3, 4))
下面的调用会失败:
distance(x=3, y=4)
错误类型为 TypeError,因为 x 和 y 是位置专用参数。
位置专用参数适合以下场景:
- 参数名称属于实现细节,不希望成为公开 API;
- 参数名称可能与
**kwargs中的业务字段冲突; - 函数希望强制调用者按照固定顺序传递核心值。
例如:
def find_substring(text, sub, /, start=0):
return text.find(sub, start)
调用者可以写:
find_substring("hello", "ll")
find_substring("hello", "l", start=3)
但不能写:
find_substring(text="hello", sub="l")
位置专用并不代表参数没有名称。函数体内仍然使用 text 和 sub 访问它们;它只限制调用者不能使用这些名称传参。
5. 关键字参数:按参数名称绑定
关键字参数使用 name=value 形式:
def connect(host, port, timeout):
return f"{host}:{port}, timeout={timeout}"
print(connect("db.example.com", 5432, 3))
print(connect(host="db.example.com", port=5432, timeout=3))
print(connect("db.example.com", port=5432, timeout=3))
关键字参数的顺序通常不重要:
connect(timeout=3, host="db.example.com", port=5432)
但关键字参数必须位于普通位置实参之后:
connect(host="db.example.com", "wrong")
这是语法错误,因为不能在关键字实参之后再放普通位置实参。函数调用中的关键字必须匹配可接受的形参名称,且同一个形参不能被赋值两次。(docs.python.org)
以下调用分别触发不同问题:
def f(a, b=10):
return a + b
f() # 缺少必需参数 a
f(1, 2, 3) # 多余的位置参数
f(1, a=2) # a 被赋值两次
f(1, unknown=2) # 未知关键字
关键字调用的价值在于,它让参数含义显式化:
create_user("Alice", True, False)
读者无法立即判断两个布尔值分别代表什么。改成关键字后更清楚:
create_user(
"Alice",
send_email=True,
is_admin=False,
)
但“可读”不是所有参数都必须使用关键字。参数种类应由函数签名表达,调用方式由接口语义决定。
6. 仅限关键字参数:用 * 强制表达含义
如果函数定义中出现一个独立的 *,其后的参数必须通过关键字传递:
def resize(image, *, width, height, keep_ratio=True):
return image, width, height, keep_ratio
正确调用:
resize("photo.jpg", width=800, height=600)
resize("photo.jpg", width=800, height=600, keep_ratio=False)
错误调用:
resize("photo.jpg", 800, 600)
width 和 height 是仅限关键字参数。它们必须写成 width=... 和 height=...。
仅限关键字参数有两个重要作用。
6.1 防止布尔参数失去语义
def export(data, *, compress=False, overwrite=False):
...
调用时:
export(data, compress=True, overwrite=False)
相比下面的写法,含义更明确:
export(data, True, False)
6.2 为 API 增加可扩展参数
def request(url, *, timeout=10, retries=3):
...
未来增加新选项时,可以继续扩展:
def request(url, *, timeout=10, retries=3, verify_ssl=True):
...
已有调用:
request("/users", timeout=5)
不需要因为参数顺序变化而重写位置实参。
7. 可变位置参数:*args
在函数定义中,*args 接收没有被前面形参消费掉的多余位置实参。它在函数体内始终是一个元组:
def show_values(first, *others):
print("first:", first)
print("others:", others)
show_values(1, 2, 3, 4)
输出:
first: 1
others: (2, 3, 4)
绑定过程为:
first -> 1
others -> (2, 3, 4)
如果没有多余的位置实参,args 是空元组:
def collect(*args):
return args
print(collect())
print(collect("a", "b"))
输出:
()
('a', 'b')
args 只是约定俗成的名称,星号才具有语法意义:
def collect(*values):
return values
7.1 *args 与仅限关键字参数的关系
如果 *args 出现,后面的普通参数天然成为仅限关键字参数:
def log(message, *tags, level="INFO"):
return message, tags, level
print(log("started", "server", "network", level="DEBUG"))
绑定结果:
message -> "started"
tags -> ("server", "network")
level -> "DEBUG"
不能把 level 作为额外位置参数:
log("started", "server", "DEBUG")
这里 "DEBUG" 会继续进入 tags,而不是绑定到 level。如果没有显式传入 level,它仍然使用默认值 "INFO"。
8. 可变关键字参数:**kwargs
在函数定义中,**kwargs 收集没有匹配到显式形参的关键字实参。它在函数体内是一个字典:
def configure(name, **options):
print("name:", name)
print("options:", options)
configure(
"service-a",
timeout=10,
retries=3,
debug=True,
)
输出:
name: service-a
options: {'timeout': 10, 'retries': 3, 'debug': True}
绑定结果:
name -> "service-a"
options -> {
"timeout": 10,
"retries": 3,
"debug": True,
}
如果没有额外关键字,kwargs 是一个空字典:
def collect_options(**kwargs):
return kwargs
print(collect_options())
输出:
{}
需要注意,**kwargs 不会接收已经匹配到显式形参的关键字:
def f(name, **kwargs):
return name, kwargs
print(f(name="Alice", age=18))
输出:
('Alice', {'age': 18})
name 被显式形参接收,只有 age 进入 kwargs。
9. 完整的参数绑定过程
考虑下面的函数:
def process(
source,
mode="safe",
/,
target=None,
*items,
verbose=False,
**options,
):
return {
"source": source,
"mode": mode,
"target": target,
"items": items,
"verbose": verbose,
"options": options,
}
它包含所有主要参数类别:
source 位置专用,无默认值
mode 位置专用,有默认值
/ 位置专用分隔符
target 位置或关键字,有默认值
*items 可变位置参数
verbose 仅限关键字,有默认值
**options 可变关键字参数
调用:
result = process(
"input.txt",
"fast",
"output.txt",
"part-1",
"part-2",
verbose=True,
retries=3,
)
可以按以下步骤理解。
第一步:绑定位置专用参数
前两个位置实参绑定到 / 之前的参数:
source -> "input.txt"
mode -> "fast"
第二步:绑定普通位置或关键字参数
第三个位置实参绑定到 target:
target -> "output.txt"
第三步:收集剩余位置实参
之后的两个位置实参没有其他固定位置参数可接收,因此进入 items:
items -> ("part-1", "part-2")
第四步:绑定仅限关键字参数
verbose=True 匹配显式的仅限关键字参数:
verbose -> True
第五步:收集剩余关键字参数
retries=3 没有对应的显式形参,因此进入 options:
options -> {"retries": 3}
最终结果:
{
'source': 'input.txt',
'mode': 'fast',
'target': 'output.txt',
'items': ('part-1', 'part-2'),
'verbose': True,
'options': {'retries': 3},
}
从规范角度看,函数调用必须为参数列表中的形参提供值:值可以来自位置实参、关键字实参或默认值;多余位置实参进入 *args,多余关键字实参进入 **kwargs。(docs.python.org)
10. 默认参数值:在定义时计算,而不是调用时计算
默认参数允许调用者省略某些实参:
def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
print(greet("Alice"))
print(greet("Alice", "Hi"))
输出:
Hello, Alice!
Hi, Alice!
关键规则是:
默认值表达式在执行函数定义时计算一次。
例如:
counter = 10
def show(value=counter):
return value
counter = 20
print(show())
输出:
10
执行 def show(...) 时,counter 的值是 10。之后即使全局名称 counter 被重新绑定到 20,默认值仍然是之前计算出的对象。
可以用一个带副作用的函数观察这一点:
def make_default():
print("计算默认值")
return []
def f(value=make_default()):
print("调用函数")
return value
print("定义之前")
# 上面的 def 执行时已经输出“计算默认值”
print("第一次调用")
f()
print("第二次调用")
f()
输出顺序类似:
计算默认值
定义之前
第一次调用
调用函数
第二次调用
调用函数
make_default() 只在定义 f 时调用一次,而不是每次调用 f() 时调用。
官方文档明确规定,默认参数值从左到右在函数定义执行时计算,并在后续调用中复用预先计算的值。(docs.python.org)
11. 可变默认值:最常见的函数参数陷阱
列表、字典和集合等可变对象作为默认值时,会在多次调用之间共享同一个对象:
def append_value(value, bucket=[]):
bucket.append(value)
return bucket
print(append_value(1))
print(append_value(2))
print(append_value(3))
输出:
[1]
[1, 2]
[1, 2, 3]
函数定义时只创建了一个列表。每次调用省略 bucket 时,形参都绑定到同一个列表。
可以通过对象身份验证:
def append_value(value, bucket=[]):
bucket.append(value)
print(id(bucket))
return bucket
append_value(1)
append_value(2)
两次调用通常打印同一个对象身份。
11.1 推荐的哨兵写法
如果 None 不是合法业务值,可以用 None 表示“调用者没有提供参数”:
def append_value(value, bucket=None):
if bucket is None:
bucket = []
bucket.append(value)
return bucket
print(append_value(1))
print(append_value(2))
输出:
[1]
[2]
这里每次调用都会在函数体内创建新列表。
但如果 None 本身是合法输入,就不能用它区分“省略参数”和“显式传入 None”:
_MISSING = object()
def normalize(value=_MISSING):
if value is _MISSING:
return "argument omitted"
if value is None:
return "argument is None"
return value
print(normalize())
print(normalize(None))
输出:
argument omitted
argument is None
哨兵对象使用 is 判断,而不是 ==。原因是哨兵需要通过对象身份唯一识别,不能依赖值相等性。
12. 默认值的语法约束
普通参数中,带默认值的参数后面不能再出现没有默认值的普通参数:
def invalid(a=1, b):
...
这是语法错误。
正确写法:
def valid(a, b=1):
...
也可以通过参数类别分隔来表达不同约束:
def valid(a=1, *, required):
...
这里 required 是仅限关键字参数。它虽然位于带默认值的 a 之后,但不属于同一段普通位置参数序列,因此是合法的:
valid(required=10)
valid(2, required=10)
同理,位置专用参数与后续参数之间也可以通过 / 明确边界:
def f(a=1, /, b):
return a, b
这里 a 位于位置专用区域,b 位于 / 之后。参数默认值约束必须结合参数类别理解,不能简单概括成“有默认值之后不能有任何必需参数”。
13. 调用时的解包:* 解包可迭代对象
函数定义中的 *args 是“收集”;函数调用中的 *value 是“展开”。
def add(a, b, c):
return a + b + c
numbers = [1, 2, 3]
print(add(*numbers))
这相当于:
print(add(1, 2, 3))
numbers 必须是可迭代对象,例如列表、元组、字符串、生成器等:
def show(a, b, c):
return a, b, c
print(show(*(x * 10 for x in range(3))))
输出:
(0, 10, 20)
生成器会在调用过程中被迭代,用于产生位置实参。
13.1 * 解包不是把列表作为一个参数传入
def count_items(items):
return len(items)
values = [1, 2, 3]
print(count_items(values)) # 3
这里列表整体是一个参数。
如果写成:
count_items(*values)
等价于:
count_items(1, 2, 3)
由于 count_items 只接受一个参数,这会触发 TypeError。
13.2 多次 * 解包
Python 3.5 起,函数调用支持多个 * 和 ** 解包,并允许普通位置实参出现在 * 解包之后:
def collect(*args):
return args
left = [1, 2]
right = [4, 5]
print(collect(*left, 3, *right))
输出:
(1, 2, 3, 4, 5)
这在构造参数序列时很方便,但也要注意:所有解包对象都会参与调用前的求值和展开,过大的生成器或无限迭代器可能导致调用无法完成。
14. 调用时的解包:** 解包映射对象
函数调用中的 **mapping 会把映射中的键值对展开为关键字实参:
def connect(host, port, *, timeout=10):
return host, port, timeout
config = {
"host": "localhost",
"port": 5432,
"timeout": 3,
}
print(connect(**config))
相当于:
print(connect(
host="localhost",
port=5432,
timeout=3,
))
传入 ** 的对象必须是映射,并且用于关键字调用的键必须是字符串。每个键会尝试匹配同名形参;如果没有显式匹配,才可能进入函数的 **kwargs。(docs.python.org)
def configure(name, **options):
return name, options
config = {
"timeout": 10,
"retries": 3,
}
print(configure("service-a", **config))
输出:
('service-a', {'timeout': 10, 'retries': 3})
14.1 显式关键字与 ** 不能重复
def f(x):
return x
options = {"x": 2}
f(x=1, **options)
这会触发 TypeError,因为 x 同时从显式关键字和映射中获得了值。
多个映射也不能产生重复键:
f(**{"x": 1}, **{"x": 2})
同样会失败。
Python 不会自动用后一个值覆盖前一个值。函数调用的参数绑定要求每个形参最多获得一个值;这与构造字典时的覆盖规则不同。
15. *args、**kwargs 与普通参数的组合
一个常见的灵活接口如下:
def build_url(path, *segments, scheme="https", **query):
full_path = "/".join((path, *segments))
if query:
query_string = "&".join(
f"{key}={value}" for key, value in query.items()
)
return f"{scheme}://{full_path}?{query_string}"
return f"{scheme}://{full_path}"
调用:
url = build_url(
"api",
"v1",
"users",
scheme="http",
active=True,
limit=20,
)
print(url)
输出:
http://api/v1/users?active=True&limit=20
绑定结果:
path -> "api"
segments -> ("v1", "users")
scheme -> "http"
query -> {"active": True, "limit": 20}
这类接口适合参数数量确实不固定的场景,例如:
- 日志标签;
- 路径片段;
- 查询参数;
- 兼容扩展选项的适配器。
但 **kwargs 会隐藏接口约束。如果函数实际上只接受固定选项,显式列出参数通常更容易发现拼写错误:
def request(url, *, timeout=10, retries=3):
...
相比:
def request(url, **options):
...
前者会立即拒绝 timout=10 这样的拼写错误;后者可能把它静默收进字典,直到更晚的阶段才暴露问题。
16. 参数求值顺序与绑定顺序不是一回事
调用函数前,实参表达式需要先求值:
def value(name, result):
print(name)
return result
def f(a, b):
return a, b
result = f(
value("first", 1),
value("second", 2),
)
输出:
first
second
实参表达式按调用表达式中的顺序求值。求值完成后,Python 再把结果绑定到形参。
因此下面代码会先计算 make_values(),再执行函数调用:
def make_values():
print("creating arguments")
return [1, 2, 3]
def f(*args):
print("inside function:", args)
f(*make_values())
输出:
creating arguments
inside function: (1, 2, 3)
如果实参求值过程中抛出异常,函数体不会开始执行:
def fail():
raise ValueError("cannot create argument")
def f(value):
print("function body")
f(fail())
这里只会得到 ValueError,不会打印 function body。
这一区分对有副作用的实参很重要:
save(
read_file(),
transform_data(),
notify(),
)
这些函数的执行顺序由调用表达式决定,而不是由 save 函数体决定。
17. return:结束当前调用并传递结果
return 只能出现在函数定义的函数体中:
def absolute(value):
if value >= 0:
return value
return -value
执行 return 时,当前函数调用立即结束,并把返回值交给调用者。return 后面的表达式会先求值,然后函数才退出。
def divide(a, b):
print("before")
result = a / b
print("after")
return result
print("unreachable")
print(divide(10, 2))
输出:
before
after
5.0
return 后的代码不会执行。
17.1 不写表达式时返回 None
def log_message(message):
print(message)
result = log_message("started")
print(result is None)
输出:
started
True
没有显式 return,或者执行了不带表达式的 return,返回值都是 None。Python 教程也明确指出,没有 return 的函数仍然会返回 None。(docs.python.org)
def f():
return
def g():
pass
print(f() is None) # True
print(g() is None) # True
因此,打印结果和返回结果必须区分:
def print_sum(a, b):
print(a + b)
def calculate_sum(a, b):
return a + b
x = print_sum(1, 2)
y = calculate_sum(1, 2)
print("x:", x)
print("y:", y)
输出:
3
x: None
y: 3
print_sum 把结果写到输出流;calculate_sum 把结果交给调用者。
18. 返回多个值:实际上返回一个元组
Python 没有特殊的“多返回值”机制。下面的代码:
def divide_with_remainder(a, b):
return a // b, a % b
等价于:
def divide_with_remainder(a, b):
return (a // b, a % b)
调用:
result = divide_with_remainder(10, 3)
print(result)
print(type(result))
输出:
(3, 1)
<class 'tuple'>
解包赋值可以把元组中的元素分别绑定:
quotient, remainder = divide_with_remainder(10, 3)
print(quotient)
print(remainder)
输出:
3
1
返回值也可以是字典、对象、生成器或自定义类型:
def parse_user(data):
return {
"name": data["name"],
"age": int(data["age"]),
}
“返回多个值”与“返回一个包含多个元素的对象”是同一个机制。接口设计时,应根据返回值之间的关系选择元组、字典、数据类或专用对象,而不是依赖调用者猜测位置含义。
19. 条件分支中的 return
return 经常用于提前退出:
def validate_age(age):
if not isinstance(age, int):
return False
if age < 0:
return False
return True
这种写法的控制流是:
进入函数
│
├─ 不是 int ─────► 返回 False
│
├─ 小于 0 ───────► 返回 False
│
└─ 其他情况 ─────► 返回 True
如果函数的所有控制路径都没有显式返回值,最终会返回 None:
def classify(value):
if value > 0:
return "positive"
if value < 0:
return "negative"
print(classify(0) is None)
输出:
True
这可能是有意设计,也可能是遗漏分支。若函数的契约要求返回字符串,就应让所有路径都返回明确值,或者在无法处理时抛出异常:
def classify(value):
if value > 0:
return "positive"
if value < 0:
return "negative"
raise ValueError("zero is not supported")
return None 和抛出异常表达的是不同协议:
return None:函数正常完成,但结果为空或不存在;raise:函数无法完成约定的操作。
调用者是否需要区分这两种状态,应由接口语义决定。
20. return 与 finally
如果 return 离开函数时经过带 finally 的 try,finally 会先执行:
def example():
try:
return "try result"
finally:
print("cleanup")
print(example())
输出:
cleanup
try result
函数并不是在执行到 return 的瞬间就完全结束,而是先执行必须完成的 finally 清理逻辑。
但如果 finally 自己再次 return,它会覆盖之前的返回值:
def dangerous():
try:
return "from try"
finally:
return "from finally"
print(dangerous())
输出:
from finally
这通常会导致控制流难以理解,也可能掩盖异常:
def hides_error():
try:
raise RuntimeError("failure")
finally:
return "success"
print(hides_error())
输出:
success
原本的异常被 finally 中的 return 覆盖。因此 finally 中应负责清理资源,而不应随意放置改变控制流的 return。
语言参考明确规定,当 return 从带 finally 的 try 中离开时,finally 会在真正离开函数前执行。(docs.python.org)
21. return 与生成器函数
如果函数体中包含 yield,它就不再是普通函数,而是生成器函数:
def numbers():
yield 1
yield 2
return "done"
generator = numbers()
print(next(generator))
print(next(generator))
try:
next(generator)
except StopIteration as exc:
print(exc.value)
输出:
1
2
done
生成器中的 return value 不会作为普通函数调用的返回值出现,而是结束生成器,并把值放入 StopIteration.value。
普通函数:
def f():
return 1
调用 f() 直接得到 1。
生成器函数:
def g():
yield 1
return 2
调用 g() 得到的是生成器对象;只有迭代到结束时,返回值才通过 StopIteration.value 暴露。生成器的 return 语义与普通函数不同。(docs.python.org)
22. 参数标注与返回值标注
函数可以添加参数和返回值标注:
def add(a: int, b: int) -> int:
return a + b
标注描述了接口意图,但不会自动执行运行时类型检查:
def add(a: int, b: int) -> int:
return a + b
print(add("a", "b"))
输出:
ab
Python 仍然允许字符串相加,因为函数标注本身不是强制类型约束。
标注可以通过函数对象查看:
def add(a: int, b: int) -> int:
return a + b
print(add.__annotations__)
通常得到:
{'a': <class 'int'>, 'b': <class 'int'>, 'return': <class 'int'>}
Python 3.14 对标注求值机制有版本变化:函数的标注现在采用惰性求值机制,函数对象还可以通过 __annotate__ 提供标注相关信息。代码不应把标注当作已经完成的运行时验证;需要验证时,应使用类型检查工具或显式运行时检查。(docs.python.org)
23. 用 inspect 查看签名和模拟绑定
当函数由装饰器、适配器或动态代码生成时,直接阅读调用错误可能不够。标准库 inspect 提供了签名检查和参数绑定工具:
from inspect import signature
def request(url, timeout=10, *, retries=3, **options):
...
sig = signature(request)
print(sig)
for name, parameter in sig.parameters.items():
print(name, parameter.kind, parameter.default)
输出形式类似:
(url, timeout=10, *, retries=3, **options)
url POSITIONAL_OR_KEYWORD <class 'inspect._empty'>
timeout POSITIONAL_OR_KEYWORD 10
retries KEYWORD_ONLY 3
options VAR_KEYWORD <class 'inspect._empty'>
Signature.bind() 可以模拟一次调用是否能够按照签名绑定:
from inspect import signature
def request(url, timeout=10, *, retries=3):
...
sig = signature(request)
bound = sig.bind(
"https://example.com",
retries=5,
)
print(bound.arguments)
输出:
{'url': 'https://example.com', 'retries': 5}
需要注意,bind() 默认只记录显式提供的参数,不会自动把默认值放入结果。可以调用 apply_defaults():
bound.apply_defaults()
print(bound.arguments)
输出:
{
'url': 'https://example.com',
'timeout': 10,
'retries': 5,
}
如果调用方式不符合签名,bind() 会抛出 TypeError:
sig.bind(retries=5)
因为缺少必需参数 url。
inspect.signature() 和 Signature.bind() 适合构建参数适配器、命令分发器、依赖注入系统和装饰器。官方文档将 Signature 定义为可调用对象的调用签名,并提供 bind() 将位置和关键字实参映射到参数。(docs.python.org)
24. 常见错误及诊断方法
24.1 位置参数缺失
def f(a, b):
return a + b
f(1)
表现为:
TypeError: missing 1 required positional argument
诊断方法:
- 查看函数签名;
- 检查没有默认值的参数;
- 检查是否误以为某个参数是可选的。
24.2 位置参数过多
def f(a, b):
return a + b
f(1, 2, 3)
如果函数没有 *args,多余的位置实参没有接收者,会触发 TypeError。
24.3 同一参数重复赋值
def f(a):
return a
f(1, a=2)
这里 a 一次来自位置,一次来自关键字,因此无法决定保留哪个值。
24.4 关键字名称拼写错误
def request(url, *, timeout=10):
return url, timeout
request("/users", timout=5)
如果没有 **kwargs,timout 不会被当作 timeout 的近似拼写,而会被视为未知关键字。
24.5 误把 *args 当成列表
def f(*args):
args.append(1)
这会失败,因为 args 是元组,元组没有 append 方法。
正确方式是创建新列表:
def f(*args):
values = list(args)
values.append(1)
return values
*args 的不可变容器性质可以保护函数内部不意外修改参数集合,但其中的元素本身仍可能是可变对象。
24.6 误把 **kwargs 当成属性对象
def f(**kwargs):
return kwargs.timeout
这会失败,因为 kwargs 是字典,应该使用键访问:
def f(**kwargs):
return kwargs["timeout"]
如果键可能不存在,可以使用:
def f(**kwargs):
return kwargs.get("timeout")
但 .get() 会把“键不存在”和“键存在但值为 None”都可能映射到 None,是否可接受取决于接口语义。
25. 参数设计中的边界与取舍
25.1 位置参数适合稳定、短小、顺序自然的核心数据
def distance(x, y):
...
x 和 y 的位置关系清晰,使用位置调用不会明显降低可读性。
25.2 仅限关键字参数适合选项和布尔开关
def send(message, *, urgent=False, retry=False):
...
这样可以避免多个布尔位置参数的含义混淆。
25.3 位置专用参数适合隐藏名称或稳定底层接口
def search(text, pattern, /, *, case_sensitive=True):
...
调用者不能依赖 text 和 pattern 作为关键字名称,未来可以调整内部命名,而不改变位置调用协议。
25.4 **kwargs 适合真正开放的扩展点
def plugin(event, **metadata):
...
如果参数集合本来就由插件生态或外部协议决定,**metadata 合理。
但如果参数集合是固定的,显式参数更容易获得:
- 自动补全;
- 静态类型检查;
- 拼写错误提示;
- 清晰的文档;
- 稳定的调用契约。
25.5 默认值必须表达“省略时的语义”
def fetch(timeout=10):
...
这表示省略 timeout 时使用 10 秒。
如果不同状态需要区分:
_MISSING = object()
def fetch(timeout=_MISSING):
if timeout is _MISSING:
timeout = load_default_timeout()
...
这里默认值不是业务值,而是“没有传参”的标记。
26. 一个完整示例:从定义到返回
下面的函数展示了参数分类、默认值、解包和返回值的完整协作:
_MISSING = object()
def build_request(
method,
path,
/,
*,
timeout=10,
headers=None,
body=_MISSING,
**query,
):
if headers is None:
headers = {}
request = {
"method": method.upper(),
"path": path,
"timeout": timeout,
"headers": headers,
"query": query,
}
if body is not _MISSING:
request["body"] = body
return request
调用:
common_query = {
"page": 2,
"limit": 20,
}
request = build_request(
"get",
"/users",
timeout=5,
headers={"Accept": "application/json"},
**common_query,
)
print(request)
结果:
{
'method': 'GET',
'path': '/users',
'timeout': 5,
'headers': {'Accept': 'application/json'},
'query': {'page': 2, 'limit': 20},
}
逐项分析:
"get"按位置绑定到位置专用参数method;"/users"按位置绑定到位置专用参数path;timeout=5通过关键字绑定到仅限关键字参数;headers=...显式提供请求头;**common_query把字典展开为关键字实参;page和limit没有显式形参,因此进入query;body未提供,因此保持哨兵_MISSING;- 函数返回一个字典,而不是直接打印请求内容。
这个例子还体现了一个重要边界:headers=None 和 body=_MISSING 的语义不同。
headers=None表示未提供请求头时使用空字典;body=_MISSING表示调用者没有提供body;- 如果调用
body=None,则表示调用者明确提供了None。
27. 与作用域、闭包和装饰器的连接
函数参数首先是局部名称,因此与作用域规则直接相关:
def outer(value):
def inner():
return value + 1
return inner
调用:
f = outer(10)
print(f())
输出:
11
inner 使用了外层函数的局部变量 value。即使 outer 已经返回,inner 仍然可以访问这个值,这就是闭包。参数 value 在这里不仅参与一次调用,还成为闭包环境中的自由变量。
如果内部函数需要修改外层变量,需要使用 nonlocal:
def make_counter(start=0):
count = start
def next_value():
nonlocal count
count += 1
return count
return next_value
函数、参数、默认值、闭包和装饰器并不是互不相关的语法点:
- 参数是调用帧中的局部绑定;
- 闭包保存外层作用域中的绑定;
- 装饰器可以替换函数对象;
inspect.signature()会影响调用签名的可见性;functools.wraps()常用于保留被包装函数的元数据。
因此,编写装饰器时,如果包装函数使用了过于宽泛的 *args, **kwargs,调用者看到的签名可能丢失。运行时行为可能仍然正确,但接口文档、自动补全和错误提示会变差。
28. 需要牢牢记住的调用模型
可以用下面的模型检查任何一次函数调用:
result = function(positional_1, positional_2, *more, keyword=value, **extra)
定义阶段
执行 def
↓
创建函数对象
↓
计算默认值
↓
绑定函数名称
调用阶段
计算实参表达式
↓
位置实参绑定位置专用和位置/关键字参数
↓
关键字实参绑定位置/关键字和仅限关键字参数
↓
多余位置实参进入 *args
↓
多余关键字实参进入 **kwargs
↓
缺失参数使用默认值
↓
仍然缺失或重复则抛出 TypeError
↓
执行函数体
↓
return 表达式
↓
调用表达式得到返回值
只要某一步无法完成,函数体就不会以正常方式执行。例如:
- 实参表达式抛出异常:函数体不会执行;
- 参数重复绑定:调用失败;
- 必需参数缺失:调用失败;
**对象不是合法映射:调用失败;*对象不可迭代:调用失败;- 函数体执行到
return:当前调用结束并产生返回值。
函数参数的本质不是“括号里写几个变量”,而是一套严格的绑定协议。位置、关键字、默认值、解包和返回共同决定了调用者如何提供数据,以及函数如何把计算结果交还给调用者。掌握这套协议后,普通函数、闭包、装饰器、回调、适配器和高阶函数都会使用同一套规则展开。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 结构化模式匹配:match、case、守卫、绑定与陷阱
- 下一篇:Python 作用域与闭包:LEGB、nonlocal、global 和迟绑定
- 延伸:Python 装饰器:函数包装、参数化、类装饰器与元数据
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论