Python中的可变对象与不可变对象、inplcae操作、函数参数的传递方式

本文主要目的是协助具备cpp基础的工程师对python中函数参数的传递方式进行了解。

在使用 Python 的过程中,往往会对 Python 中函数传递的形参到底是值传递还是引用传递产生疑惑。不少教程给出的答案都是:对于不可变对象是值传递,对于可变对象是引用传递。这一答案在实践表象层面是成立的,但是却没有给出其背后的原理解答,对于初学者(尤其是习惯了 C/C++ 内存控制的工程师)而言很容易发生混乱。

针对这一问题,本文给出的核心结论是:Python 中函数传递的形参一律是"传对象引用 (Pass-by-Object-Reference)"。更深入地说,Python 中所有变量的赋值本质上都是指针引用的传递。 至于为何可变对象表现出"引用传递"现象,而不可变对象表现出"值传递"现象?这是由对象本身的内部可变性以及操作类型(原地 vs 非原地)决定的,与函数传递形参的机制本身无关。

为了彻底讲清楚这个问题,会对python中一些相关的基础概念进行讲解。

一、Python中的内存模型与变量赋值机制

Python中的变量 与cpp中的变量 不同,cpp中的变量代表一块内存区域,在Python中的变量则相当于是对对象的引用 (类似于cpp中的std::shared_ptr

  1. cpp中的内存模型与变量赋值机制
    在 C++ 中,当你声明一个变量时,编译器会在内存中划出一块固定大小的区域,并将这个变量名直接与这块内存地址绑定。变量本身就是那个存储数据的"盒子"。
cpp 复制代码
int a = 10; // 在栈上开辟 4 个字节,命名为 a,里面存入 10
int b = a;  // 在栈上【新开辟】 4 个字节,命名为 b,将 a 盒子里的内容【拷贝】过来
b = 20;     // 修改 b 盒子里的内容为 20。a 盒子完全不受影响。
  1. python中的内存模型与变量赋值机制
    在python中,各个对象(如整数10、列表[1,2])都是被单独创建在堆内存 中的,而我们作为程序员书写的变量名 ab只是贴在这些对象上的标签 ,或者说是指向这些对象的指针
python 复制代码
a = [1, 2, 3] # 在内存中创建一个列表对象,把标签 'a' 贴上去
b = a         # 把标签 'b' 贴到 'a' 所贴的【同一个对象】上!(没有发生拷贝)
b.append(4)   # 通过标签 'b' 找到了对象,并修改了它
print(a)      # 输出: [1, 2, 3, 4]。因为 'a' 和 'b' 贴在同一个东西上。

这段代码翻译成cpp,类似这个样子

cpp 复制代码
// C++ 模拟 Python 行为
#include <memory>
#include <vector>

// a 是一个智能指针,指向堆上的 vector
auto a = std::make_shared<std::vector<int>>(std::initializer_list<int>{1, 2, 3}); 

// b 也是一个智能指针,赋值操作仅仅是让指针指向同一个地址,引用计数 +1
auto b = a; 

// 通过指针 b 修改了底层对象的数据
b->push_back(4); 

// 此时 a->size() 也会是 4,因为它们指向同一个 vector

可能有读者会疑惑,为什么这里选择用list来举例,而不是用基本的类型(如int),这是由于int是不可变对象,在这里使用不可变对象进行举例可能会产生疑惑。在这一小节中,读者只需要建立一个概念:python中程序员书写的每个变量都类似于cpp中的std::shared_ptr,在已有一个变量A之后,令新变量B=A就相当于cpp中的std::shared_ptr<> B = A 。实际使用过程中遇到的诸如修改B会不会修改A的问题,是由变量的类型决定的(会在下一节中讲到),与python的变量绑定赋值机制无关。

二、可变对象与不可变对象

python中的对象分为可变对象和不可变对象

2.1 不可变对象

  1. 不可变对象:一旦在内存中创建,其内部的数据就不允许被修改 ,任何试图修改的操作都会在内存中开辟一个新的空间,创建一个新的对象
  2. 哪些类型的变量属于不可变对象?
  • 数值类型: int, float, bool, complex
  • 字符串: str
  • 元组: tuple
  • 不可变集合: frozenset

2.2 可变对象

  1. 可变对象:可以在不改变自身内存地址的情况下,动态修改其内部的数据
  2. 哪些类型的变量属于可变对象?
  • 列表: list (高度对应 C++ 的 std::vector,可以动态扩容)
  • 字典: dict (对应 std::unordered_map)
  • 集合: set (对应 std::unordered_set)
  • 自定义类的实例 (默认情况下): 大部分用户通过 class 定义的实例都是可变的
  • NumPy Arrays / PyTorch Tensors: 这两个成熟库的对象都是可变对象,支持高效的原地内存操作

2.3 简单代码验证

可以通过如下代码进行简单验证

python 复制代码
# --- 不可变对象 ---
x = 10
print(id(x))  # 假设输出: 14073...112
x += 1        # 尝试修改
print(id(x))  # 输出一个全新的地址: 14073...144。旧的整数 10 并没有被修改,而是创建了 11

# --- 可变对象 ---
v = [1.0, 2.0, 3.0] # 模拟一个 3D 向量
print(id(v))  # 假设输出: 20056...800
v[0] = 5.0    # 原地修改元素
v.append(4.0) # 原地扩容
print(id(v))  # 地址依然是: 20056...800。这是在原内存上操作的。

2.4 可变\不可变对象与变量赋值机制的关系

在能够区分可变、不可变对象之后,我们就可以更深入地理解python中的变量赋值机制了。

  1. 对于不可变对象(以int类型为例),给出如下例程

注:id()函数的作用是打印该变量的地址

python 复制代码
# Step 1
a = 2
print(id(a))	# 结果为9097968
b = a
print(id(b))	# 结果为9097968

# Step 2
a = 10
print(id(a))	# 结果为9098000
print(id(b))	# 结果为9097968

Step 1:我们首先创建了一个int类型的变量a = 10,然后我们声明一个新的对象b = a,并打印二者的地址,发现二者的地址是一样的。这说明python中的变量赋值就是传引用,这一步骤类似于cpp中的:

cpp 复制代码
std::shared_ptr<int> A = std::make_shared<int>(2);
std::shared_ptr<int> B = A;

Step 2: 我们将变量a重新赋值为11,**由于a是int类型,是不可变类型,所以a=10这句话相当于新开辟了一块内存,这块内存中的数据是10,然后让a重新指向这块内存。**因此,此时再打印变量a的地址,会发现已经发生变化;而打印变量b的地址,没有发生变化,因为b并不是完全等于ab = a只是让b指向了该语句执行时a所指的内存

该图片参考自深度好文! Python函数参数传递:到底是值传递还是传用传递?

  1. 对于可变对象(以list为例),给出如下例程
python 复制代码
# Step 1
lista = [1,2,3]
listb = lista
print(id(lista))		# 140433391343744
print(id(listb))		# 140433391343744
print("lista:", lista)	# [1, 2, 3]
print("listb:", listb)	# [1, 2, 3]

# Step 2
lista.append(4)
print(id(lista))		# 140433391343744
print(id(listb))		# 140433391343744
print("lista:", lista)		# [1, 2, 3, 4]
print("listb:", listb)		# [1, 2, 3, 4]

Step 1:新建一个列表A并赋值,新建一个列表B = A,可以看到A和B内容一致,指向同一块内存

Step 2:修改列表A,并打印A、B的内容和地址,发现A、B的内容一致,地址也一致。因为list是可变对象,可以在不改变自身内存地址的情况下,动态修改其内部的数据。

截至这里,读者应该对python中的变量赋值机制以及可变、不可变对象有了一定的了解。然而,在coding的过程中,我们有时会遇到:在对可变对象进行操作时,其内存地址也会发生改变 。这是因为对可变对象的操作分为原地操作非原地操作

三、可变对象的原地操作与非原地操作

原地操作(inplace操作)指的是直接在原对象所在的内存地址上修改其数据,而不分配新的内存空间去创建新对象。

典型的inplace操作有:

  • 列表: list.append(), list.extend(), list.sort(), list.clear()
  • 字典: dict.update(), dict.setdefault()
  • NumPy
    a += b (注意:a = a + b 不是原地操作,它会创建新数组。a += b 是原地修改 a)
    np.add(a, b, out=a) (显式指定输出到原有内存)
    切片赋值: a[0:5] = 0
  • PyTorch: 所有带有下划线 _ 结尾的方法都是原地操作。例如 tensor.add_(b), tensor.zero_(), tensor.copy_(other)。

典型的非原地操作有

  • 绝大多数算术运算符: c = a + b,string3 = string1 + string2 总是创建新对象
  • 内置返回新对象的方法: sorted(list) (返回新列表,区别于 list.sort())。
  • 对不可变对象的所有操作: 字符串的方法如 str.replace(),或整数的自增。

list类型为例,运行一个例程:

python 复制代码
lista = [1,2,3]
listb = lista
print(id(lista))				# 139630020089984
print(id(listb))				# 139630020089984
print("lista:", lista)		# [1, 2, 3]
print("listb:", listb)		# [1, 2, 3]

lista = lista + [4]
print(id(lista))					# 139630022248896
print(id(listb))					# 139630020089984
print("lista:", lista)		# [1, 2, 3, 4]
print("listb:", listb)		# [1, 2, 3]

此时现象就和刚刚讲过的不可变对象相似了,因为=是一个非原地操作,所以让lista指向了一块新的内存

四、函数的传参机制

讲到这里,其实读者应该已经明晰为什么python的传参机制有时像值传递,有时像地址传递:在函数传参的过程中,其实都是传引用,这是由python的变量赋值机制决定的;而修改函数内部的形参到底会不会影响函数外的实参,是由形参的类型(可变or不可变)、以及在函数内部进行的操作类型(原地or非原地)决定的

相关推荐
寻寻觅觅☆7 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
YJlio7 小时前
1.7 通过 Sysinternals Live 在线运行工具:不下载也能用的“云端工具箱”
c语言·网络·python·数码相机·ios·django·iphone
fpcc7 小时前
并行编程实战——CUDA编程的Parallel Task类型
c++·cuda
l1t7 小时前
在wsl的python 3.14.3容器中使用databend包
开发语言·数据库·python·databend
ceclar1238 小时前
C++使用format
开发语言·c++·算法
山塘小鱼儿8 小时前
本地Ollama+Agent+LangGraph+LangSmith运行
python·langchain·ollama·langgraph·langsimth
码说AI9 小时前
python快速绘制走势图对比曲线
开发语言·python
lanhuazui109 小时前
C++ 中什么时候用::(作用域解析运算符)
c++
charlee449 小时前
从零实现一个生产级 RAG 语义搜索系统:C++ + ONNX + FAISS 实战
c++·faiss·onnx·rag·语义搜索