关于Python中List内存占用的理解

思考这部分内容是因为在如下代码中遇到了问题

1
2
3
4
5
6
7
8
9
10
link = []  
network = []
for i in range(rows):
for j in range(cols):
if net_link[i][j] == 1:
link.clear()
link.append((i, j))
if i > j: link.append("high")
else: link.append("low")
network.append(link)

在这个状态下,每次循环打印linknetwork发现link的值是对的,但是network里面的每一项都会被最新的link覆盖。

这是因为:每一次执行network.append(link),实际上是把link的引用添加到network中,而不是创建了一份副本。

可以通过以下方法去验证:

1
2
3
# 每次进入if语句执行完network.append之后,打印新添加的变量的引用信息
print(f"network[{times}]: {id(network[times])}")
times += 1

根据打印可以发现,network的每个数据的引用都是一样的,也就是说,通过append添加列表,实际上是存了一份列表的地址放在network内存空间中。

因此每次link在清空之后重新修改,这个地址上存储的信息就会发生变化,network里的每一个元素也会发生变化。

如何解决这个问题

1
2
3
# 在将link添加到network时创建link的副本,两种方法都可以
network.append(link.copy())
network.append(link.[:])

问题研究到这里,我突然发现其实从来没有思考过python使用中内存的问题,于是就针对这一点做了一些小实验。

python中整数的常量池

在python中有一个会使用常量池来优化内存使用,所谓常量池就是python程序在初始化时就会开辟一块内存空间存放一些常量,对于整数型常量一般包括-5到256之间的整数。

测试代码如下:

1
2
3
4
5
6
7
8
9
print(hex(id(-6)))
print(hex(id(-5)))
print(hex(id(-4)))
print(hex(id(-3)))
print(hex(id(-2)))
print(hex(id(254)))
print(hex(id(255)))
print(hex(id(256)))
print(hex(id(257)))

打印结果如下:

1
2
3
4
5
6
7
8
9
0x24d84a9f610
0x24d84656870
0x24d84656890
0x24d846568b0
0x24d846568d0
0x24d84686950
0x24d84686970
0x24d84686990
0x24d8481cb10

能够看出来从-5到256之间,一个常数占据32个地址空间。这部分地址空间除了存储常数本身,还包括引用计数类型指针等信息,具体可以查找CPython的源码。

还可以再举一个例子测试一下:

1
2
3
x = 10
print(hex(id(x)))
print(hex(id(10)))

运行之后会发现两个打印是相同的。

对x=10的理解

实验做到这里,我发现我对于x=10这样简单的创建一个变量的理解是有问题的。

在C语言中,当我输入int x = 10;,程序运行之后会为变量x开辟一个空间,存储的值是10;同时初始化一个指针变量int *p = &x,指向x的地址,此时p也占据了一定的内存空间,存储的值是x的地址;接下来如果我修改x = 20,x的地址不会发生任何变化(也就是p的值不会变),但是x地址上存储的信息会变成20。

以上是C的初始化变量的内存状态。

但是在python中不太一样,对于下面的代码,两行的打印是同一个,一方面说明id()这个函数获取的不是元素本身的内存地址,另一方面,执行x=10时,并不是直接保存10的值,而是保存了指向常量池中10这个常数的地址信息,因此如果修改x的参数,那么id(x)也会发生变化。

1
2
3
4
5
6
x = 10
print(hex(id(x)))
print(hex(id(10)))
# 修改x的值
x = 20
print(hex(id(x))) # 此时x指向常量池20的位置

因此,在python中创建这样一个变量x = 10,本质上是创建了一个指向整数对象10的引用,通过id可以获取到对象的内存地址。

还有一个方法可以验证

1
2
3
4
5
a = 1
b = a
c = 1
print(a is b)
print(a is c)

两个的打印都是True,前者比较好理解,因为是直接赋值的,后者也是True,说明a和c本质上指向了同一个对象。

列表及相关操作中的内存理解

首先,通过my_list=[]这种表达创建的列表是一个列表对象,因此通过id(my_list)可以直接获取到这个列表的地址。

