Effective Python -- 第 4 章 元类及属性(上)

第 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__ 的方法具体运作细节。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值