Python 参数传递到底是值传递还是引用传递?从底层模型到实战避坑全面解析

Python 参数传递到底是值传递还是引用传递?从底层模型到实战避坑全面解析

在 Python 编程中,有一个问题几乎每个开发者都会遇到:

Python 的参数传递,到底是值传递,还是引用传递?

很多初学者会这样理解:

def change(x):
    x = 100

a = 1
change(a)
print(a)  # 1

于是得出结论:Python 是值传递。

但再看下面这段代码:

def add_item(items):
    items.append("Python")

languages = ["Java"]
add_item(languages)
print(languages)  # ['Java', 'Python']

这一次,函数内部修改了外部变量,于是又有人说:Python 是引用传递。

那么问题来了:
为什么有时候函数内部改不了外部变量,有时候又能改?
Python 参数传递到底是哪一种模型?

答案是:

Python 既不是传统意义上的值传递,也不是传统意义上的引用传递,而是“对象引用传递”,也常被称为“共享传参”或“传对象引用”。

更准确地说:

函数调用时,Python 会把实参所绑定的对象引用,赋值给函数的形参。形参和实参最初指向同一个对象,但形参本身是函数内部的局部变量名。

理解这句话,你就能真正理解 Python 的变量、对象、函数参数、可变对象、不可变对象,以及那些常见但隐蔽的 bug。


一、先从 Python 的变量模型讲起

在很多语言里,变量像一个盒子,值被放进盒子里。

但在 Python 中,更合适的理解是:

变量名是标签,对象才是真正的数据实体。

例如:

a = [1, 2, 3]

可以理解为:

变量名 a  --->  列表对象 [1, 2, 3]

当我们写:

b = a

并不是复制了一个新列表,而是让 b 也指向同一个列表对象:

a  ----\
        ---> [1, 2, 3]
b  ----/

所以:

a = [1, 2, 3]
b = a

b.append(4)

print(a)  # [1, 2, 3, 4]
print(b)  # [1, 2, 3, 4]

因为 ab 指向同一个对象。

我们可以用 id() 来验证:

a = [1, 2, 3]
b = a

print(id(a))
print(id(b))
print(a is b)  # True

is 判断两个变量是否引用同一个对象,== 判断两个对象的值是否相等:

x = [1, 2]
y = [1, 2]

print(x == y)  # True,内容相等
print(x is y)  # False,不是同一个对象

这是理解 Python 参数传递的基础。


二、函数调用时到底发生了什么?

看一段简单代码:

def show(value):
    print(value)

name = "Python"
show(name)

当调用 show(name) 时,Python 做的事情可以理解为:

value = name

也就是说,函数形参 value 成为了一个新的局部变量名,它和外部变量 name 指向同一个字符串对象。

示意图如下:

调用前:

name ---> "Python"

调用 show(name) 后:

name  ----\
           ---> "Python"
value ----/

这就是 Python 参数传递的核心:

参数传递不是复制整个对象,而是把对象引用绑定给形参。

但是,形参是一个新的局部变量名。你可以让它重新绑定到别的对象,而不会影响外部变量名。


三、为什么不可变对象看起来像“值传递”?

看这个例子:

def change_number(x):
    x = 100
    print("函数内:", x)

n = 1
change_number(n)
print("函数外:", n)

输出:

函数内: 100
函数外: 1

很多人据此认为 Python 是值传递。其实不是。

调用函数时:

n  ---> 1
x  ---> 1

进入函数后执行:

x = 100

这不是把原来的整数对象 1 改成 100,而是让局部变量 x 重新绑定到整数对象 100

n  ---> 1

x  ---> 100

外部变量 n 仍然指向 1,所以不会改变。

字符串也是类似:

def change_text(s):
    s += " world"
    print("函数内:", s)

text = "hello"
change_text(text)
print("函数外:", text)

输出:

函数内: hello world
函数外: hello

