首页 > 代码库 > Python学习笔记 | 变量 + 引用 + 拷贝 + 作用域

Python学习笔记 | 变量 + 引用 + 拷贝 + 作用域

在Python中,变量是没有类型的,在使用变量的时候,不需要提前声明,只需要给这个变量赋值即可。但是,当用变量的时候,必须要给这个变量赋值;如果只写一个变量,而没有赋值,那么Python认为这个变量没有定义(not defined)。

一、变量和对象

1. 可变对象和不可变对象

在Python中,对象分为两种:可变对象和不可变对象,不可变对象包括int,float,long,str,tuple等,可变对象包括list,set,dict等。需要注意的是:这里说的不可变指的是值的不可变。对于不可变类型的变量,如果要更改变量,则会创建一个新值,把变量绑定到新值上,而旧值如果没有被引用就等待垃圾回收。另外,不可变的类型可以计算hash值,作为字典的key。可变类型数据对对象操作的时候,不需要再在其他地方申请内存,只需要在此对象后面连续申请(+/-)即可,也就是它的内存地址会保持不变,但区域会变长或者变短。
  1. $ python3
  2. Python 3.5.3 (default, Feb 27 2017, 09:17:48) [GCC 6.3.0 64 bit (AMD64)] on win32
  3. Type "help", "copyright", "credits" or "license" for more information.
  4. >>> a = ‘phoenix‘
  5. >>> a
  6. ‘phoenix‘
  7. >>> id(a)
  8. 40655088
  9. >>> a = ‘wangzifeng‘
  10. >>> id(a)
  11. 40666096
  12. >>> a_list = [1, 2, 3]
  13. >>> id(a_list)
  14. 40649352
  15. >>> a_list.append(4)
  16. >>> a_list
  17. [1, 2, 3, 4]
  18. >>> id(a_list)
  19. 40649352

2. 变量无类型,对象有类型

Python中的变量是没有类型的,但Python其实是区分类型的:Python的所有变量其实都是指向内存中的对象的一个指针,都是值的引用,而其类型是跟着对象走的。总结来说:在Python中,类型是属于对象的,而不是变量,变量和对象是分离的,对象是内存中储存数据的实体,变量则是指向对象的指针。在《Learning Python》一书中有一个观点:变量无类型,对象有类型,大概也是说的这个意思。下面是一张说明变量的图:

技术分享

二、赋值、引用和拷贝

1. 几个例子

1)第一个为函数值传递
看下面这个例子:
  1. >>> def func_int(a):
  2. ... a += 4
  3. ...
  4. >>> def func_list(a_list):
  5. ... a_list[0] = 4
  6. ...
  7. >>> t = 0
  8. >>> func_int(t)
  9. >>> print(t)
  10. 0
  11. >>> t_list = [1,2,3]
  12. >>> func_list(t_list)
  13. >>> print(t_list)
  14. [4, 2, 3]
    对于上面的输出,不少Python初学者都比较疑惑:第一个例子看起来像是传值,而第二个例子却是传引用。其实,解释这个问题也非常容易,主要是因为可变对象和不可变对象的原因:对于可变对象,对象的操作不会重建对象,而对于不可变对象,每一次操作就重建新的对象。
    在函数参数传递的时候,Python其实就是把参数里传入的变量对应的对象的引用依次赋值给对应的函数内部变量。参照上面的例子来说明更容易理解,func_int中的局部变量"a"其实是全局变量"t"所指向对象的另一个引用,由于整数对象是不可变的,所以当func_int对变量"a"进行修改的时候,实际上是将局部变量"a"指向到了整数对象"1"。所以很明显,func_list修改的是一个可变的对象,局部变量"a"和全局变量"t_list"指向的还是同一个对象。
