变量作为Python中最基础的概念,但是我却很难捋清楚它的概念,因此放在了笔记的后面。 Python的变量使用非常灵活方便,但是这也导致了Python变量的底层原理不易理解,这一点与C/C++的变量完全不同。

在这篇笔记中,重点区分几个概念:

  • 字面量:字面值是内置数据类型常量值的表示法
  • 对象:对象具有类型,类型实例化得到对象
  • 变量:可以指向一个任何类型对象的指针

关于字面量和字面量集的表示语法在前面已经介绍过,这里不再重复。

对象

在 Python 中,一切皆为对象,函数也是对象。 每一个对象都具有类型,对象是类型的实例,这些是面向对象的通用概念,在Python中也一样成立。

我们不需要像 C++一样手动管理内存中对象的创建与销毁,而是由 Python 虚拟机的负责(本质还是通过 C 的 malloc 之类的函数),内存回收机制 GC 负责管理和自动销毁没有被变量直接指向或间接使用的对象。GC的判定方法可以理解为对每一个对象都维持一个引用计数,如果计数为零则删除,和 C++的智能指针类似。事实上,我们无法在Python中显式地销毁内存中的任何对象。

基本数据类型可以分成两类:

  1. 不可变对象类型,主要包括:
    • 整数类型 int
    • 浮点数类型 float(虽然名为float,实质上就是双精度浮点数,相当于C语言的double)
    • 字符串类型 str
    • 元组 tuple
  2. 可变对象类型,通常包括:
    • 列表 list
    • 集合 set
    • 字典 dict

这些基础的数据类型都有相应的字面量进行对应,可以通过字面量或字面量集直接创建相应的对象。可变与不可变类型的区别体现在修改对象时的行为,具体见下文。除了这些最基础的类型,还有可调用类型,自定义类型等等。

每个对象有独立的标识,关于标识有如下特点:

  • 对象一旦被创建后,它的标识就不会改变

  • 标识是一个整数,可以将其理解为该对象在内存中的地址(CPython就是这么实现的)

  • 可以通过id(x)获取变量x指向的对象的标识

  • 如果变量xy分别指向两个对象,可以使用is运算符比较两个对象是否是同一个,两个同时存在的对象不可能具有相同的内存地址

    1
    2
    3
    4
    5
    6
    7
    x is y

    # true if id(x) == id(y)
    # x和y正在指向同一个对象

    # false if id(x) != id(y)
    # x和y正在指向不同的对象

  • 两个生命周期不重合的对象可能具有相同的标识符,因为它们可以先后被分配在内存中的同一位置

每一个对象都有自己的类型,如果变量x指向一个对象,那么可以使用type(x)获取对象的类型信息,例如

1
2
3
s = 'abc'
print(type(s))
# <class 'str'>

Python的对象除了最简单的基本对象,还有很多对象是存在从属关系的,一个对象可能拥有其它对象的指针/引用,不妨称为复合对象, 复合对象和可变对象是很相似的概念,只是从不同角度进行的定义,例如一个列表[1,2,3],首先它是一个列表对象,它拥有三个指针,分别指向三个值为1,2,3的整数对象。

Python的类型也可以分为基本的类型和用户自定义的类型。在 Python3 中所有的类型都继承自 object 类型,因此都有一些公共的属性和方法。(但是在 Python2 中自定义类是否继承 object 有细微区别)

变量

在Python中,变量就是指向一个可以指向任何类型对象的指针,变量没有固定的类型,查看变量的类型就是它当前指向的对象的类型。 变量并不代表内存层面上的数据,Python 不支持常量(不可修改的变量)。

Python的变量在使用时非常自由,使用之前不需要进行声明或者定义,可以认为Python维护了一个记录所有变量的数据库(这里先忽略变量的作用域问题,最后再讨论变量作用域),其中每一个变量都正在指向一个对象,关于变量的基本使用有如下特点:

  • 如果需要添加新的变量x,首先需要直接将一个对象赋值给它,例如x=2x就会被添加进入数据库中

  • 直接将不在数据库中的名称作为变量使用,会报错未定义

    1
    2
    3
    Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
    NameError: name 'a' is not defined

  • 可以使用del语句删除变量,此时会将变量从数据库中移除,删除变量并不意味着它指向的对象的销毁,只是把指向这个对象的指针销毁了而已,对象的销毁判断和时机完全由GC控制

    1
    2
    del 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
2
x = 1
x = 2

