Python 类变量与实例变量的陷阱

Python 类变量与实例变量的陷阱,不仅类方法和实例方法之间有区别,Python的对象模型中类变量和实例变量也有所区别。

这种区别非常重要,在刚接触Python时给我带来了不少烦恼。在很长一段时间里,我都没有花时间从头开始理解这些概念,所以我早期面向对象的代码中充满了令人惊讶的行为和奇怪的错误。本节将通过一些实践示例来理清曾经引起我困惑的地方。

就像我刚刚说的那样,Python对象有两种数据属性:类变量实例变量

类变量在类定义内部声明(但位于实例方法之外),不受任何特定类实例的束缚。类变量将其内容存储在类本身中,从特定类创建的所有对象都可以访问同一组类变量。这意味着修改类变量会同时影响所有对象实例。

实例变量总是绑定到特定的对象实例。它的内容不存储在类上,而是存储在每个由类创建的单个对象上。因此实例变量的内容与每个对象实例相关,修改实例变量只会影响对应的对象实例。

好吧,这些描述相当抽象,下面来看一些代码。这里继续使用老掉牙的“狗狗示例”。出于某种原因,许多面向对象的教程总是使用汽车或宠物来举例说明,这个传统很难打破。

快乐的狗需要什么?四条腿和一个名字:

class Dog:
    num_legs = 4 # <- 类变量

    def __init__(self, name):
        self.name = name  # <- 实例变量

好吧,这就是用狗狗示例来描述的面向对象的形式。创建新的Dog实例能正常工作,并且每个实例都会获得一个名为name的实例变量:

>>> jack = Dog('Jack')
>>> jill = Dog('Jill')
>>> jack.name, jill.name
('Jack', 'Jill')

涉及类变量时就比较灵活了,在每个Dog实例或类本身上可以直接访问num_legs类变量:

>>> jack.num_legs, jill.num_legs
(4, 4)
>>> Dog.num_legs
4

然而,如果尝试通过类访问实例变量,会失败并抛出AttributeError。实例变量是特定于每个对象实例的,在运行__init__构造函数时创建,并不位于类本身中。
这就是类变量和实例变量之间的核心区别:

>>> Dog.name
AttributeError:
"type object 'Dog' has no attribute 'name'"

好吧,到目前为止还行。
假如有一天,一只名为Jack的狗在吃晚餐时与微波炉靠得太近,发生变异又长出了一双腿,那么如何在代码中表示呢?

第一个想法可能是简单地修改Dog类中的num_legs变量:

>>> Dog.num_legs = 6

但记住,我们不希望所有的狗都开始用六条腿四处乱跑。由于修改了变量,因此现在把所有的狗都变成了超级狗。这会影响到所有的狗,甚至是之前创建的狗:

>>> jack.num_legs, jill.num_legs
(6, 6)

所以这种方式不行,原因是修改类名称空间上的类变量会影响类的所有实例。现在撤销这个对类变量的改动,而是尝试仅向Jack添加额外两条腿:

>>> Dog.num_legs = 4
>>> jack.num_legs = 6

来看看这种方式创造了什么怪物:

>>> jack.num_legs, jill.num_legs, Dog.num_legs
(6, 4, 4)

好吧,看起来“相当不错”(除了可怜的Jack多了两条腿)。但这种改动是如何影响Dog对象的呢?

这里的难点在于,虽然得到了想要的结果(为Jack添加两条腿),但在Jack实例中引入了一个num_legs实例变量。而新的num_legs实例变量“遮盖”了相同名称的类变量,在访问对象实例作用域时覆盖并隐藏类变量:

>>> jack.num_legs, jack.__class__.num_legs
(6, 4)

从上面可以看到,类变量没有同步更新,这是因为写入到jack.num_legs创建了一个与类变量同名的实例变量

这不一定是坏事,重要的是要意识到背后发生的事情。在最终了解Python中的类层面和实例层面的作用域规则之前,很容易因为这些问题在程序中引入bug。

说实话,试图通过对象实例修改类变量时意外地创建了一个名称相同的实例变量,从而隐藏了原来的类变量。这有点像是Python中的一个OOP陷阱。

Python 类变量与实例变量的陷阱 与狗无关的例子

在本节的写作过程中,没有狗受到伤害(这里只是为了描述起来更加生动有趣,并不能真的能为狗添加两条腿)。下面用一个更加实际的例子来介绍类变量的用途,在更接近实际的应用程序中使用类变量。

下面就来看这样一个例子,其中的CountedObject类记录了它在程序生命周期中实例化的次数(实际上这可能是一个有趣的性能指标):

class CountedObject:
    num_instances = 0

    def __init__(self):
        self.__class__.num_instances += 1

CountedObject保留一个用作共享计数器的num_instances类变量。当声明该类时,计数器初始化为零后就不再改变了。

每次创建此类的新实例时,会运行__init__构造函数并将共享计数器递增1:

>>> CountedObject.num_instances
0
>>> CountedObject().num_instances
1
>>> CountedObject().num_instances
2
>>> CountedObject().num_instances
3
>>> CountedObject.num_instances
3

注意这段代码需要额外的__class__来确保增加的是类上的计数器变量,有时候很容易犯下面这种错误:

# 警告:这种实现有bug

class BuggyCountedObject:
    num_instances = 0

    def __init__(self):
        self.num_instances += 1   # !!!

从中可以看到,这个糟糕的实现永远不会增加共享计数器变量:

>>> BuggyCountedObject.num_instances
0
>>> BuggyCountedObject().num_instances
1
>>> BuggyCountedObject().num_instances
1
>>> BuggyCountedObject().num_instances
1
>>> BuggyCountedObject.num_instances
0

相信你现在意识到哪里出错了。这个糟糕的实现永远不会增加共享计数器,因为我犯了在前面的Jack示例中已经解释的错误。这个实现不起作用,因为在构造函数中创建一个名称相同的实例变量,意外地“遮盖”了num_instance类变量。

代码先正确地计算了计数器的新值(从0增加到1),然后将结果存储在实例变量中,因此该类的其他实例看不到修改后的计数器值。

不难看出这是一个易犯错误。在处理类上的共享状态时,应小心并仔细检查共享状态的作用范围。自动化测试和同行评审对此有很大帮助。

尽管类变量中有陷阱,但希望你能明白其优点以及如何在实践中使用。祝你好运!

关键要点

  • 类变量用于类的所有实例之间共享数据。类变量属于一个类,在类的所有实例中共享,而不是属于某个特定的实例。

  • 实例变量是特定于每个实例的数据,属于单个对象实例,不与类的其他实例共享。每个实例变量都针对特定实例单独存储了一份。

  • 因为类变量可以被同名的实例变量“遮盖”,所以很容易(意外地)由于覆盖类变量而引入bug和奇怪的行为。

Python教程

Java教程

Web教程

数据库教程

图形图像教程

大数据教程

开发工具教程

计算机教程