☰
Python函数参数传递机制详解:从位置参数到*args与**kwargs的实战指南
2026/10/14 5:56:18 网站建设 项目流程

1. 从一次"看似简单"的传参翻车说起

先交代一下背景。上个月我在整理一个数据清洗项目时,需要给一组处理函数传入不同的阈值参数。一开始图省事,把所有参数全塞进一个字典里,函数里再用**kwargs兜底,结果调试了整整一个下午:某些参数被静默吞掉,某些参数类型对不上,还有几个函数因为重复传参直接抛了TypeError: got multiple values for argument。更离谱的是,有个函数我明明传了默认值,运行结果却还和使用者上次传入的数据相关联——最后才发现是把可变对象当成默认参数了。

那会儿我才意识到,Python 里的传参远没有"把值传进去"这么简单。它牵扯到函数签名的契约、参数绑定的顺序、底层的引用传递机制,以及*args、**kwargs这一堆"魔法参数"的展开与压缩规则。

这篇笔记主要写给两类人:一类是刚学 Python 不久,对参数传递只停留在"定义函数、调用函数"层面,想知道背后到底发生了什么的新手;另一类是写过一阵子代码,但被某些诡异报错折磨过,想系统梳理 arg 参数规则的初中级开发者。我会把我在实际项目中踩过的坑、验证过的行为、整理过的规则,尽量一遍讲透。这篇先覆盖参数的本质、五种常见参数形式、传递机制和调用端的解包玩法,算是"之一",后续再整理更复杂的作用域与闭包相关笔记。

我尽量少讲枯燥的定义,多用"这行代码为什么这么写""那句话到底什么意思"的角度来拆。毕竟笔记这东西,记的是思考过程,不是教科书。

2. 参数的本质:函数签名就是一份"使用说明书"

2.1 位置参数:最朴素的"按顺序对号入座"

我们先从最简单的场景开始。定义一个函数:

def calculate(a, b, c): return a * 100 + b * 10 + c

调用时传三个值:

result = calculate(1, 2, 3)

这就是位置参数(positional arguments)。Python 解释器做的事情非常机械:它把1、2、3这仨实参,按照函数定义时参数列表的顺序,一一绑定到形参a、b、c上。你传几个值,它就对号入座几次。

理解这件事的关键在于:Python 在做参数绑定时,并不关心"语义",它只关心"顺序"和"个数"。所以如果你写了calculate(3, 2, 1),得到的结果和calculate(1, 2, 3)完全不是一回事。这种"按位置对应"的方式最直观,也最容易被误用——我见过不少同事在调用函数时,把参数顺序记错,导致结果偏差。所以后来我写函数签名时,一旦参数超过四个,就会开始考虑用关键字参数或数据结构来约束调用方。

2.2 关键字参数:给参数"点名",彻底摆脱顺序焦虑

再看这个调用方式:

result = calculate(c=3, a=1, b=2)

这里赫然写着c=3、a=1、b=2。Python 不再死板地按位置匹配,而是根据你给的"名字"去对应形参。此时调用顺序完全无所谓,因为a=1会精确地绑到名为a的形参上。

关键字参数(keyword arguments)带来的灵活性非常明显:当函数参数特别多时,调用者不需要记住每个位置的顺序,只要知道参数名就行。但这也引入了一个新的头痛点——如果位置参数和关键字参数混用,规则就来了:

# 合法:先位置,后关键字 calculate(1, b=2, c=3) # 非法:关键字参数后面不能再跟位置参数 calculate(1, b=2, 3)

第二条会直接报SyntaxError: positional argument follows keyword argument。原因是解释器在解析实参序列时,遇到了b=2之后又来一个裸的3,它不知道该把这个3算成是"继续按位置匹配下一个形参"还是其他什么情况。从语言设计的角度说,一旦出现关键字参数,位置的坐标意义就模糊了,所以索性禁掉这种写法。这条规则我一开始总觉得"不够灵活",但实战多了以后发现,它其实是在强制调用方把代码写得清晰可读。

