深入理解 Python 参数传递:是“值传递”还是“引用传递”?

在开始我们的 Python 进阶之旅之前,我想请你先回想一下你在使用 C++ 或 Java 等其他编程语言时的经验。在这些语言中,我们经常被教导要严格区分“值传递”和“引用传递”。前者传递的是数据的副本,后者则传递原始内存地址。这通常是我们理解函数如何操作数据的基石。

然而,当你深入挖掘 Python 的核心机制时,你会发现这里的情况有些不同。Python 并不严格遵循上述任何一种传统的模型。这也是许多从其他语言转过来的开发者——甚至是有经验的 Python 开发者——容易感到困惑的地方。

在这篇文章中,我们将通过实际代码和底层原理的探讨,揭示 Python 参数传递的真正面貌:“传递对象引用”(Call by Object Reference),有时也被称为“调用共享”。我们将一起通过详尽的示例,分析为何有时函数能改变外部的变量,而有时却不能。这将帮助你更自信地编写可预测、无副作用的代码。

什么是“传递对象引用”?

让我们先达成一个共识:在 Python 中,一切皆对象。无论是简单的整数,还是复杂的列表,它们都是对象。当我们把参数传递给函数时,Python 传递的不是对象本身的副本(值传递),也不是一个传统的 C 语言指针(引用传递),而是对象引用的副本

简单来说,函数内部获得的参数和外部变量指向的是内存中的同一个对象。但是,这个链接是否会“断裂”,或者说修改是否会生效,完全取决于该对象是可变的还是不可变的

  • 可变对象:如列表、字典、集合。它们可以在原处被修改,就像我们在一个已经建好的房子里重新装修。
  • 不可变对象:如整数、字符串、元组。它们一旦创建就无法改变,任何试图修改的操作本质上都是创建了一个新对象,就像把旧房子拆了并在旁边盖了个新的。

第一部分:可变对象的“引用传递”行为

当我们处理列表或字典等可变对象时,Python 的行为非常类似于我们传统理解中的“引用传递”。因为函数接收的是原始对象的引用,所以调用者和函数共享同一个对象。在函数内部所做的任何就地修改,都会直接反映到函数外部。

#### 示例 1:共享引用与原地修改

让我们从一个最直观的例子开始。这里我们定义一个函数,它接收一个列表并向其中添加元素。

def add_element(target_list):
    """
    向列表中添加一个元素。
    注意:这里使用的是 .append() 方法,这是一个“就地”修改操作。
    """
    target_list.append("New Item")
    print("函数内部打印:", target_list)

# 初始化一个列表
my_list = ["Initial Item"]

print("调用前:", my_list)
add_element(my_list)
print("调用后:", my_list)

输出:

调用前: [‘Initial Item‘]
函数内部打印: [‘Initial Item‘, ‘New Item‘]
调用后: [‘Initial Item‘, ‘New Item‘]

深入解析:

在这个例子中,INLINECODE487ae6d7 和函数参数 INLINECODEcdeddae0 都指向内存中同一个列表对象。当我们调用 INLINECODEc70f5746 时,我们直接修改了该内存地址处的数据。因此,函数外部的 INLINECODEd762e329 能够“看到”这个变化。这就是为什么我们常说可变对象表现得像引用传递。

#### 示例 2:陷阱——重新赋值

然而,如果你在函数内部尝试重新赋值整个变量,情况就会发生变化。这是初学者最容易掉进的陷阱。

def reassign_list(target_list):
    """
    尝试在函数内部将变量指向一个全新的列表。
    """
    print(f"函数开始时,参数 ID: {id(target_list)}")
    
    # 关键点:这里我们在局部作用域创建了一个新的列表对象
    # 并将 target_list 这个名字绑定到了这个新对象上
    target_list = ["Brand New List", 1, 2, 3]
    
    print(f"函数内部新列表 ID: {id(target_list)}")
    print("函数内部显示内容:", target_list)

