在 Python 中,万物都是对象——这门惹人喜爱的编程语言通过一套极其精简的语法规则定义类(Class),实现对于面向对象编程的支持。而在这些为了支持面向对象编程而引入的诸多概念中,最容易引起混淆的当属类属性(Class Attribute)。许多程序员对于 Python 的语言细节理解不到位,容易误用类属性并导致错误发生。
本文深入讨论 Python 中与类属性相关的一些问题,并主要参考自 Charles Marsh 撰写的《Python Class Attributes: An Overly Thorough Guide》一文。阅读本文需要有基本的面向对象编程(Object-oriented programming,OOP)的相关知识。
引发问题
我们或多或少已经了解到 Python 中的类属性与一些其他程序设计语言中的 static
变量类似,其值能够在多个实例中共享,就像这样:
>>> class Foo(object): x=1
...
>>> foo1 = Foo()
>>> foo1.x
1
>>> foo2 = Foo()
>>> foo2.x
1
>>> Foo.x = 2 # 将 Foo 类的 x 设置为 2
>>> Foo.x
2
>>> foo1.x
2
>>> foo2.x
2
好吧,截止到上边我们的程序都能够按照期望运行。其中我们通过 Foo.x = 2
一行将类变量 x
进行赋值,将其值从 1
修改为 2
,并通过打印 foo1.x
与 foo2.x
验证了这一点。
我们理解在 foo1
与 foo2
之间共享变量 x
,如果我们将赋值语句由 Foo.x = 2
改为 foo1.x = 2
的话,将会发生什么情况呢:
# ...之前的定义保持不变
>>> foo1.x = 2
>>> Foo.x
1
>>> foo1.x
2
>>> foo2.x
1
显然,程序的运行结果超出了我们的预期,看起来就像是我们单独为 foo1
定义了它自己的 x
一样(是的,事实就是这样)。我们开始糊涂了:那 foo2
中的 x
还是不是 Foo
中的 x
呢?另外,如果类属性并非一个不可变(immutable)的值,而是一个列表的话,会发生什么情况呢:
>>> class Bar(object): data = []
...
>>> bar1 = Bar()
>>> bar2 = Bar()
>>> bar1.data.append("aaa") # 类似地,我们从对象 bar1 访问类属性
>>> Bar.data
['aaa']
>>> bar1.data
['aaa']
>>> bar2.data
['aaa']
让我们保持清醒,接下来将会解释为什么会发生这些情况,继续前进。
类属性 vs 实例属性
首先,我们要理解 Python 中的类属性是类的一种属性,而不是类的实例的一种属性:
class MyClass(object):
class_var = 1
def __init__(self, i_var):
self.i_var = i_var
上例中,class_var
是类属性,而 i_var
是实例属性。类的所有实例均能访问到 class_var
,并且它也能被类本身所访问:
>>> foo = MyClass(2)
>>> bar = MyClass(3)
>>> foo.class_var, foo.i_var
1, 2
>>> bar.class_var, bar.i_var
1, 3
>>> MyClass.class_var # 注意这一行
1
理解了类属性的基本概念,我们现在开始解释为什么通过赋值方式修改实例的 class_var
会出现意想不到的效果:这一切要从 Python 中被称为“名称空间”的概念说起。
类名称空间 vs 实例名称空间
名称空间(Namespace)是一种将从名称到对象的一种映射。同样的名称在不同的名称空间内没有任何关系。抽象地来讲,可以将名称空间理解为 Python 中的字典数据类型。
根据上下文的不同,一些名称空间可能需要点操作符(.
)或本地变量来访问,比如:
class MyClass(object):
class_var = 1 # 不需要 . 操作符
def __init__(self, i_var):
self.i_var = i_var
# 注意到访问类名称空间时,我们也使用了 . 操作符
>>> MyClass.class_var
1
上例中,在类方法 __init__
中使用了 self.
前缀来访问类的实例名称空间,而在类外通过 MyClass.
前缀访问类名称空间。Python 中的类与类的实例都有它们各自的名称空间,被分别定义于 MyClass.__dict__
与 instance_of_MyClass.__dict__
中。
当你试图去访问类的实例中的一个属性时,将首先查找实例的名称空间。如果它找到了这个属性,就会返回相应的值。但如果不是,它将继续查找类本身的名称空间,并且(若存在的话)返回其属性值:
>>> foo = MyClass(2)
>>> foo.i_var # i_var 在 foo 的实例名称空间内
2
>>> foo.class_var # class_var 在实例名称空间找不到
>>> # 于是便在类名称空间(MyClass.__dict__)内查找
1
查找时,实例的名称空间总是优先于类的名称空间:如果两个名称空间中都含有相同名称的变量,则优先会去查找实例的名称空间。下述为经过简化的,表示属性查找过程的代码:
def instlookup(inst, name):
if inst.__dict__.has_key(name):
return inst.__dict__[name]
else:
return inst.__class__.__dict__[name]
类属性是如何被赋值的
我们可以简要通过如下几点概括 Python 中的类属性如何处理赋值:
- 如果类属性通过类名访问,它将覆盖所有实例的相应值,例如:
>>> foo = MyClass(2)
>>> foo.class_var
1
>>> MyClass.class_var = 2
>>> foo.class_var
2
从名称空间的角度来看,我们设置了 MyClass.__dict__['class_var'] = 2
(实际上这并不是准确的代码,应该是 setattr(MyClass, 'class_var', 2)
,因为 __dict__
对象返回一个 dictproxy——一个不可变的封装器,不能被赋值,但它有助于抽象出来做解释)。随后,我们访问 foo.class_var
,此时 class_var
在类名称空间中有值存在,因而返回 2
。
- 如果 Python 的类属性由类的实例访问,它将仅覆盖与这个实例相关联的值。从本质上来讲,它将覆盖类属性而创建与这个实例相对应的属性,例如:
>>> foo = MyClass(2)
>>> foo.class_var
1
>>> foo.class_var = 2
>>> foo.class_var
2
>>> MyClass.class_var # 类属性保持不变
1
从名称空间的角度看,我们向 foo.__dict__
中添加了名为 class_var
的属性,所以当我们查找 foo.class_var
时,得到了值 2
。然而,其他的实例在访问 class_var
时,由于 class_var
并没有在它们的实例名称空间被定义,所以转而在 MyClass.__dict__
中寻找 class_var
,从而得到值 1
。这也解释了文章开头我们所遇到的奇怪问题。
Namespaces are one honking great idea — let’s do more of those!
Tim Peters,《The Zen of Python》
最佳实践:将不可变的值用于类属性
什么样的值适合通过类属性被定义?我们建议仅将不可变(immutable)的值用于类属性的定义中。比如:
class MyClass(object):
pi = 3.14159 # 好的定义
message = "Hello,%s" # 好的定义
const_array = [1, 2, 4, 8] # 好的定义
data = [] # 不好的定义,因为设置这个属性的本意是希望所有类去更改它
本文参考资料的作者 Marsh 在他的博客文章中给出的建议是:如果你只是用一个类属性给一个可能的 Python 实例变量分配一个默认值,那么不要使用可变(mutable)值——这种情况下,每个实例最终都会用自己的实例属性覆盖类属性,所以使用空列表作为默认值导致了一个很容易被忽略的小语法陷阱:
>>> a = MyClass()
>>> b = MyClass()
>>> a.data.append(1)
>>> a.data
[1]
>>> b.data
[1]
>>> b.data.append(2)
>>> a.data
[1, 2]
>>> b.data
[1, 2]
然而,如果我们:
# ...接上例
>>> a.data = [1]
>>> a.data
[1]
>>> b.data # 此时 b.data 访问到的还是类属性
[1, 2]
>>> b.data = [2]
>>> a.data
[1]
>>> b.data
[2]
如果我们真的需要使用列表或其他可变对象作为类属性的类型,一个比较好的方法是将其默认值设置为 None
:
class MyClass(object): data = None
小彩蛋与其他细节问题
在文初参考资料中,作者写到在一次面试经历里,他被要求使用 Python 程序设计语言实现一个 API——抽象地来说,需要实现一个可以存储 data
以及 other_data
的类型。作者的实现方法为:
class Service(object):
data = []
def __init__(self, other_data):
self.other_data = other_data
...
此时,面试官打断了他:
- 面试官:
data = []
这一行代码,在什么时候会被执行呢?
我们的作者回答不上,于是便偷偷将代码改成了:
class Service(object):
def __init__(self, other_data):
self.data = []
self.other_data = other_data
...
其实,关于类属性何时被赋值,我们只需要将表达式右侧改为函数调用就可以很明显的看出来了。在 Python 交互式解析器中:
>>> def data():
... print("data 被求值")
... return [1]
...
>>> class MyClass(object):
... data = data()
...
data 被求值
可以很明显的看出来,类属性在定义类时即被求值并定义。所以其实在这里类属性还有一个很常见的功能:当一个类要被实例化多次,并且每次均需加载相同的、非常多的数据时,不妨将这些数据加载到类属性而非实例本身的 __init__
方法中,以增强性能。如果你对于使用类属性如何提升性能很感兴趣,请见文末附录。
附录:类属性与实例属性的性能差异
我们查看下列代码对应的汇编语句:
import dis
class Bar(object):
y = 2
def __init__(self, x):
self.x = x
class Foo(object):
def __init__(self, x):
self.y = 2
self.x = x
dis.dis(Bar)
## Disassembly of __init__:
## 7 0 LOAD_FAST 1 (x)
## 3 LOAD_FAST 0 (self)
## 6 STORE_ATTR 0 (x)
## 9 LOAD_CONST 0 (None)
## 12 RETURN_VALUE
dis.dis(Foo)
## Disassembly of __init__:
## 11 0 LOAD_CONST 1 (2)
## 3 LOAD_FAST 0 (self)
## 6 STORE_ATTR 0 (y)
## 12 9 LOAD_FAST 1 (x)
## 12 LOAD_FAST 0 (self)
## 15 STORE_ATTR 1 (x)
## 18 LOAD_CONST 0 (None)
## 21 RETURN_VALUE
经过测试,在 10000000 次迭代中,执行 Foo(2)
的时间为 6.043 秒,而执行 Bar(2)
仅需 4.940 秒,使用类属性带来的性能提升是显著的。