Python 基础体系 · 第 12/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 函数与参数:位置、关键字、默认值、解包和返回

函数是 Python 中组织可执行逻辑的基本单位。它不仅封装一段代码,还定义了一份调用协议:调用者可以按位置传参、按关键字传参、省略带默认值的参数,也可以通过 *** 批量传入参数;函数则负责把这些实参绑定到形参,并通过 return 把结果交给调用者。

理解函数,不能只记住 def 的语法。真正需要掌握的是四个连续阶段:

  1. 执行 def,创建函数对象;
  2. 调用函数,计算实参;
  3. 按规则把实参绑定到形参;
  4. 执行函数体,并通过 return 结束调用或返回结果。

Python 3.14 的函数定义语法支持位置专用参数、位置或关键字参数、仅限关键字参数、可变位置参数和可变关键字参数。(docs.python.org)


1. 函数是对象,def 本身是可执行语句

最基本的函数定义如下:

def add(a, b):
    return a + b

这里有三个不同概念:

  • add:当前命名空间中的一个名称;
  • ab:函数定义中的形参;
  • 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):
    ...

这里的 namepunctuation 是形参。

实参是调用函数时提供的值:

greet("Alice", "!")

这里的 "Alice""!" 是实参。

调用函数时,可以把它抽象为一个映射过程:

B:实参集合形参名称B: \text{实参集合} \rightarrow \text{形参名称}

例如:

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,因为 xy 是位置专用参数。

位置专用参数适合以下场景:

  1. 参数名称属于实现细节,不希望成为公开 API;
  2. 参数名称可能与 **kwargs 中的业务字段冲突;
  3. 函数希望强制调用者按照固定顺序传递核心值。

例如:

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")

位置专用并不代表参数没有名称。函数体内仍然使用 textsub 访问它们;它只限制调用者不能使用这些名称传参。


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)

widthheight 是仅限关键字参数。它们必须写成 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. returnfinally

如果 return 离开函数时经过带 finallytryfinally 会先执行:

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 从带 finallytry 中离开时,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

诊断方法:

  1. 查看函数签名;
  2. 检查没有默认值的参数;
  3. 检查是否误以为某个参数是可选的。

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)

如果没有 **kwargstimout 不会被当作 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):
    ...

xy 的位置关系清晰,使用位置调用不会明显降低可读性。

25.2 仅限关键字参数适合选项和布尔开关

def send(message, *, urgent=False, retry=False):
    ...

这样可以避免多个布尔位置参数的含义混淆。

25.3 位置专用参数适合隐藏名称或稳定底层接口

def search(text, pattern, /, *, case_sensitive=True):
    ...

调用者不能依赖 textpattern 作为关键字名称,未来可以调整内部命名,而不改变位置调用协议。

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},
}

逐项分析:

  1. "get" 按位置绑定到位置专用参数 method
  2. "/users" 按位置绑定到位置专用参数 path
  3. timeout=5 通过关键字绑定到仅限关键字参数;
  4. headers=... 显式提供请求头;
  5. **common_query 把字典展开为关键字实参;
  6. pagelimit 没有显式形参,因此进入 query
  7. body 未提供,因此保持哨兵 _MISSING
  8. 函数返回一个字典,而不是直接打印请求内容。

这个例子还体现了一个重要边界:headers=Nonebody=_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 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。