my_list = ["Original Data"]
print(f"原始列表 ID: {id(my_list)}")

reassign_list(my_list)
print("函数调用结束后,外部列表:", my_list)

输出:

原始列表 ID: 140234567890000
函数开始时,参数 ID: 140234567890000
函数内部新列表 ID: 140234567800000  <-- 注意 ID 变了,说明是同一个新对象
函数内部显示内容: ['Brand New List', 1, 2, 3]
函数调用结束后,外部列表: ['Original Data']

深入解析:

这里发生了什么?

  • 初始时,函数参数 INLINECODE37dc3ddc 和外部的 INLINECODEe65e03dd 确实指向同一个对象(ID 相同)。
  • 但是,当我们执行 target_list = [...] 时,Python 解释器会在内存中创建一个新的列表对象。
  • 然后,函数内部的局部变量名 target_list 被解除了与旧对象的绑定,转而绑定到了这个新对象上。
  • 这种“重绑定”操作仅仅影响函数内部的局部作用域。外部的 my_list 依然牢牢地绑定在旧对象上。

#### 示例 3:清空列表与操作符的陷阱

除了 INLINECODE93b01628,我们还需要注意像 INLINECODEc58c28de 或 INLINECODEba88aa87 这样的增强赋值操作符。它们的行为取决于对象是否实现了 INLINECODE8c28aa8c 方法。对于列表,+= 实际上是就地修改,但如果遇到类型错误,可能会退化为重新赋值。

def modify_with_plus_equals(target_list):
    # 对于列表,+= 会调用 extend 方法,是就地修改
    target_list += ["Added with +="]
    print("+= 操作后 ID:", id(target_list))

my_list = ["Start"]
print("原始 ID:", id(my_list))
modify_with_plus_equals(my_list)
print("最终结果:", my_list)
print("最终 ID:", id(my_list)) # ID 保持不变

实用见解: 当你编写旨在修改传入数据的函数时,确保使用的是 INLINECODE9b1fd5d1、INLINECODE00e7ac28 或 INLINECODEb9d92f3b 等方法,而不是直接对参数变量名进行赋值。如果你想让函数修改外部数据,最好的做法是显式地传入一份数据的副本(例如使用 INLINECODEb5964b1e 或 my_list.copy())。

第二部分:不可变对象的“值传递”行为

对于整数、浮点数、字符串和元组,情况有所不同。因为它们是不可变的,你无法在原处改变它们的状态。因此,Python 对它们的处理方式看起来就像是“值传递”。

#### 示例 4:整数和字符串的“假象”

让我们看看当你尝试在函数内部“修改”一个整数时会发生什么。

def try_to_change_number(x):
    print(f"函数内收到参数的 ID: {id(x)}")
    print(f"尝试加 10 之前,x 是: {x}")
    
    # 这行代码实际上做了两件事:
    # 1. 计算 x + 10 的结果(这会在内存中创建一个新的整数对象)
    # 2. 将局部变量名 x 指向这个新对象
    x = x + 10
    
    print(f"函数内赋值后 x 的 ID: {id(x)}") # ID 变了!
    print("函数内部 x 的值:", x)

num = 50
print(f"外部变量 num 的 ID: {id(num)}")
print("调用前 num:", num)

try_to_change_number(num)
print("调用后 num:", num)

输出:

外部变量 num 的 ID: 140234567890050
调用前 num: 50
函数内收到参数的 ID: 140234567890050  <-- 初始指向同一对象
尝试加 10 之前,x 是: 50
函数内赋值后 x 的 ID: 140234567890150 <-- 创建了新对象
函数内部 x 的值: 60
调用后 num: 50  <-- 外部不受影响

深入解析:

INLINECODEd2a099f2 并没有把内存地址为 INLINECODE0ded5e88 的数据改成 INLINECODE6262c12e。因为整数是不可变的,Python 只能创建一个新的整数对象 INLINECODEf4e3c969,然后把函数里的局部变量 INLINECODE65af736f 指向它。外部的 INLINECODE3f567c46 依然指向 50。这就造成了“值传递”的错觉:函数没有修改原始数据。

