深入理解 Python 中的类属性

在 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.xfoo2.x 验证了这一点。

我们理解在 foo1foo2 之间共享变量 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 秒,使用类属性带来的性能提升是显著的。