1. 程式人生 > 實用技巧 >屬性訪問、特性和修飾符

屬性訪問、特性和修飾符

一個物件是一系列功能的集合,包括了方法和屬性。object類的預設行為包括設定、獲取和刪除屬性。可以通過修改這些預設行為來決定物件中哪些屬性是可用的。

本章會專注於有關屬性訪問的以下5種方式。

  • 內部整合屬性處理方式,這也是最簡單的方式。
  • 重溫@property修飾符。特性擴充套件了屬性的概念,包含了方法的處理。
  • 使用底層的特殊方法來控制屬性的訪問:__getattr__()__setattr__()__delattr__()。這些特殊方法會簡化屬性的處理過程。
  • 使用__getattribute__()方法在更細粒度的層面上操作屬性,也可以用來編寫特殊的屬性處理邏輯。
  • 最後,會介紹一些修飾符。它們用於屬性訪問,但它們的設計也會相對複雜些。修飾符在Python中的特性、靜態方法和類方法中被廣泛使用。

本章會具體介紹預設方法,我們需要知道在什麼情況下需要重寫這些預設行為。在一些情形下,需要使用屬性完成一些不僅僅是一個例項變數能夠完成的工作。在其他情況下,我們可能需要禁止屬性的新增,也可能在一些場景需要建立邏輯更為複雜的屬性。

正如我們研究修飾符那樣,我們會從Python內部的工作機制入手。我們不會經常顯式地使用修飾符,而是隱式地使用它們。在Python中,修飾符能夠被用來完成很多功能。

預設情況下,建立任何類內部的屬性都將支援以下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__()方法中沒必要為所有的屬性賦值。基於這樣的考慮,一個特性的存在與否就構成了物件狀態的一部分。

可選特性更好地完善了類定義,它使得特性在類的定義中發揮了很大的作用。特性通常可以根據類層次結構進行選擇性的新增(或刪除)。

因此,可選特性隱藏了一種非正式的子類關係。當使用可選特性時,要考慮到對多型性的影響。

在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語句的這種使用方式(檢測物件屬性是否存在,存在則丟擲異常),很容易造成困惑而且應該避免使用。

特性是一個函式,看起來(在語法上)就是一個簡單的屬性。我們可以獲取、設定和刪除特性值,正如我們可以獲取、設定和刪除屬性值。這裡有一個重要的區別:特性是一個函式,而且可以被呼叫,而不僅僅是用於儲存的物件的引用。

除了複雜程度,特性和屬性的另一個區別在於,我們不能輕易地為已有物件新增新特性。但是預設情況下,我們可以很容易地給物件新增新屬性。在這一點上,特性和屬性有很大區別。

可以用兩種方式來建立特性。我們可以使用@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

當每次獲取總值時,都會重新掃描每張牌並完成延遲計算,這個過程也是非常耗時的。

以下是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

由於使用賦值運算子(=)實現方式的程式碼意圖會更顯然一些,因此許多程式設計師會更傾向於使用這種方式。

在之前的例子中,我們使用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 個用於屬性訪問的標準函式:__getattr__()__setattr__()__delattr__()。此外,還可以用dir()函式來檢視屬性的名稱。下一部分會介紹__getattribute__()函式的使用。

關於屬性,之前章節中介紹瞭如下的幾種預設操作。

  • __setattr__()函式用於屬性的建立和賦值。
  • __getattr__()函式可以用來做兩件事。首先,如果屬性已經被賦值,__getattr__()則不會被呼叫,直接返回屬性值即可。其次,如果屬性沒有被賦值,那麼將使用__getattr__()函式的返回值。如果找不到相關屬性,要記得丟擲AttributeError異常。
  • __delattr__()函式用於刪除屬性。
  • __dir__()函式用於返回屬性名稱列表。

__getattr__()函式只是複雜邏輯中的一個小步驟而已,僅當屬性未知的情況下它才會被使用。如果屬性已知,這個函式將不會被使用。__setattr__()函式和__delattr__()函式沒有內部的處理過程,也沒有和其他函式邏輯有互動。

關於控制屬性訪問的設計,可以有很多選擇。基於這3個基本的設計出發點:擴充套件、封裝和建立,以下是具體描述。

  • 擴充套件類。通過重寫__setattr__()__delattr__()函式使得它幾乎是不可變的。也可以使用slots替換內部的__dict__物件。
  • 封裝類。提供物件(或物件集合)屬性訪問的代理實現。這可能需要完全重寫和屬性相關的那3個函式。
  • 建立類並提供和特性功能一樣的函式。使用這些方法來對特性邏輯集中處理。
  • 建立延遲計算屬性,僅當需要時才觸發計算過程。對於一些屬性,它的值可能來自檔案、資料庫或網路。這是__getattr__()函式的常見用法。
  • 建立主動計算屬性,其他屬性更新時會相應地更新主動計算屬性的值,這是通過重寫__setattr__()函式實現的。

我們不必對以上各項逐一討論。我們只會詳細看一下其中兩種最常用的:擴充套件和封裝。我們會建立不可變物件並看一些其他有關實現提前屬性的方式。

如果一個屬性是不允許被賦值或建立的,就被稱為不可變的。以下程式碼演示了我們所期望和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__的主要目的是通過限制屬性的數量來節約記憶體。

我們也可以通過讓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__來引入其他屬性。

