写在最前:python的地址是什么?

python由于解释器的存在,因此与C/C++的很大不同便是不直接通过地址操纵内存资源,而是使用引用。在 Python 中,引用可以近似理解为 “对对象内存地址的高层抽象表示”。每个 Python 对象在内存中都有唯一的标识符(可通过 id() 函数获取,类似于 “内存地址的编号”)。当你创建变量并赋值时,变量(引用)会指向该对象的内存位置

1
2
a = 10
print(id(a)) # 输出 a 指向的对象的内存标识符(类似地址)

有了引用的概念后,我们接下来就要谈谈python的三种存储方式:引用赋值浅拷贝深拷贝

python赋值的三种手段

引用赋值

引用赋值就是利用上文提出的引用概念,它的机制是将新变量和老变量指向同一个数组对象(也就是共享内存中的数据缓冲区)如果其中一个发生了变化,自然另一个也会发生变化。引用赋值常用在可变对象的赋值(=)运算中(需要提到的是在python中万物皆为对象,即使类本身也可以视为一种对象,比如为list、自定义的实例化对象obj、以及各种含有可变对象的容器如tuple[list]等可变对象成员),换句话说对可变对象的赋值运算默认就是引用赋值

1
2
3
4
5
import numpy as np
a = np.array([1, 2, 3])
b = a # 引用赋值,b和a共享同一块数据
b[0] = 100 # 修改b
print(a) # [100, 2, 3](a也被修改)

浅拷贝 (view())

创建新的数据对象,但新对象和原对象共享数据缓冲区(内存中的实际数据)。修改新对象的数据,原对象会同步变化(因为内存共享);但修改原对象的属性则不会影响新对象,比如说改变数组对象的形状(shape),原数组不受影响(因为数组对象本身是新的)。

1
2
3
4
5
6
7
a = np.array([1, 2, 3])
b = a.view() # 浅拷贝(视图)
b[0] = 100 # 修改元素(共享数据,a会变)
print(a) # [100, 2, 3]

b.shape = (3, 1) # 修改新数组的形状(不影响原数组)
print(a.shape) # (3,)(a的形状不变)

需要注意的是,引用赋值和浅拷贝最大的不同便是浅拷贝一定是创建了一个新的对象,对于浅拷贝来说a与b是不同的两个个体,即使他们共享数据缓存!

深拷贝 (copy())

类似于C++中的深拷贝,创建完全独立的新数组,包括新的数据缓冲区(不共享任何内存)。修改新数组的元素或形状,原数组完全不受影响。

1
2
3
4
a = np.array([1, 2, 3])
b = a.copy() # 深拷贝
b[0] = 100 # 修改b
print(a) # [1, 2, 3](a不受影响)

举个numpy数组的例子

我们假设把numpy数组拆成两个部分:

  • 对象本体:存储数组的「属性信息」(比如 shapedtypendim 等,相当于数组的 “配置文件”)
  • 数据缓冲区:存储数组的「实际数值」(比如 [1,2,3],相当于数组的 “核心数据”)。

1. = 号:引用赋值(不是拷贝)

  • 对象本体:同一个(两个变量指向内存中同一个数组对象,属性完全共享);
  • 数据缓冲区:同一个(实际数值完全共享)。

简单说:a = b 后,ab 是「同一个东西」,只是名字不同。修改 a 的任何部分(属性或数据),b 会完全同步变化 —— 因为它们本来就是一个对象。
例子:

1
2
3
4
5
6
a = np.array([1,2,3])
b = a # 引用赋值,不是拷贝
a.shape = (3,1) # 修改a的属性(shape)
print(b.shape) # (3,1)(b的属性也变了,因为是同一个对象)
b[0] = 100 # 修改b的数据
print(a) # [[100],[2],[3]](a的数据也变了)

2. 浅拷贝(view() / 切片):新对象 + 共享数据

  • 对象本体:新的(创建一个全新的数组对象,属性初始和原数组一致,但后续可独立修改);
  • 数据缓冲区:同一个(实际数值共享,修改一方的数据,另一方会同步变)。
1
2
3
4
5
6
a = np.array([1,2,3])
b = a.view() # 浅拷贝(view),新对象+共享数据
b.shape = (3,1) # 修改b的属性(shape)
print(a.shape) # (3,)(a的属性不变,因为是不同对象)
b[0] = 100 # 修改b的数据(共享缓冲区)
print(a) # [100, 2, 3](a的数据变了)

3. 深拷贝(copy() or deepcopy()):新对象 + 新数据

  • 对象本体:新的(独立属性);
  • 数据缓冲区:新的(独立数值)。

完全独立的副本,修改任何一方的属性或数据,都和原数组无关。

1
2
3
4
5
6
a = np.array([1,2,3])
b = a.copy() # 深拷贝,新对象+新数据
b.shape = (3,1) # 修改b的属性
print(a.shape) # (3,)(a不变)
b[0] = 100 # 修改b的数据
print(a) # [1,2,3](a不变)

