Python Property 教學:保護變數資料的 Getter 與 Setter

by 好豪

筆者以前在學習 Java 的時候,學過用 封裝(Encapsulation)來保護變數資料,並透過 Getter 與 Setter 方法操作資料來讓程式碼更穩固,Getter 方法寫出回傳資料的內容與方式,Setter 方法則是寫下設定或更新資料的詳細邏輯。有些變數的資料在程式裡有特殊功能或者特別重要,因此需要 Getter 與 Setter 來保護它們不被隨意取用或更改。

在 Python 要實現這種(接近於)封裝,有一種特殊且方便的工具:Property(屬性),可以用簡潔的語法寫出 Getter 與 Setter 的功能。這則筆記將簡介 Python 的 Property 語法,並且用實例程式碼介紹使用 Getter 與 Setter 有什麼好處、以及使用時該注意的地方。相信你閱讀之後,將會再提升 Python 物件導向程式設計的技能!



Property 簡介

Python 的 Property(屬性)是一種 Decorator(裝飾器),也就是 Property 的語法要被寫在函式定義 def 的前面,Property 的裝飾器主要有 3 種語法:

  • Getter: @property
  • Setter: @變數名稱.setter
  • Deleter: @變數名稱.deleter

以下將用範例程式碼簡介這 3 種語法,首先,我們先複習一下最基本的類別(class)以及物件內的資料操作:

class Person:
    def __init__(self, name):
        self.name = name

這個類別的設計十分簡單,你在初始化資料後,可以隨意取用、更新、甚至刪除物件內部的變數資料:

>>> p = Person("Wilson Huang")
>>> p.name
'Wilson Huang'
>>> p.name = "Michael Jackson"
>>> p.name
'Michael Jackson'
>>> del p.name
>>> p.name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Person' object has no attribute 'name'

接下來,我們簡單加上一行 @property 的裝飾器,Property 立刻就讓資料變成只能取用,不能更新或者刪除:

class Person:
    def __init__(self, name):
        self._name = name

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