不可變性真的有必要嗎?
為確保一個物件沒有被誤用可能需要非常多的工作量。實際上,比起構建一個非常安全的不可變類,我們更關心的是如何通過異常丟擲的診斷資訊來進行錯誤追蹤。

可以定義一個物件,當其內部一個值發生變化時,相關的屬性值也會立刻更新。這種及時更新屬性值的方式使得計算結果在訪問時無需再次計算,從而優化了屬性訪問的過程。

我們可以定義許多這樣特性的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

可以設計一個模型,追蹤變數的賦值順序,這個模型可以幫助來解決這樣的情景。為了計算結果的正確性,在為另一個變數賦值之前,不得不先清空一個變數。

__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__()函式的行為就足以滿足需求了。

修飾符可看作屬性的訪問中介。修飾符類可以被用來獲取、賦值或刪除屬性值,修飾符物件通常在類定義時被建立。

修飾符模式有兩部分:擁有者類(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裝飾器的實現就是很好的例子。此處不再贅述。

經常會遇到一些物件,內部的屬性值是緊密結合的。為了舉例說明,在這裡看一些和測量單位緊密相關的數值。

以下程式碼實現了一個簡單的非資料修飾符類的實現,但未包含__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

我們使用ratedistance引數建立了RTD_1的例項,它們用於完成ratedistance修飾符中__set__()函式的計算邏輯。

當呼叫str(m1)函式時,會呼叫RTD_1中全域性的__str__()函式,進而呼叫了速率、時間和距離修飾符的__format__()函式,並會返回帶有單位的數值。

由於非資料修飾符不包含__get__()函式,也沒有返回內部數值,因此只能直接訪問各個元素值來獲得資料。

資料修飾符使得設計變得更復雜了,因為它的介面是受限制的。它必須包含__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屬性。

在本章中,我們看了一些物件屬性的工作方式。我們可以使用object類中已經定義好的功能來獲取和設定屬性值,可通過定義特性來改變屬性的行為。

對於更復雜的情況,可以重寫__getattr__()__setattr__()__delattr__()__getattribute__()函式的實現。這樣一來,可以從根本上更細粒度地控制(也可能帶來疑惑)Python的行為。

Python在內部使用了修飾符實現一些函式、靜態方法和特性。對於一些場景,使用修飾符顯得更自然,也體現了一種程式語言的優勢。

其他語言(尤其Java和C++)的程式設計師通常一開始要把所有的屬性定義為私有的,並編寫可擴充套件的getter和setter函式。對於在編譯期處理型別定義的語言來說,這種編碼方式是可取的。

在Python中,建議把所有的屬性公有,這意味著如下幾點。

  • 此處應當有很好的文件說明。
  • 此處應能夠正確地反映出物件的狀態,它們不應當是暫時性的或者臨時變數。
  • 在少數情形下,屬性包含了容易產生歧義的值,可使用單下劃線(_)來標記為“不在介面定義範圍內”,以此表明它並不是真正意義上的私有。

私有屬性是令人厭煩的。封裝,並不會因為某種程式語言缺乏一種複雜的私有機制而被破壞,它只會被糟糕的設計所破壞。

在大多數情況,屬性附加在類的外部。之前的Hand類的例子中演示了這一點。使用這個類的其他版本時,只需簡單地把物件新增到hand.cards中,使用延遲計算方式實現的total特性,就可以有效地工作了。

有時對一個屬性值的改變會造成其他屬性值的改變,需要更復雜的類定義。

  • 選擇在函式內部維護狀態的改變,當函式定義包含多個引數值時可以考慮這種做法。
  • 一個setter特性或許比一個函式要表達的意圖更清晰。當只需訪問一個值時,這個做法是明智的。
  • 我們也可以使用原地運算子,在第7章“建立數值型別”中會介紹。

在使用方面沒有嚴格的規定。這樣一來,當需要為單一引數賦值時,在方法函式與屬性之間的區別在API語法上的差異以及在傳達意圖上是否有更好的方式。

對於已經計算的值,特性允許延遲計算,然而屬性卻需要主動計算。這需要在效能上進行考慮,至於使用哪一種還要看具體的應用場景。

Python中定義了一些修飾符,我們並不需要重新建立特性、類方法或靜態方法。

建立修飾符的典型例子是完成Python和非Python之間的對映。例如,對於物件關係資料庫對映,需要在Python類中定義大量的屬性,並確保它們的順序和SQL資料表中列的順序是一致的。再如,當需要完成Python之外的對映時,修飾符類可以用來完成編碼和解碼的工作,或從外部資源中獲取資料。

當建立一個網路服務客戶端時,可以考慮使用修飾符來完成網路請求。可考慮使用__get__()方法構造HTTP GET請求物件,也可使用__set__()方法來構造HTTP PUT請求物件。

在一些情形下,一個請求的構造可能需要由多個修飾符來完成。這樣的話,在構造一個新的HTTP請求物件之前,__get__()函式可以先從快取中獲取可用的例項。

許多資料修飾符的操作使用特性會更容易一些,可以這樣的思考順序來設計:先考慮特性。如果特性的邏輯非常複雜,可以考慮使用修飾符或對類進行重構。

在下一章中,會著重介紹抽象基類(Abstract Base Classes,ABC),在第5章、第6章和第7章也會深入探討。這些抽象基類會幫助我們定義一些可以和Python機制無縫整合的類,這也使得類層次結構的設計能夠在一致性設計和擴充套件性上發揮更大的作用。