Python 类属性:过于详尽的指南

已发表: 2022-03-11

我最近进行了一次编程面试,在电话屏幕上我们使用了协作文本编辑器。

我被要求实现某个 API,并选择在 Python 中实现。 抽象出问题陈述,假设我需要一个类,其实例存储一些data和一些other_data

我深吸一口气,开始打字。 几行之后,我有这样的事情:

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

我的面试官阻止了我:

  • 采访者:“那一行: data = [] 。 我认为那不是有效的 Python?”
  • 我:“我很确定它是。 它只是为实例属性设置一个默认值。”
  • 采访者:“那段代码什么时候被执行?”
  • 我:“我不确定。 我会修复它以避免混淆。”

作为参考,并让您了解我的目标,以下是我修改代码的方式:

 class Service(object): def __init__(self, other_data): self.data = [] self.other_data = other_data ...

事实证明,我们都错了。 真正的答案在于理解 Python 类属性和 Python 实例属性之间的区别。

Python 类属性与 Python 实例属性

注意:如果您对类属性有专家处理,您可以直接跳到用例。

Python 类属性

我的面试官错了,因为上面的代码语法上是有效的。

我也错了,因为它没有为实例属性设置“默认值”。 相反,它将data定义为具有值[]属性。

根据我的经验,Python 类属性是一个很多人都知道的话题,但很少有人能完全理解。

Python 类变量与实例变量:有什么区别?

Python 类属性是类的属性(我知道是圆形的),而不是类实例的属性。

让我们使用一个 Python 类示例来说明差异。 这里, class_var是类属性, i_var是实例属性:

 class MyClass(object): class_var = 1 def __init__(self, i_var): self.i_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 ## <— This is key ## 1

对于 Java 或 C++ 程序员,类属性与静态成员相似但不完全相同。 我们稍后会看到它们有何不同。

类与实例命名空间

为了理解这里发生了什么,让我们简单谈谈Python 命名空间

命名空间是从名称到对象的映射,其特性是不同命名空间中的名称之间的关系为零。 它们通常被实现为 Python 字典,尽管这是抽象出来的。

根据上下文,您可能需要使用点语法(例如, object.name_from_objects_namespace )或作为局部变量(例如, object_from_namespace )来访问命名空间。 作为一个具体的例子:

 class MyClass(object): ## No need for dot syntax class_var = 1 def __init__(self, i_var): self.i_var = i_var ## Need dot syntax as we've left scope of class namespace MyClass.class_var ## 1

Python 类类的实例都有各自不同的命名空间,分别由预定义属性MyClass.__dict__instance_of_MyClass.__dict__表示。

当您尝试从类的实例访问属性时,它首先查看其实例名称空间。 如果找到该属性,则返回关联的值。 如果没有,它命名空间中查找并返回属性(如果存在,则抛出错误)。 例如:

 foo = MyClass(2) ## Finds i_var in foo's instance namespace foo.i_var ## 2 ## Doesn't find class_var in instance namespace… ## So look's in class namespace (MyClass.__dict__) foo.class_var ## 1

实例命名空间优先于类命名空间:如果两者中存在同名的属性,则将首先检查实例命名空间并返回其值。 这是用于属性查找的代码(源代码)的简化版本:

 def instlookup(inst, name): ## simplified algorithm... 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。

  • 如果通过访问实例设置 Paython 类变量,它将仅覆盖该实例的值。 这实质上覆盖了类变量并将其转换为一个实例变量,直观地,仅适用于该实例。 例如:

     foo = MyClass(2) foo.class_var ## 1 foo.class_var = 2 foo.class_var ## 2 MyClass.class_var ## 1

    在命名空间级别……我们将class_var属性添加到foo.__dict__ ,因此当我们查找foo.class_var时,我们返回 2。同时, MyClass的其他实例在它们的实例命名空间中不会class_var ,因此它们会继续查找class_varMyClass.__dict__ ,因此返回 1。

可变性

测验问题:如果您的类属性具有可变类型怎么办? 您可以通过特定实例访问类属性来操作(破坏?)类属性,进而最终操作所有实例正在访问的引用对象(正如 Timothy Wiseman 所指出的那样)。

这可以通过例子得到最好的证明。 让我们回到我之前定义的Service ,看看我对类变量的使用是如何导致问题的。

 class Service(object): data = [] def __init__(self, other_data): self.other_data = other_data ...