2)第二个为赋值/拷贝
x = x + y,x 出现两次,必须执行两次,性能不好,合并必须新建对象 x,然后复制两个列表合并。这个属于复制/拷贝。
x += y,x 只出现一次,也只会计算一次,性能好,不生成新对象,只在内存块末尾增加元素。相当于x.extend(y),在原地修改x对象。
当 x、y 为list时, += 会自动调用 extend 方法进行合并运算,in-place change。这个属于共享引用。
  1. >>> L = [1,2]
  2. >>> M = L
  3. >>> L = L + [3,4]
  4. >>> print(L,M)
  5. [1, 2, 3, 4] [1, 2]
  6. >>> L = [1,2]
  7. >>> M = L
  8. >>> L += [3,4]
  9. >>> print(L,M)
  10. [1, 2, 3, 4] [1, 2, 3, 4]

总结,对于不可变对象,每一次的修改和赋值都会生成一个新的对象;对于可变对象,赋值只是更改别名,不会创建新的对象,不过加法生成一个新的对象,除非是使用可变对象自己的内建方法来修改。
3)第三个为赋值/拷贝
为什么给x[1]赋值结果不是[1, [1,2,3], 3]呢?
  1. >>> x = [1,2,3]
  2. >>> x[1] = x
  3. >>> x
  4. [1, [...], 3]
