[译] 字段访问、属性和描述符 1 - young
注:原书作者 Steven F. Lott,原书名为 Mastering Object-oriented Python
对象就是一些特性的集合,包括方法和字段。object
类的默认行为包括设置、获取和删除字段。我们经常需要修改这些行为来改变一个对象的字段。
本章将重点关注以下五个层次的字段访问:
内置字段的处理,这是最简单的,但最不精明的选择。
回顾一下
@property
装饰器。属性扩展了字段的概念,把处理过程包含到了已定义的方法函数中。如何利用低级别的特殊方法去控制字段访问方法:
__getattr__()
、__setattr__()
和__delattr__()
。这些特殊的方法允许我们构建更复杂的字段处理。了解
__getattribute__()
方法,它提供了更细粒度的字段控制。这可以让我们写不寻常的字段处理。最后,我们将看看描述符。这些都是用来访问一个字段的,但它们涉及到更复杂的设计决策。在Python中大量使用描述符来实现属性、静态方法和类方法。
在这一章,我们将会看到默认处理如何工作的细节。我们需要决定何时何地来覆写默认行为。在某些情况下,我们希望我们的字段不仅仅是实例化变量。在其他情况下,我们可能想要防止字段的添加。我们的字段可能有更复杂的行为。
同样,在我们探索了解描述符时,我们将更深入的理解Python的内部是怎样工作的。我们不需要经常显式的使用描述符。我们经常隐式的使用它们,因为它们是实现Python一些特性的机制。
基本属性处理
默认情况下,我们创建的任何类对字段都将允许以下四个行为:
通过设置值来创建一个新的字段
给存在的字段设置值
获取字段的值
删除字段
我们可以使用像下面代码这样简单的表示。创建一个简单的、通用的类和该类的一个对象:
>>> class Generic:... pass...>>> g = Generic()
前面的代码允许我们创建、获取、设置和删除字段。我们可以轻松地创建和获取一个字段。以下是一些示例:
>>> g.attribute = "value">>> g.attribute'value'>>> g.unsetTraceback (most recent call last): File "<stdin>", line 1, in <module>AttributeError: 'Generic' object has no attribute 'unset'>>> del g.attribute>>> g.attributeTraceback (most recent call last): File "<stdin>", line 1, in <module>AttributeError: 'Generic' object has no attribute 'attribute'
我们可以添加、更改和删除字段。如果我们试图获取一个未设置的字段或删除一个不存在的字段时会发生异常。
稍微更好的方法就是使用types.SimpleNamespace
类的一个实例。设置特性是一样的,但是我们不需要创建额外的类定义。我们创建一个SimpleNamespace
类对象来代替,如下:
>>> import types>>> n = types.SimpleNamespace()
在以下代码中,我们可以看到为SimpleNamespace
类工作的相同用例:
>>> n.attribute = "value">>> n.attribute'value'>>> del n.attribute>>> n.attributeTraceback (most recent call last): File "<stdin>", line 1, in <module>AttributeError: 'namespace' object has no attribute 'attribute'
我们可以为这个对象创建字段。任何试图使用未定义的字段都会抛出异常。当我们创建一个object
类实例时SimpleNamespace
会有不同的行为。一个简单的object
类实例不允许创建新的字段;它缺乏内部__dict__
结构,Python会保存字段和值到该结构里面。
1、字段和__init__()方法
大多数时候,我们使用类的__init__()
方法来创建一系列的初始字段。理想情况下,我们为__init__()
中所有字段提供默认值。
不需要提供所有字段到__init__()
方法。正因为如此,存在或不在的字段可以作为一个对象状态的一部分。
一个可选字段可以超越类定义的限制。对于一个类来说,有一组好的字段定义其意义甚大。通过创建一个子类或父类,字段通常可以更清晰地被添加(或删除)。
因此,可选字段意味着一种非正式的子类关系。因此,当我们使用可选字段时会碰到可怜的多态性。
思考一下21点游戏,只有允许一次分牌。如果一手牌已经分牌,就不能再分牌。有几种方法,我们可以模拟一下:
我们可以由
Hand.split()
方法创建一个SplitHand
子类。在此我们不详细展示。我们可以在
Hand
对象中创建一个状态字段,由Hand.split()
方法创建。理想情况下,这是一个布尔值,但是我们可以实现它作为一个可选字段。
下面是通过一个可选字段检测可分离和不可分离的Hand.split()
:
def split(self, deck): assert self.cards[0].rank == self.cards[1].rank try: self.split_count raise CannotResplit except AttributeError: h0 = Hand(self.dealer_card, self.cards[0], deck.pop()) h1 = Hand(self.dealer_card, self.cards[1], deck.pop()) h0.split_count = h1.split_count = 1 return h0, h1
实际上,split()
方法是检测是否有split_count
字段。如果有这个字段,则是已经分牌的手牌且该方法抛出异常。如果split_count
字段不存在,允许分牌。
一个可选字段的优势是使__init__()
方法有相对整洁的状态标识。劣势是模糊了对象的状态。使用try:
块来确定对象状态可能会变得非常混乱,我们应该避免。
创建属性
属性是一个方法函数,它(语法上)看上去像一个简单的字段。我们可以获取、设置和删除属性值就像我们如何获取、设置和和删除字段值一样。这里有一个重要的区别,属性实际上是一个函数且可以处理,而不是简单地保存一个引用到一个对象。
除了更加尖端之外,属性和字段之间的另一个差别就是我们不能轻易将新属性附加到现有对象上;然而,默认情况下我们可以地轻易给对象添加字段。在这方面属性和简单的字段是不一样的。
有两种方法创建属性。我们可以使用@property
装饰器或者我们可以使用property()
函数。纯粹是语法的差异。我们将更多的关注装饰器。
我们看看属性的两个基本设计模式:
及早计算:在这个设计模式中,当我们通过属性设置一个值时,其他字段也同样计算。
延迟计算:在这个设计模式中,计算将被推迟直到需要的时候,通过属性。
为了比较前两种属性处理,我们将分割Hand
对象的常见的特性到一个抽象父类,如下所示:
class Hand: def __str__(self): return ", ".join(map(str, self.card)) def __repr__(self): return "{__class__.__name__}({dealer_card!r}, {_cards_str})" .format(__class__=self.__class__, _cards_str=", " .join(map(repr, self.card)), **self.__dict__)
在前面的代码中,我们只是定义了一些字符串表示方法。
下面是Hand
的一个子类,total
是一个延迟属性,只有在需要的时候进行计算:
class Hand_Lazy(Hand): def __init__(self, dealer_card, *cards): self.dealer_card = dealer_card self._cards = list(cards) @property def total(self): delta_soft = max(c.soft-c.hard for c in self._cards) hard_total = sum(c.hard for c in self._cards) if hard_total + delta_soft <= 21:return hard_total + delta_soft return hard_total @property def card(self): return self._cards @card.setter def card(self, aCard): self._cards.append(aCard) @card.deleter def card(self): self._cards.pop(-1)
Hand_Lazy
类初始化一个带有一组Cards
对象的Hand
对象。total
属性是一个只有在需要的时候计算总和的方法。此外,我们定义了一些其他属性更新手中的牌。Card
属性可以获取、设置或删除手中的牌。我们将在setter
和deleter
属性章节看到这些。
我们可以创建一个Hand
对象,total
作为一个简单的字段出现:
>>> d = Deck()>>> h = Hand_Lazy(d.pop(), d.pop(), d.pop())>>> h.total19>>> h.card = d.pop()>>> h.total29
在每次需要总和的时候,通过重新扫描手中的牌延迟计算。这可是非常昂贵的开销。
1、及早计算属性
以下是Hand
的一个子类,total
是一个简单的字段,它会在每张牌被添加后立即计算:
class Hand_Eager(Hand): def __init__(self, dealer_card, *cards): self.dealer_card = dealer_card self.total = 0 self._delta_soft = 0 self._hard_total = 0 self._cards = list() for c in cards:self.card = c @property def card(self): return self._cards @card.setter def card(self, aCard): self._cards.append(aCard) self._delta_soft = max(aCard.soft - aCard.hard, self._delta_soft) self._hard_total += aCard.hard self._set_total() @card.deleter def card(self): removed = self._cards.pop(-1) self._hard_total -= removed.hard # Issue: was this the only ace? self._delta_soft = max(c.soft - c.hard for c in self._cards) self._set_total() def _set_total(self): if self._hard_total+self._delta_soft <= 21:self.total = self._hard_total + self._delta_soft else:self.total = self._hard_total
在这种情况下,每添加一张牌,total
字段就会更新。
其他Card
属性——deleter
——及早地更新total
字段无论牌在何时被删除。我们将在下一节详细查看deleter
。
客户端认为这两个子类之间的语法相同(Hand_Lazy()
和Hand_Eager()
)
d = Deck()h1 = Hand_Lazy(d.pop(), d.pop(), d.pop())print(h1.total)h2 = Hand_Eager(d.pop(), d.pop(), d.pop())print(h2.total)
在这两种情况下,客户端软件简单的使用total
字段。
使用属性的优势是,当实现改变时语法没有改变。我们可以做一个类似getter/setter
简单要求的方法函数。然而,getter/setter
方法函数涉及到并没有什么用处的额外语法。以下是两个例子,其中一个是使用setter
方法,另一个是使用赋值运算符:
obj.set_something(value)obj.something = value
赋值运算符(=)的存在意图很简单。许多程序员发现赋值语句比setter
方法函数看起来更清晰。
2、setter
和deleter
特性
在前面的例子中,我们定义了Card
属性来处理额外的牌到Hand
类对象。
自从setter
(和deleter
)属性的创建来自getter
,我们必须经常定义getter
属性使用如下代码:
@propertydef card(self): return self._cards@card.setterdef card(self, aCard): self._cards.append(aCard)@card.deleterdef card(self): self._cards.pop(-1)
这允许我们用一条简单的语句添加一张牌到手中像下面这样:
h.card = d.pop()
前面的赋值语句有一个缺点,因为它看起来像一张牌替代了所有的牌。另一方面,它也有一个优势,因为它使用简单赋值来更新一个可变对象的状态。我们可以使用__iadd__()
特殊方法,这样做更简洁。但我们会等到第七章《创建数字》引入其他特殊方法。
我们当前的例子,没有令人信服的理由来使用deleter
属性。即使没有一个令人信服的理由,还是有一些deleter
用法。无论如何,我们还可以利用它来删除最后一张处理过的牌。这可以用作分牌过程的一部分。
我们可以思考一下以下版本的split()
,如下代码显示:
def split(self, deck): """Updates this hand and also returns the new hand.""" assert self._cards[0].rank == self._cards[1].rank c1 = self._cards[-1] del self.card self.card = deck.pop() h_new = self.__class__(self.dealer_card, c1, deck.pop()) return h_new
前面的方法更新给定的手牌并返回新的Hand
对象。下面是一个分牌的例子:
>>> d = Deck()>>> c = d.pop()>>> h = Hand_Lazy(d.pop(), c, c) # Force splittable hand>>> h2 = h.split(d)>>> print(h)2?, 10?>>> print(h2)2?, A?
一旦我们有两张牌,我们可以使用split()
产生第二个手牌。一张牌从最初的手牌中被移除。
这个版本的split()
当然是可行的。然而,似乎有所好转的使用split()
方法返回两个新的Hand
对象。这样,旧的、预分牌的Hand
实例可以用作收集统计数据。
对字段访问使用特殊方法
我们来看看这三个规范的访问字段的特殊方法:getattr()
、setattr()
和delattr()
。此外,我们会知道__dir__()
方法会显示字段名称。我们推迟到下一节来介绍__getattribute__()
。
第一节默认行为的展示如下:
__setattr__()
方法将创建并设置字段。__getattr__()
方法将做两件事。首先,如果一个字段已经有值,__getattr__()
不使用,只是返回字段值。其次,如果字段没有值,那么__getattr__()
会有机会返回有意义的值。如果没有字段,它一定会抛出一个AttributeError
异常。__delattr__()
方法删除一个字段。__dir__()
方法返回字段名称列表。
__getattr__()
方法函数在更大的处理过程中只有一个步骤;只有当字段是未知的才会去使用。如果字段是已知的,不使用这种方法。__setattr__()
和__delattr__()
方法没有内置的处理。这些方法不与额外的处理过程进行交互。
对于控制字段访问我们有许多设计可选。这根据我们的三个基本设计来选择是扩展、包装或发明。选择如下:
我们可以扩展一个类,通过重写
__setattr__()
和__delattr__()
使它几乎不可变。我们也可以通过__slots__
替换内部的__dict__
。我们可以包装类和委托字段访问到即将包装的对象(或复合对象)。这可能涉及到覆写所有三种方法。
我们可以在一个类中实现类属性行为。使用这些方法,我们可以确保所有属性集中处理。
我们可以创建延迟字段值尽管它的值在需要的时候没有(或不能)计算。可能会有一个字段没有值,直到从文件、数据库或网络中读取到。这对于
__getattr__()
是常用用法。我们可以有及早字段,在其他字段中自动设置时创建一个字段值。这是通过覆写
__setattr__()
做到的。
我们不会看所有这些选择。相反,我们将关注两个最常用的技术:扩展和包装。我们将创建不可变对象,看看其他方法来及早计算属性值。
1、通过__slots__
创建不可变对象
如果我们不能够设置一个字段或创建一个新的,且对象是不可变的。则以下是我们希望在交互式Python中所能够看到的:
>>> c = card21(1,'?')>>> c.rank = 12Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 30, in __setattr__TypeError: Cannot set rank>>> c.hack = 13Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 31, in __setattr__AttributeError: 'Ace21Card' has no attribute 'hack'
前面的代码显示,我们是不允许改变这个对象的字段或添加一个到这个对象种。
为了让此操作可以顺利工作我们需要变化这个类定义中的两个地方。我们将忽略很多类,只关注三个特性,使一个对象不可变,如下所示:
class BlackJackCard: """Abstract Superclass""" __slots__ = ('rank', 'suit', 'hard', 'soft') def __init__(self, rank, suit, hard, soft): super().__setattr__('rank', rank) super().__setattr__('suit', suit) super().__setattr__('hard', hard) super().__setattr__('soft', soft) def __str__(self): return "{0.rank}{0.suit}".format(self) def __setattr__(self, name, value): raise AttributeError("'{__class__.__name__}' has no attribute '{name}'" .format(__class__ = self.__class__, name = name))
我们做了三个重要的变动:
我们设置
__slots__
到只被允许的字段。这个将关闭对象内部__dict__
的特性且允许限制字段。我们定义的
__setattr__()
会引发一个异常比不做任何事有用的多。我们定义
__init__()
使用的超类版本的__setattr__()
这样值就可以正确设置,尽管这个类中缺少了正常工作的__setattr__()
方法。
小心一些,如果这样做我们可以绕过不变性特性。
object.__setattr__(c, 'bad', 5)
这给我们带来了一个问题。我们如何防止“邪恶的”程序员绕过不变性特性?这个问题是愚蠢的。我们并不能阻止邪恶的程序员。另一个同样愚蠢的问题是,为什么一些邪恶的程序员写代码来规避不变性?我们并不能阻止邪恶的程序员做邪恶的事情。
如果这个虚构的程序员不喜欢类中的不变性,他们可以修改类的定义来删除重新定义的__setattr__()
。不可变对象的重点是保证__hash__()
返回一个一致的值,而不是阻止人们写烂的代码。
不要滥用__slots__
__slots__
特性的主要目的是通过限制字段的数量来节省内存。
2、创建不可变对象作为元组的子类
我们也可以通过给Card
属性一个元组子类并覆写__getattr__()
来创建一个不可变对象。在这种情况下,我们将翻译__getattr__(name)
请求为self[index]
请求。在第六章《创建容器和集合》中我们将看到,self[index]
是由__getitem__(index)
来实现的。
下面是内置tuple
类的一个小扩展:
class BlackJackCard2(tuple): def __new__(cls, rank, suit, hard, soft): return super().__new__(cls, (rank, suit, hard, soft)) def __getattr__(self, name): return self[{'rank':0, 'suit':1, 'hard':2 , 'soft':3}[name]] def __setattr__(self, name, value): raise AttributeError
在本例中,我们只是简单的抛出了AttributeError
异常而不是提供详细的错误消息。
当我们使用前面的代码中,我们看到以下交互:
>>> d = BlackJackCard2('A', '?', 1, 11)>>> d.rank'A'>>> d.suit'?'>>> d.bad = 2Traceback (most recent call last): File "<stdin>", line 1, in <module> File "<stdin>", line 7, in __setattr__AttributeError
我们不能轻易的改变牌值。然而,我们仍然可以调整d.__dict__
来引入额外的字段。
有这必要吗
也许,简单的工作可以确保对象不是不小心误用。实际上,我们对从异常得到的诊断信息和跟踪,比我们在极其安全的不可变类中更感兴趣。
3、及早计算字段
我们可以定义一个对象,它的字段在设置值后尽可能快的及早计算。对象最优访问就是进行一次计算结果多次使用。
我们能够定义很多的setter
属性来做这些。然而,过多的setter
属性,每个字段都计算,会使得计算变得冗长复杂。
我们可以集中式的进行字段处理。在接下来的例子中,我们将对其调整来扩展Python的内部dict
类型。扩展dict
的优点是,它能够很好地处理字符串的format()
方法。同时,我们不必过多担心设置额外的被忽略的字段值。
我们希望类似下面的代码:
>>> RateTimeDistance(rate=5.2, time=9.5){'distance': 49.4, 'time': 9.5, 'rate': 5.2}>>> RateTimeDistance(distance=48.5, rate=6.1){'distance': 48.5, 'time': 7.950819672131148, 'rate': 6.1}
我们可以在RateTimeDistance
对象中设置值。额外的字段可以很轻松的被计算。我们可以一次性做到这些,如下代码所示:
>>> rtd = RateTimeDistance()>>> rtd.time = 9.5>>> rtd{'time': 9.5}>>> rtd.rate = 6.24>>> rtd{'distance': 59.28, 'time': 9.5, 'rate': 6.24}
下面是内置dict
类型的扩展。我们扩展了基本dict
映射用来实现计算缺失的字段:
class RateTimeDistance(dict): def __init__(self, *args, **kw): super().__init__(*args, **kw) self._solve() def __getattr__(self, name): return self.get(name,None) def __setattr__(self, name, value): self[name] = value self._solve() def __dir__(self): return list(self.keys()) def _solve(self): if self.rate is not None and self.time is not None:self['distance'] = self.rate * self.time elif self.rate is not None and self.distance is not None:self['time'] = self.distance / self.rate elif self.time is not None and self.distance is not None:self['rate'] = self.distance / self.time
dict
类型使用__init__()
来填充内部字典,然后试图解决当前数据太多的问题。它使用__setattr__()
来添加新项目到字典。它也试图在每次设置值的时候解答等式。
在__getattr__()
中,在等式中我们使用None
表明值的缺失。这允许我们设置一个字段为None
表明它是一个缺失的值,这将迫使为此寻找解决方案。例如,我们可以基于用户输入或者一个网络请求,所有参数被赋予一个值,但一个变量设置为None
。
我们可以如下使用:
>>> rtd = RateTimeDistance(rate=6.3, time=8.25, distance=None)>>> print("Rate={rate}, Time={time}, Distance={distance}".format(**rtd))Rate=6.3, Time=8.25, Distance=51.975
请注意,我们不能轻易地在这个类里面设置字段值。
让我们考虑下面这行代码:
self.distance = self.rate * self.time
如果我们要编写之前的代码片段,我们会在__setattr__()
和_solve()
之间进行无限的递归调用。当我们使用self['distance']
到这个例子中,我们避免了递归调用__setattr__()
。
同样重要的是要注意,一旦设置了所有三个值,该对象不能轻易被改变来提供新的解决方案。
我们不能简单地给rate
设置一个新值且计算time
新值必须让distance
不变。为了调整这个模型,我们需要清除一个变量以及为另一个变量设置一个新值:
>>> rtd.time = None>>> rtd.rate = 6.1>>> print("Rate={rate}, Time={time}, Distance={distance}".format(**rtd))Rate=6.1, Time=8.25, Distance=50.324999999999996
这里,我们清除time
且改变rate
得到一个新的解决方案来使用既定的distance
值。
我们可以设计一个模型,跟踪设置变量的顺序;这一模型可以节省我们在设置另一个变量重新计算相关结果之前清除一个变量。