接下来先看一个列表示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 初始化简单列表
my_list = [1, 2, 3]
print(hex(id(my_list)))
print(hex(id(my_list[0])))
print(hex(id(my_list[1])))
print(hex(id(my_list[2])))
print(hex(id(1)))
print(hex(id(2)))
print(hex(id(3)))
# 直接添加一常量
my_list.append(4)
print(hex(id(my_list)))
print(hex(id(my_list[3])))
print(hex(id(4)))
# 添加一个参数
x = 5
my_list.append(x)
print(my_list)
print(hex(id(my_list)))
print(hex(id(my_list[4])))
print(hex(id(5)))
# 改变这个参数
x = 6
print(my_list)
print(hex(id(my_list)))
print(hex(id(my_list[4])))
print(hex(id(6)))

返回显示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0x209f5421b40
0x209e1186930
0x209e1186950
0x209e1186970
0x209e1186930
0x209e1186950
0x209e1186970
0x209f5421b40
0x209e1186990
0x209e1186990
[1, 2, 3, 4, 5]
0x209f5421b40
0x209e11869b0
0x209e11869b0
[1, 2, 3, 4, 5]
0x209f5421b40
0x209e11869b0
0x209e11869d0

首先,在初始化列表之后,my_list会占用一个内存空间,这个列表中的每一个元素存储的都是指向常量池中对应常量的地址信息,因此单独获取每个元素的id,和单独获取对应常量的id结果是一样的。

接下来向列表中添加一个常量元素,通过打印也能看出向列表中添加的实际上是指向这个元素的地址。

然后定义一个变量x,并把x添加到my_list中,通过id获取列表中第5个元素,可以看到实际上存储的信息是存储5这个常量的地址信息,结合前面分析的初始化一个整数型变量本质上是创建了一个指向这个整数的引用,所以虽然看上去append是添加了一个变量,但是其实是添加了一个常量。

通过修改x再观察my_list也可以发现修改x对my_list没有任何影响。

接下来让my_list变得再复杂一些:

1
2
3
4
5
6
7
8
9
10
11
y = [1, 2, 3]
z = [4, 5, 6]
my_list = [1, 2, y]
print(my_list)
y[0] = 2
print(my_list)
my_list.append(z)
my_list.append(z)
print(my_list)
z[2] = 0
print(my_list)

返回显示如下:

1
2
3
4
[1, 2, [1, 2, 3]]
[1, 2, [2, 2, 3]]
[1, 2, [2, 2, 3], [4, 5, 6], [4, 5, 6]]
[1, 2, [2, 2, 3], [4, 5, 0], [4, 5, 0]]

这里my_list中的元素不仅仅是常数了,还多了列表,但是同样的,my_list中存储的列表也只是列表对象的地址,因此无论是列表y还是后来通过append函数添加的列表z,当它们发生变化时,my_list也会发生变化了,这就回到了最初遇到的问题,也就是append(list)只能添加这个列表的地址,而要想永远添加不被覆盖,需要先创造一份副本,再将副本添加my_list中。

列表的拷贝

如果需要确保两个列表在内存中完全独立,可以创建列表的副本(深拷贝或浅拷贝)。

  • 浅拷贝:创建一个新的列表对象,但列表中的元素仍然引用原始对象。
  • 深拷贝:创建一个新的列表对象,并递归地复制所有子对象,确保新列表与原列表完全独立。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import copy

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

# 浅拷贝
b = a.copy()
print(a is b) # False,a 和 b 是不同的对象
print(a[2] is b[2]) # True,浅拷贝时嵌套的列表仍然引用同一个对象
print(a[1] is b[1]) # True

# 深拷贝
c = copy.deepcopy(a)
print(a is c) # False,a 和 c 是不同的对象
print(a[2] is c[2]) # False,深拷贝时嵌套的对象也会复制
print(a[1] is c[1]) # True

以上也仅限于可变对象,对于a[0]a[1]这种不可变对象,无论是哪种类型的拷贝,还是引用相同的对象。

还可以使用a[:]的方法创建副本,与浅拷贝功能相同。

总结

  1. python中的整数、字符串等都属于不可变对象,不管出现在哪里出现了几次,本质是引用的同一个对象。
  2. list中添加或者删除操作,实际操作的都是这个对象的引用信息,所以如果这个对象是一个可变对象,对这个可变对象进行修改,会影响到其他列表。
  3. 如果想要保证列表在内存中完全独立,可以在append添加时直接创建副本,也就是对列表进行拷贝操作

关于Python中List内存占用的理解
https://bingbytebard.github.io/2024/12/05/关于List内存占用的理解/
Author
Hazel
Posted on
December 5, 2024
Licensed under