其实是Python没有赋值,只有引用。这种做法相当于创建了一个引用自身的结构,所以导致了无限循环。我们进一步说说这个问题。
Python 没有「变量」,我们平时所说的变量其实只是「标签」,是引用。
执行 values = [012]的时候,Python 做的事情是首先创建一个列表对象 [0, 1, 2],然后给它贴上名为 values 的标签。如果随后又执行values = [345]的话,Python 做的事情是创建另一个列表对象 [3, 4, 5],然后把刚才那张名为 values 的标签从前面的 [0, 1, 2] 对象上撕下来,重新贴到 [3, 4, 5] 这个对象上。
至始至终,并没有一个叫做 values 的列表对象容器存在,Python 也没有把任何对象的值复制进 values 去。过程如下左图所示。
执行values[1] = values的时候,Python 做的事情则是把 values 这个标签所引用的列表对象的第二个元素指向 values 所引用的列表对象本身。执行完毕后,values 标签还是指向原来那个对象,只不过那个对象的结构发生了变化,从之前的列表 [0, 1, 2] 变成了 [0, ?, 2],而这个 ? 则是指向那个对象本身的一个引用。如上中图所示。
要达到你所需要的效果,即得到 [0, [0, 1, 2], 2] 这个对象,你不能直接将 values[1] 指向 values 引用的对象本身,而是需要吧 [0, 1, 2] 这个对象「复制」一遍,得到一个新对象,再将 values[1] 指向这个复制后的对象。Python 里面复制对象的操作因对象类型而异,复制列表 values 的操作是values[:] (#生成对象的拷贝或者是复制序列,不再是引用和共享变量,但此法只能顶层复制)。所以你需要执行values[1] = values[:],Python 做的事情是,先 dereference 得到 values 所指向的对象 [0, 1, 2],然后执行 [0, 1, 2][:] 复制操作得到一个新的对象,内容也是 [0, 1, 2],然后将 values 所指向的列表对象的第二个元素指向这个复制二来的列表对象,最终 values 指向的对象是 [0, [0, 1, 2], 2]。过程如下右图所示:
技术分享     技术分享    技术分享
往更深处说,values[:] 复制操作是所谓的「浅复制」(shallow copy),当列表对象有嵌套的时候也会产生出乎意料的错误,比如问:此时 a 和 b 分别是多少?
  1. $ python3
  2. Python 3.5.3 (default, Feb 27 2017, 09:17:48) [GCC 6.3.0 64 bit (AMD64)] on win32
  3. Type "help", "copyright", "credits" or "license" for more information.
  4. >>> a = [0,[1,2],3]
  5. >>> b = a[:]
  6. >>> a[0] = 8
  7. >>> a[1][1] = 9
  8. >>> a
  9. [8, [1, 9], 3]
  10. >>> b
  11. [0, [1, 9], 3]
  12. >>>
正确答案是 a 为 [8, [1, 9], 3],b 为 [0, [1, 9], 3]。发现b 的第二个元素也被改变了。想想是为什么?具体见下左图。
技术分享   技术分享
正确的复制嵌套元素的方法是进行「深复制」(deep copy),方法如下。
  1. import copy
  2. = [0, [1, 2], 3]
  3. = copy.deepcopy(a)
  4. a[0] = 8
  5. a[1][1] = 9
具体见上右图。这就引出了我们下面要说到的浅拷贝和深拷贝。

2. 浅拷贝和深拷贝

如果我们一定要复制一个可变对象的副本怎么办?简单的赋值已经证明是不可行的,所以Python提供了copy模块,专门用于复制可变对象。copy中有两个方法:copy()和deepcopy(),前一个是浅拷贝,后一个是深拷贝。浅拷贝仅仅复制了第一个传给它的对象,下面的不管了;而深拷贝则将所有能复制的对象都复制了,相当于递归拷贝。
现总结如下:
  • 没有限制条件的分片表达式(L[:])能够复制序列,但此法只能浅层复制;
  • 字典 copy 方法,D.copy() 能够复制字典,但此法只能浅层复制;
  • 有些内置函数,例如 list,能够生成拷贝 list(L);
  • copy 标准库模块能够生成完整拷贝:deepcopy 本质上是递归 copy;
  • 对于不可变对象和可变对象来说,浅复制都是复制的引用,只是因为复制不变对象和复制不变对象的引用是等效的(因为对象不可变,当改变时会新建对象重新赋值)。所以看起来浅复制只复制不可变对象(整数,实数,字符串等),对于可变对象,浅复制其实是创建了一个对于该对象的引用,也就是说只是给同一个对象贴上了另一个标签而已。

三、变量作用域及陷阱

1. 作用域

在Python程序中创建、改变或查找变量名时,都是在一个保存变量名的地方进行中,那个地方我们称之为命名空间。作用域这个术语也称之为命名空间。具体地说,在代码中变量名被赋值(Python中变量声明即赋值,global 声明的只是变量的使用域)的位置决定了该变量能被访问的范围。函数定义了本地作用域,而模块定义的是全局作用域。
每一个模块都是全局作用域。也就是说,创建于模块文件顶层的变量具有全局作用域,对于外部访问就成了一个模块对象的属性。全局作用域的作用范围仅限于单个文件。“全局”指的是在一个文件的顶层变量名对于这个文件而言是全局的。每次对函数的调用都创建了一个新的本地作用域。Python中也有递归,即可以调用自身,每次调用都会创建新的本地命名空间。赋值的变量名除非声明为全局变量,否则均为本地变量。如果需要在函数内部对模块文件顶层的变量名赋值,需要在函数内部通过 global 语句声明该变量。所有的变量可归纳为本地、全局或者内置三种。范围分别为def内部,在一个模块的命名空间内部和预定义的 __builtin__ 模块提供的变量。
变量名引用分为三个作用域进行查找:首先是本地,然后是函数内(如果有的话),之后是全局,最后是内置。在默认情况下,变量名赋值会创建或者改变本地变量。全局声明将会给映射到模块文件内部的作用域的变量名赋值。Python 的变量名解析机制也称为 LEGB 法则,具体如下:
当在函数中使用未确定的变量名时,Python搜索4个作用域:本地作用域(L),之后是上一层嵌套结构中 def 或 lambda 的本地作用域(E),之后是全局作用域(G),最后是内置作用域(B)。按这个查找原则,在第一处找到的地方停止。如果没有找到,Python 会报错的。下图说明了搜索流程(由内及外):
技术分享
Python像PHP一样提供了一个global语法,global定义的本地变量会变成其对应全局变量的一个别名,即是同一个变量。下面的例子可以帮你更好的理解:
  1. a = 44
  2.  
  3. def test1():
  4.     a = 14
  5.     print a
  6. test1()  # 输出:14
  7.  
  8. def test2():
  9.     global a
  10.     print a
  11. test2()  # 输出:44

2. 几个例子

1)为什么修改全局的dict变量不用global关键字
为什么修改字典d的值不用global关键字先声明呢?
  1. >>> s = ‘phoenix‘
  2. >>> d = {‘a‘:1}
  3. >>> def test():
  4. ... s = ‘bar‘
  5. ... d[‘b‘] = 2
  6. ...
  7. >>> test()
  8. >>> print(s)
  9. phoenix
  10. >>> print(d)
  11. {‘a‘: 1, ‘b‘: 2}
  12. >>> def test1():
    ...     s = ‘bar‘
    ...     t[‘b‘] = 2
    ...
    >>> test1()
    Traceback (most recent call last):
      File "<stdin>", line 1, in <module>
      File "<stdin>", line 3, in test1
    NameError: name ‘t‘ is not defined
