屬性與方法

April 24, 2022

在〈類別入門〉中,Account 類別擁有 namenumberbalance 三個屬性可供存取,雖然你設計了depositwithdraw 方法,希望使用者想變更 Account 物件的狀態時,都透過這些方法,然而,可能會有人如下誤用:

acct = Account('Justin', '123-4567', 1000)
acct.balance = 1000000

定義內部屬性

如果想避免使用者這類誤用,可以使用 self.__xxx 的方式定義內部值域。例如:

class Account:
    def __init__(self, name, number, balance):
        self.__name = name
        self.__number = number
        self.__balance = balance

    def deposit(self, amount):
        if amount <= 0:
            print('存款金額不得為負')
        else:
            self.__balance += amount

    def withdraw(self, amount):
        if amount > self.__balance:
            print('餘額不足')
        else:
            self.__balance -= amount

    def __str__(self):
        return f"Account('{self.__name}', '{self.__number}', {self.__balance})" 

Account 類別的方法定義中,可以使用 self.__nameself.__numberself.__balance 來存取屬性;然而,若使用者建立 Account 實例並指定給 acct,不能使用 acct.__nameacct.__numberacct.__balance 來進行屬性的存取(子類別中定義的方法也不能以 self.__nameself.__numberself.__balance 來存取屬性),這會引發 AttributeError,因為屬性若使用 __xxx 這樣的名稱,會自動轉換為「_類別名稱__xxx」。

Python 沒有完全阻止存取,只要在原本的屬性名稱前加上 _類別名稱,仍舊可以存取到名稱為 __ 開頭的屬性:

acct = Account('Justin', '123-4567', 1000)
print(acct._Account__name)
acct._Account__balance = 1

然而並不建議這麼做,__xxx 名稱的屬性,慣例上是作為類別定義時,內部相關流程操作之用,外界最好不要知道其存在,更別說是操作了,如果真想這麼做,最好是清楚地知道自己在做些什麼。

兩個底線 __ 開頭的屬性,基本上表示私有(private),只在定義的類別中使用;如果屬性想讓子類別存取,Python 的慣例會使用一個底線 _,代表它是一個受保護的(protected)屬性,這僅僅只是一個提示,python 直譯器不會做任何變換,只是提示使用者存取該屬性必須慎重。

定義外部屬性

之前使用了 __xxx 這樣的格式來定義了內部屬性,不過留下了一個問題,若只是想取得帳戶名稱、帳號、餘額等資訊,以便在相關使用者介面上顯示,那該怎麼辦呢?以 acct._Account__name 這樣的方式是不建議的,那還能用什麼方式?

基本上,可以直接定義一些方法來傳回 self.__nameself.__number 這些內部屬性的值,例如:

class Account:
    一些程式碼
    def name(self):
        return self.__name

    def number(self):
        return self.__number

    def balance(self):
        return self.__balance

這麼一來,使用者就可以使用 acct.name()acct.number() 這樣的方式來取得值,不過,針對這種情況,可以考慮在這類方法上加註 @property,例如:

class Account:
    def __init__(self, name, number, balance):
        self.__name = name
        self.__number = number
        self.__balance = balance

    @property
    def name(self):
        return self.__name

    @property
    def number(self):
        return self.__number

    @property
    def balance(self):
        return self.__balance

    其他程式碼同前一範例故略 


acct = Account('Justin', '123-4567', 1000)

acct.deposit(500)
acct.withdraw(200)

print('帳戶名稱:', acct.name)
print('帳戶號碼:', acct.number)
print('帳戶餘額:', acct.balance)     

現在使用者可以使用 acct.nameacct.numberacct.balance 這樣的形式取得值,然而,就目前的程式碼撰寫,無法直接使用 acct.balance = 10000 這樣的形式來設定屬性值,因為 @property 只允許 acct.balance 這樣的形式取值。

如果在程式設計的一開始,沒有使用 self.__balance 的方式,而是以 self.balance 定義內部屬性,而使用者也使用了 acct.balance 取得值,後來進一步考慮要避免被誤用,想修改為 self.__balance 定義內部屬性,這時就可以像上面的範例,定義一個方法並加註 @property,如此一來,使用者原本的程式碼也不會受到影響,這是固定存取原則(Uniform access principle)的實現。

如果這個範例,想進一步提供 acct.balance = 10000 這樣的形式,可以使用 @name.setter@number.setter@balance.setter 標註對應的方法。例如:

class Account:

    @name.setter
    def name(self, name):
        # 可實作一些設值時的條件控制
        self.__name = name

    @number.setter
    def number(self, number):
        # 可實作一些設值時的條件控制
        self.__number = number

    @balance.setter
    def balance(self, balance):
        # 可實作一些設值時的條件控制
        self.__balance = balance


@property 標註的 xxx 取值方法(Getter),可以使用 @xxx.setter 標註對應的設值方法(Setter),使用 @xxx.deleter 來標註對應的刪除值之方法。取值方法傳回值可以是即時運算的結果,而設值方法中,必要時可以使用流程語法等來實作一些存取控制。

綁定與未綁定方法

定義在類別中的方法,本質上也是函式,以目前定義的 Account 類別為例,執行 type(Account.deposit) 的話,會得到 <class 'function'>,如果建立了一個實例並指定給 acct,呼叫 acct.deposit(500) 時,會將 acct 參考的實例傳給 deposit 的第一個 self 參數。實際上,也可以如下取得相同效果:

