在 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 屬性時,採取特殊操作,比對 filldeduct 使用的類別的當前介面。

    @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_quotaquota_consumed

玄貓(BlackCat)特別喜歡 @property,因為它讓你能夠隨著時間的推移,逐步改進到更好的資料模型。閱讀上面的 Bucket 範例,你可能會想到,「filldeduct 應該在 Bucket 類別中實作為例項方法」。