我的目标是让空列表 ( [] ) 作为data的默认值,并且每个Service实例都有自己的数据,这些数据会随着时间的推移逐个实例地更改。 但是在这种情况下,我们会得到以下行为(回想一下Service接受一些参数other_data ,在这个例子中是任意的):

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data.append(1) s1.data ## [1] s2.data ## [1] s2.data.append(2) s1.data ## [1, 2] s2.data ## [1, 2]

这不好——通过一个实例更改类变量会更改所有其他实例!

在命名空间级别……所有Service实例都在访问和修改Service.__dict__中的同一个列表,而没有在它们的实例命名空间中创建自己的data属性。

我们可以使用赋值来解决这个问题; 也就是说,我们可以分配我们的Service对象拥有自己的列表,而不是利用列表的可变性,如下所示:

 s1 = Service(['a', 'b']) s2 = Service(['c', 'd']) s1.data = [1] s2.data = [2] s1.data ## [1] s2.data ## [2]

在这种情况下,我们添加了s1.__dict__['data'] = [1] ,所以原来的Service.__dict__['data']保持不变。

不幸的是,这需要Service用户对其变量有深入的了解,并且肯定容易出错。 从某种意义上说,我们将解决症状而不是原因。 我们更喜欢构造正确的东西。

我个人的解决方案:如果您只是使用类变量为可能的 Python 实例变量分配默认值,请不要使用 mutable values 。 在这种情况下,每个Service实例最终都会用自己的实例属性覆盖Service.data ,因此使用空列表作为默认值会导致一个容易被忽略的小错误。 除了上述内容,我们还可以:

  1. 完全坚持实例属性,如介绍中所示。
  2. 避免使用空列表(可变值)作为我们的“默认值”:

     class Service(object): data = None def __init__(self, other_data): self.other_data = other_data ...

    当然,我们必须适当地处理None情况,但这是一个很小的代价。

那么什么时候应该使用 Python 类属性呢?

类属性很棘手,但让我们看一下它们会派上用场的几种情况:

  1. 存储常量。 由于类属性可以作为类本身的属性来访问,因此使用它们来存储类范围的、特定于类的常量通常很好。 例如:

     class Circle(object): pi = 3.14159 def __init__(self, radius): self.radius = radius def area(self): return Circle.pi * self.radius * self.radius Circle.pi ## 3.14159 c = Circle(10) c.pi ## 3.14159 c.area() ## 314.159
  2. 定义默认值。 作为一个简单的例子,我们可以创建一个有界列表(即,一个只能包含一定数量或更少元素的列表)并选择默认上限为 10 个项目:

     class MyClass(object): limit = 10 def __init__(self): self.data = [] def item(self, i): return self.data[i] def add(self, e): if len(self.data) >= self.limit: raise Exception("Too many elements") self.data.append(e) MyClass.limit ## 10

    然后,我们也可以通过分配给实例的limit属性来创建具有自己特定限制的实例。

     foo = MyClass() foo.limit = 50 ## foo can now hold 50 elements—other instances can hold 10

    仅当您希望MyClass的典型实例仅包含 10 个或更少元素时,这才有意义——如果您为所有实例提供不同的限制,那么limit应该是一个实例变量。 (但请记住:使用可变值作为默认值时要小心。)

  3. 跟踪给定类的所有实例的所有数据。 这有点具体,但我可以看到一个场景,您可能希望访问与给定类的每个现有实例相关的一段数据。

    为了使场景更具体,假设我们有一个Person类,每个人都有一个name 。 我们希望跟踪所有已使用的名称。 一种方法可能是遍历垃圾收集器的对象列表,但使用类变量更简单。

    请注意,在这种情况下, names将仅作为类变量访问,因此可变默认值是可以接受的。

     class Person(object): all_names = [] def __init__(self, name): self.name = name Person.all_names.append(name) joe = Person('Joe') bob = Person('Bob') print Person.all_names ## ['Joe', 'Bob']

    我们甚至可以使用这种设计模式来跟踪给定类的所有现有实例,而不仅仅是一些相关的数据。

     class Person(object): all_people = [] def __init__(self, name): self.name = name Person.all_people.append(self) joe = Person('Joe') bob = Person('Bob') print Person.all_people ## [<__main__.Person object at 0x10e428c50>, <__main__.Person object at 0x10e428c90>]
  4. 性能(有点……见下文)。

相关: Toptal 开发人员的 Python 最佳实践和技巧

引擎盖下

注意:如果你担心这个级别的性能,你可能不想一开始就使用 Python,因为差异将在十分之一毫秒的数量级上——但稍微摸索一下还是很有趣的,并有助于说明。

