在 Python 專案開發中,尤其大型專案或公開 API 設計時,屬性命名看似小事,卻可能暗藏風險。子類別可能意外覆寫父類別屬性,導致難以追蹤的錯誤。為避免此類別問題,私有屬性便能派上用場。Python 的私有屬性機制透過名稱修飾(name mangling),有效降低命名衝突風險。然而,私有屬性並非絕對安全,仍可透過名稱修飾後的完整名稱存取。因此,它更像是一種命名慣例,提醒開發者避免直接存取。私有屬性主要用於避免與子類別的命名衝突以及隱藏內部實作細節,提升程式碼可維護性。
許多從其他語言轉換到 Python 的開發者,常習慣在類別中使用 Get/Set 方法。然而,Python 社群並不鼓勵這種做法。直接使用 Public 屬性更簡潔易讀,也更 Pythonic。若需對屬性存取進行特殊處理,例如驗證、計算等,則可使用 @property 裝飾器。它能有效控制屬性存取邏輯,例如透過設定電壓改變電流、驗證數值有效性,甚至實作屬性不可變性。@property 的一大優點是可以讓屬性的方法在子類別之間分享,提升程式碼的重用性。但它也有一些限制,例如無法在無關的類別之間分享屬性邏輯,此時可考慮使用描述器。使用 @property 時,應避免在 getter 方法中修改其他屬性,也要避免執行耗時操作,以免造成意外副作用。
@property 的另一項進階用法是將單純數值屬性轉換為即時計算,這在逐步最佳化資料模型時特別有用。以漏桶配額為例,透過 @property,我們可以即時計算當前配額等級,並在不修改既有程式碼的情況下,逐步改進資料模型,同時確保新舊程式碼都能正常運作。@property 的使用,讓程式碼更具彈性,也更易於維護和擴充。
玄貓解密:Python私有屬性與命名衝突的攻防之道
在Python中,屬性命名看似小事,實則不然。當涉及到繼承,尤其是在大型專案或公開API設計中,屬性命名不當可能導致子類別意外覆寫父類別的屬性,引發難以追蹤的錯誤。玄貓將探討Python私有屬性的使用,以及如何利用它來避免命名衝突,提升程式碼的健壯性。
1. 命名衝突的風險:一個不小心就踩坑
在Python中,子類別可以覆寫父類別的屬性。這在某些情況下是期望的行為,但在另一些情況下則可能導致問題。考慮以下情境:
class ApiClass(object):
def __init__(self):
self._value = 5
def get(self):
return self._value
class Child(ApiClass):
def __init__(self):
super().__init__()
self._value = 'hello' # Conflicts
a = Child()
print(a.get(), 'and', a._value, 'should be different')
>>>
hello and hello should be different
在這個例子中,Child類別重新定義了_value屬性,導致get()方法傳回了錯誤的值。這種情況在大型專案中尤其危險,因為很難追蹤所有可能的命名衝突。
2. 玄貓的解法:使用私有屬性來築起防護牆
為了避免這種情況,可以使用私有屬性。在Python中,以雙底線開頭的屬性被視為私有屬性。Python會對私有屬性進行名稱修飾(name mangling),將其名稱更改為_類別名稱__屬性名稱,從而降低子類別意外覆寫父類別屬性的風險。
class ApiClass(object):
def __init__(self):
self.__value = 5
def get(self):
return self.__value
class Child(ApiClass):
def __init__(self):
super().__init__()
self._value = 'hello' # OK!
a = Child()
print(a.get(), 'and', a._value, 'are different')
>>>
5 and hello are different
在這個修改後的例子中,ApiClass使用了私有屬性__value。即使Child類別定義了_value屬性,也不會與父類別的私有屬性衝突。
3. 玄貓的提醒:私有屬性並非絕對安全
需要注意的是,Python的私有屬性並不是真正的私有。仍然可以透過名稱修飾後的名稱來存取私有屬性。例如,在上面的例子中,可以透過a._ApiClass__value來存取ApiClass的私有屬性。
class ApiClass(object):
def __init__(self):
self.__value = 5
def get(self):
return self.__value
class Child(ApiClass):
def __init__(self):
super().__init__()
self._value = 'hello' # OK!
a = Child()
print(a._ApiClass__value)
輸出結果:
5
因此,私有屬性更多的是一種命名慣例,用於提醒開發者不要直接存取這些屬性。
4. 玄貓的經驗:何時應該使用私有屬性?
玄貓認為,私有屬性主要用於以下情況:
- 避免與不受控制的子類別發生命名衝突:當你的類別可能被外部開發者繼承時,使用私有屬性可以降低命名衝突的風險。
- 隱藏內部實作細節:私有屬性可以隱藏類別的內部實作細節,使程式碼更易於維護和修改。
5. 玄貓的建議:擁抱開放,謹慎使用私有屬性
雖然私有屬性可以避免命名衝突,但過度使用私有屬性可能會使程式碼難以擴充和修改。玄貓建議,在設計類別時,應盡可能地開放內部API和屬性,讓子類別可以更靈活地進行擴充。只有在確實需要避免命名衝突或隱藏內部實作細節時,才應考慮使用私有屬性。
玄貓帶你深入collections.abc:開發客製化容器的正確姿勢
在Python中,容器是一種可以容納其他物件的物件。Python提供了許多內建的容器型別,例如列表、元組、集合和字典。但有時,我們需要建立自己的客製化容器型別,以滿足特定的需求。玄貓將帶你深入collections.abc模組,學習如何正確地開發客製化容器型別。
1. 從繼承內建型別開始:簡單但有限
對於簡單的使用情境,可以直接繼承Python的內建容器型別。例如,以下程式碼建立了一個名為FrequencyList的類別,它繼承自list,並新增了一個frequency()方法,用於計算列表中每個元素的頻率。
class FrequencyList(list):
def __init__(self, members):
super().__init__(members)
def frequency(self):
counts = {}
for item in self:
counts.setdefault(item, 0)
counts[item] += 1
return counts
foo = FrequencyList(['a', 'b', 'a', 'c', 'b', 'a', 'd'])
print('Length is', len(foo))
foo.pop()
print('After pop:', repr(foo))
print('Frequency:', foo.frequency())
輸出結果:
Length is 7 After pop: [‘a’, ‘b’, ‘a’, ‘c’, ‘b’, ‘a’] Frequency: {‘a’: 3, ‘b’: 2, ‘c’: 1}
這種方法簡單易用,但存在一些限制。例如,如果我們想要建立一個不繼承自`list`的容器型別,這種方法就不適用了。
### 2. 玄貓的挑戰:從零開始開發容器
假設我們想要為一個二元樹類別提供序列語意(sequence semantics),使其可以像列表或元組一樣被索引。
```python
class BinaryNode(object):
def __init__(self, value, left=None, right=None):
self.value = value
self.left = left
self.right = right
為了使BinaryNode類別具有序列語意,我們需要實作一些特殊方法,例如__getitem__(),它用於根據索引存取序列中的元素。
class IndexableNode(BinaryNode):
def _search(self, count, index):
# ...
# Returns (found, count)
pass
def __getitem__(self, index):
found, _ = self._search(0, index)
if not found:
raise IndexError('Index out of range')
return found.value
tree = IndexableNode(
10,
left=IndexableNode(
5,
left=IndexableNode(2),
right=IndexableNode(
6, right=IndexableNode(7))),
right=IndexableNode(
15, left=IndexableNode(11)))
print('LRR =', tree.left.right.right.value)
print('Index 0 =', tree[0])
print('Index 1 =', tree[1])
print('11 in the tree?', 11 in tree)
print('17 in the tree?', 17 in tree)
print('Tree is', list(tree))
輸出結果:
LRR = 7
Index 0 = 2
Index 1 = 5
11 in the tree? True
17 in the tree? False
Tree is [2, 5, 6, 7, 10, 11, 15]
但是,僅僅實作__getitem__()方法是不夠的。例如,如果我們嘗試使用len()函式來取得二元樹的長度,會發生TypeError。
len(tree)
輸出結果:
TypeError: object of type ‘IndexableNode’ has no len()
這是因為`len()`函式需要另一個特殊方法`__len__()`。
### 3. 玄貓的發現:collections.abc的威力
為了避免這些問題,可以使用`collections.abc`模組中定義的抽象基底類別(abstract base classes)。這些抽象基底類別為每種型別的容器定義了一組典型的介面。當我們繼承這些抽象基底類別時,如果忘記實作必要的方法,模組會發出警告。
```python
from collections.abc import Sequence
class BadType(Sequence):
pass
foo = BadType()
輸出結果:
TypeError: Can't instantiate abstract class BadType with abstract methods __getitem__, __len__
當我們實作了抽象基底類別要求的所有方法後,它會自動提供額外的方法,例如index()和count()。
class SequenceNode(IndexableNode):
def __len__(self):
#_, count = self._search(0, None)
nodes = []
def loop(node):
if node is None:
return
nodes.append(node)
loop(node.left)
loop(node.right)
loop(self)
return len(nodes)
from collections.abc import Sequence
class BetterNode(SequenceNode, Sequence):
pass
tree = BetterNode(
10,
left=BetterNode(
5,
left=BetterNode(2),
right=BetterNode(
6, right=BetterNode(7))),
right=BetterNode(
15, left=BetterNode(11)))
print('Index of 7 is', tree.index(7))
print('Count of 10 is', tree.count(10))
輸出結果:
Index of 7 is 3
Count of 10 is 1
Pythonic 屬性設計:擺脫 Get/Set 方法的迷思
許多從其他程式語言轉到 Python 的開發者,可能會習慣性地在類別中使用顯式的 getter 和 setter 方法。但這種做法在 Python 中並不常見,而與往往不是最佳實踐。
1. 傳統 Get/Set 方法的不足
以下是一個使用傳統 getter 和 setter 方法的 OldResistor 類別範例:
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
r0 = OldResistor(50e3)
print('Before: %5r' % r0.get_ohms())
r0.set_ohms(10e3)
print('After: %5r' % r0.get_ohms())
這種方式雖然簡單,但缺乏 Python 的風格。特別是在需要原地修改屬性的情況下,程式碼會變得相當冗長:
r0.set_ohms(r0.get_ohms() + 5e3)
2. Pythonic 的屬性設計:直接使用 Public 屬性
在 Python 中,更推薦的做法是從簡單的 public 屬性開始:
class Resistor(object):
def __init__(self, ohms):
self.ohms = ohms
self.voltage = 0
self.current = 0
r1 = Resistor(50e3)
r1.ohms = 10e3
r1.ohms += 5e3
這樣的程式碼更簡潔易讀,也更符合 Python 的哲學。
3. 何時使用 @property?
如果之後需要對屬性的存取進行特殊處理(例如驗證、計算),可以使用 @property 裝飾器。
3.1 使用 @property 實作電壓控制電阻
以下範例展示如何使用 @property,透過設定電壓來改變電流:
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
r2 = VoltageResistance(1e3)
print('Before: %5r amps' % r2.current)
r2.voltage = 10
print('After: %5r amps' % r2.current)
在這個例子中,當設定 voltage 屬性時,@voltage.setter 方法會被呼叫,進而更新 current 屬性。
3.2 使用 @property 進行數值驗證
@property 也能用於驗證指定給屬性的值,確保數值有效。玄貓在開發金融科技系統時,需要確保所有金額都是正數,這時 @property 就派上用場:
class BoundedResistance(Resistor):
def __init__(self, ohms):
super().__init__(ohms)
@property
def ohms(self):
return self._ohms
@ohms.setter
def ohms(self, ohms):
if ohms <= 0:
raise ValueError('%f ohms must be > 0' % ohms)
self._ohms = ohms
r3 = BoundedResistance(1e3)
try:
r3.ohms = 0
except ValueError as e:
print(e)
try:
BoundedResistance(-5)
except ValueError as e:
print(e)
3.3 使用 @property 實作屬性不可變性
@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
r4 = FixedResistance(1e3)
try:
r4.ohms = 2e3
except AttributeError as e:
print(e)
4. @property 的限制與替代方案
@property 的一個主要缺點是,屬性的方法只能在子類別之間分享。如果需要在無關的類別之間分享屬性邏輯,可以考慮使用描述器(Descriptors),詳見 Item 31。
5. 避免 @property 的副作用
在使用 @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)
這種做法會導致難以預測的行為。玄貓認為,最好的做法是在 @property.setter 方法中僅修改相關的物件狀態。
此外,避免在 @property 方法中執行耗時的操作,例如動態匯入模組、執行複雜的輔助函式或進行昂貴的資料函式庫查詢。使用者期望屬性的存取是快速與簡單的。對於複雜或耗時的操作,應該使用普通方法。
玄貓的建議
- 從簡單的 public 屬性開始設計類別介面,避免使用 set 和 get 方法。
- 在需要時,使用
@property來定義屬性的特殊行為。 - 遵循最小驚奇原則,避免在
@property方法中產生意外的副作用。 - 確保
@property方法快速執行;對於耗時或複雜的工作,使用普通方法。
### 避免重構:善用 @property 的時機
`@property` 裝飾器是 Python 內建的功能,它讓存取物件屬性時,能以更聰明的方式運作(參考 Item 29:「使用 Plain Attributes 取代 Get 和 Set 方法」)。`@property` 的一個常見進階用法是將原本單純的數值屬性,轉換為即時計算。這非常實用,因為它讓你可以在不重寫任何呼叫點的情況下,將類別的所有現有用法遷移到新的行為。它也為隨著時間的推移改進介面提供了一個重要的權宜之計。
舉例來說,假設你想用簡單的 Python 物件來實作一個漏桶配額。在這裡,`Bucket` 類別代表剩餘的配額以及配額可用的持續時間:
```python
from datetime import datetime, timedelta
class Bucket:
def __init__(self, period):
self.period_delta = timedelta(seconds=period)
self.reset_time = datetime.now()
self.quota = 0
def __repr__(self):
return f'Bucket(quota={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)
最終,因為試圖扣除比可用配額更多的配額,所以無法取得進展。在這種情況下,儲存桶的配額等級保持不變。
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)
這個實作的問題在於,永遠不知道儲存桶最初的配額等級是多少。配額在整個週期內被扣除,直到達到零。在這種情況下,deduct 將始終傳回 False。當這種情況發生時,如果能夠知道對 deduct 的呼叫者是因為 Bucket 用完了配額,還是因為 Bucket 從一開始就沒有配額而被阻止,這將會很有用。
為了修正這個問題,可以更改類別來追蹤週期中發出的 max_quota 和週期中消耗的 quota_consumed。
from datetime import datetime, timedelta
class Bucket:
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 (f'Bucket(max_quota={self.max_quota}, '
f'quota_consumed={self.quota_consumed})')
使用 @property 方法來即時計算當前配額等級,使用這些新屬性。
@property
def quota(self):
return self.max_quota - self.quota_consumed
當分配 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 的新用法可以做正確的事情,並直接存取 max_quota 和 quota_consumed。
玄貓(BlackCat)特別喜歡 @property,因為它讓你能夠隨著時間的推移,逐步改進到更好的資料模型。閱讀上面的 Bucket 範例,你可能會想到,「fill 和 deduct 應該在 Bucket 類別中實作為例項方法」。