第 4 章 元类及属性(上)
第 29 条:用纯属性取代 get 和 set 方法
从其他语言转入 Python 的开发者,可能会在类中明确地实现 getter(获取器)和 setter(设置器)方法。
class OldResistor(object):
def __init__(self, ohms):
self._ohms = ohms
def get_ohms(self):
return self._ohms
def set_ohms(self, ohms):
self._ohms = ohms
这种 setter 和 getter 用起来虽然简单,但却不像 Python 的编程风格。
r0 = OldResistor(50e3)
print('Before: %5r' % r0.get_ohms())
r0.set_ohms(10e3)
print('After: %5r' % ro.get_ohms())
>>>
Before: 50000.0
After: 10000.0
对于就地自增的代码来说,这种用法尤其显得麻烦。
r0.set_ohms(r0.get_ohms() + 5e3)
setter 和 getter 等工具方法,确实有助于定义类的接口,它也使得开发者能够更加方便地封装功能、验证用法并限定取值范围。在设计自己的类时,应该认真考虑这些机制,因为自己的类以后可能会逐渐进化,而这些机制可以确保进化过程不会影响原有的调用代码。
但是,对于 Python 语言来说,基本上不需要手工实现 setter 或 getter 方法,而是应该先从简单的 public 属性开始写起。
class Resistor(object):
def __init__(self, ohms):
self.ohms = ohms
self.voltage = 0
self.current = 0
r1 = Resistor(50e3)
r1.ohms = 10e3
改用简单的属性来实现 Resistor 类之后,原地自增操作就变得清晰而自然了。
r1.ohms += 5e3
以后如果想在设置属性的时候实现特殊行为,那么可以改用 @property 修饰器和 setter 方法来做。下面这个子类继承自 Resistor,它在给 voltage(电压)属性赋值的时候,还会同时修改 current(电流)属性。请注意:setter 和 getter 方法的名称必须与相关属性相符,方能使这套机制正常运作。
class voltageResistance(Resistor):
def __init__(self, ohms) :
super().__init__(ohms)
self._voltage = 0
@property
def voltage(self):
return self._voltage
@voltage.setter
def voltage(self, voltage):
self._voltage = voltage
self.current = self._voltage / self.ohms
现在,设置 voltage 属性时,将会执行名为 voltage 的 setter 方法,该方法会更新本对象的 current 属性,令其与电压和电阻相匹配。
r2 = VoltageResistance(1e3)
print('Before: %5r amps' % r2.current)
r2.voltage = 10
print('After: %5r amps' % r2.current)
>>>
Before: 0 amps
After: 0.01 amps
为属性指定 setter 方法时,也可以在方法里面做类型验证及数值验证。下面定义的这个类,可以保证传入的电阻值总是大于 0 欧姆:
class BoundedResistance(Resistor):
def __init__(self, ohms):
super().__init__(ohms)
@property
def ohms(self):
return se1f._ohms
@ohms.setter
def ohms(self, ohms) :
if ohms <= 0:
raise valueError('%f ohms must be > 0' % ohms)
self._ohms = ohms
如果传入无效的属性值,程序就会抛出异常。
r3 = BoundedResistance(1e3)
r3.ohms = 0
>>>
ValueError: 0.000000 ohms must be > 0
给构造器传入无效数值,同样会引发异常。
BoundedResistance(-5)
>>>
ValueError: -5.000000 ohms must be > 0
之所以会抛出异常,是因为 BoundedResistance.__init__
会调用 Resistor.__init__
,而 Resistor.__init__
又会执行 self.ohms = -5。这条赋值语句使得 BoundedResistance 中的 @ohms.setter 得到执行,于是,在对象构造完毕之前,程序会先运行 setter 里面的验证代码。
甚至可以用 @property 来防止父类的属性遭到修改。
class FixedResistance(Resistor):
#...
@property
def ohms(self):
return self._ohms
@ohms.setter
def ohms(self, ohms):
if hasattr(self, '_ohms'):
raise AttributeError("Can't set attribute")
self._ohms = ohms
构建好对象之后,如果试图修改 ohms 属性,那就会引发异常。
r4 = FixedResistance(1e3)
r4.ohms = 2e3
>>>
AttributeError: Can't set attribute
@property 的最大缺点在于:和属性相关的方法,只能在子类里面共享,而与之无关的其他类,则无法复用同一份实现代码。不过,Python 也提供了描述符机制,开发者可以通过它来复用与属性有关的逻辑,此外,描述符还有其他一些用法。
最后,要注意用 @property 方法来实现 setter 和 getter 时,不要把程序的行为写得太过奇怪。例如,不应该在某属性的 getter 方法里面修改其他属性的值。
class MysteriousResistor(Resistor):
@property
def ohms(self):
self.voltage = self._ohms * self.current
return self._ohms
#...
上面这种写法,会导致非常古怪的行为。
r7 = MysteriousResistor(10)
r7.current = 0.01
print('Before: %5r' % r7.voltage)
r7.ohms
print('After: %5r' % r7.voltage)
>>>
Before: 0
After: 0.1
最恰当的做法是:只在 @property.setter 里面修改相关的对象状态,而且要防止该对象产生调用者所不希望有的副作用。例如,不应该动态地引人模块、不应该执行缓慢的辅助函数,也不应该执行开销比较大的数据库查询操作等。所编写的类,要迅速为用户返回简单的属性,而这种属性,也应该和其他 Python 对象一样,非常易于使用。至于那些比较复杂或速度比较慢的操作,还是应该放在普通的方法里面。
总结
- 编写新类时,应该用简单的 public 属性来定义其接口,而不要手工实现 set 和 get 方法。
- 如果访问对象的某个属性时,需要表现出特殊的行为,那就用 @property 来定义这种行为。
- @property 方法应该遵循最小惊讶原则,而不应产生奇怪的副作用。
- @property 方法需要执行得迅速一些,缓慢或复杂的工作,应该放在普通的方法里面。
第 30 条:考虑用 @property 来代替属性重构
Python 内置的 @property 修饰器,使开发者可以把类设计得较为灵巧,从而令调用者能够轻松地访问该类的实例属性。此外,@property 还有一种高级的用法,就是可以把简单的数值属性迁移为实时计算(on-the-fly calculation,按需计算、动态计算)的属性,这种用法也是比较常见的。采用 @property 来迁移属性时,只需要给本类添加新的功能,原有的那些调用代码都不需要修改,因此,它是一种非常有效的编程手法。而且在持续完善接口的过程中,它也是一种重要的缓冲方案。
例如,要用纯 Python 对象实现带有配额的漏桶。下面这段代码,把当前剩余的配额以及重置配额的周期,放在了 Bucket 类里面:
class Bucket(object):
def __init__(self, period):
self.period_delta = timedelta(seconds=period)
self.reset_time = datetime.now()
self.quota = 0
def __repr__(self):
return 'Bucket(quota=%d)' % self.quota
漏桶算法若要正常运作,就必须保证:无论向桶中加多少水,都必须在进入下一个周期时将其清空。
def fill(bucket, amount):
now = datetime.now()
if now = bucket.reset_time > bucket.period_delta:
bucket.quota = 0
bucket.reset_time = now
bucket.quota += amount
每次在执行消耗配额的操作之前,都必须先确认桶里有足够的配额可供使用。
def deduct(bucket,amount):
now = datetime.now()
if now - bucket.reset_time > bucket.period_delta:
return False
if bucket.quota - amount < 0:
return False
bucket.quota -= amount
return True
使用这个类的对象之前,要先往桶里添水。
bucket = Bucket(60)
fill(bucket, 100)
print(bucket)
>>>
Bucket(quota=100)
然后,消耗自己所需的配额。
if deduct(bucket, 99):
print('Had 99 quota')
else:
print('Not enough for 99 quota')
print(bucket)
>>>
Had 99 quota
Bucket(quota=1)
这样继续消耗下去,最终会导致待消耗的配额比剩余的配额还多。到了那时, Bucket 对象就会阻止这一操作,而漏桶中剩余的配额则将保持不变。
if deduct(bucket, 3):
print('Had 3 quota')
else:
print('Not enough for 3 quota')
print(bucket)
>>>
Not enough for 3 quota
Bucket(quota=1)
上面这种实现方式的缺点是:以后无法得知漏桶的初始配额。配额会在每个周期内持续流失,如果降到 0,那么 deduct 就总是会返回 False。此时,依赖 deduct 的那些操作,就会受到阻塞,但是,却无法判断出:这究竟是由于 Bucket 里面所剩的配额不足,还是由于 Bucket 刚开始的时候根本就没有配额。
为了解决这一问题,在类中使用 max_quota 来记录本周期的初始配额,并且用 quota_consumed 来记录本周期内所消耗的配额。
class Bucket(object):
def __init__(self, period):
self.period_delta = timedelta(seconds=period)
self.reset_time = datetime.now()
self.max_quota = 0
self.quota_consumed = 0
def __repr__(self):
return ('Bucket(max_quota=%d, quota_consumed=%d)' % (self.max_quota, self.quota_consumed))
我们可以根据这两个新属性,在 @property 方法里面实时地算出当前所剩的配额。
@property
def quota(self):
return self.max_quota - self.quota_consumed
在设置 quota 属性的时候,setter 方法应该采取一些措施,以保证 quota 能够与该类接口中的 fill 和 deduct 相匹配。
@quota.setter
def quota(self, amount):
delta = self.max_quota - amount
if amount == 0:
# Quota being reset for a new period
self.quota_consumed = 0
self.max_quota = 0
elif delta < 0:
# Quota being filled for the new period
assert self.quota_consumed == 0
self.max_quota = amount
else:
# Quota being consumed during the period
assert self.max_quota >= self.quota_consumed
self.quota_consumed += delta
运行测试代码之后,可以产生与刚才那种实现方案相同的结果。
bucket = Bucket(60)
print('Initial', bucket)
fill(bucket, 100)
print('Filled', bucket)
if deduct(bucket, 99):
print('Had 99 quota')
else:
print('Not enough for 99 quota')
print('Now', bucket)
if deduct(bucket, 3):
print('Had 3 quota')
else:
print('Not enough for 3 quota')
print('Still', bucket)
>>>
Initial Bucket(max_quota=0, quota_consumed=0)
Filled Bucket(max_quota=100, quota_consumed=0)
Had 99 quota
Now Bucket(max_quota=100, quota_consumed=99)
Not enough for 3 quota
Still Bucket(max_quota=100, quota_consumed=99)
上面这种写法,最好的地方就在于:从前使用 Bucket.quota 的那些旧代码,既不需要做出修改,也不需要担心现在的 Bucket 类是如何实现的。而将来要使用 Bucket 的那些新代码,则可以直接访问 max_quota 和 quota_consumed,以执行正确的操作。
使用 @property,它可以帮助开发者逐渐完善数据模型。看了上面这个 Bucket 范例之后,你可能在想:为什么一开始不把 fill 和 deduct 直接设计成 Bucket 类的实例方法,而要把它们放在 Bucket 外面呢?这样想确实有道理,但在实际工作中,经常接触到的对象,恰恰就是这种接口设计得比较糟糕,或仅仅能够充当数据容器的对象。若是代码越写越多、功能越来越膨胀,或是参与项目的人都不考虑长远的维护事宜,那就更容易出现这种局面。
在处理实际工作中的代码时,@property 固然是一项非常有效的工具,但是也不能滥用。如果你发现自己正在不停地编写各种 @property 方法,那恐怕就意味着当前这个类的代码写得确实很差。此时,应该彻底重构该类,而不应该继续修补这套糟糕的设计。
总结
- @property 可以为现有的实例属性添加新的功能。
- 可以用 @property 来逐步完善数据模型。
- 如果 @property 用得太过频繁,那就应该考虑彻底重构该类并修改相关的调用代码。
第 31 条:用描述符来改写需要复用的 @property 方法
Python 内置的 @property 修饰器,有个明显的缺点,就是不便于复用。受它修饰的这些方法,无法为同一个类中的其他属性所复用,而且,与之无关的类,也无法复用这些方法。
例如,要编写一个类,来验证学生的家庭作业成绩都处在 0 ~ 100。
class Homework(object):
def __init__(self):
self._grade = 0
@property
def grade(self):
return self._grade
@grade.setter
def grade(self, value) :
if not (0 <= value <= 100):
raise ValueError('Grade must be between 0 and 100')
self._grade = value
由于有了 @property,所以上面这个类用起来非常简单。
galileo = Homework()
galileo.grade = 95
现在,假设要把这套验证逻辑放在考试成绩上面,而考试成绩又是由多个科目的小成绩组成的,每一科都要单独计分。
class Exam(object):
def __init__(self):
self._writing_grade = 0
self._math_grade = 0
@staticmethod
def _check_grade(value):
if not (0 <= value <= 100):
raise ValueError('Grade must be between 0 and 100')
Exam 类的代码写起来非常枯燥,因为每添加一项科目,就要重复编写一次 @property 方法,而且还要把相关的验证逻辑也重做一遍。
@property
def writing_grade(self):
return self._writing_grade
@writing_grade.setter
def writing_grade(self, value):
self._check_grade(value)
self._writing_grade = value
@property
def math_grade(self):
return self._math_grade
@math_grade.setter
def math_grade(self, value):
self._check_grade(value)
self._math_grade = value
此外,这种写法也不够通用。如果要把这套百分制的验证逻辑放在家庭作业和考试之外的场合,那就需要反复编写例行的 @property 代码和 _check_grade 方法。
还有一种方式能够更好地实现上述功能,那就是采用 Python 的描述符(descriptor)来做。Python 会对访问操作进行一定的转译,而这种转译方式,则是由描述符协议来确定的。描述符类可以提供 __get__
和 __set__
方法,使得开发者无需再编写例行代码,即可复用分数验证功能。由于描述符能够把同一套逻辑运用在类中的不同属性上面,所以从这个角度来看,描述符也要比 mix-in 好一些。
下面定义了名为 Exam 的新类,该类将几个 Grade 实例用作自己的类属性。Grade 类实现了描述符协议。在解释 Grade 类的工作原理之前,大家首先要明白的是:当程序访问到 Exam 实例的描述符属性时,Python 会对这种访问操作进行转译。
class Grade(object):
def __get__(*args, **kwargs):
#...
def __set__(*args, **kwargs):
# ...
class Exam(object):
# Class attributes
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()
为属性赋值时:
exam = Exam()
exam.writing_grade = 40
Python 会将代码转译为:
Exam.__dict__['writing_grade'].__set__(exam, 40)
而获取属性时:
print(exam.writing_grade)
Python 也会将其转译为:
print(Exam.__dict__[ 'writing_grade'].__get__(exam, Exam))
之所以会有这样的转译,关键就在于 object 类的 __getattribute__
方法。简单来说,如果 Exam 实例没有名为 writing_grade 的属性,那么 Python 就会转向 Exam 类,并在该类中查找同名的类属性。这个类属性,如果是实现了 __get__
和 __set__
方法的对象,那么 Python 就认为此对象遵从描述符协议。
明白了这种转译方式之后,可以先按照下面这种写法,试着把 Homework 类里面的 @property 分数验证逻辑,改用 Grade 描述符来实现。
class Grade(object):
def __init__(self):
self._value = 0
def __get__(self, instance, instance_type):
return self._value
def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError( 'Grade must be between 0 and 100')
self._value = value
不幸的是,上面这种实现方式是错误的,它会导致不符合预期的行为。在同一个 Exam 实例上面多次操作其属性时,尚且看不出错误。
first_exam = Exam()
first_exam.writing_grade = 82
first_exam.science_grade = 99
print('Writing', first_exam.writing_grade)
print('Science', first_exam.science_grade)
>>>
Writing 82
Science 99
但是,如果在多个 Exam 实例上面分别操作某一属性,那就会导致错误的结果。
second_exam = Exam()
second_exam.writing_grade = 75
print('Second', second_exam.writing_grade, 'is right')
print('First', first_exam.writing_grade, 'is wrong')
>>>
Second 75 is right
First 75 is wrong
产生这种问题的原因是:对于 writing_grade 这个类属性来说,所有的 Exam 实例都要共享同一份 Grade 实例。而表示该属性的那个 Grade 实例,只会在程序的生命期中构建一次,也就是说:当程序定义 Exam 类的时候,它会把 Grade 实例构建好,以后创建 Exam 实例时,就不再构建 Grade 了。
为了解决此问题,需要把每个 Exam 实例所对应的值记录到 Grade 中。下面这段代码,用字典来保存每个实例的状态。
class Grade(object):
def __init__(self):
self._values = {}
def __get__(self, instance, instance_type):
if instance is None: return self
return self._values.get(instance, 0)
def __set__(self, instance, value):
if not (0 <= value <= 100):
raise ValueError('Grade must be between 0 and 100')
self._values[instance] = value
上面这种实现方式很简单,而且能够正确运作,但它仍然有个问题,那就是会泄漏内存。在程序的生命期内,对于传给 __set__
方法的每个 Exam 实例来说, _values 字典都会保存指向该实例的一份引用。这就导致该实例的引用计数无法降为 0,从而使垃圾收集器无法将其回收。
使用 Python 内置的 weakref 模块,即可解决此问题。该模块提供了名为 WeakKeyDictionary 的特殊字典,它可以取代 _values 原来所用的普通字典。WeakKeyDictionary 的特殊之处在于:如果运行期系统发现这种字典所持有的引用,是整个程序里面指向 Exam 实例的最后一份引用,那么,系统就会自动将该实例从字典的键中移除。Python 会做好相关的维护工作,以保证当程序不再使用任何 Exam 实例时,_value 字典会是空的。
class Grade(object):
def __init__(self):
self._values = weakKeyDictionary()
# ...
改用 WeakKeyDictionary 来实现 Grade 描述符,即可令程序的行为符合我们的需求。
class Exam(object):
math_grade = Grade()
writing_grade = Grade()
science_grade = Grade()
first_exam = Exam()
first_exam.writing_grade = 82
second_exam = Exam()
second_exam.writing_grade = 75
print('First ', first_exam.writing_grade, 'is right')
print('Second', second_exam.writing_grade, 'is right')
>>>
First 82 is right
Second 75 is right
总结
- 如果想复用 @property 方法及其验证机制,那么可以自己定义描述符类。
- WeakKeyDictionary 可以保证描述符类不会泄漏内存。
- 通过描述符协议来实现属性的获取和设置操作时,不要纠结于
__getattribute__
的方法具体运作细节。