Python学习笔记——9.变量
变量作为Python中最基础的概念,但是我却很难捋清楚它的概念,因此放在了笔记的后面。 Python的变量使用非常灵活方便,但是这也导致了Python变量的底层原理不易理解,这一点与C/C++的变量完全不同。
在这篇笔记中,重点区分几个概念:
- 字面量:字面值是内置数据类型常量值的表示法
- 对象:对象具有类型,类型实例化得到对象
- 变量:可以指向一个任何类型对象的指针
关于字面量和字面量集的表示语法在前面已经介绍过,这里不再重复。
对象
在 Python 中,一切皆为对象,函数也是对象。 每一个对象都具有类型,对象是类型的实例,这些是面向对象的通用概念,在Python中也一样成立。
我们不需要像 C++一样手动管理内存中对象的创建与销毁,而是由 Python 虚拟机的负责(本质还是通过 C 的 malloc 之类的函数),内存回收机制 GC 负责管理和自动销毁没有被变量直接指向或间接使用的对象。GC的判定方法可以理解为对每一个对象都维持一个引用计数,如果计数为零则删除,和 C++的智能指针类似。事实上,我们无法在Python中显式地销毁内存中的任何对象。
基本数据类型可以分成两类:
- 不可变对象类型,主要包括:
- 整数类型 int
- 浮点数类型 float(虽然名为float,实质上就是双精度浮点数,相当于C语言的double)
- 字符串类型 str
- 元组 tuple
- 可变对象类型,通常包括:
- 列表 list
- 集合 set
- 字典 dict
这些基础的数据类型都有相应的字面量进行对应,可以通过字面量或字面量集直接创建相应的对象。可变与不可变类型的区别体现在修改对象时的行为,具体见下文。除了这些最基础的类型,还有可调用类型,自定义类型等等。
每个对象有独立的标识,关于标识有如下特点:
对象一旦被创建后,它的标识就不会改变
标识是一个整数,可以将其理解为该对象在内存中的地址(CPython就是这么实现的)
可以通过
id(x)
获取变量x
指向的对象的标识如果变量
x
和y
分别指向两个对象,可以使用is
运算符比较两个对象是否是同一个,两个同时存在的对象不可能具有相同的内存地址1
2
3
4
5
6
7x is y
# true if id(x) == id(y)
# x和y正在指向同一个对象
# false if id(x) != id(y)
# x和y正在指向不同的对象两个生命周期不重合的对象可能具有相同的标识符,因为它们可以先后被分配在内存中的同一位置
每一个对象都有自己的类型,如果变量x
指向一个对象,那么可以使用type(x)
获取对象的类型信息,例如
1
2
3s = 'abc'
print(type(s))
# <class 'str'>
Python的对象除了最简单的基本对象,还有很多对象是存在从属关系的,一个对象可能拥有其它对象的指针/引用,不妨称为复合对象,
复合对象和可变对象是很相似的概念,只是从不同角度进行的定义,例如一个列表[1,2,3]
,首先它是一个列表对象,它拥有三个指针,分别指向三个值为1,2,3的整数对象。
Python的类型也可以分为基本的类型和用户自定义的类型。在 Python3 中所有的类型都继承自 object 类型,因此都有一些公共的属性和方法。(但是在 Python2 中自定义类是否继承 object 有细微区别)
变量
在Python中,变量就是指向一个可以指向任何类型对象的指针,变量没有固定的类型,查看变量的类型就是它当前指向的对象的类型。 变量并不代表内存层面上的数据,Python 不支持常量(不可修改的变量)。
Python的变量在使用时非常自由,使用之前不需要进行声明或者定义,可以认为Python维护了一个记录所有变量的数据库(这里先忽略变量的作用域问题,最后再讨论变量作用域),其中每一个变量都正在指向一个对象,关于变量的基本使用有如下特点:
如果需要添加新的变量
x
,首先需要直接将一个对象赋值给它,例如x=2
,x
就会被添加进入数据库中直接将不在数据库中的名称作为变量使用,会报错未定义
1
2
3Traceback (most recent call last):
File "<stdin>", line 1, in <module>
NameError: name 'a' is not defined可以使用
del
语句删除变量,此时会将变量从数据库中移除,删除变量并不意味着它指向的对象的销毁,只是把指向这个对象的指针销毁了而已,对象的销毁判断和时机完全由GC控制1
2del a
del b,c删除的变量仍然可以再次添加到数据库中进行使用,只需要重新赋值即可
关于变量的赋值操作,有如下的便捷形式:
支持连续对多个变量同时赋值
1
x = y = z = 1
支持多个变量分别赋值,实质是元组的解包:将右侧打包为元组
(1,2,3)
分别赋值到左侧的对应变量1
x,y,z = 1,2,3
可以使用如下语句直接交换两个变量
1
x,y = y,x
Python并不支持不可变的变量(常量),因为Python的变量的实质只是指向对象的指针,通常约定全大写的变量为常量,例如
1
PI=3.1415926
也可以采用某些特殊的处理来确保变量是不可变的,参考类的只读属性的实现。
Python只有变量没有常量,变量指向的对象类型则分为可变和不可变的。
底层原理浅析
变量赋值
考虑如下变量赋值语句的实质:
1 | x = 1 |
x=1
会在内存空间创建了一个值为1的整数对象,然后将指向这个对象的指针交给
x
,然后 x=2
则是创建了一个值为2的整数对象,并使x
指向它。
实践中上述操作会被优化:Python在启动时就会在内存中直接创建所有的小整数对象,因为这些对象在Python语句执行中频繁使用。
并且由于整数类型是不可变的,因此Python会进一步使用优化策略:不再重复创建两个同样的整数对象,如果已经存在一个值为2整数对象,那么Python在执行z=1
的时候就会让z
直接指向它。
考虑如下变量赋值语句 1
2x = [1,2,3]
y = x
首先创建一个值为[1,2,3]
的列表对象,然后令x
指向它。第二行的y=x
不会涉及到对象的创建,只是让变量y
和变量x
指向同一个对象。
变量修改
对变量的修改有两种可能:
- 如果指向的对象类型是不可变的,修改变量这个指针自身,将变量指向新的对象,新对象的值根据原对象的值和修改语句确定
- 如果指向的对象是可变的,那么直接修改变量指向的对象的值
例一 1
2
3
4
5x = 100
id(x) # 140402669096272
x += 100
id(x) # 140402669099472
由于整数类型是不可变的,因此首先x
指向一个值为100的整数对象,修改会将x
指向另一个值为200的整数对象,这一点通过不同的标识可以验证。
例二 1
2
3
4
5x={}
id(x) # 140367902138240
x['a']=1
id(x) # 140367902138240
由于字典类型是可变的,因此首先x
指向一个值为空的字典对象,修改x
指向的字典对象,向其中添加一个键值对,这个过程中x
始终指向同一个对象,这一点通过相同的标识验证。
深复制与浅复制
考虑下面的语句 1
2x = [1,2,3]
y = x
这里y=x
只是让两个变量指向了同一个对象,并没有真正在内存中复制对象!我们关注如何在内存中复制对象。
值得注意的是,对于复合对象天然是存在两种复制操作的:
- 浅复制:只复制顶层对象,复制后两个顶层对象会指向同样的次级对象
- 深复制:复制顶层对象,然后递归地复制所有的子级对象,复制后两个顶层对象之间没有任何联系
Python提供了copy这个模块,可以分别实现任意对象的浅复制和深复制,下面给出两个例子进行分析
1
2
3
4
5
6
7
8
9
10
11
12import copy
x = [[1,2],3]
y = copy.copy(x) # shallow copy
x is y # False
y[0][0]=100
# x: [[100, 2], 3]
y[1]=300
# x: [[100, 2], 3]
这里对x
指向的列表对象[[1,2],3]
执行一个浅复制,将y
指向复制得到的新列表对象:
- 对
y
指向的顶层对象的修改不会影响到x
- 对
y
指向的子级对象的修改会影响到x
,因为x
和y
指向的两个列表对象共有子级对象
1 | import copy |
这里对x2
指向的列表对象[[1,2],3]
执行一个深复制,将y2
指向复制得到的新列表对象,对y2
指向的对象的任何层次的修改都不会影响到x2
。
除了专门的copy模块,很多内置类型都提供了copy()
方法,通常是浅复制,例如
1
2
3
4
5
6x = [[1,2],3]
y = x.copy() # shallow copy
z = x[:] # shallow copy
x = {'a':1}
y = x.copy() # shallow copy
科学计算中常用的numpy数组也提供了copy()
方法,考虑到科学计算的实际需求,这里提供的是深复制,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import numpy as np
x = np.array([[1,2],[3,4]])
y = x.copy() # deep copy
print(x is y)
# False
x[1][1]=300
print(f'{x = }\n{y = }')
'''
x = array([[ 1, 2],
[ 3, 300]])
y = array([[1, 2],
[3, 4]])
'''
y[0][0]=100
print(f'{x = }\n{y = }')
'''
x = array([[ 1, 2],
[ 3, 300]])
y = array([[100, 2],
[ 3, 4]])
'''
注:
- 对于不可变对象,其实在内存中复制与否都是无所谓的:因为任何的修改尝试都会导致创建具有新的值的不可变对象,在这里提到的对于不可变对象的复制都会被Python解释器优化
- 对于非复合对象,相当于只有顶层对象,此时深复制和浅复制没有区别
变量作用域
在Python中所有的变量被分配到具体的不同作用域中,有如下特点:
- 默认有全局作用域和内置作用域,在具体的函数,类或者模块的内部会开辟对应的局部作用域,局部作用域是可以嵌套的,局部作用域与全局作用域也是嵌套关系
- 同一个作用域内的变量不会重名,不同作用域之间的变量可以重名
- 添加一个新变量时,新变量所属的作用域由当前位置决定
- 获取一个现有的变量时,会根据当前位置,按照优先级顺序从当前作用域依次向外层作用域查找,最后会去内置作用域查找
例一,不同的位置决定了不同的作用域 1
2
3
4
5
6
7var1 = 5 # var1 属于全局作用域
def func():
var2 = 6 # var2 属于func局部作用域
def inner_func():
var3 = 7 # var3 属于内嵌的inner_func局部作用域
例二,在外部不能直接获取局部作用域中的变量 1
2
3
4
5def fun():
x = 1 # 创建局部作用域的变量x
fun()
print(x) # 报错,x未定义
例三,可以在局部作用域中直接获取全局作用域的变量 1
2
3
4
5
6x = 10 # 创建全局作用域的变量x
def fun():
return x # 获取全局作用域的变量x
print(fun())
例四,在局部作用域中不能给全局作用域中的变量赋值或修改,而是会在局部作用域中创建一个新变量,并且此后,局部作用域中的变量会遮盖全局作用域的同名变量
1
2
3
4
5
6
7
8
9x = 0 # 创建全局作用域的变量x
def sum(arg1, arg2):
x = arg1 + arg2 # 创建局部作用域的变量x
print ("sum: x=", x) # 获取局部作用域的变量x
return x
sum(10, 20)
print ("x=", x) # 获取全局作用域的变量x
例五,可以使用global
关键词来声明要使用和修改全局作用域中的变量,此后会抑制在当前局部作用域中创建同名变量,例如
1
2
3
4
5
6
7
8
9
10
11
12x = 0 # 创建全局作用域的变量x
def sum(arg1, arg2):
# 使用global关键字声明使用全局作用域的变量x
global x
x = arg1 + arg2 # 修改全局作用域的变量x
print ("sum: x=", x) # 获取全局作用域的变量x
return x
sum(10, 20)
print ("x=", x) # 获取全局作用域的变量x
例六,下面的函数执行时会触发错误,a=a+1
的语法解析有错误,不能对局部作用域的变量x
在赋值之前获取
1
2
3
4
5
6x=1 # 创建全局作用域的变量x
def fun():
x=x+1 # 报错,无法使用未定义的局部变量x
fun()
注:
- Python的作用域比C++少得多,后者在每一个
{}
结构都会创建新的作用域,Python只会在函数,类和模块的内部创建局部作用域,在判断或循环结构中不会创建局部作用域 - 除了
global
之外,还有作用相反的nolocal
,它会使用上一层作用域中的变量,但是注意如果上层作用域中没有找到,会直接报错而不是继续向上查找。