因为字符串是不可变对象。s += " world" 实际上创建了一个新字符串,并让局部变量 s 指向新对象。

所以,不可变对象在函数里被“修改”时,通常表现得像值传递。

但这只是表象。


四、为什么可变对象看起来像“引用传递”?

再看列表:

def add_item(items):
    items.append("Python")

languages = ["Java"]
add_item(languages)

print(languages)

输出:

['Java', 'Python']

调用函数时:

languages  ----\
                ---> ['Java']
items      ----/

形参 items 和实参 languages 指向同一个列表对象。

执行:

items.append("Python")

这不是让 items 重新绑定,而是修改它所指向的那个列表对象本身。

修改后:

languages  ----\
                ---> ['Java', 'Python']
items      ----/

所以函数外部看到的列表也变了。

这就是为什么可变对象看起来像引用传递。

常见可变对象包括:

list
dict
set
bytearray
大多数自定义类实例

常见不可变对象包括:

int
float
bool
str
tuple
frozenset
bytes
None

五、Python 真正的模型:对象共享传参

为了避免“值传递”和“引用传递”带来的误解,建议记住下面这句话:

Python 的参数传递是对象共享传参:函数形参和外部实参共享同一个对象引用,但形参变量名本身可以在函数内部重新绑定。

它有两个关键点。

第一,函数参数接收的是对象引用,不是对象完整副本。

第二,函数内部给形参重新赋值,只会改变局部变量绑定,不会改变外部变量绑定。

看这个例子:

def demo(items):
    print("进入函数:", id(items))
    items.append(3)
    print("append 后:", items, id(items))

    items = ["new"]
    print("重新赋值后:", items, id(items))

data = [1, 2]
print("调用前:", data, id(data))

demo(data)

print("调用后:", data, id(data))

可能输出:

调用前: [1, 2] 1400000001
进入函数: 1400000001
append 后: [1, 2, 3] 1400000001
重新赋值后: ['new'] 1400000999
调用后: [1, 2, 3] 1400000001

解释一下:

items.append(3)

修改的是共享列表对象,所以外部能看到变化。

但:

items = ["new"]

只是让局部变量 items 指向新列表,不会影响外部变量 data

这段代码是理解 Python 参数传递模型的经典例子。


六、+= 的特殊陷阱:它到底是修改还是重新绑定?

+= 在 Python 中很容易让人误判。

对于不可变对象:

def add_number(x):
    x += 1

n = 10
add_number(n)
print(n)  # 10

整数不可变,x += 1 等价于创建新整数并重新绑定 x,不会影响外部 n

但对于列表:

def extend_list(items):
    items += [3, 4]

data = [1, 2]
extend_list(data)

print(data)  # [1, 2, 3, 4]

列表的 += 通常是原地扩展,等价于:

items.extend([3, 4])

所以外部列表会被修改。

再对比:

def add_list(items):
    items = items + [3, 4]

data = [1, 2]
add_list(data)

print(data)  # [1, 2]

items = items + [3, 4] 会创建一个新列表,然后让局部变量 items 指向它,因此不影响外部。

这就是 Python 中非常值得注意的区别:

items += other

可能原地修改。

items = items + other

通常创建新对象并重新绑定。

在代码审查中,我经常会特别留意 +=。它不是语法糖那么简单,它背后调用的是对象自己的原地操作协议。


七、默认参数陷阱:参数传递模型的经典事故现场

Python 中最经典的坑,莫过于可变默认参数:

def add_task(task, task_list=[]):
    task_list.append(task)
    return task_list

print(add_task("写文档"))
print(add_task("写测试"))
print(add_task("发布上线"))

你可能以为输出是:

['写文档']
['写测试']
['发布上线']

实际输出却是:

['写文档']
['写文档', '写测试']
['写文档', '写测试', '发布上线']

原因是:函数默认参数在函数定义时只创建一次。

也就是说,task_list=[] 这个列表对象会被多次调用共享。