x=1会在内存空间创建了一个值为1的整数对象,然后将指向这个对象的指针交给 x,然后 x=2 则是创建了一个值为2的整数对象,并使x指向它。

实践中上述操作会被优化:Python在启动时就会在内存中直接创建所有的小整数对象,因为这些对象在Python语句执行中频繁使用。 并且由于整数类型是不可变的,因此Python会进一步使用优化策略:不再重复创建两个同样的整数对象,如果已经存在一个值为2整数对象,那么Python在执行z=1的时候就会让z直接指向它。

考虑如下变量赋值语句

1
2
x = [1,2,3]
y = x

首先创建一个值为[1,2,3]的列表对象,然后令x指向它。第二行的y=x不会涉及到对象的创建,只是让变量y和变量x指向同一个对象。

变量修改

对变量的修改有两种可能:

  • 如果指向的对象类型是不可变的,修改变量这个指针自身,将变量指向新的对象,新对象的值根据原对象的值和修改语句确定
  • 如果指向的对象是可变的,那么直接修改变量指向的对象的值

例一

1
2
3
4
5
x = 100
id(x) # 140402669096272

x += 100
id(x) # 140402669099472

由于整数类型是不可变的,因此首先x指向一个值为100的整数对象,修改会将x指向另一个值为200的整数对象,这一点通过不同的标识可以验证。

例二

1
2
3
4
5
x={}
id(x) # 140367902138240

x['a']=1
id(x) # 140367902138240

由于字典类型是可变的,因此首先x指向一个值为空的字典对象,修改x指向的字典对象,向其中添加一个键值对,这个过程中x始终指向同一个对象,这一点通过相同的标识验证。

深复制与浅复制

考虑下面的语句

1
2
x = [1,2,3]
y = x

这里y=x只是让两个变量指向了同一个对象,并没有真正在内存中复制对象!我们关注如何在内存中复制对象。

值得注意的是,对于复合对象天然是存在两种复制操作的:

  • 浅复制:只复制顶层对象,复制后两个顶层对象会指向同样的次级对象
  • 深复制:复制顶层对象,然后递归地复制所有的子级对象,复制后两个顶层对象之间没有任何联系

Python提供了copy这个模块,可以分别实现任意对象的浅复制和深复制,下面给出两个例子进行分析

1
2
3
4
5
6
7
8
9
10
11
12
import 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,因为xy指向的两个列表对象共有子级对象
1
2
3
4
5
6
7
8
9
10
11
12
import copy

x2 = [[1,2],3]
y2 = copy.deepcopy(x2) # deep copy

x2 is y2 # False

y2[0][0]=100
# x2: [[1, 2], 3]

y2[1]=300
# x2: [[1, 2], 3]

这里对x2指向的列表对象[[1,2],3]执行一个深复制,将y2指向复制得到的新列表对象,对y2指向的对象的任何层次的修改都不会影响到x2

除了专门的copy模块,很多内置类型都提供了copy()方法,通常是浅复制,例如

1
2
3
4
5
6
x = [[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
24
import 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
7
var1 = 5 # var1 属于全局作用域

def func():
var2 = 6 # var2 属于func局部作用域

def inner_func():
var3 = 7 # var3 属于内嵌的inner_func局部作用域

例二,在外部不能直接获取局部作用域中的变量

1
2
3
4
5
def fun():
x = 1 # 创建局部作用域的变量x

fun()
print(x) # 报错,x未定义

例三,可以在局部作用域中直接获取全局作用域的变量

1
2
3
4
5
6
x = 10 # 创建全局作用域的变量x

def fun():
return x # 获取全局作用域的变量x

print(fun())

例四,在局部作用域中不能给全局作用域中的变量赋值或修改,而是会在局部作用域中创建一个新变量,并且此后,局部作用域中的变量会遮盖全局作用域的同名变量

1
2
3
4
5
6
7
8
9
x = 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
12
x = 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
6
x=1 # 创建全局作用域的变量x

def fun():
x=x+1 # 报错,无法使用未定义的局部变量x

fun()

注:

  • Python的作用域比C++少得多,后者在每一个{}结构都会创建新的作用域,Python只会在函数,类和模块的内部创建局部作用域,在判断或循环结构中不会创建局部作用域
  • 除了global之外,还有作用相反的nolocal,它会使用上一层作用域中的变量,但是注意如果上层作用域中没有找到,会直接报错而不是继续向上查找。