前言

最近使用描述符对自己的催化动力学模拟程序进行了改进,在Python描述符的帮助下实现了更加灵活而且强大有效的属性管理,使得程序各个组件的数据封装更加完善管理也更加有条理。

本文就以自己程序中运用描述符来进行有效的python属性管理为例子,介绍python中描述符的概念以及如何更好的使用描述符这个强有力的工具帮助我们有效管理python程序中的数据访问控制。

正文

在其他语言中我们经常会在类中实现gettersetter等工具方法,这样有助于定义类的接口,也使得开发者能够方便的封装功能,例如验证用法或者进行取值范围的检测。但是在Python中我们一般都是直接从public属性写起,但是当我们对属性有特殊需求,例如进行类型验证(Python是动态类型),数值范围检测,返回深复制(而不是引用)的时候,我们一般会考虑使用:

  1. 内建的@property装饰器
  2. 使用描述符

@property装饰器使用起来方便快捷,例如

1
2
3
4
5
6
7
8
9
class KineticModel(obejct):
# ...
@property
def kB(self):
return self.__kB
@property.setter
def kB(self, kB):
#...

但是@property的缺点就在于他无法被复用,同一套逻辑不能在不同的属性之间重复使用,这样除了对波尔兹曼常数进行处理外如果还有普朗克常数需要做同样的处理,难道要重复写一次settergetter函数?这显然已经”Repeat yourself”了。

这时候就要召唤Python的描述符机制了,他的存在是python开发者能够复用与属性相关的逻辑。

描述符协议

Python描述符协议是一种再模型中引用属性时将要发生事件的方法。Python会对属性的访问操作进行一定的转译,这种转译的方式就是由描述符协议确定的。借助Python提供给我们的描述符协议,我们就可以用来以Python的方式实现与私有变量类似的功能。

描述符协议包括几个方法:

  • descr.__get__(self, obj, type=None) –> value, 用于访问属性
  • descr.__set__(self, obj, value) –> None, 用于设置属性的值
  • descr.__delete__(self, obj) –> None, 控制属性的删除操作

任何对象如果定义了上面的任何一个方法便实现了描述符协议,也就变成了一个描述符了。我们通过将之前的gettersetter方法中的逻辑重写到__get____set__方法中,便可以把同一套逻辑运用在不同类中不同的属性上面了。

创建描述符

这里只介绍使用类方法创建描述符。

我的动力学模型中的KineticModel需要很多类属性,例如基本的基元反应式rxn_expression(这里我用了一个包含string的list来表示)、模型反应发生的温度temperature(用一个float类型表示)。

