目录

Python中的不变类型与可变类型

发布于 2023/10/25 更新于 2023/10/25

作者 趣宽科技 码云上的源文件

H1

一切皆对象

在Python中,一切类型都可以被视为对象(object),包括:变量、常量、函数、列表等等。在下面的代码中,测试是否为对象时都返回True

# 一切皆对象
x = 10
def my_func():
    return "qklabs"
y = [1,2,3]

print("x is object:{}".format(isinstance(x,object)))
print("10 is object:{}".format(isinstance(10,object)))
print("my_func is object:{}".format(isinstance(my_func,object)))
print("y is object:{}".format(isinstance(y,object)))
print("bool is object:{}".format(isinstance(True,object)))
print("string is object:{}".format(isinstance("qklabs",object)))
                    

H1

不可变对象(Immutable objects)

Python中分为不可变(Immutable)和可变(Mutable)对象。不可变对象是指对象的实例一旦创建就不可改变,这表现为该对象在内存中的地址不变,同时该地址中的内容也保持不变。如下面的代码,无论声明多少个变量,只要它的值相同,这些对象的内存地址就会不变。

a = 5
b = 5
c = b

# int类型是不可变类型,下面输出的变量的内存地址是一样的。
# 说明不可变类型只要值相同,不管创建多少个变量,它们的都指向相同的内存地址
print("a address:{}".format(hex(id(a))))
print("b address:{}".format(hex(id(b))))
print("c address:{}".format(hex(id(c))))
                    

这与c++语言是不同的,在c++中,每个变量或常量都有独立的内存空间,地址都不相同,不管它们的值是相等还是不相等,通过指针,可以修改内存地址中的内容。在Python中,不可变对象是不允许修改对应的内存地址的内容的。但这不代表不允许修改变量的值,如:

a += 10
# 当 a+10后,a创建了一个新的对象,该对象指向了新的地址
print("after add, a address:{}".format(hex(id(a))))
                    

当变量a增加一个值之后,它指向了一个新的地址。这与c++也是不同的,在c++中,任然保持原有的a内存地址不变。

H1

可变对象(Mutable objects)

顾名思义,可变对象就是可以修改对象对应的内存中的内容,而该对象的地址在内容修改之后仍然保持不变。并且每个对象都有独立的内存地址,如列表类型:

list_1 = [1,2]
list_2 = [1,2]
# 每个对象都有独立的内存地址
print("list_1 address:{}".format(hex(id(list_1))))
print("list_2 address:{}".format(hex(id(list_2))))

# 修改list_1中的变量,在此打印对象的地址,发现它与修改之前一致。
list_1[0] = 3
print("list_1 address:{}".format(hex(id(list_1))))
                    

H1

不可变对象与可变对象的组合

在Python中如果不可变对象与可变对象组合在一起形成的对象,是可变还是不可变呢,看下面的代码:

# 下面演示可变和不可变的组合的情况,这里的list是可变类型,tuple是不可变类型
tuple1 = ([1,2], "qklabs")
print("tuple1 address:{}".format(hex(id(tuple1))))
# 修改其中的list内容
tuple1 = ([2,2], "qklabs")
# tuple是不可变类型,list是可变类型,因此修改了list,导致对象的地址也发生变化,说明组合后的类型是不可变类型。
print("after changed: tuple1 address:{}".format(hex(id(tuple1))))
                    

说明组合后的对象是不可变对象。

H1

为什么需要不可变对象

不可变类型是通过Python内存管理机制来实现的,这是因为Python对不可变类型进行了缓存,无论声明多少个值相同的变量,实际上都指向同个内存地址。这种机制减少了内存消耗和提升程序运行速度,通过直接从缓存获取运算结果比重新进行计算要快得多。

H1

实现自定义类的不可变性质

默认情况下,Python自定义类的对象是可变对象,如下例子:

class Symbol:
    def __init__(self, name):
        self.name = name