2.3 默认参数:函数签名的"降级方案"

再看一个几乎每天都用的特性:

def connect(host, port=3306, timeout=5): ...

这里的port=3306和timeout=5是默认参数。调用connect("localhost")时,port和timeout会自动绑定到默认值;调用connect("localhost", 3307)时,port被覆盖为3307;也可以用关键字方式覆盖:connect("localhost", timeout=3)。

默认参数的价值在于:它把一个函数的"常用配置"与"非常用配置"分开了,调用者不需要每次写全所有参数。但这个特性也埋着一个经典大坑——默认参数在函数定义时求值一次,之后就是同一个对象。我在第 4 节会专门用一个完整案例做实验验证,这里先埋个伏笔。

3. 五种常见参数形式与组合顺序的硬规则

3.1 摆一张"全家福":参数形式到底有几种

Python 函数定义中可以出现的参数形式,如果要把标准库和语言特性全算上,大体是这么五类:

  1. 位置或关键字参数:上面看到的普通a、b、c,既可以用位置传入,也可以用关键字传。
  2. 仅位置参数(positional-only):定义时写在/之前的那部分。
  3. 仅关键字参数(keyword-only):定义时写在*或*args之后的那部分。
  4. 可变位置参数:*args,把所有额外位置实参收集成元组。
  5. 可变关键字参数:**kwargs,把所有额外关键字实参收集成字典。

我们逐一看一下它们在同一个函数签名中怎么排列才合法。一个标准的、覆盖了几乎所有形式的签名长这样:

def demo(a, b, /, c, d=4, *args, e, f=6, **kwargs): pass

初看这行会感觉密密麻麻,但它的排列顺序不是随便定的,而是语言设计者为了消除歧义,给出了这么一条从前往后的规则链:

  1. 位置或关键字参数(a、b等普通形参)
  2. 仅位置参数分隔符/之前的参数,只能按位置传
  3. 默认参数(c、d=4),但它们仍属于位置或关键字参数
  4. *args:吸收所有剩余的位置实参,打包成元组
  5. 仅关键字参数(e、f=6):必须用param=value的方式传入
  6. **kwargs:吸收所有剩余的未匹配关键字实参,打包成字典

为什么要这么排?核心原因是:解释器需要一条"无歧义的解析路径"。当它看到一个实参列表时,它要能明确回答"这个值到底该绑给哪个形参"。

拿/举个例子。在 Python 3.8 引入仅位置参数之前,所有参数都既可以用位置传也可以用关键字传。但某些场景下,我们希望参数名对调用方完全"不可见"。最典型的情形是:参数名是内部实现细节,未来想改名而不用担心破坏外部调用。此时就需要/把前面的参数锁死为"只能传位置"。比如:

def safe_divide(top, bottom, /): ...

调用safe_divide(bottom=2, top=8)是非法的,因为top和bottom在/之前,调用方没法用关键字来指定。这在底层库、框架的开发中特别有用,因为它把"参数名的可读性"让位给了"实现的自由"。

3.2 一个生活化的类比

如果觉得上面这些符号太抽象,我们可以做个类比。把函数定义想象成食堂打菜的固定流程:

  • /之前的参数是"必刷卡进门",没有讨价还价的余地,只能按窗口顺序来。
  • 普通参数是"自选窗口",你可以按打菜顺序(位置),也可以直接喊菜名(关键字)。
  • *args是"多打的赘余",食堂阿姨会把多出来的菜装进同一个托盘(元组)。
  • *之后、**kwargs之前的部分是"套餐外单点",你必须明确喊出名字(关键字),否则阿姨不知道你要啥。
  • **kwargs是"打包袋",所有没被前面的窗口消化掉的杂项,全扔进袋子里给你带走。

这样一对比,那个又长又复杂的签名其实就是在表达一个"先锁定必须的,再吸收多出来的,最后统一收纳额外的"的完整策略。

3.3 实际项目中最常被问起的组合