acct = Account('Justin', '123-4567', 1000)
Account.deposit(acct, 500)

不過,若試著將 acct.depositacct.withdraw 指定給一個變數,會發現變數實際上參考著一個綁定方法:

>>> acct = Account('Justin', '123-4567', 1000)
>>> deposit = acct.deposit
>>> withdraw = acct.withdraw
>>> deposit
<bound method Account.deposit of <bank.Account object at 0x00000212ECE54970>>
>>> withdraw
<bound method Account.withdraw of <bank.Account object at 0x00000212ECE54970>>
>>> deposit(500)
>>> withdraw(200)
>>> print(acct)
Account(Justin, 123-4567, 1300)
>>>

綁定方法是 method 的實例(type(acct.deposit) 會得到 <class 'method'>),行為上可以像函式進行呼叫,試著呈現 acct.depositacct.withdraw 的字串描述時,會出現 'bound method' 這樣的字樣,也就是說,此綁定方法綁定了一個 Account 實例,也就是方法的第一個參數 self 參考至 Account 實例。

在 Python 中,實作了 __call__ 方法的物件,行為上可以像函式進行呼叫,稱為 callable 物件,其實到目前為止,你已經用過不少 callable 物件,例如,類別本身就是個 callable 物件。

使用 acct.deposit(500) 的方式來呼叫方法時,acct 參考的物件,實際上就會傳給 deposit 方法的第一個參數,相對地,如果在類別中定義了一個方法,沒有任何參數會怎樣呢?

>>> class Some:
...     def nothing():
...         print('nothing')
...
>>> s = Some()
>>> s.nothing()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: nothing() takes 0 positional arguments but 1 was given
>>>

如果透過類別的實例呼叫方法時,點運算子左邊的物件會傳給方法作為第一個引數,然而,這邊的 nothing 方法沒有定義任何參數,因此發生了 TypeError,並說明錯誤在於,試圖在呼叫時給予一個引數。

有沒有辦法取得綁定方法綁定的物件呢?雖然不鼓勵,不過確實可以透過綁定方法的 __self__ 屬性來取得。

相對於綁定方法,像這樣定義在類別中,沒有定義 self 參數的方法,稱為未綁定方法(Unbound method),這類方法,充其量只是將類別名稱作為一種名稱空間,可以透過類別名稱來呼叫它,或取得函式物件進行呼叫:

>>> Some.nothing()
nothing
>>> nothing = Some.nothing
>>> nothing()
nothing
>>>

靜態方法

現在假設,想在 Account 類別增加一個 default 函式,以便建立預設帳戶,只需要指定名稱與帳號,開戶時餘額預設為 100:

class Account:
        def default(name, number):
        return Account(name, number, 100)

當然,這個需求也可以在 __init__ 上使用預設引數來達成,這邊只是為了示範,在更真實的情境中,可能是建構實例前,有個複雜的流程,像是收集、確認開戶者的其他資格等,因而有了 default 這類函式存在的需求。

你原本的用意,是希望 default 函式是以 Account 類別作為名稱空間,因為它與建立帳戶有關,而使用者應該要以 Account.default('Monica', '765-4321') 這樣的方式來呼叫它,然而,若使用者如下誤用,正好也能夠執行:

acct = Account('Justin', '123-4567', 1000)

# 顯示Account(Account(Justin, 123-4567, 1000), 1000, 100)
print(acct.default(1000)) 

就這個例子來說,acct 參考的物件,傳給了 default 方法的第一個參數name,而執行過程正好也沒有引發錯誤,只不過顯示了怪異的結果。

若在定義類別時,希望某方法不被拿來作為綁定方法,可以使用 @staticmethod 加以標註。例如:

class Account:
        @staticmethod
    def default(name, number):
        return Account(name, number, 100)

這麼一來,可以使用 Account.default('Monica', '765-4321') 這樣的方式來呼叫它,就算使用者透過類別的實例來呼叫它,像是 acct.default('Monica', '765-4321')acct 也不會被傳入作為 default 的第一個參數。

雖然可以透過實例來呼叫 @staticmethod 標註的方法,但建議透過類別名稱來呼叫,明確地讓類別名稱作為靜態方法的名稱空間。

類別方法

來仔細看看上面的例子,default 方法中寫死了 Account 這個名稱,萬一要修改類別名稱的話,還要記得修改 default 中的類別名稱,我們可以讓 default 的實作更有彈性。

首先得知道的是,在 Python 中定義的類別,也會產生對應的物件,這個物件會是 type 的實例。例如:

>>> class Some:
...     pass
...
>>> Some
<class '__main__.Some'>
>>> type(Some)
<class 'type'>
>>> s = Some()
>>> s.__class__
<class '__main__.Some'>
>>> s.__class__()
<__main__.Some object at 0x00000212ED2C9430>
>>>

可以看到,也可以使用物件的 __class__ 屬性來得知,該物件是從哪個類別建構而來,也可以透過取得的 type 實例來建構物件。

因此,只要能在先前的 default 方法中,取得目前所在類別的 type 實例,就可以不用寫死類別名稱了,對於這個需求,可以在 default 方法上標註 @classmethod。例如:

class Account:
        @classmethod
    def default(cls, name: str, number: str):
        return cls(name, number, 100)

類別中的方法若標註了 @classmethod,第一個參數一定是接受所在類別的 type 實例,因此,在 default 方法中,就可以使用第一個參數來建構物件。

分享到 LinkedIn 分享到 Facebook 分享到 Twitter