为了能够在对属性进行赋值的时候进行相应的类型检测,我就定义了几个基本类型的描述符,提供了检测数据类型的相应逻辑,下面是个简单的整型描述符(当然这不是最后的使用的版本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Float(object):
def __init__(self, name):
self.name = name
def __get__(self):
private_name = "_{}__{}".format(instance.__class__.__name__, self.name)
if private_name not in instance.__dict__:
instance.__dict__[private_name] = self.default
def __set__(self, instance, value):
# 检测类型
if type(value) is not float:
msg = "{} ({}) is not a float number".format(self.name, value)
raise ValueError(msg)
# 将对象的相应属性进行赋值,注意这里我使用了`mangled name`用来进行私有化处理
private_name = "_{}__{}".format(instance.__class__.__name__, self.name)
instance.__dict__[private_name] = value

这样我们就可以在我们类中相应的类属性定义成相应的描述符对象,后面我们就可以像使用正常属性一样使用他,但是他却拥有了类型检测功能:

1
2
3
4
5
6
7
8
...
class KineticModel(obejct):
# 设置temperature为类属性
temperature = Float("temperature")
...

当我试图向其赋值一个字符串时,便会抛出异常:

描述符的原理

上面进行了基本的描述符创建和使用效果,那么描述符是如何工作的才能让我们以这种方式操作属性呢?

一句话总结就是通过将属性访问进行了转译

描述符触发

当我们进行属性访问时便会触发描述符(如果这个属性具有描述符定义的时候),当我们对对象obj的属性d进行访问时候,obj.d,描述符的触发过程大致:先在对象obj的字典中寻找d,如果d是个含有__get__()的对象,则直接调用d.__get__(obj).

官方文档中对具体的触发细节进行了更详细的描述,具体的触发又分为我们访问的是类属性还是实例属性:

  1. 如果是对实例属性进行访问,则属性访问转译的关键就在于基类object__getattribute__方法,我们知道这个内置方法是在进行属性访问的时候无条件调用的,因此这个方法中将obj.d转译成了type(obj).__dict__['d'].__get__(obj, type(obj))
    其实现的C代码参见:https://docs.python.org/3/c-api/object.html#c.PyObject_GenericGetAttr

  2. 如果是对类对象的属性进行访问,则属性的访问转译关键在于元类type__getattribute__方法,它将cls.d转译成cls.__dict__['d'].__get__(None, cls),这里__get__()instance没有也就是相应的None了。
    其实现的C代码参见:https://hg.python.org/cpython/file/3.5/Objects/typeobject.c#l2936

描述符优先级

首先,描述符和描述符之间也是有区别的:

  1. 如果一个对象同时定义了__get__()__set__()方法,则这个描述符被称为data descriptor
  2. 如果一个对象只定义了__get__()方法,则这个描述符被称为non-data descriptor

我们对属性进行访问的时候需要几行打交道的基本上包含这几个对象:

  1. data descriptor
  2. non-data descriptor
  3. 实例的字典
  4. 内置的__getattr__()函数

他们几个的优先级顺序是:

1
data descriptor >> instance's dict >> non-data descriptor >> __getattr__()

也就是说如果实例obj重现了同名的data descriptor d 和 实例属性d, 当我们访问d的时候,由于data descriptor具有更高的优先级,python便会调用type(obj).__dict__['d'].__get__(obj, type(obj))而不是返回obj.__dict__['d']

但是如果描述符是个non-data descriptor,则正好相反,python会返回obj.__dict__['d']

描述符实现惰性访问(按需访问)

很多时候一个类的属性,我们并不需要在这个类初始化的时候就进行初始化,我们可以在第一次使用这个属性的时候顺便将这个属性初始化,这样在后面重复使用这个属性的时候便直接返回结果就可以了,这样既可以减少计算的次数,也在一定程度上减少了内存的需求。

因此我在定义自己的描述符__get__()的时候进行了判断是否该相应的实例属性已经初始化,若未初始化则进行初始化,若已初始化直接返回,达到了惰性访问的目的:

1
2
3
4
5
6
def __get__(self, instance, owner):
private_name = "_{}__{}".format(instance.__class__.__name__, self.name)
# 是否实例属性已存在
if private_name not in instance.__dict__:
instance.__dict__[private_name] = self.default
return instance.__dict__[private_name]

创建只读描述符

当我们想让一个属性(描述符)禁止调用者进行修改的时候,可以通过在__set__()方法中抛出AttributeError异常来实现,例如:

1
2
3
4
5
6
7
8
9
def __set__(self, instance, value):
private_name = "_{}__{}".format(instance.__class__.__name__, self.name)
# 在第一次赋值后便无法修改属性的值
if private_name not in instance.__dict__:
instance.__dict__[private_name] = value
else:
msg ="Changing value of {}.{} is not allowed".format(instance.__class__.__name__,
self.name)
raise AttributeError(msg)

这样便实现了私有变量的效果,可以将数据更安全的进行封装,防止在外部调用的时候意外修改了对象的数据造成不想要的结果。

对于mutable的变量可以使用深复制

如果实例属性是字典或者列表这类的变量,python都会返回对象的引用,因此在获取其值以后也是有可能修改其内部数据的,因此如果真的想要是这个属性不被做任何的修改,可以使用deepcopy直接返回对象的深复制,这样在外部无论怎么蹂躏这个对象,都跟返回他的对象本身没有关系了。

1
2
3
4
5
6
7
8
def __get__(self, instance, owner):
private_name = "_{}__{}".format(instance.__class__.__name__, self.name)
if private_name not in instance.__dict__:
instance.__dict__[private_name] = self.default
if self.deepcopy:
return copy.deepcopy(instance.__dict__[private_name])
else:
return instance.__dict__[private_name]

需要注意的点

描述符都是针对类属性,因此如果把数据存放在描述符对象中的时候,会出现意想不到的结果。
例如我想针对每个学生类创建对应的身高描述符,而且把身高数据放在描述符中,我可以这样定义描述符:

我们创建了两个学生实例,但是身高属性却是同一个对象,这是因为描述符是类属性,因此每个实例中进行访问的时候都是访问的类属性的引用

这个时候我们可以不把数据放在描述符中,而是在相应的实例对象中创建私有变量,这样不同的对象的私有变量是不同的变量,便不会出想上图的问题。

1
2
3
4
5
6
7
8
9
class Height(object):
def __init__(self, name):
self.name = name
def __get__(self, instance, cls):
return getattr(instance, self.name)
def __set__(self, instance, value):
setattr(instance, self.name, value)

同时也可以将相应的对象和值以字典的键值对存到描述符的字典中,但是这样会造成引用计数无法为0导致无法进行垃圾回收从而导致内存泄漏的风险,因此这个方法就不详细描述了。

总结

本文总结了Python中的描述符相关的概念和使用,描述符可以帮助我们实现强大而灵活的属性管理,通过结合使用描述符可以实现优雅的编程,但是同时也应该保持谨慎的态度,避免由于覆盖普通对象行为而产生不必要的代码复杂性。

参考

Comments