这是因为,在s = ‘bar‘这句中,它是“有歧义的“,因为它既可以是表示引用全局变量s,也可以是创建一个新的局部变量,所以在python中,默认它的行为是创建局部变量,除非显式声明global,global定义的本地变量会变成其对应全局变量的一个别名,即是同一个变量。
在d[‘b‘]=2这句中,它是“明确的”,因为如果把d当作是局部变量的话,它会报KeyError,所以它只能是引用全局的d,故不需要多此一举显式声明global。
上面这两句赋值语句其实是不同的行为,一个是rebinding(不可变对象), 一个是mutation(可变对象)。
但是如果是下面这样:
  1. = {‘a‘:1}
  2. def f():
  3.     d = {}
  4.     d[‘b‘] = 2
  5. f()
  6. print d  # {‘a‘: 1}
在d = {}这句,它是”有歧义的“了,所以它是创建了局部变量d,而不是引用全局变量d,所以d[‘b‘]=2也是操作的局部变量。
其实,这一切现象的本质就是“它是否是明确的”
仔细想想,就会发现不止dict不需要global,所有“明确的”东西都不需要global。
  • 因为int类型str类型之类的不可变对象,每一次操作就重建新的对象,他们只有一种修改方法,即x = y, 恰好这种修改方法同时也是创建变量的方法,所以产生了歧义,不知道是要修改还是创建。
  • 而dict/list/对象等可变对象,操作不会重建对象,可以通过dict[‘x‘]=y或list.append()之类的来修改,跟创建变量不冲突,不产生歧义,所以都不用显式global。
2)可变对象 list 的 = 和 append/extend 差别在哪?
接上面的理论,下面咱们再看一例常见的错误:
  1. >>> list_a = []
  2. >>> def test1():
  3. ... list_a = [1]
  4. ...
  5. >>> test1()
  6. >>> print(list_a)
  7. []
  8. >>> list_b = []
  9. >>> def test2():
  10. ... list_b.append(1)
  11. ...
  12. >>> test2()
  13. >>> print(list_b)
  14. [1]
大家可以看到为什么语句1不能改变 list_a 的值,而语句2却可以?他们的差别在哪呢?
因为赋值“ = ”创建了局部变量,而 .append() 或者 .extend() 重用了全局变量。

3)陷阱:使用可变的默认参数

常见到如下的代码:
  1. def foo(a, b, c=[]):
  2. # append to c
  3. # do some more stuff
永远不要使用可变的默认参数,可以使用如下的代码代替:
  1. def foo(a, b, c=None):
  2.     if c is None:     
  3.         c = []
  4.     # append to c
  5.     # do some more stuff
??与其解释这个问题是什么,不如展示下使用可变默认参数的影响:??
  1. In[2]: def foo(a, b, c=[]): ...        
  2. ...        c.append(a) ...        
  3. ...        c.append(b) ...        
  4. ...        print(c) ...
  5. ...
  6. In[3]: foo(1, 1)
  7. [1, 1]
  8. In[4]: foo(1, 1)
  9. [1, 1, 1, 1]
  10. In[5]: foo(1, 1)
  11. [1, 1, 1, 1, 1, 1]
同一个变量c在函数调用的每一次都被反复引用。这可能有一些意想不到的后果。

Reference

1. http://xianglong.me/article/python-variable-quote-copy-and-scope/


Python学习笔记 | 变量 + 引用 + 拷贝 + 作用域