属性访问、特性和修饰符
一个对象是一系列功能的集合,包括了方法和属性。object
类的默认行为包括设置、获取和删除属性。可以通过修改这些默认行为来决定对象中哪些属性是可用的。
本章会专注于有关属性访问的以下5种方式。
- 内部集成属性处理方式,这也是最简单的方式。
- 重温@property修饰符。特性扩展了属性的概念,包含了方法的处理。
- 使用底层的特殊方法来控制属性的访问:
__getattr__()
、__setattr__()
和__delattr__()
。这些特殊方法会简化属性的处理过程。 - 使用
__getattribute__()
方法在更细粒度的层面上操作属性,也可以用来编写特殊的属性处理逻辑。 - 最后,会介绍一些修饰符。它们用于属性访问,但它们的设计也会相对复杂些。修饰符在Python中的特性、静态方法和类方法中被广泛使用。
本章会具体介绍默认方法,我们需要知道在什么情况下需要重写这些默认行为。在一些情形下,需要使用属性完成一些不仅仅是一个实例变量能够完成的工作。在其他情况下,我们可能需要禁止属性的添加,也可能在一些场景需要创建逻辑更为复杂的属性。
正如我们研究修饰符那样,我们会从Python内部的工作机制入手。我们不会经常显式地使用修饰符,而是隐式地使用它们。在Python中,修饰符能够被用来完成很多功能。
3.1 属性的基本操作
默认情况下,创建任何类内部的属性都将支持以下4种操作。
- 创建新属性。
- 为已有属性赋值。
- 获取属性的值。
- 删除属性。
我们可以使用如下简单的代码来对这些操作进行测试,创建一个简单的泛型类并将其实例化。
>>> class Generic:
... pass
...
>>> g= Generic()
以上代码允许我们创建、获取、赋值和删除属性。我们可以容易地创建和获取一个属性,以下是一些例子。
>>> g.attribute= "value"
>>> g.attribute
'value'
>>> g.unset
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Generic' object has no attribute 'unset'
>>> del g.attribute
>>> g.attribute
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'Generic' object has no attribute 'attribute'
我们可以添加、修改和删除属性。如果试图获取一个未赋值的属性或者删除一个不存在的属性则会抛出异常。
另一种更好的办法是从types.SimpleNamepace
类创建实例。此时不需要额外定义一个新类,就能实现同样的功能。我们可以像如下代码这样创建SimpleNamespace
类的对象。
>>> import types
>>> n = types.SimpleNamespace()
在如下代码中,可以看到使用SimpleNamespace
类能够完成同样的任务。
>>> n.attribute= "value"
>>> n.attribute
'value'
>>> del n.attribute
>>> n.attribute
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
AttributeError: 'namespace' object has no attribute 'attribute'
我们可以为这个对象添加属性,试图获取任何一个未定义的属性将会引发异常。比起我们之前看到的,使用创建object
类实例的实现方式,使用SimpleNamesp ace
类的做法会略有不同。object
类的实例不允许创建新属性,因为它缺少Python内部用来存储属性值的__dict__
结构。
特性和__init__()
方法
大多数情况,我们使用类的__init__()
方法来初始化花色特性。理想情况下,可以在__init__()
方法中提供所有属性的默认值。
而在__init__()
方法中没必要为所有的属性赋值。基于这样的考虑,一个特性的存在与否就构成了对象状态的一部分。
可选特性更好地完善了类定义,它使得特性在类的定义中发挥了很大的作用。特性通常可以根据类层次结构进行选择性的添加(或删除)。
因此,可选特性隐藏了一种非正式的子类关系。当使用可选特性时,要考虑到对多态性的影响。
在21点游戏中,需要考虑这样的规则:只允许发牌一次。即如果已经发牌了,就不能再次发牌。我们可以考虑用以下几种方式来实现。
- 基于
Hand.split
方法提出一个子类,并将其命名为SplitHand
,在这里省略该类的具体实现。 - 也可以为
Hand
对象创建一个Status
属性,其值可以从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
属性不存在,说明这是第1次发牌,就是允许的。
使用可选属性的一个好处是使得__init__()
方法看起来相对整洁了一些,不好的地方在于它隐藏了一些对象的状态。对于try
语句的这种使用方式(检测对象属性是否存在,存在则抛出异常),很容易造成困惑而且应该避免使用。
3.2 创建特性
特性是一个函数,看起来(在语法上)就是一个简单的属性。我们可以获取、设置和删除特性值,正如我们可以获取、设置和删除属性值。这里有一个重要的区别:特性是一个函数,而且可以被调用,而不仅仅是用于存储的对象的引用。
除了复杂程度,特性和属性的另一个区别在于,我们不能轻易地为已有对象添加新特性。但是默认情况下,我们可以很容易地给对象添加新属性。在这一点上,特性和属性有很大区别。
可以用两种方式来创建特性。我们可以使用@property
修饰符或者使用property()
函数。它们只是语法不同。我们会详细介绍使用修饰符的方式。
我们先看一下关于特性的两个基本设计模式。
- 主动计算(Eager Calculation) :每当更新特性值时,其他相关特性值都会立即被重新计算。
- 延迟计算(Lazy calculation) :仅当访问特性时,才会触发计算过程。
为了对比这两种模式,我们会把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.total
19
>>> h.card= d.pop()
>>> h.total
29
当每次获取总值时,都会重新扫描每张牌并完成延迟计算,这个过程也是非常耗时的。
3.2.1 主动计算特性
以下是Hand
类的子类,其中的total
属性的实现方式为主动计算,每当有新牌添加时,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值的更新,即每当牌被移除时也会触发total
属性值的计算过程。关于deleter的内容将会在下一部分中具体介绍。
有关对Hand
类的两个子类(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
由于使用赋值运算符(=)实现方式的代码意图会更显然一些,因此许多程序员会更倾向于使用这种方式。
3.2.2 setter和deleter特性
在之前的例子中,我们使用card
特性来处理从牌对象到Hand
对象的构造过程。
由于setter(和deleter)特性是基于getter属性创建的,因此先要使用如下代码定义一个getter特性。
@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)
可以简单地使用如下代码完成发牌。
h.card= d.pop()
以上代码有一个缺陷,看起来像是使用一张牌来替换所有的牌。另外,它更新了可变对象的状态。可以使用__iadd__()
特殊方法来使实现更简洁。我们会在第7章“创建数值类型”中详细介绍这类特殊方法。
对于当前示例,虽没有明确的理由需要使用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
对象并返回了新的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()
函数实现分牌并返回一个新的Hand
对象。相应的,一张牌会从初始的Hand对象中移除。
这个版本的split()
函数是有效的。然而,直接使用split()
函数返回两个新的Hand
对象会更好一些。而对于分牌前的Hand
对象,可以使用备忘录模式来存放一些统计的数据。
3.3 使用特殊方法完成属性访问
本节将介绍 3 个用于属性访问的标准函数:__getattr__()
、__setattr__()
和__delattr__()
。此外,还可以用dir ()函数来查看属性的名称。下一部分会介绍__getattribute__()
函数的使用。
关于属性,之前章节中介绍了如下的几种默认操作。
__setattr__()
函数用于属性的创建和赋值。__getattr__()
函数可以用来做两件事。首先,如果属性已经被赋值,__getattr__()
则不会被调用,直接返回属性值即可。其次,如果属性没有被赋值,那么将使用__getattr__()
函数的返回值。如果找不到相关属性,要记得抛出AttributeError
异常。__delattr__()
函数用于删除属性。__dir__()
函数用于返回属性名称列表。
__getattr__()
函数只是复杂逻辑中的一个小步骤而已,仅当属性未知的情况下它才会被使用。如果属性已知,这个函数将不会被使用。__setattr__()
函数和__delattr__()
函数没有内部的处理过程,也没有和其他函数逻辑有交互。
关于控制属性访问的设计,可以有很多选择。基于这3个基本的设计出发点:扩展、封装和创建,以下是具体描述。
- 扩展类。通过重写
__setattr__()
和__delattr__()
函数使得它几乎是不可变的。也可以使用slots 替换内部的__dict__
对象。 - 封装类。提供对象(或对象集合)属性访问的代理实现。这可能需要完全重写和属性相关的那3个函数。
- 创建类并提供和特性功能一样的函数。使用这些方法来对特性逻辑集中处理。
- 创建延迟计算属性,仅当需要时才触发计算过程。对于一些属性,它的值可能来自文件、数据库或网络。这是
__getattr__()
函数的常见用法。 - 创建主动计算属性,其他属性更新时会相应地更新主动计算属性的值,这是通过重写
__setattr__()
函数实现的。
我们不必对以上各项逐一讨论。我们只会详细看一下其中两种最常用的:扩展和封装。我们会创建不可变对象并看一些其他有关实现提前属性的方式。
3.3.1 使用__slots__
创建不可变对象
如果一个属性是不允许被赋值或创建的,就被称为不可变的。以下代码演示了我们所期望和Python的一种交互方式。
>>> c= card21(1,'♠')
>>> c.rank= 12
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 30, in __setattr__
TypeError: Cannot set rank
>>> c.hack= 13
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 31, in __setattr__
AttributeError: 'Ace21Card' has no attribute 'hack'
以上代码中,我们不能对当前对象属性的值进行修改。
需要相应对类的定义做两处修改。在以下代码中,我们仅关注与对象不可变相关的 3个部分。
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
) )
我们做了如下3处明显的修改。
- 把
__slots__
设为唯一被允许操作的属性。这会使得对象内部的__dict__
对象不再有效并阻止对其他属性的访问。 - 在
__setattr__()
函数中,代码逻辑仅仅是抛出异常。 - 在
__init__()
函数中,调用了基类中__setattr__()
实现,为了确保当类没有包含有效的__setattr__()
函数时,属性依然可被正确赋值。
如果需要,也可以像如下代码绕过不可变对象。
object.__setattr__(c, 'bad', 5)
这会引发一个问题。“如何阻止恶意程序员绕过不可变对象?”这样的问题是愚蠢的。我们永远无法阻止恶意程序员的行为。另一个同样愚蠢的问题是,“为什么一些恶意程序员会试图绕过对象的不可变性?”当然,我们无法阻止恶意程序员做恶意的事情。
如果一个程序员不喜欢一个类的不可变性,他们可以修改它并移除重定义过的__setattr__()
函数。一个类似的例子是:对__hash__()
来说,不可变对象的目的是能够返回一致的值而非阻止程序员写糟糕的代码。
不要误解__slots__ __slots__ 的主要目的是通过限制属性的数量来节约内存。 |
3.3.2 使用tuple子类创建不可变对象
我们也可以通过让Card特性成为tuple
类的子类并重写__getattr__()
函数来实现一个不可变对象。这样一来,我们将把对__getattr__(name)
的访问转换为对self[index]
的访问。正如我们在第6章“创建容器和集合”中会看到的,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
在以上代码中,只抛出了异常而并未包含异常的详细错误信息。
可以按照如下代码这样使用这个类。
>>> d = BlackJackCard2( 'A', '♠', 1, 11 )
>>> d.rank
'A'
>>> d.suit
'♠'
>>> d.bad= 2
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 7, in __setattr__AttributeError
尽管无法轻易改变纸牌的面值,但是我们仍可通过操作d.__dict__
来引入其他属性。
不可变性真的有必要吗? 为确保一个对象没有被误用可能需要非常多的工作量。实际上,比起构建一个非常安全的不可变类,我们更关心的是如何通过异常抛出的诊断信息来进行错误追踪。 |
3.3.3 主动计算的属性
可以定义一个对象,当其内部一个值发生变化时,相关的属性值也会立刻更新。这种及时更新属性值的方式使得计算结果在访问时无需再次计算,从而优化了属性访问的过程。
我们可以定义许多这样特性的setter来达到此目的。然而,如果有很多特性setter,每个setter都要去计算多个与之相关的属性值,这样做有时又是多余的。
我们可以对有关属性的操作集中处理。以下例子中,会对Python的内部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__()
函数来为字典添加新项,每当属性的赋值操作发生时就会调用_solve()
函数。
在__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__()
函数的递归调用。
一旦3个属性被赋值,对象的灵活性会下降,了解这一点也是很重要的。
在不改变distance
的情况下,不能通过对rate
赋值,从而计算出time
的值。现在对这个模型做适度调整,清空一个变量的同时为另一个变量赋值。
>>> 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
可以使用distance
既定的值,清空了time
并修改了rate
。
可以设计一个模型,追踪变量的赋值顺序,这个模型可以帮助来解决这样的情景。为了计算结果的正确性,在为另一个变量赋值之前,不得不先清空一个变量。
3.4 __getattribute__()
方法
__getattribute__()
方法提供了对属性更底层的一些操作。默认的实现逻辑是先从内部的__dict__
(或__slots__
)中查找已有的属性。如果属性没有找到则调用__getattr__()
函数。如果值是一个修饰符(参见3.5“创建修饰符”),对修饰符进行处理。否则,返回当前值即可。
通过重写这个方法,可以达到以下目的。
- 可以有效阻止属性访问。在这个方法中,抛出异常而非返回值。相比于在代码中仅仅使用下划线(_)为开头来把一个名字标记为私有的方式,这种方法使得属性的封装更透彻。
- 可仿照
__getattr__()
函数的工作方式来创建新属性。在这种情况下,可以绕过__getattribute__()
的实现逻辑。 - 可以使得属性执行单独或不同的任务。但这样会降低程序的可读性和可维护性,这是个很糟糕的想法。
- 可以改变修饰符的行为。虽然技术上可行,改变修饰符的行为却是个糟糕的想法。
当实现__getattribute__()
方法时,将阻止任何内部属性访问函数体,这一点很重要。如果试图获取self.name
的值,会导致无限递归。
__ getattribute __ () 函数不能包含任何self.name属性的访问,因为会导致无限递归。 |
为了获得__getattribute__()
方法中的属性值,必须显式调用object
基类中的方法,像如下代码这样。
object.__getattribute__(self, name)
可以通过使用__getattribute__()
方法阻止对内部__dict__
属性的访问来实现不可变。以下代码中的类定义隐藏了所有名称以下划线(_)为开头的属性。
class BlackJackCard3:
"""Abstract Superclass"""
def __init__( self, rank, suit, hard, soft ):
super().__setattr__( 'rank', rank )
super().__setattr__( 'suit', suit )
super().__setattr__( 'hard', hard )
super().__setattr__( 'soft', soft )
def __setattr__( self, name, value ):
if name in self.__dict__:
raise AttributeError( "Cannot set {name}".
format(name=name) )
raise AttributeError( "'{__class__.__name__}' has no attribute
'{name}'".format( __class__= self.__class__, name= name ) )
def __getattribute__( self, name ):
if name.startswith('_'): raise AttributeError
return object.__getattribute__( self, name )
以上代码重写了__getattribute__()
的方法逻辑,当访问私有名称或Python内部名称时代码会抛出异常。和之前的例子相比,这样做的其中一个好处是:对象被封装得更彻底了,我们完全无法改变。
以下代码演示了和这个类的交互过程。
>>> c = BlackJackCard3( 'A', '♠', 1, 11 )
>>> c.rank= 12
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 9, in __setattr__
File "<stdin>", line 13, in __getattribute__
AttributeError
>>> c.__dict__['rank']= 12
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 13, in __getattribute__
AttributeError
一般情况下,不会轻易使用__getattribute__()
。该函数的默认实现非常复杂,大多数情况下,使用特性或改变__getattr__()
函数的行为就足以满足需求了。
3.5 创建修饰符
修饰符可看作属性的访问中介。修饰符类可以被用来获取、赋值或删除属性值,修饰符对象通常在类定义时被创建。
修饰符模式有两部分:拥有者类(owner class
)和属性修饰符(attribute descriptor
)。拥有者类使用一个或多个修饰符作为它的属性。在修饰符类中可以定义获取、赋值和删除的函数。一个修饰符类的实例将作为拥有者类的属性。
特性是基于拥有者类的函数。修饰符不同于特性,与拥有者类之间没有耦合。因此,修饰符通常可以被重用,是一种通用的属性。拥有者类可同时包含同一个修饰符类的不同实例,管理相似行为的属性。
和属性不同,修饰符是在类级别定义的。它的引用并非在__init__()
初始化函数中被创建。修饰符可在初始化过程中被赋值,修饰符通常作为类定义的一部分,处于任何函数之外。
当定义拥有者类时,每个修饰符对象都是修饰符类的实例,绑定在类级别的属性上。
为了标识为修饰符,修饰符类必须实现以下3个方法的一个或多个。
Descriptor.__get__( self, instance, owner )
→ object:在这个方法中,instance
参数来自被访问对象的self
变量。owner
变量是拥有者类的对象。如果这个修饰符在类中被调用,instance
参数默认值将为None
。此方法负责返回修饰符的值。Descriptor.__set__( self, instance, value )
:在这个方法中,instance
参数是被访问对象的self
变量,而value
参数为即将赋的新值。Descriptor.__delete__( self, instance )
:在这个方法中,instance
参数是被访问对象的self
变量,并在这个方法中实现属性值的删除。
有时,修饰符类也需要在__init__()
函数中初始化修饰符内部的一些状态。
基于方法的定义,如下是两种不同的修饰符类型。
- 非数据修饰符 :这类修饰符需要定义
__set__()
或__delete__()
或两者皆有,但不能定义__get__()
。非数据修饰符对象经常用于构建一些复杂表达式的逻辑。它可能是一个可调用对象,可能包含自己的属性或方法。一个不可变的非数据修饰符必须实现__set__()
函数,而逻辑只是单纯地抛出AttributeError
异常。这类修饰符的设计相对简单一些,因为接口更灵活。 - 数据修饰符 :这类修饰符至少要定义
__get__()
函数。通常,可通过定义__get__()
和__set__()
函数来创建一个可变对象。这类修饰符不能定义自己内部的属性或方法,因为它通常是不可见的。对修饰符属性的访问,也相应地转换为对修饰符中的__get__()
、__set__()
或delete__()
方法的调用。这样对设计是一个挑战,因此不会作为首要选择。关于修饰符的使用有大量的例子。在Python中使用修饰符的场景主要有如下几点。 - 类内部的方法被实现为修饰符。它们是非数据修饰符,应用在对象和不同的参数值上。
property()
函数是通过为命名的属性创建数据修饰符来实现的。- 类方法或静态方法被实现为修饰符,修饰符作用于类而非实例。
在第11章“用SQLite保存和获取对象”中,我们会讲到对象关系映射,如何大量使用修饰符的ORM类,完成从Python类定义到SQL表和列的映射。
当设计修饰符时,通过考虑以下3种常见的场景。
- 修饰符对象包含或获取数据。在这种情况下,修饰符对象的self变量是相关的并且修饰符是有状态的。使用数据修饰符时,
__get__()
方法用于返回内部数据。使用非数据修饰符时,由修饰符中其他方法或属性提供数据。 - 拥有者类实例包含数据。这种情况下,修饰符对象必须使用
instance
参数获取拥有者对象中的数据。使用数据修饰符时,__get__()
函数从实例中获取数据。使用非数据修饰符时,由修饰符中其他方法提供数据。 - 拥有者类包含数据。在这种情况下,修饰符对象必须使用
owner
参数。由修饰符实现的静态方法或类方法的作用范围通常是全局的,这种做法是常见的。
我们会详细看一下第1种情况。使用__get___()
和__set__()
函数创建数据修饰符,以及不使用__get__()
方法的情况下创建非数据修饰符。
第2种情况(数据包含在拥有者实例中),正是@property
装饰器的用途。比起传统的特性,修饰符带来的好处是,它把计算逻辑从拥有者类搬到了修饰符类中。而完全采用这样的设计思路来设计类是片面的,有些场景不能获得最大的收益。如果计算逻辑相当复杂,使用策略模式则更好。
对于第3种情况,@staticmethod
和@classmethod
装饰器的实现就是很好的例子。此处不再赘述。
3.5.1 使用非数据修饰符
经常会遇到一些对象,内部的属性值是紧密结合的。为了举例说明,在这里看一些和测量单位紧密相关的数值。
以下代码实现了一个简单的非数据修饰符类的实现,但未包含__get__()
函数。
class UnitValue_1:
"""Measure and Unit combined."""
def __init__( self, unit ):
self.value= None
self.unit= unit
self.default_format= "5.2f"
def __set__( self, instance, value ):
self.value= value
def __str__( self ):
return "{value:{spec}} {unit}".format( spec=self.default_
format, **self.__dict__)
def __format__( self, spec="5.2f" ):
#print( "formatting", spec )
if spec == "": spec= self.default_format
return "{value:{spec}} {unit}".format( spec=spec,
**self.__dict__)
这个类定义了简单的数值对,一个是可变的(数值)而另一个是不可变的(单位)。
当访问这个修饰符时,修饰符对象自身先要可用,它内部的属性或方法才可以被使用。可以使用这个修饰符来创建一些类,这些类用于计量以及其他与物理单位相关数值的管理。
以下这个类用来完成速率—时间—距离的计算。
class RTD_1:
rate= UnitValue_1( "kt" )
time= UnitValue_1( "hr" )
distance= UnitValue_1( "nm" )
def __init__( self, rate=None, time=None, distance=None ):
if rate is None:
self.time = time
self.distance = distance
self.rate = distance / time
if time is None:
self.rate = rate
self.distance = distance
self.time = distance / rate
if distance is None:
self.rate = rate
self.time = time
self.distance = rate * time
def __str__( self ):
return "rate: {0.rate} time: {0.time} distance:
{0.distance}".format(self)
一旦对象被创建并且属性被加载,默认的值就会被计算出来。一旦值被计算,修饰符就可以被用来获取数值或单位名称。另外,修饰符还包含了str()
函数和一些字符串格式化功能的函数。
以下是一个修饰符和RTD_1类交互的例子。
>>> m1 = RTD_1( rate=5.8, distance=12 )
>>> str(m1)
'rate: 5.80 kt time: 2.07 hr distance: 12.00 nm'
>>> print( "Time:", m1.time.value, m1.time.unit )
Time: 2.0689655172413794 hr
我们使用rate
和distance
参数创建了RTD_1的实例,它们用于完成rate
和distance
修饰符中__set__()
函数的计算逻辑。
当调用str(m1)
函数时,会调用RTD_1中全局的__str__()
函数,进而调用了速率、时间和距离修饰符的__format__()
函数,并会返回带有单位的数值。
由于非数据修饰符不包含__get__()
函数,也没有返回内部数值,因此只能直接访问各个元素值来获得数据。
3.5.2 使用数据修饰符
数据修饰符使得设计变得更复杂了,因为它的接口是受限制的。它必须包含__get__()
方法,并且只能包含__set__()
方法和__delete__()
方法。与接口相关的限制:可以包含以上方法的1~3个,不能包含其他方法。引入额外的方法将意味着Python不能把这个类正确地识别为一个数据修饰符。
接下来将实现一个非常简单的单位转换,实现过程由修饰符中__get__()
和__set__()
方法来完成。
以下是一个单位修饰符的基类定义,实现了标准单位之间的转换。
class Unit:
conversion= 1.0
def __get__( self, instance, owner ):
return instance.kph * self.conversion
def __set__( self, instance, value ):
instance.kph= value / self.conversion
以上的类通过简单的乘除运算实现了标准单位和非标准单位的互转。
使用这个基类,可以定义一些标准单位的转换。在之前的例子中,标准单位是KPH(千米每小时)。
以下是两个转换修饰符类。
class Knots( Unit ):
conversion= 0.5399568
class MPH( Unit ):
conversion= 0.62137119
从基类继承的方法完成了此过程的实现,唯一的改变是转换因数。这些类可用于包含单位转换的数值,可以用在MPH(英里每小时)或海里的转换。以下是一个标准单位的修饰符定义千米每小时。
class KPH( Unit ):
def __get__( self, instance, owner ):
return instance._kph
def __set__( self, instance, value ):
instance._kph= value
这个类仅仅是定义了一个标准,因此没有任何转换逻辑。它使用了一个私有变量来保存KPH中的速度值。避免算术转换只是一种优化技巧,以防止任何对公有属性的引用,这是避免无限递归的前提。
以下是一个类,包含了给定测量的一些转换过程。
class Measurement:
kph= KPH()
knots= Knots()
mph= MPH()
def __init__( self, kph=None, mph=None, knots=None ):
if kph: self.kph= kph
elif mph: self.mph= mph
elif knots: self.knots= knots
else:
raise TypeError
def __str__( self ):
return "rate: {0.kph} kph = {0.mph} mph = {0.knots}
knots".format(self)
对于不同的单位来说,每一个类级别的属性都是一个修饰符,在get和set函数中提供转换过程的实现,可以使用这个类来转换速度之间的各种单位。
以下是使用这个Measurement
类进行交互的例子。
>>> m2 = Measurement( knots=5.9 )
>>> str(m2)
'rate: 10.92680006993152 kph = 6.789598762345432 mph = 5.9 knots'
>>> m2.kph
10.92680006993152
>>> m2.mph
6.789598762345432
我们创建了Measurement类的对象,设置了不同的修饰符。例子中,我们设置了knots(海里)修饰符。
当数值需要显示在一个格式化的字符串中时,修饰符中的__get__()
方法就会被调用。这些函数从拥有者类的对象中获取KPH(千米每小时)的属性值,设置转换因数并返回结果。
KPH(千米每小时)属性也使用了一个修饰符。这个修饰符没有做任何转换。然而,只是简单地返回了拥有者类对象中缓存的一个私有的数值。当使用KPH和Knots修饰符时,需要拥有者类实现一个KPH属性。
3.6 总结、设计要素和折中方案
在本章中,我们看了一些对象属性的工作方式。我们可以使用object
类中已经定义好的功能来获取和设置属性值,可通过定义特性来改变属性的行为。
对于更复杂的情况,可以重写__getattr__()
、__setattr__()
和__delattr__()
或__getattribute__()
函数的实现。这样一来,可以从根本上更细粒度地控制(也可能带来疑惑)Python的行为。
Python在内部使用了修饰符实现一些函数、静态方法和特性。对于一些场景,使用修饰符显得更自然,也体现了一种编程语言的优势。
其他语言(尤其Java和C++)的程序员通常一开始要把所有的属性定义为私有的,并编写可扩展的getter和setter函数。对于在编译期处理类型定义的语言来说,这种编码方式是可取的。
在Python中,建议把所有的属性公有,这意味着如下几点。
- 此处应当有很好的文档说明。
- 此处应能够正确地反映出对象的状态,它们不应当是暂时性的或者临时变量。
- 在少数情形下,属性包含了容易产生歧义的值,可使用单下划线(_)来标记为“不在接口定义范围内”,以此表明它并不是真正意义上的私有。
私有属性是令人厌烦的。封装,并不会因为某种编程语言缺乏一种复杂的私有机制而被破坏,它只会被糟糕的设计所破坏。
3.6.1 特性与属性对比
在大多数情况,属性附加在类的外部。之前的Hand
类的例子中演示了这一点。使用这个类的其他版本时,只需简单地把对象添加到hand.cards
中,使用延迟计算方式实现的total
特性,就可以有效地工作了。
有时对一个属性值的改变会造成其他属性值的改变,需要更复杂的类定义。
- 选择在函数内部维护状态的改变,当函数定义包含多个参数值时可以考虑这种做法。
- 一个setter特性或许比一个函数要表达的意图更清晰。当只需访问一个值时,这个做法是明智的。
- 我们也可以使用原地运算符,在第7章“创建数值类型”中会介绍。
在使用方面没有严格的规定。这样一来,当需要为单一参数赋值时,在方法函数与属性之间的区别在API语法上的差异以及在传达意图上是否有更好的方式。
对于已经计算的值,特性允许延迟计算,然而属性却需要主动计算。这需要在性能上进行考虑,至于使用哪一种还要看具体的应用场景。
3.6.2 修饰符的设计
Python中定义了一些修饰符,我们并不需要重新创建特性、类方法或静态方法。
创建修饰符的典型例子是完成Python和非Python之间的映射。例如,对于对象关系数据库映射,需要在Python类中定义大量的属性,并确保它们的顺序和SQL数据表中列的顺序是一致的。再如,当需要完成Python之外的映射时,修饰符类可以用来完成编码和解码的工作,或从外部资源中获取数据。
当创建一个网络服务客户端时,可以考虑使用修饰符来完成网络请求。可考虑使用__get__()
方法构造HTTP GET请求对象,也可使用__set__()
方法来构造HTTP PUT请求对象。
在一些情形下,一个请求的构造可能需要由多个修饰符来完成。这样的话,在构造一个新的HTTP请求对象之前,__get__()
函数可以先从缓存中获取可用的实例。
许多数据修饰符的操作使用特性会更容易一些,可以这样的思考顺序来设计:先考虑特性。如果特性的逻辑非常复杂,可以考虑使用修饰符或对类进行重构。
3.6.3 展望
在下一章中,会着重介绍抽象基类(Abstract Base Classes,ABC),在第5章、第6章和第7章也会深入探讨。这些抽象基类会帮助我们定义一些可以和Python机制无缝集成的类,这也使得类层次结构的设计能够在一致性设计和扩展性上发挥更大的作用。