#### 示例 5:元组的情况

元组是不可变的列表。如果你试图在函数内部修改元组,Python 会直接抛出错误。

def modify_tuple(t):
    # 尝试修改元组的第一个元素
    # 这会抛出 TypeError: ‘tuple‘ object does not support item assignment
    try:
        t[0] = 99
    except TypeError as e:
        print(f"错误捕获: {e}")

my_tuple = (1, 2, 3)
modify_tuple(my_tuple)

唯一的“修改”元组的方式,也是通过重新赋值创建一个新元组,这与整数的例子是一样的原理。

综合对比:何时变,何时不变?

为了让你在工作中能够快速判断,我们总结了一个详细的对比表。这不仅仅是理论,更是你调试代码时的实战指南。

方面

可变对象

不可变对象 :—

:—

:— 典型类型

INLINECODEaa3fe224, INLINECODE7317d9b8, INLINECODE761e8691, 自定义类实例

INLINECODE888eeef2, INLINECODE54f8d145, INLINECODEd7bc498b, INLINECODE8bb93a5f, INLINECODEc32b6836 传递本质

传递的是对象的引用(地址)

传递的也是对象的引用(地址) 函数内修改原值?

(如果使用 INLINECODE4f6e2e30, INLINECODE0571f09a 等方法)

(只能通过重新赋值生成新对象) 函数内重新赋值

不影响外部(局部变量指向新对象)

不影响外部(局部变量指向新对象) 外部影响

若函数进行了就地操作,外部可见变化。

外部永远看不到变化,因为引用没变。 实际表现

类似 C++ 的引用传递

类似 C++ 的值传递

实战中的最佳实践

理解了原理之后,我们在实际开发中应该如何应用呢?这里有几点资深开发者的建议:

  • 默认参数陷阱(大坑):永远不要使用可变对象(如空列表 INLINECODE6366a0b3 或空字典 INLINECODE751f34e4)作为函数的默认参数。因为在 Python 中,默认参数在函数定义时只被评估一次。这意味着这个列表会被所有调用该函数的实例共享!

错误写法*:def foo(items=[]):
正确写法*:def foo(items=None): if items is None: items = []

  • 防御性拷贝:如果你编写一个处理列表的函数,但你不希望意外修改原始数据,可以在函数开头显式地复制一份:local_list = input_list.copy()
  • 明确意图:如果你的函数旨在修改传入的数据,请在文档字符串中明确写出“此函数会就地修改输入列表”。如果不打算修改,请考虑返回一个新对象,而不是修改旧对象(纯函数风格)。

总结

让我们回顾一下今天探讨的核心内容:

Python 的参数传递机制既不是单纯的值传递,也不是单纯的引用传递,而是“传递对象引用”。我们掌握了以下关键点:

  • 对于可变对象(如列表):传递引用使得函数能够直接修改原始数据,这对内存效率很高,但也容易产生副作用。
  • 对于不可变对象(如整数、字符串):虽然传递的是引用,但因为对象本身无法改变,任何“修改”操作实际上都是创建新对象,导致外部看起来像值传递。
  • 重新赋值是断开链接:无论是在处理可变还是不可变对象,一旦你在函数内部使用了 = 赋值,局部变量就会指向新对象,从而断开与外部原始变量的联系。

理解这些细微的区别,将帮助你更深入地理解 Python 的内存管理模型,并在编写复杂函数时避免难以追踪的 Bug。下次当你遇到函数没有按预期修改列表时,不妨停下来思考一下:我是就地修改了对象,还是创建了一个新的?

希望这篇文章能让你对这个话题有更清晰的认识!继续动手写代码,观察 id() 的变化,你会发现其中的乐趣。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/18616.html
点赞
0.00 平均评分 (0% 分数) - 0