TIPS:这里需要注意listnumpy.array中的copy方法是不一样的

对象类型 copy() 是浅拷贝还是深拷贝? 对应的 “浅拷贝替代”
Python 内置容器(列表、字典) 浅拷贝 无(copy() 本身就是浅拷贝)
numpy 数组 深拷贝 view()numpy 的浅拷贝)

跟C++做对比

C++ 中,“拷贝” 的前提是 创建了新的对象实例(新的内存块用于存储对象本身),浅拷贝和深拷贝的区别只在于「对象内部成员的处理方式」:

  • 浅拷贝:创建新对象(新对象有自己的内存空间),但新对象的「指针成员」仍指向原对象的内存(共享内部数据)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    class MyArray {
    public:
    int* data; // 指针成员(指向堆内存)
    MyArray(int val) { data = new int(val); }
    MyArray(const MyArray& other) : data(other.data) {} // 浅拷贝:只拷贝指针,不拷贝指针指向的内存
    };

    MyArray a(10);
    MyArray b = a; // 浅拷贝:创建了新对象b,但b.data和a.data指向同一块堆内存
  • 深拷贝:创建新对象,且新对象的「指针成员」指向「新的堆内存」(完全复制内部数据),和原对象无任何共享。

有了这些概念后我们终于可以开始思考进一步的问题,类以及类的成员函数是怎么存的呢?

复杂的结构:类成员

这个问题我们先要引入引用计数这个概念,这来自于python的内存管理

引用计数

一个对象B引用了对象A,那么B实际上指向了A的数据部分,他们拥有着同样的id(),同时在这个时间段内A的引用计数+1。而一旦B不再引用A后,则A的引用计数-1,当A的引用计数为0时,A的数据内存便会立即释放。这就是python的内存回收机制之一,另一个是**GC**。具体规则如下:

  • 当一个对象被新的引用指向时(如赋值、作为参数传递、被容器存储),引用计数 +1
  • 当引用失效时(如变量被删除、离开作用域、容器移除元素),引用计数 -1
  • 当引用计数归 0 时,对象被回收。

例如:

1
2
a = obj()  # 创建对象,引用计数=1
a = None # 引用计数=0 → 对象立即释放

注意:Python 的引用计数机制是实时检测的:当对象的引用计数变为 0 时,解释器会立即触发内存回收(逻辑上是 “即时”,底层执行可能有微小调度,但无 “定时器定点释放”)。换句话说,释放时机是 “引用计数为 0 的那一刻”,没有额外的延迟或定时器。

而对于三种赋值方式,显然引用赋值会导致引用计数+1(准确来说是被引用的一方+1,但是引用赋值后A与B本质是一体的,所以简称为引用计数+1)。至于深浅拷贝对引用计数的影响,取决于它们是否创建新对象以及是否引用原对象的内部元素。核心规律是:

  • 只要创建新对象,新对象的引用计数初始为 1
  • 只要引用了原对象(或其内部元素),被引用对象的引用计数就会增加。

浅拷贝对引用计数的影响:

浅拷贝会创建新的容器对象(如新列表、新字典),但新容器中的元素仍然是原容器中元素的引用(即共享内部元素)。具体影响:

  1. 新容器对象:引用计数初始为 1(因为刚创建,被当前变量引用);
  2. 原容器的内部元素:每个元素的引用计数 +1(因为新容器也引用了它们);
  3. 原容器对象:引用计数不变(浅拷贝不影响原容器本身的引用)。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import sys

# 原列表(容器)和内部元素
a = [1, 2, [3, 4]] # a 是原容器,引用计数初始为1
# 查看原内部元素的引用计数(以 [3,4] 为例)
inner = a[2]
print(sys.getrefcount(inner)) # 输出:3(inner即[3,4]被a[2]引用,然后引用赋值给inner再次被引用,而inner与他是引用赋值所以两者等价,故inner的引用计数为2。然后sys.getrefcount(obj) 函数在调用时,会临时将 obj 作为参数传递,这会额外增加一次对 obj 的引用(函数调用期间有效)。因此,实际输出值 = 真实引用计数 + 1(临时引用)。)

# 浅拷贝:创建新容器 b
b = a.copy() # 新容器 b,引用计数为1

# 原内部元素的引用计数增加(因为 b 也引用了它)
print(sys.getrefcount(inner)) # 输出:4(a、inner、b 都引用了 inner,最后再+1)

# 原容器 a 的引用计数不变(仍为1,仅被变量 a 引用)
print(sys.getrefcount(a)) # 输出:2(原列表 a 的真实引用计数是 1 和 getrefcount 临时引用 +1)

那么这里print(sys.getrefcount(a[2]))是多少呢?当然,答案是4

深拷贝的影响