回想一下,类的命名空间是在定义类时创建和填充的。 这意味着我们只对给定的类变量进行一次赋值,而每次创建新实例时都必须为实例变量赋值。 让我们举个例子。

 def called_class(): print "Class assignment" return 2 class Bar(object): y = called_class() def __init__(self, x): self.x = x ## "Class assignment" def called_instance(): print "Instance assignment" return 2 class Foo(object): def __init__(self, x): self.y = called_instance() self.x = x Bar(1) Bar(2) Foo(1) ## "Instance assignment" Foo(2) ## "Instance assignment"

我们只分配给Bar.y一次,但在每次调用__init__时都分配给instance_of_Foo.y

作为进一步的证据,让我们使用 Python 反汇编程序:

 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

当我们查看字节码时,很明显Foo.__init__必须做两次赋值,而Bar.__init__只做一次。

在实践中,这种增益究竟是什么样的? 我将首先承认时序测试高度依赖于通常不可控的因素,并且它们之间的差异通常很难准确解释。

但是,我认为这些小片段(与 Python timeit 模块一起运行)有助于说明类变量和实例变量之间的差异,因此无论如何我都将它们包括在内。

注意:我使用的是装有 OS X 10.8.5 和 Python 2.7.2 的 MacBook Pro。

初始化

10000000 calls to `Bar(2)`: 4.940s 10000000 calls to `Foo(2)`: 6.043s

Bar的初始化速度快了一秒多,所以这里的差异似乎在统计上是显着的。

那么为什么会这样呢? 一种推测性的解释:我们在Foo.__init__中做了两个赋值,但在Bar.__init__中只做了一个。

任务

10000000 calls to `Bar(2).y = 15`: 6.232s 10000000 calls to `Foo(2).y = 15`: 6.855s 10000000 `Bar` assignments: 6.232s - 4.940s = 1.292s 10000000 `Foo` assignments: 6.855s - 6.043s = 0.812s

注意:没有办法在每次试验中使用 timeit 重新运行您的设置代码,因此我们必须在试验中重新初始化我们的变量。 第二行时间代表上述时间减去之前计算的初始化时间。

从上面看, Foo处理分配的时间似乎只需要Bar的 60% 左右。

为什么会这样? 一种推测性的解释:当我们分配给Bar(2).y时,我们首先查看实例命名空间( Bar(2).__dict__[y] ),找不到y ,然后查看类命名空间( Bar.__dict__[y] ),然后进行正确的分配。 当我们分配给Foo(2).y时,我们执行的查找次数是立即分配给实例命名空间( Foo(2).__dict__[y] )的一半。

总而言之,尽管这些性能提升在现实中并不重要,但这些测试在概念层面上很有趣。 如果有的话,我希望这些差异有助于说明类和实例变量之间的机械区别。

综上所述

类属性在 Python 中似乎没有得到充分利用; 许多程序员对他们的工作方式以及为什么他们可能会有所帮助有不同的印象。

我的看法:Python 类变量在优秀代码学院中占有一席之地。 小心使用时,它们可以简化事情并提高可读性。 但是,当不小心被扔进给定的班级时,他们肯定会绊倒你。

附录:私有实例变量

我想包括但没有自然入口点的一件事……

Python 没有私有变量,可以这么说,但是类和实例命名之间的另一个有趣的关系是名称修饰。

在 Python 风格指南中,据说伪私有变量应该以双下划线为前缀:'__'。 这不仅向其他人表明您的变量应该被私下处理,而且也是一种防止访问它的方法。 这就是我的意思:

 class Bar(object): def __init__(self): self.__zap = 1 a = Bar() a.__zap ## Traceback (most recent call last): ## File "<stdin>", line 1, in <module> ## AttributeError: 'Bar' object has no attribute '__baz' ## Hmm. So what's in the namespace? a.__dict__ {'_Bar__zap': 1} a._Bar__zap ## 1

看一下:实例属性__zap自动以类名作为前缀以产生_Bar__zap

虽然仍然可以使用a._Bar__zap和获取,但此名称修饰是一种创建“私有”变量的方法,因为它可以防止您其他人意外或无知访问它。

编辑:正如 Pedro Werneck 亲切地指出的那样,这种行为主要是为了帮助子类化。 在 PEP 8 风格指南中,他们认为它有两个目的:(1) 防止子类访问某些属性,以及 (2) 防止这些子类中的命名空间冲突。 虽然有用,但变量修饰不应被视为邀请编写具有假定公私区别的代码,例如 Java 中存在的。

相关:变得更高级:避免 Python 程序员犯的 10 个最常见错误