类的属性
在 Python 中,数据属性和处理数据的方法统称为属性(attribute),方法也可称为方法属性,本质上是可调用的(callable)属性。Python 提供了丰富的 API 用于控制访问属性,以及实现动态属性。即使访问不存在的属性,也可以通过特殊方法实现“虚拟属性”,从而即时计算属性的值。
处理属性的特殊属性
为了方便处理属性,Python 定义了一些特殊属性,包括:
__class__
:对象所属类的引用。obj.__class__
与type(obj)
效果一致。类和类的实例都具有属性,有些属性只能在类中查询,比如特殊方法;__dict__
:存储类或实例的可写属性的字典。如果设置了__slots__
属性,实例可能没有__dict__
属性;__slots__
:类可以定义这个属性,限制实例能拥有哪些属性。该属性的值可以是个可迭代对象,但通常会使用元组。如果类设置了__slots__
属性且__slots__
中不包含'__dict__'
,那么该类的实例没有__dict__
属性。
__dict__
默认情况下,Python 会使用名为 __dict__
的字典存储类和实例中的可写属性。其中,类属性字典由名为 mappingproxy
的代理对象包装,mappingproxy
定义在 collections.abc
模块中,特别指代类属性字典的类型:mappingproxy = type(type.__dict__)
。类属性字典包含显式定义在类中的字段和方法,以及一些可写的特殊属性,包括模块、字典、弱引用和文档字符串。
1 | class Foo: |
类属性不仅限于类字典中所展示的,还包含一些不可变的类属性,比如所属类的引用 __class__
,直接父类组成的元组 __bases__
等。
实例属性字典则是普通的字典类型,为实例属性赋值,会动态的修改实例字典。如果属性不存在,则将其添加到字典中,包括在初始化方法 __init__
中赋值的实例属性。
1 | foo = Foo() |
__slots__
Python 解释器会默认在类的构造方法 __new__
中创建 __dict__
存放实例属性,在访问时通过访问实例字典读取属性值。由于字典底层使用了散列表结构,对属性的存取会相当迅速。但同时,为了减少散列冲突,散列表的大小通常要远大于键的数量,这种基于空间换时间的考量会导致字典会消耗大量内存。为此,Python 提供了 __slots__
属性,该属性会覆盖 __dict__
属性,使用类似元组的结构存储实例变量,从而达到节省内存的目的。
我沿用之前定义的 Person 类做了测试,它包含 name 和 age 两个实例属性。使用列表推导生成一百万个 Person 对象,分别对默认使用 __dict__
和添加了 __slots__
属性的内存占用情况进行测试。
1 | ➜ time python3 slots.py --use-dict |
可以看到使用 __slots__
后内存占用得到显著优化,只占了使用 __dict__
的一半不到,运行速度也更快。
定义 __slots__
的方式是,创建一个名为 __slots__
的类属性,把它的值设为一个字符串构成的可迭代对象(通常使用元组),其中的元素名称代表实例属性,比如__slots__ = ('name', 'age')
。定义 __slots__
属性相当于告诉解释器:这个类的所有实例属性都在这儿了。实例不能再有 __slots__
所列之外的其他属性。但应该明白,__slots__
并不是用来禁止类的用户新增实例属性的手段,而只是一种内存优化方案。
如果你阅读 collections.abc
模块的源码,会发现其中的类都存在一行 __slots__ = ()
代码。即使这些类没有实例属性,使用空元组定义的 __slots__
属性可以避免类的构造方法创建 __dict__
空字典,空字典也会在堆上分配内存空间。对于集合这种基本数据类型,有必要为其声明空元组形式的 __slots__
属性。此外,对于模式固定的数据库记录,以及特大型数据集,也有必要声明 __slots__
属性。
上面介绍的这些特殊属性,在一些访问和处理属性的内置函数和特殊方法中会被使用。下面列出这些函数和方法。
处理属性的内置函数
dir([object])
:列出对象的大多数属性。object 参数是可选的,缺省时会列出当前模块的属性。dir 函数能够审查对象有没有 __dict__
和 __slots__
属性,并列出其中的键。
getattr(object, name[, default])
:从对象中读取属性值。获取的属性可能来自对象所属的类或超类。如果没有找到指定属性,则抛出 AttributeError 异常,或返回预设默认值。
hasattr(object, name)
:会调用 getattr 函数查看能否获取指定的属性,当抛出 AttributeError 异常时返回 False。
setattr(object, name, value)
:为对象指定的属性设值。这个函数可能会创建一个新属性,或者覆盖现有的属性。前提是对象能够接受这个值,比如设定了 __slots__
的对象不能添加新属性。
vars([object])
:返回对象的 __dict__
属性,参数缺省时返回当前模块的 __dict__
属性。vars 函数不能处理设定了 __slots__
属性的对象。
处理属性的特殊方法
__getattribute__(self, name)
:除了访问特殊属性和特殊方法,尝试获取指定的属性时总会调用这个方法。dot 运算符、getattr
和 hasattr
会调用这个方法。该方法内部定义了属性访问规则,当未找到指定属性时抛出 AttributeError 异常,__getattr__
方法会被调用。
__getattr__(self, name)
:仅当获取指定属性失败时,即处理不存在的属性时被调用。用户自定义的类可以实现 __getattr__
方法从而动态计算属性的值。
__setattr__(self, name, value)
:尝试为指定属性设值时总会调用该方法。dot 运算符和 setattr
会调用这个方法。该方法内部定义了属性设值规则。
__delattr__(self, name)
:使用 del 关键字删除属性时会调用这个方法。
__dir__(self)
:内置函数 dir()
会调用这个方法。
属性访问规则
Python 解释器在访问属性时会按照一定的规则,从入口方法 __getattribute__
开始,按照顺序依次查找,如果找到则返回,未找到则抛出异常,调用 __getattr__
动态计算虚拟属性。属性访问规则如下:
__getattribute__
方法- 数据描述符
- 实例对象的字典
- 类的字典
- 非数据描述符
- 父类的字典
__getattr__
方法
注:其中,数据描述符是实现了 __get__
和 __set__
描述符协议的类。描述符的内容,会在后面做详细介绍。
查询属性的入口方法 __getattribute__
实现逻辑的伪代码如下:
1 | def __getattribute__(name): |
为实例属性赋值则没有这么麻烦,__setattr__
作为入口方法,只需要判断属性是否是数据描述符,如果是则调用其 __set__
方法,如果不是则为实例字典添加新的属性。__setattr__
实现逻辑的伪代码如下:
1 | __setattr__(name, value): |
由此也可以发现,Python 存取属性的方式特别不对等。通过实例访问属性时,如果实例中没有指定属性,那么会尝试获取类属性。而为实例中的属性赋值时,如果属性不存在会在实例中创建该属性,根本不影响类。
下面介绍如何使用 __getattr__
方法动态计算虚拟属性。
自定义 __getattr__
即时计算属性
处理 JSON 是非常常见的需求,JavaScript 对 JSON 具有天生的支持,可以使用 dot 运算符链式获取属性的值,如 res.cities[0].ext.province
。而 Python 原生的字典不支持使用 dot 运算符直接获取属性,只能使用 res['cities'][0]['ext']['province']
的形式,会显得格外冗长。但可以通过实现一个近似字典的类,达到同样的效果。如下是 Python 中的效果演示:
1 | from json_parser import JsonParser |
能够使用 dot 运算符链式获取属性的关键在于定义在 JsonParser 中的 __getattr__
方法。前面已经说过,Python 解释器在查询对象属性失败时会调用 __getattr__
方法动态计算属性。下面代码定义了动态计算的逻辑:
1 | class JsonParser: |
通过 __getattr__
方法递归地创建 JsonParser 类,并将下级的 JSON 结构 _data[name]
作为构造参数传入。构造方法 __new__
会判断传入参数的类型,如果是映射类型直接创建 JsonParser 对象,如果是可变序列,则通过列表推导式返回 JsonParser 列表。之所以要这么处理是因为 JSON 结构可能是数组,除了映射结构还需要对数组类型进行解析,以支持 cities[0]
式的访问。
特性
在 Java 中,为了控制属性的访问权限,一般会将属性设置为私有属性,并为可以公开的属性设置公有的 getter 和 setter 方法。这样做还有一个好处,可以在方法内添加对属性的验证,比如保证商品的数量不会是负数。如果想更进一步,可以按照领域驱动设计的理念,可以将属性设置为实体类 Entity,在类中对属性进行校验。这两种思想在 Python 中也都有对应的实现,前一种对应于特性,后一种对应于描述符。
特性经常用于把公开的属性变成使用读值方法和设置方法管理的属性,且在不影响客户端代码的前提下实施业务规则。使用 get/set + 属性名
的命名方式不符合 Python 一贯的简约作风,为此 Python 提供了特性,即 property。property 是一个类形式的函数装饰器,本质上它是一个描述符类(实现了描述符协议)。
1 | class property(object): |
使用函数形式的装饰器会返回一个嵌套的高阶函数,类形式的装饰器也类似,使用 @property
装饰的方法会被包装成特性类。特性类具有 getter、setter 和 deleter 方法属性,这三个属性也都返回 property 对象。
因此,用 @property
装饰的读值方法,如下的 amount(self)
方法,相当于返回一个 property(amount)
特性对象,将读值方法作为初始化参数 fget 传入。而后可以使用 @amount.setter
装饰设值方法,此时设值方法 amount 返回的是特性对象,setter 是它的方法属性。相当于 property(amount).setter(amount)
,第二个 amount 是设值方法,将设值方法作为 fset 参数传入 setter 方法。也因此,@amount.setter
必须要定义在被 @property
装饰的设置方法之后。如下所示:
1 | class LineItem: |
读值方法可以不与实例属性名一致,但要保证,读值方法名称、设值方法名称和 @amount.setter
装饰器中的名称三者保持一致,即都为 amount。这样,在访问属性时可以通过 item.amount
的形式对真正的实例属性 self._amount
进行读值和赋值。其实,初始化函数中的 self.amount = amount
语句就已经在使用特性的设置方法了。
1 | 1.0, 5) item = LineItem( |
可以看到,真正被操作的实例属性 _amount
被保存在实例字典中。
任何对 item.amount
的读值和设值操作,都会经过由特性包装的读值和设值方法进行处理。由于在设值方法中对属性值做了非负验证,所以将其设置为负值会抛出 ValueError 异常。
需要注意的是,特性是类属性,被保存于类的 __dict__
字典中。在使用 obj.attr
这样的表达式时,不会从 obj 开始查询 attr 属性,而是从实例所属的类,即 obj.__class__
开始,仅当类中没有名为 attr 的特性时,才会去查询实例字典。也就是说,特性的读值和设值方法要优先于实例字典,只有直接存取 __dict__
属性才能跳过特性的处理逻辑。
1 | LineItem.__dict__ |
这条规则不仅适用于特性,还适用于数据描述符,其实,特性也是数据描述符。或者换句话说,正是由于数据描述符的访问优先级要高于实例字典,特性的读值和设值方法访问才优先于实例字典。下面我们介绍描述符。
描述符
描述符是 Python 的独有特征,不仅在应用层,内置库和标准库中也有使用。除了特性之外,使用描述符的还有方法、classmethod 和 staticmethod 装饰器,以及 functools 模块中的诸多类。理解描述符是精通 Python 的关键,本章的话题就是描述符。
描述符是实现了特定协议的类,这个协议包括 __get__
、__set__
和 __delete__
方法。特性类 property 实现了完整的描述符协议。通常,可以只实现部分协议。其实,我们在真实代码中见到的大多数描述符只实现了 __get__
和 __set__
方法,还有很多只实现了其中的一个。
定制描述符实现属性验证
描述符是对多个属性运用相同存取逻辑的一种方式。假设我们想为之前定义的 LineItem 类中的 price 和 amount 属性都设置非负验证,一种方式是为它们都编写读值和设值方法,但这会造成代码重复。为了避免这个问题,Python 提出了一种面向对象的解决方式,那就是定制描述符类。
在下面的代码中,定义了一个名为 Quantity 的描述符类,用于管理 LineItem 的属性。我们将 LineItem 类称为托管类,被管理的属性称为托管属性。Quantity 类的实例属性 attribute 指代托管属性的名称,由初始化方法传入。通过在托管类中声明类属性的形式,如 price = Quantity('price')
将描述符实例绑定给 price 属性。
1 | class Quantity: |
描述符类中定义了 __set__
方法,当尝试为托管属性赋值时,会调用这个方法并对值做验证。
1 | 1.0, 5) item = LineItem( |
__set__
方法的签名:def __set__(self, instance, value) -> None: ...
。第一个参数 self 是描述符实例,即 LineItem.price
或 LineItem.amount
;第二个参数 instance 是托管类实例,即 LineItem 实例;第三个参数 value 是要设置的值。在为属性赋值时,必须直接操作托管实例的 __dict__
,如果使用内置的 setattr 函数,将会重复调用 __set__
导致无限递归。
由于读值方法不需要特殊的逻辑,所以这个描述符类没有定义 __get__
方法。一般情况下,如果没有 __get__
方法,为了给用户提供内省和其他元编程技术支持,通过托管类访问属性会返回描述符实例。通过实例访问则会去实例字典中查询对应属性。
1 | def __get__(self, instance, owner): |
__get__
方法的签名:def __get__(self, instance, owner) -> Any: ...
。与 __set__
方法相同,__get__
方法的第一个参数代表描述符实例,第二个参数代表托管类实例。而第三个参数 owner 是托管类的引用,当通过托管类访问属性时会被使用,返回类字典中的描述符实例,可以理解为 instance.__class__
。
此时通过托管类访问属性会得到描述符实例,通过实例访问属性会得到托管属性的值。
1 | LineItem.amount |
同一时刻,内存中可能存在许多 LineItem 实例,但只会存在两个描述符实例:LineItem.price
和 LineItem.amount
。这是因为描述符实例被定义为 LineItem 的类属性,会出现在 LineItem 的类字典中,由全部实例共享。
描述符分类
我们将同时实现了 __get__
和 __set__
方法的描述符类称为数据描述符,将只实现了 __get__
的描述符类称为非数据描述符。在 CPython 的描述符对象 descrobject 的源码中,会检查描述符是否有 __set__
方法来返回描述符是否是数据描述符:
1 | int PyDescr_IsData(PyObject *ob) { |
Python 社区在讨论这些概念时会用不同的术语,数据描述符也被称为覆盖型描述符或强制描述符,非数据描述符也被称为非覆盖型描述符或遮盖型描述符。总之,这两者的区别在于是否实现了 __set__
方法。之所以这么分类,是由于 Python 中存取属性方式的不对等性,我们在属性访问规则一节中提到了这点。这种不对等的处理方式也对描述符产生影响。
描述符的覆盖体现在,如果实现了 __set__
方法,即使描述符是类属性,也会覆盖对实例属性的赋值操作。比如 item.amount = -1
不会直接修改实例字典,而是强制执行描述符的 __set__
方法对数值进行非负验证。
如果没有实现 __set__
方法,比如 Python 中的方法就是以非覆盖型描述符实现的,只定义了 __get__
方法。如果类中定义了名为 method 的方法,使用 obj.method = 1
会直接修改实例字典,即实例属性会遮盖同名描述符属性,但类中的描述符属性依然存在。如下:
1 | class C: |
综上所述,数据描述符的表现形式更像可以被随意赋值的数据,提供了完备的取值方法 __get__
和设值方法 __set__
。而非数据描述符表现形式不像数据,比如 Python 中的方法,为非数据描述符赋值会遮盖掉实例的同名描述符属性。
以上讨论的都是是否存在 __set__
方法的情形,其实,也可以没有读值方法 __get__
,比如我们定义的 Quantity 描述符。一般情况下,没有读值方法时访问属性会返回描述符对象本身。然而访问 LineItem 实例属性 item.amount
会得到对应数值。这是因为在它的初始化方法 __init__
中已经调用了描述符的 __set__
方法,该方法为实例字典 __dict__
创建了同名实例属性,由于实例属性会遮盖同名描述符属性,读取属性会返回实例字典中的值而不是描述符对象。这也是为什么将实现了 __set__
的描述符称为遮盖型描述符的原因。
总之,按照属性访问规则,数据描述符在实例字典之前被访问(调用 __get__
和__set__
方法),非数据描述符在实例字典之后被访问(可能会被遮盖)。
方法是描述符
定义在类中的方法会变成绑定方法(bound method),这是 Python 语言底层使用描述符的最好例证。
1 | class C: |
通过类和实例访问函数返回的是不同的对象。CPython 中定义的函数对象 funcobject 实现了描述符协议的 __get__
方法,即如下的 func_descr_get
方法。与描述符一样,通过托管类访问函数时,传入的 obj 参数为空,函数的 __get__
方法会返回自身的引用。通过实例访问函数时,返回的是绑定方法对象,并把托管实例绑定给函数的第一个参数(即 self),这与 functool.partial 函数的行为一致。
1 | /* Bind a function to an object */ |
绑定方法对象还有个 __call__
方法,用于处理真正的调用过程。这个方法会调用 __func__
属性引用的原始函数,把函数的第一个参数设为绑定方法的 __self__
属性。这就是形参 self 的隐式绑定过程。
使用描述符的最佳实践
使用特性以保持简单:内置的 property 类创建的是数据描述符,__get__
和 __set__
方法都实现了。特性的 __set__
方法默认抛出 AttributeError: can’t set attribute,因此创建只读属性最简单的方式是使用特性。且由于特性存在 __set__
方法,不会被同名实例属性遮盖。
只读描述符也要实现 __set__
方法:如果使用描述符类实现只读数据属性,要记住,__get__
和 __set__
方法必须都定义。否则,实例的同名属性会遮盖描述符。只读属性的 __set__
方法只需抛出 AttributeError 异常,并提供合适的错误消息。
非特殊的方法可以被实例属性遮盖:Python 的方法只实现了 __get__
方法,所以对与方法名同名的属性将会遮盖描述符,也就是说 obj.method = 1
负值后通过实例访问 method 将会得到数字 1,但不影响类或其他实例。然而,特殊方法不受这个问题影响。因为解释器只会在类中查询特殊方法。也就是说 repr(x)
执行的其实是 x.__class__.__repr__(x)
,因此 x 的 __repr__
属性对 repr(x)
方法调用没有影响。出于同样的原因,实例的 __getattr__
属性不会破坏常规的属性访问规则。
用于验证的描述符可以只实现 __set__
方法:对仅用于验证的描述符来说,__set__
方法应该检查 value 参数是否有效,如果有效,使用与描述符实例同名的名称作为键,直接在实例字典中设值,如 Quantity 中的 instance.__dict__[self.attribute] = value
语句。这样,从实例字典中读取同名属性就不需要经过 __get__
方法处理。
仅有 __get__
方法的描述符可以实现高效缓存:如果仅实现了 __get__
方法,那么创建的是非数据描述符。这种描述符可用于执行某些耗费资源的计算,然后为实例设置同名属性,缓存结果。同名实例属性会遮盖描述符,因此后续访问会直接从实例字典中获取值,而不会再出发描述符的 __get__
方法。
描述符应用场景
当将描述符逻辑抽象到单独的代码单元中,如 Quantity 类中,就可以在整个应用中进行重用。在一些框架中,会将描述符定义在单独的工具模块中,比如 Django 框架中与数据库交互的模型字段类,就是描述符类。你会发现下面这段 Django 的测试用例的代码与我们定义的 LineItem 非常类似。只不过我们的描述符类 Quantity 换成了他们的 models.CharFiled 等。
1 | from django.db import models |
当然,目前定义的描述符类还有提升的空间,比如 price = Quantity('price')
使用字符串对属性名进行初始化可能并不那么可靠。又比如想为字段设置更多限定,比如 Django 中设置的字段 max_length 等。其实,Django 框架使用到了 Python 更高阶的类元编程的特性 —— 元类。除了开放框架,一般用不到这个特性。后面我们会对元类加以介绍。