深拷贝会创建完全独立的新对象,包括所有嵌套的内部元素都会被复制(即新容器和内部元素都是全新的,不引用原对象的任何部分)。具体影响:

  1. 新容器对象:引用计数初始为 1
  2. 新容器的内部元素:都是全新的副本,引用计数初始为 1(仅被新容器引用);
  3. 原对象(容器及内部元素):引用计数完全不变(深拷贝不引用原对象的任何部分)。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys
import copy

# 原列表和内部元素
a = [1, 2, [3, 4]]
inner = a[2]
print(sys.getrefcount(inner)) # 输出:2(被 a 和 inner 引用)

# 深拷贝:创建完全独立的新容器 b
b = copy.deepcopy(a) # 新容器 b,引用计数为1

# 原内部元素的引用计数不变(b 中的元素是新副本,不引用 inner)
print(sys.getrefcount(inner)) # 输出:2(仍仅被 a 和 inner 引用)

# 查看 b 中内部元素的引用计数(新副本)
b_inner = b[2]
print(sys.getrefcount(b_inner)) # 输出:2(被 b 和 b_inner 引用)

类成员该怎么存?

实例的属性(包括 “数据属性”)都存储在 __dict__ 中,而引用计数管理的是「整个实例对象的生命周期」(而非单独的 “数据部分”) —— 拷贝(浅 / 深)对引用计数的影响,本质是对「实例对象本身」和「__dict__ 中属性值(可能是对象引用)」的引用计数影响。

__dict__

类的成员可以分成类自身的静态成员以及实例化成员(动态成员)。对实例化成员而言,假设你有一个自定义类实例 A(A=Obj(...)),其 “成员”(属性)无论是 “数据部分”(如 A.data)还是其他属性(如 A.generation),只要是通过 self.xxx = ... 定义的实例属性,都会以「键值对」的形式存储在 A.__dict__ 中,而它的静态成员(比如A中静态字段)则存储在**Obj.__dict__**。

1
2
3
4
5
6
7
8
class MyObj:
def __init__(self, data):
self.data = data # 数据属性(比如 numpy 数组)
self.generation = 0 # 普通属性(整数)

A = MyObj(np.array([1,2,3]))
print(A.__dict__)
# 输出:{'data': array([1,2,3]), 'generation': 0}

可见A 的所有实例属性(包括 “数据部分”data 和 “普通属性”generation)都在 __dict__ 里,__dict__ 本身是一个字典,存储的是「属性名 → 属性值(可能是对象引用)」的映射。当你拷贝 A 得到 B 时,浅拷贝和深拷贝对 __dict__ 及引用计数的影响不同,但核心都是:引用计数影响的是「被引用的对象」(包括实例 A/B 本身,以及 __dict__ 中的属性值对象),而非 “数据部分” 或 “属性部分” 的割裂影响

1. 浅拷贝 B(如 B = copy.copy(A)

  • B.__dict__ 的特点:创建一个「新的 __dict__ 字典」,但字典中的「属性值是原 A.__dict__ 中属性值的引用」(共享属性值对象)。

    即:

    B.__dict__["data"]A.__dict__["data"]指向同一个numpy数组(数据对象)

    B.__dict__["generation"]A.__dict__["generation"]指向同一个整数0

  • 引用计数的影响

    • 实例 A 本身的引用计数:不变(浅拷贝不影响原实例的引用);
    • 实例 B 本身的引用计数:初始为 1(刚创建,被变量 B 引用);
    • __dict__ 中属性值对象的引用计数:
      • 若属性值是「复杂对象」(如 datanumpy 数组):引用计数 +1(因为 B.__dict__ 也引用了它);
      • 若属性值是「基础类型」(如 generation 是整数):引用计数也会 +1(但基础类型有 “小整数池” 优化,实际计数变化可能被忽略,逻辑上仍遵循规则)。

2. 深拷贝 B(如 B = copy.deepcopy(A)

  • B.__dict__ 的特点:创建一个「新的 __dict__ 字典」,且字典中的「属性值是原属性值的独立副本」(不共享任何对象引用)。即:B.__dict__["data"]A.__dict__["data"]的深拷贝(新的 numpy 数组);B.__dict__["generation"]A.__dict__["generation"]的副本(新的整数0)。
  • 引用计数的影响
    • 实例 A 本身及 A.__dict__ 中所有属性值的引用计数:完全不变(深拷贝不引用原对象的任何部分);
    • 实例 B 本身的引用计数:初始为 1
    • B.__dict__ 中属性值对象的引用计数:初始为 1(仅被 B.__dict__ 引用)

最后:一个担忧

对复杂数据结构的内存引用机制有了了解后,应该会自然想到如果相互之间互相引用也许很容易造成**“一些可能的未知的问题”。事实上没错,这会造成循环引用的问题,这也衍生出了强引用&弱引用**的概念,这些就留给下一篇探讨吧。