我在团队内做过几次 Python 基础内训,回答最多的一个问题是:"我到底什么时候用*args和**kwargs?"

我的答案是:大多数业务代码里,你不应该频繁使用它们。*args和**kwargs的主要应用场景有三类:

  • 装饰器中需要透传任意签名,第 5 节会细讲;
  • 适配器模式,你需要一个回调函数接口,但不想限定回调的具体参数;
  • 框架函数,比如给一个任务调度器注册任务,任务的入参本来就由调用者决定。

如果只是自己业务里的一个普通函数,把参数一个个明确写出来,比用**kwargs兜底要安全可靠得多。原因后面会提到:**kwargs会让 IDE 无法补全参数名,typing 检查形同虚设,错误也难暴露。所以"笔记之一"里我最想强调的一个准则就是:魔法参数是给框架和通用工具用的,不是给业务代码偷懒用的。

4. 参数传递的底层机制:可变与不可变对象的天壤之别

4.1 传的是"值"吗?用id()做一次验证

我第一次系统学习 Python 传参时也困惑过:它到底传的是值还是引用?

最靠谱的验证方式是用内置函数id()查看对象地址。看这段代码:

def probe(x): print("in function:", id(x)) x = 100 print("after assignment:", id(x)) y = 10 print("outside: ", id(y)) probe(y)

输出大概是:

outside: 140726635968256 in function: 140726635968256 after assignment: 140726635968256

等一下,三个id()是一样的。这说明什么?y和函数里的x最初指向同一个整数对象10。当执行x = 100时,x只是重新绑定到了一个新的整数对象上,而外面的y依然指向原来的10。所以在"重新赋值"这个动作下,外面的变量不受影响。

那如果传入的是一个可变对象呢?比如列表:

def append_item(items): items.append("new") my_list = ["old"] append_item(my_list) print(my_list) # ['old', 'new']

列表my_list被修改了。再看看id():

def probe_list(lst): print("in:", id(lst)) lst.append(1) print("after append:", id(lst)) a = [] print("out:", id(a)) probe_list(a)

你会发现,无论append前后,id()始终一致。

把两个实验放在一起,结论其实就一句话:**Python 传递的是"对象的引用"。**准确点说,是传了一个指向同一对象的"引用副本"进来。如果这个对象本身是不可变的(整数、字符串、元组),那你只能重新绑定名字,原对象不动;如果这个对象是可变容器(列表、字典、集合),你就能通过引用的操作去修改这个容器本身。

4.2 经典之坑:不要把可变对象当默认参数

在我最开始提到的数据清洗项目里,这个坑让我排查了很久。简化后的代码如下:

def build_task(requirements, cache=[]): cache.append(requirements) return cache

第一次调用build_task("task_a"),返回['task_a'];第二次调用build_task("task_b"),你期待的是['task_b'],但实际上拿到的却是['task_a', 'task_b']——上一次的调用结果留在了里面。

原因正是默认参数在定义时只求值一次,cache=[]在函数定义那个瞬间创建了一个列表对象,之后每次调用都用同一个列表对象。这就像你给每个进门的客人发的不是新的一张纸,而是同一张已经写满字的纸,后一个客人当然会看到前一个人的笔迹。

正确的写法是:默认值用None,在函数内部判断后创建新对象。

def build_task(requirements, cache=None): if cache is None: cache = [] cache.append(requirements) return cache

这个处理模式我后来几乎成了肌肉记忆。判断cache is None并重新赋值,虽然多写了两行,但每次调用都能拿到全新的列表。这也是 Python 语言设计者们给出的一个"不容易误用"的规范。

4.3 传参错误级别的再分类:从"改名字"到"改对象"

为了彻底搞清这条线,我整理了一张对照表,放在手边随时参考:

传入对象的类型函数内执行"重新赋值"函数内执行"修改内容"外部是否受影响
不可变(int、str、tuple)名字重绑到新对象无法修改(本质是创建新对象)不受影响
可变(list、dict、set)名字重绑到新对象直接改容器内部受影响
可变(嵌套在容器里)重绑外层指针修改内层可变对象视情况受影响