(補充:前單底線的變數(_name)是用來提示此變數「最好不要更動」的慣例,詳情請參考好豪的 Python 底線用法教學文章

>>> p = Person("Wilson Huang")
>>> p.name
'Wilson Huang'
>>> p.name = "Michael Jackson"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> del p.name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't delete attribute

上方範例的 @property 讓函數名稱(name)變成了 Getter 方法,上方範例中不能更新或刪除資料是因為我們沒寫清楚該怎麼做,接下來我們就用 @變數名稱.setter@變數名稱.deleter 把如何更新與刪除資料的邏輯寫出來:

class Person:
    def __init__(self, name):
        self._name = name

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

    @name.setter
    def name(self, new_name):
        self._name = new_name

    @name.deleter
    def name(self):
        del self._name
>>> p = Person("Wilson Huang")
>>> p.name
'Wilson Huang'
>>> p.name = "Michael Jackson"
>>> p.name
'Michael Jackson'
>>> del p.name
>>> p.name
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 6, in name
AttributeError: 'Person' object has no attribute '_name'

加上 Setter 與 Deleter 後,再次可以達成更新與刪除資料了。你是否發現,現在 Property 做到的事情跟最一開始只有 3 行程式碼的簡單類別範例一樣呢?差別在於,現在我們透過 Property 語法把類別該如何取用、更新、還有刪除資料明確寫出來,接下來的篇幅,筆者好豪將介紹這麼做的好處。

Property 的好處

保護變數不可被更動

當我們需要保護類別內的屬性資料、不希望被使用者隨意更動時,Property 可以為我們達成這項保護的目標。

同樣先從簡單的類別舉例,假設類別內資料要記錄一個人的出生年份與年齡,一個 1994 年出生的人,今年(2023 年)該被設定成 29 歲:

class Person:
    def __init__(self, birth_year):
        self.birth_year = birth_year
        self.age = age

然而,變數沒有被保護的情況下,1994 年出生的人,還是可以把年齡變數設定成只有 3 歲喔!邏輯不通、但是程式語法可行:

>>> p = Person(1994, 29)
>>> p.birth_year
1994
>>> p.age
29
>>> p.age = 3
>>> p.birth_year
1994
>>> p.age
3

為了不讓這種邏輯不通的事情發生,我們可以用 @property 來保護變數,讓特定資料只能被取用、不可被更改:

import datetime

class Person:
    def __init__(self, birth_year):
        self.birth_year = birth_year

    @property
    def age(self):
        now = datetime.datetime.now()
        return now.year - self.birth_year

@property 改寫後,年齡變數被 Property 保護、不可更動,但是出生年份資訊是可以更動的,而且可以發現更新出生年份資料後、年齡也會隨之被計算成正確的資料:

>>> p = Person(1994)
>>> p.birth_year
1994
>>> p.age
29
>>> p.age = 3
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute
>>> p.birth_year = 1983
>>> p.age
40

@property 之後,類別內的資料是不是變得更安全可靠了呢!

(如果你的類別內所有屬性資料都需要被保護不被更動,推薦你改成使用 dataclass 內的 frozen 功能,語法會更加簡潔!)

更高彈性的 Get 與 Set 邏輯

如果類別內的狀態會不斷更新,你的資料取用與更新就必需要與內部狀態聯動。例如我們設計一個簡單的類別記錄每個人的姓氏、名字、加上全名:

class Person:
    def __init__(self, first_name, last_name, full_name):
        self.first_name = first_name
        self.last_name = last_name
        self.full_name = full_name

在這個類別的資料是可以任意更動的,如果只改了名字、但是忘記手動把全名一起改掉的話,就會發生資料與內部狀態不一致的情形:

>>> p = Person("Wilson", "Huang", "Wilson Huang")
>>> p.first_name
'Wilson'
>>> p.last_name
'Huang'
>>> p.full_name
'Wilson Huang'
>>> p.first_name = "Kenny"
>>> p.first_name
'Kenny'
>>> p.last_name
'Huang'
>>> p.full_name
'Wilson Huang'

@property 的 Getter 寫法就能解決問題,讓 Getter 取決於類別內部的目前狀態來回傳資料:

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return self.first_name + " " + self.last_name
>>> p = Person("Wilson", "Huang")
>>> p.full_name
'Wilson Huang'
>>> p.first_name
'Wilson'
>>> p.last_name
'Huang'
>>> p.full_name
'Wilson Huang'
>>> p.first_name = "Kenny"
>>> p.first_name
'Kenny'
>>> p.last_name
'Huang'
>>> p.full_name
'Kenny Huang'

# 注意,目前沒有設定 Setter,所以 full_name 不可更動
>>> p.full_name = "Michael Jackson"
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

Property 也讓 Setter 設定資料的時候有更高的彈性。例如,可以在設定全名之後,同時讓類別內的姓氏跟名字資料都更新;並且做點文字處理、幫使用者刪除掉多餘的空白,避免打錯字:

class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        return self.first_name + " " + self.last_name

    @full_name.setter
    def full_name(self, new_name):
        self.first_name, self.last_name = \
            [x for x in new_name.split(" ") if x != '']
>>> p = Person("Wilson", "Huang")
>>> p.full_name
'Wilson Huang'
>>> p.first_name
'Wilson'
>>> p.last_name
'Huang'

# 多打了空白也沒關係!
>>> p.full_name = "  Michael           Jackson     "
>>> p.full_name
'Michael Jackson'
>>> p.first_name
'Michael'
>>> p.last_name
'Jackson'

資料驗證

筆者認為用 Getter 或 Setter 最重要的好處之一就是驗證資料,如果資料有任何不合理狀況,我們可以為使用者建立基本的防呆機制。

舉例而言,人的年齡超過 200 歲或是小於 0 歲都是不合理的,要是使用者手滑、不小心輸入詭異的年齡數字,可以透過 @變數名稱.setter 驗證資料、擋下不恰當的數字、避免建立無效資料:

class Person:
    def __init__(self, age):
        self._age = age        

    @property
    def age(self):
        return self._age

    @age.setter
    def age(self, new_age):
        if new_age < 0 or new_age > 200:
            raise ValueError("輸入為不合理的年齡數字!")
        self._age = new_age
>>> p = Person(18)
>>> p.age
18
>>> p.age = 30
>>> p.age
30
>>> p.age = -23
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 10, in age
ValueError: 輸入為不合理的年齡數字!
>>> p.age = 9527
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 10, in age
ValueError: 輸入為不合理的年齡數字!


使用警告

Property 的語法跟撰寫邏輯都很簡單,本文也介紹了不少它的好處,但是還是有些眉眉角角是使用 Property 需要注意的。

首先,雖然前面提到 Property 有保護變數的好處,但是 Python 實際上沒有 100% 做到封裝(Encapsulation)、或者說變數並不完全是私有(Private)的。換言之,只要使用者有心為之,還是可以繞道更動被 Property 保護的變數資料。例如:

class Person:
    def __init__(self, age):
        self._age = age        

    @property
    def age(self):
        return self._age
# 一般情況下,Property 裝飾過的變數是不能更動的
>>> p = Person(18)
>>> p.age
18
>>> p._age
18
>>> p.age = 30
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

# 但使用者其實可以「繞過」Property、直接更改類別內的屬性資料
>>> p._age = 30
>>> p.age
30
>>> p._age
30

關於這點,Python 社群有「請當遵守規定的成熟大人(”We are all consenting adults”)」這句共同默契,意思是,Python 當然還是有辦法做到完整保護變數不被更動,但是比起花心力處理這種問題,不如大家都說好遵守這些寫程式的慣例、會比較輕鬆且有效率。也就是,有前底線(_var)或 Property 裝飾過的變數,請大家守規矩、乖乖不要亂更動!


另外,筆者好豪也在工作中從前輩的經驗中學到,計算成本高、或者資料取得邏輯複雜時,並不適合用 Property 寫成 Getter,因為 Getter 原本的目的單純是取得資料、不該有意外狀況。從資料庫讀取資料就是典型的不適合寫成 Getter 的範例:

# Pseudo-Code

@property
def data(self):
    return database.query("SELECT * FROM my_database")

一來,從資料庫讀取資料可能計算量大、一次要花很久的時間;再者,讀取資料庫有可能發生錯誤,Getter 如果要處理這每一種錯誤,會變得太過繁雜。

總之,低計算量、而且不會有意外(Exception)的取用資料行為,才適合用 Property 寫成 Getter。

結語

這篇文章介紹 Property 可以保護變數不被更動、提供更高彈性的取用與更動資料邏輯、還有資料驗證等等好處。然而實際寫 Python 程式的時候,我們通常還是該從簡單的類別還有單純的屬性來儲存資料就好、避免程式碼從一開始就不必要地複雜,等到我們確定需要上述的幾種 Property 的好處時,再用 @property 來改寫就好。

參考資料:


你正在努力學習 Python 嗎?此部落格還有更多 Python 技巧筆記,歡迎你繼續閱讀。如果這篇文章有幫助到你,也歡迎追蹤好豪的 Facebook 粉絲專頁,我會持續分享 Python 相關知識;也歡迎點選下方按鈕將本文加入書籤、或者分享給更多正在學 Python 的朋友。

推薦閱讀