sb1 = Symbol("1")
sb2 = Symbol("1")
print("Symbol 1 is Symbol 1: {}".format(sb1 == sb2))
# 对象sb1和sb2的地址并不相同,
print("Symbol 1 address:{} Symbol 2 address: {}".format(hex(id(sb1)), hex(id(sb2)) ))
                    

即使该对象的成员变量的值一样,它们仍然不相等。所以上述的sb1 == sb2返回False。在一些场合中,我们需要使用不可变对象,使得Symbol("1") == Symbol("1")成立。例如在符号运算中,比较两个符号是否相等,如:Symbol("x") == Symbol("x")这是最快速的方式。当然也可以通过运算符重载而无需将该类变成不可变类型的类。但是Python中,不可变类型的好处还不止这些。下面的代码将Symbol类变成不可变类型,这里定义了一个新的类SymbolCache:

from functools import lru_cache
class SymbolCache:
    @lru_cache(maxsize=None)
    def __new__(cls, *args, **kwargs):
        return object.__new__(cls)

    def __init__(self, name):
        self.name = name
        
sbc1 = SymbolCache("1")
sbc2 = SymbolCache("1")
print("SymbolCache 1 is SymbolCache 1: {}".format(sbc1 == sbc2))
# 对象sbc1和sbc2的地址相同
print("SymbolCache 1 address:{} SymbolCache 2 address: {}".format(hex(id(sbc1)), hex(id(sbc2)) ))        
                    

上述的测试结果是 sbc1==sbc2是成立的,同时它们的对象地址也相同。这里利用Python的缓存(Cache)机制将数据存储在内存中,以便下次能够更快地访问这些数据,这也是一个典型的用空间换时间的例子。一般用于缓存的内存空间是固定的,当有更多的数据需要缓存的时候,需要将已缓存的部分数据清除后再将新的缓存数据放进去。需要清除哪些数据,就涉及到了缓存置换的策略,LRU(Least Recently Used,最近最少使用)是很常见的一个,也是 Python 中提供的缓存置换策略。在类的构造过程中,通过装饰器lru_cache将该类返回的对象缓存,也就是说将类的对象设置成不可变对象。这里的maxsize=None,表示不限制缓存大小。

H1

不可变对象在运算中的加速

在运算中,不可变对象如果已经缓存,可以直接从缓存中获取,而无需重复计算,看下面的例子:

def add(n1, n2):
    print(f"计算 n1 + n2......")
    return n1+n2
    
r1 = add(1, 2)
print("n1 + n2 = {}".format(r1))
r2 = add(1, 2) 
r3 = add(3, 4) + r2
print("n1 + n2 = {}".format(r3))    
                    

上述代码定义了一个简单的add函数实现两个数相加。我们调用了该函数三次,打印了三次"计算n1+n2....”信息,说明函数确实执行了三次。但是这里的r2计算和r1计算是一样的,有没有可能在执行r2计算时不重复计算,而直接从缓存获取计算结果呢?这就需要将该函数的返回值定义为不可变类型,如下:

from functools import lru_cache
                    
@lru_cache(maxsize=1000, typed=True)
def add(n1, n2):
    print(f"计算 n1 + n2......")
    return n1+n2


r1 = add(1, 2)
print("n1 + n2 = {}".format(r1))
r2 = add(1, 2)      # 由于该计算已经计算过,直接从缓存获取, 因此值打印两次“计算 n1 + n2......”
r3 = add(3, 4) + r2
print("n1 + n2 = {}".format(r3))
                    

我们可以看到r2的运算过程被跳过,而是直接从缓存中获取了。这种机制可以大大加快运算速度。有人可能会说,把r2的运算直接删除掉不就行了,但现实中很多运算不能省略掉必需的步骤,程序开发者不可能在每一步运算时去人为判断该步骤是否已运算过。

H1

部分可变和不可变类型

数据类型 类型
Bool Immutable
Int Immutable
Float Immutable
List Mutable
Tuple Immutable
Str Immutable
Set Mutable
Dict Mutable