正确写法是:

def add_task(task, task_list=None):
    if task_list is None:
        task_list = []
    task_list.append(task)
    return task_list

print(add_task("写文档"))
print(add_task("写测试"))
print(add_task("发布上线"))

输出:

['写文档']
['写测试']
['发布上线']

这个问题在 Web 接口、数据处理、任务队列、配置构建中非常常见。尤其是写工具函数时,千万不要把 listdictset 作为默认参数。

错误写法:

def create_user(name, tags=[]):
    tags.append("new")
    return {"name": name, "tags": tags}

推荐写法:

def create_user(name, tags=None):
    if tags is None:
        tags = []
    return {"name": name, "tags": tags}

这不是“风格问题”,而是稳定性问题。


八、实战案例:清洗用户数据时,函数应该修改原数据吗?

假设我们有一批用户数据:

users = [
    {"name": " alice ", "email": "ALICE@example.com"},
    {"name": " bob ", "email": "BOB@example.com"},
]

现在要清洗数据:去掉姓名两边空格,把邮箱转小写。

第一种写法:原地修改。

def normalize_users_inplace(users):
    for user in users:
        user["name"] = user["name"].strip().title()
        user["email"] = user["email"].lower()

normalize_users_inplace(users)

print(users)

优点是节省内存,适合数据量很大、明确允许修改原数据的场景。

缺点是副作用明显,调用者如果不知道函数会修改原数据,就可能踩坑。

第二种写法:返回新数据。

def normalize_users(users):
    result = []

    for user in users:
        normalized = {
            "name": user["name"].strip().title(),
            "email": user["email"].lower(),
        }
        result.append(normalized)

    return result

new_users = normalize_users(users)

print(users)
print(new_users)

这种写法更安全,函数没有悄悄修改传入对象。

在真实项目中,我建议遵循一个原则:

默认不要修改传入参数;如果要原地修改,必须在函数名、文档或注释中明确表达。

例如:

def sort_records_inplace(records):
    """原地排序 records,会修改传入列表。"""
    records.sort(key=lambda item: item["created_at"])

而如果函数返回新对象,可以这样命名:

def sorted_records(records):
    """返回排序后的新列表,不修改原 records。"""
    return sorted(records, key=lambda item: item["created_at"])

清晰的命名,本质上是在降低团队沟通成本。


九、浅拷贝与深拷贝:避免“我明明复制了,怎么还会变?”

参数传递和对象共享还会引出另一个问题:复制。

def update_first(items):
    items[0].append(99)

data = [[1, 2], [3, 4]]
copied = data.copy()

update_first(copied)

print(data)
print(copied)

输出:

[[1, 2, 99], [3, 4]]
[[1, 2, 99], [3, 4]]

为什么 data 也变了?

因为 copy() 是浅拷贝,只复制外层列表,内层列表仍然共享。

data   ---> 外层列表 A ---> 内层列表 [1, 2]
copied ---> 外层列表 B ----/

如果需要完全隔离嵌套对象,可以使用深拷贝:

import copy

data = [[1, 2], [3, 4]]
copied = copy.deepcopy(data)

copied[0].append(99)

print(data)    # [[1, 2], [3, 4]]
print(copied)  # [[1, 2, 99], [3, 4]]

但深拷贝不是万能药。它可能带来性能开销,也可能在复杂对象、文件句柄、数据库连接等场景中产生意料之外的问题。

实践建议是:

简单一层数据:浅拷贝通常足够
嵌套可变数据:考虑深拷贝
配置和常量:优先设计为不可变对象
大型数据:避免盲目复制,明确所有权

十、面向对象中的参数传递:self 也是一个引用

很多人学类的时候会问:self 到底是什么?

看代码:

class Counter:
    def __init__(self):
        self.value = 0

    def increase(self):
        self.value += 1

counter = Counter()
counter.increase()

print(counter.value)  # 1

调用:

counter.increase()

可以粗略理解为:

Counter.increase(counter)

也就是说,self 是指向当前实例对象的局部变量名。

counter  ----\
              ---> Counter 实例对象
self     ----/

在方法内部:

self.value += 1

修改的是实例对象的属性,所以外部的 counter.value 会变化。

但如果你在方法内部写:

class Counter:
    def reset_wrong(self):
        self = Counter()

这并不会让外部对象被替换,只是让局部变量 self 重新绑定到了一个新对象。

正确的重置应该是:

class Counter:
    def __init__(self):
        self.value = 0

    def reset(self):
        self.value = 0

这再次说明:
重新绑定局部变量不会影响外部绑定,修改共享对象本身才会影响外部。


十一、进阶视角:参数传递与不可变设计

当项目变大后,最可怕的不是代码报错,而是状态被悄悄改掉。

例如:

def apply_discount(order):
    order["price"] *= 0.8
    return order

这个函数会修改传入的订单字典。如果调用方还在其他地方使用原订单对象,就可能出现难以追踪的问题。

更稳妥的写法是返回新对象:

def apply_discount(order):
    return {
        **order,
        "price": order["price"] * 0.8,
    }

对于业务核心数据,还可以使用不可变数据模型:

from dataclasses import dataclass, replace

@dataclass(frozen=True)
class Order:
    id: int
    price: float

def apply_discount(order: Order) -> Order:
    return replace(order, price=order.price * 0.8)

order = Order(id=1, price=100)
new_order = apply_discount(order)

print(order)      # Order(id=1, price=100)
print(new_order)  # Order(id=1, price=80.0)

不可变对象的好处是:

更容易推理
更适合缓存
更适合并发
更少出现隐藏副作用
更方便测试

这也是为什么很多现代编程范式都强调“少修改共享状态”。


十二、与装饰器结合:观察函数是否修改了参数

在调试复杂项目时,我们可以写一个简单装饰器,观察函数调用前后参数的变化。

import copy
import functools