如果你能完整理解这张表,那在 Python 传参问题上,你基本已经超越了相当一部分写了三五年业务代码但没深究过底层的人。很多"诡异"的 BUG,根源都来自"我以为传的是副本,结果传的是引用"的误判。

5. 解包的艺术:调用端的*和**

5.1*可迭代对象:把列表拆成位置实参

许多人在学习*args和**kwargs时,只关注它们在函数定义中的"收包"作用,忽略了它们在函数调用时的"解包"作用。我个人认为,解包才是更能体现 Python 表达力的部分。

看一个典型需求:有一个函数接收三个坐标点,但你的数据是一份列表。

def report_point(x, y, z): print(f"({x}, {y}, {z})") coords = [1, 2, 3] report_point(*coords) # 等价于 report_point(1, 2, 3)

这里的*coords就是把列表里的三个元素"摊开",按顺序当作三个位置实参传进去。如果列表长度不是 3,解释器会直接报TypeError,告诉你需要多少个、给了多少个。

这个技巧在什么场景下特别有用?我自己用最多的是时间戳和日期相关参数的传递。比如某个函数接收year, month, day,而我从配置文件里读到的恰好是一个元组(2025, 6, 15),一行func(*date_tuple)就够了,不用手动写func(date_tuple[0], date_tuple[1], date_tuple[2])。

再进阶一点:*不只能解列表,任何可迭代对象都可以,包括元组、字符串、range、生成器。

def merge(a, b, c, d): ... values = range(1, 5) merge(*values)

5.2**字典:把字典展开成关键字实参

如果说*对应位置参数,那**就对应关键字参数。它会把字典中的键值对展开成key=value的实参传入:

def make_config(host, port, debug=False): ... params = {"host": "127.0.0.1", "port": 8090, "debug": True} make_config(**params)

这行代码等价于:

make_config(host="127.0.0.1", port=8090, debug=True)

这种写法的意义在于:当你的配置项比较多时,可以把配置集中在一个字典中进行管理,然后一次性传给函数。这在读取yaml、json配置文件时会非常顺手。我经常遇到这种情况:从一个配置文件里加载了一组字段,而某个类的构造器恰好需要这些字段,直接SomeClass(**config_dict)就能完成实例化。

但这里有几个需要注意的约束,都是踩过坑才总结出来的:

  • 字典的键必须恰好对应函数形参名,多一个或者少一个都不行。多出来的键会报TypeError: unexpected keyword argument。
  • 键必须都是字符串。
  • 不能和显式传入的关键字参数重复。比如make_config(**params, host="localhost"),解释器会报TypeError: got multiple values for keyword argument 'host'。

5.3 合并字典与"一键转发"的组合拳

解包语法还有一个广为人知的妙用,就是从 Python 3.5 起,字典合并可以写成:

base = {"name": "demo", "version": "1.0"} extra = {"debug": True} merged = {**base, **extra}

这就把两个字典合并成一个新字典。如果两个字典有重复键,后出现的会覆盖先出现的。

把这和函数调用结合起来,你会看到一种非常流畅的编码风格:

def build_report(metrics, **options): defaults = {"format": "json", "verbose": False} params = {**defaults, **options} return render_report(metrics, **params)

render_report可能也定义了它自己的一组参数,甚至是一组带**kwargs的函数。这种"字典兜底 + 解包转发"的组合在写中间层、适配层时特别常见。

不过我要提醒一句:解包虽然方便,阅读代码时要花点心思去还原实参到底有哪些。尤其是当字典来自外部输入时,你几乎没法静态判断它到底会展开成什么。所以我在团队代码规范里会建议:解包转发只用于"边界层"(数据入口、框架回调、装饰器),不要在业务函数内部频繁使用。

5.4 调用端解包的一次完整实验

为了帮助大家把"收包"和"解包"两套机制对应起来,我写了一个综合的小例子:

def show_info(name, age, **extra): print(name, age, extra) info = {"name": "alice", "age": 30, "city": "beijing"} show_info(**info)

运行结果是:

alice 30 {'city': 'beijing'}

说明:name和age被解包出来精确匹配了形参;剩余的city掉进了**extra这个"收纳袋"。这两个方向的机制合在一起,构成了 Python 函数签名的完整闭环:定义时用*args、**kwargs收包,调用时用*iterable、**mapping解包。

理解了这一点之后,再回头去看各种框架源码里的def foo(*args, **kwargs)加上内部bar(*args, **kwargs)的透传写法,就不会再觉得它神秘了。那本质上是把一个函数收到的任意实参,原样转交给了另一个函数。

6. 装饰器为什么非用*args, **kwargs不可

接触过一点装饰器的人,对下面这段代码应该不陌生:

import functools def log_call(func): @functools.wraps(func) def wrapper(*args, **kwargs): print(f"calling {func.__name__}") return func(*args, **kwargs) return wrapper

第 5 节刚讲完解包机制,再看这段代码就会觉得一切顺理成章:wrapper内部通过*args, **kwargs接收所有实参,再原封不动地用func(*args, **kwargs)转发给被装饰的函数。

为什么装饰器必须这么写?因为装饰器在定义时根本不知道被装饰的目标函数是什么签名。它可能被用在只有def hello(): ...这样的无参函数上,也可能被用在def connect(host, port=3306): ...这种有默认参数的函数上。如果wrapper只定义成def wrapper():,那它无法接收被装饰函数的参数;如果定义成def wrapper(a, b):,那它又无法适配无参函数。

所以,*args和**kwargs是装饰器实现"签名透传"的唯一通用方案。

这个思路我在写日志装饰器、计时装饰器、重试装饰器时都用到了。比如一个简单的重试装饰器:

import time def retry(times=3, delay=1): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(times): try: return func(*args, **kwargs) except Exception as e: if attempt == times - 1: raise time.sleep(delay) return wrapper return decorator

这里func(*args, **kwargs)是必须的:调用方传入的任何参数都会原样传给被装饰函数,而装饰器本身不关心参数具体是什么。这样一个装饰器可以挂在任意函数上:挂在一个def read_file(path, encoding="utf-8")上,或者挂在一个def send_email(to, subject, body)上,都能正常工作。

如果你到了这一层再看标准库里的functools.wraps,也能顺带理解它的作用:它会把wrapper的元信息更新为func的元信息,避免装饰之后的函数丢失原本的名字和文档字符串。我一直觉得,装饰器这块知识是个分水岭,懂不懂*args, **kwargs,直接决定了你有没有真正理解 Python 函数式编程的拼接逻辑。

当然,前面说了这么多"魔法参数"的好处,也要重申一遍:装饰器里的*args, **kwargs是为了透传,业务函数里如果滥用,就会让调用方失去 IDE 补全、失去类型检查、失去参数数量的静态保障。魔法是用来搭桥的,不是用来把桥拆了的。

7. 我踩过的三个参数坑:完整排查记录

既然是笔记,如果不把"排查过程"记下来,总觉得少了一半内容。下面三个坑都来自我的实际经历,每个我都尽量还原排查思路,而不是直接给结论。

7.1 坑一:默认参数被"共享"导致的幻觉状态

现象:

def register_user(name, roles=[]): roles.append(name) return roles first = register_user("alice") print(first) # ['alice'] second = register_user("bob") print(second) # 期望 ['bob'],实际是 ['alice', 'bob']

排查过程:我先是在register_user函数内打印了roles的id(),发现两次调用中id(roles)完全一样。接着我去翻了 Python 官方文档,确认了"默认参数在定义时只计算一次"的规则。再观察名字roles指向的对象——它从函数定义那一刻起就存在,每次调用只是往这个已经存在的列表里追加元素。