def watch_mutation(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        before_args = copy.deepcopy(args)
        before_kwargs = copy.deepcopy(kwargs)

        result = func(*args, **kwargs)

        if before_args != args or before_kwargs != kwargs:
            print(f"[警告] {func.__name__} 修改了传入参数")

        return result

    return wrapper

使用示例:

@watch_mutation
def add_item(items):
    items.append("new")

data = []
add_item(data)

输出:

[警告] add_item 修改了传入参数

这类工具在排查副作用问题时非常有用。不过要注意,deepcopy 有性能成本,不建议在线上高频路径中随意使用,更适合作为调试工具或测试辅助工具。


十三、单元测试:把“是否修改参数”写进测试

优秀的 Python 最佳实践,不只是写出功能,还要明确函数边界。

假设我们希望 normalize_users() 不修改原始数据,可以这样测试:

def normalize_users(users):
    return [
        {
            "name": user["name"].strip().title(),
            "email": user["email"].lower(),
        }
        for user in users
    ]

def test_normalize_users_does_not_mutate_input():
    users = [
        {"name": " alice ", "email": "ALICE@example.com"}
    ]

    original = [user.copy() for user in users]

    normalize_users(users)

    assert users == original

这个测试看似简单,但它保护的是函数设计契约:

调用这个函数不会改变传入参数。

在团队协作中,这类测试非常有价值,因为它能防止后续重构时无意引入副作用。


十四、性能视角:复制更安全,但不是越多越好

既然修改传入参数容易出问题,那是不是所有函数都应该复制数据?

也不是。

对于小型数据,复制通常没问题。
但对于大型列表、数据表、图结构、机器学习样本、实时流数据,盲目复制会带来明显的内存和性能压力。

比较合理的做法是:

业务核心对象:优先安全,避免隐式修改
性能敏感路径:允许原地修改,但必须明确约定
工具函数:默认返回新对象
底层算法:可提供 inplace 参数

例如很多数据处理库会采用这样的设计:

def normalize(records, inplace=False):
    if not inplace:
        records = [record.copy() for record in records]

    for record in records:
        record["name"] = record["name"].strip().title()

    return records

调用方可以明确选择:

new_records = normalize(records)
normalize(records, inplace=True)

这比函数偷偷修改参数要清晰得多。


十五、常见面试题:下面代码输出什么?

例 1:整数重新绑定

def f(x):
    x += 1

a = 10
f(a)
print(a)

答案:

10

整数不可变,x += 1 重新绑定局部变量。


例 2:列表原地修改

def f(x):
    x.append(1)

a = []
f(a)
print(a)

答案:

[1]

列表可变,append() 修改共享对象。


例 3:列表重新绑定

def f(x):
    x = x + [1]

a = []
f(a)
print(a)

答案:

[]

x + [1] 创建新列表,x = ... 只重新绑定局部变量。


例 4:列表原地扩展

def f(x):
    x += [1]

a = []
f(a)
print(a)

答案:

[1]

列表的 += 会原地扩展。


例 5:字典修改

def f(d):
    d["lang"] = "Python"

config = {}
f(config)
print(config)

答案:

{'lang': 'Python'}

字典是可变对象,函数内部修改会影响外部。


十六、最佳实践清单

在真实项目中,建议记住这些原则:

  1. 不要简单说 Python 是值传递或引用传递。
    更准确的说法是对象共享传参,或者对象引用按赋值传递。

  2. 区分“重新绑定”和“修改对象”。
    x = new_obj 是重新绑定;x.append()x.update() 是修改对象。

  3. 警惕可变默认参数。
    不要写 def func(items=[]),优先写 def func(items=None)

  4. 函数是否修改参数,要通过命名和文档说清楚。
    例如 normalize() 返回新对象,normalize_inplace() 原地修改。

  5. 嵌套结构复制时,区分浅拷贝和深拷贝。
    copy() 只复制外层,deepcopy() 递归复制内部对象。

  6. 业务核心数据尽量不可变。
    可以使用 tuplefrozensetdataclass(frozen=True) 等方式降低副作用。

  7. 性能敏感场景允许原地修改,但要显式声明。
    例如提供 inplace=True 参数,让调用者自己选择。

  8. 用测试保护函数边界。
    如果函数承诺不修改输入,就应该写测试验证。


十七、总结:理解参数传递,就是理解 Python 的对象世界

Python 的参数传递问题,看似只是基础语法,背后却连接着变量模型、对象模型、可变性、函数设计、面向对象、测试、性能优化和工程实践。

一句话总结:

Python 函数调用时,形参会绑定到实参所引用的对象。函数内部如果修改这个共享对象,外部能看到变化;如果只是让形参重新绑定到新对象,外部不会受影响。

所以,Python 不是传统意义上的值传递,也不是 C++ 那种典型引用传递。它更像是:

对象引用被传入函数;
函数形参成为新的局部名字;
局部名字和外部名字最初共享同一个对象;
修改对象会影响共享者;
重新绑定名字只影响当前作用域。

当你真正理解这一点,很多 Python 行为都会变得清晰:

为什么列表参数会被函数修改?
为什么整数参数不会变?
为什么默认参数里的列表会被复用?
为什么 copy 后嵌套列表还会互相影响?
为什么 self 改属性有效,self = NewObject() 却无效?

Python 的魅力,恰恰在于它表面简洁,内部统一。
你越理解它的对象模型,就越能写出稳定、优雅、可维护的代码。

最后留给你两个问题:

你在项目中是否遇到过函数意外修改参数导致的 bug?
在你的团队里,是否会明确区分“返回新对象”和“原地修改”的函数命名?

欢迎在评论区分享你的经历。真正的 Python 成长,往往不是从背语法开始,而是从理解这些“看似简单却影响深远”的细节开始。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值