结论与修复:把默认值改成None,在函数体内判断并新建。这个行为在日常中非常隐蔽:只要你不做多次调用、不检验累积效果,几乎不会发现异常。但一旦函数被放到服务端被大量重复调用,就会表现为"用户看到别人的数据"这类诡异故障。我把这条归为本篇笔记里优先级最高的一条经验。

7.2 坑二:关键字参数与变量重名导致的意外覆盖

现象:

def generate_report(title="default", **kwargs): if "title" in kwargs: title = kwargs["title"] ... generate_report(title="actually", extra=1)

我在调用generate_report时显式传了title="actually",也期望函数体里用kwargs["title"]判断。结果发现函数体里根本走不到"从 kwargs 取值"的逻辑,因为title已经作为形参被绑定为"actually"了,字典里根本不存在"title"。

排查过程:我先打印了locals(),看到kwargs里并没有title,才意识到问题出在"位置或关键字参数"和"**kwargs的边界划分"——凡是和具名形参同名的参数,会在绑定阶段就被具名形参截胡,根本不会进入**kwargs。

解决方案:统一命名策略,形参和关键字参数的名字不要混淆;如果确实需要二选一的分发逻辑,建议把具名形参从函数签名中去掉,一律走**kwargs,再由内部代码做合法性校验。

这个坑给我的教训是:当函数定义里同时有具名形参和**kwargs时,同名参数永远优先绑定到具名形参。这不是什么边缘规则,而是很多人都会误判的行为。

7.3 坑三:解包生成器时"耗尽"导致回调静默失败

现象:某项目里,我把一个生成器对象传给了func(*gen)解包。第一次传给一个函数,一切正常;第二次又拿同一个生成器对象传给另一个函数,发现第一个参数直接变成了默认值,甚至报参数缺失。

排查过程:排查时我先怀疑第二个函数的签名不对,打印参数后才发现生成器已经被上一次解包迭代完毕。生成器是"一次性"的,迭代完就空了。而列表或元组解包则是可重复的,因为它们的元素一直存在。

解决方案:如果同一个数据需要被多次解包,就不要用生成器存着,转成列表再传;或者在函数内部尽量不反复解包同一对象。

这类问题最难排查的地方在于:在单次调用场景下,生成器解包完全正常,只有当你把同一个对象复用到第二次调用时才会暴露。特别是在多线程或异步环境里,你根本不会意识到"迭代状态"被谁消费掉了。

7.4 参数排查的通用套路

经历过这些坑之后,我总结了一套排查流程,往后不管遇到什么参数相关的问题,我都会按这个顺序走:

  1. 打印locals(),确认当前函数内实际绑定了哪些参数、绑定值是什么。
  2. 打印id(),确认可变对象是不是同一个实例。
  3. 区分"重新赋值"和"修改内容",判断传入的可变对象是否被外部共享。
  4. 检查函数签名的参数顺序,确认*args、**kwargs的截胡区域是否正确。
  5. 检查调用端是否解包了生成器,必要时转成列表。

这套流程看起来简单,但真的能解决 90% 以上的传参问题。剩下的 10%,基本都能归结为对"参数绑定时机"和"对象引用共享"这两件事理解不够深。

8. 这篇笔记先记到这里

这次我刻意把范围控制在"函数参数"本身,没有往作用域、闭包、偏函数那部分扩展——那些内容更适合单独成篇。

写这份笔记的过程中,我自己也重新做了一遍实验,把多年"凭感觉知道写法"的东西变成了"能解释清楚为什么"的规则。尤其是默认参数共享和解包生成器的两个问题,以前我只是看到报错会修,但没认真想过背后的机制,这次算是彻底补上了。

如果这篇笔记恰好帮到了正在学习 Python 参数体系的你,那我最后一个实操建议是:打开一个交互式环境,多动手改一改参数顺序、多跑几次id()验证、多尝试多种调用方式组合。Python 给开发者留了很多"安全的试错空间",你直接在交互式命令行里试,远比翻文档来得记忆深刻。等把这一层理顺了,下一篇笔记我再整理闭包、作用域与函数式编程相关的进阶内容。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询