物件導向 (OOP) 是一種程式設計的規範 (paradigm),遵循之後有助於寫出更容易管理、容易維護,且容易閱讀的程式碼。而 Python 本身借鏡了許多 OOP 的概念,雖然沒有像 Java 那樣嚴格,但諸如 flaskPygame 等等熱門框架也以 OOP 為主旋律,多去了解絕對不吃虧。

一切都由物件組成

OOP 的核心概念其實很簡單,那就是將我們日常生活中的人事物「建模」成一個一個又一個的物件,而每個物件當中會包含:

  • 屬性 (attribute):類似於字典 (dictionary) 的 key-value 資料
  • 方法 (method):函式,沒錯,就是函式

舉個例子,假設我們用一個物件來代表「人」這個概念,那該物件裡面可能會有以下屬性:

  • 姓名
  • 年齡
  • 住址
  • 興趣
  • 職業

除此之外,該物件也會包含人的行為,這些行為便是所謂的方法,所以物件將包含以下函式:

  • 走路
  • 跑步
  • 吃飯
  • 嗚啦

先有藍圖,才有物件

想像你是全知全能的天神,某日心血來潮想為世界增添趣味,於是決定創造名為哥布林的生物,讓他們自生自滅繁衍生息。你給自己泡了杯咖啡,創造了一隻、兩隻、三隻哥布林,漸漸發現如此單調重複的工作 超 . 級 . 無 . 聊,於是靈機一動,先打造了一套哥布林模型,注入神力讓模型依照既定規則自動生成哥布林……

這基本上就是我們建立物件的方式。Python 透過 class 關鍵字讓我們先產出物件的藍圖,後續依循藍圖去建立單一物件。所以物件的屬性、方法都要在 class 當中先定義好。

class Goblin:
  health = 5
  damage = 3

Xavier = Goblin()
print(Xavier.health) # 5

 

可以注意到若要從物件中提取屬性值,語法為 <object_name>.<attribute>

相較於 class 是物件的藍圖,我們依據藍圖所生成的物件則是 instance,實體。真實的資料會包裹在實體中。


為物件增添函式 - method

前面提過綁定在 class 當中的函式被稱之為方法。方法和一般函式最大的差異,在於它能夠直接存取該物件的屬性。

class Goblin:
  health = 5

  def take_damage(self, damage):
    self.health -= damage

Xavier = Goblin()
# Xaiver 會被帶入成 `take_damage` 方法的第一個參數 (self)
Xavier.take_damage(2)
print(Xavier.health) # 3

方法的第一個參數永遠都是呼叫 class 的物件實體本身。其實這個參數要叫什麼名字都可以,Python 是依照參數位置區別,但慣例上使用 self 這個名稱,淺顯易懂。

換個說法,self 是指向到物件實體的參數,我們需要使用它來讀取或更新 class 裡面所定義的屬性值。因此物件中的方法比起一般函式,比較少去回傳資料,而是直接修改資料。但方法一樣可以回傳資料的喔。

看到這裡我們發現了一個大問題,目前 class 的屬性都是固定值,比方說 health = 5,造成每個建立出來的物件實體屬性值都相同,但每隻哥布林的健康狀況理當不一樣才對。有沒有辦法把屬性值當作參數,在建立物件實體的時候丟進去生成呢?


__init__ 建構方法

既然方法都能透過 self 參數來存取屬性了,我們不妨也在 class 建立一個方法來為屬性賦值。

Python 其實提供了 __init__() 這個特殊函式,負責為物件實體屬性賦值。當我們建立新的物件實體,Python 便會自動去呼叫 __init__(),省去了我們手動為每個物件賦值的苦勞,可謂懶人救星。

class Goblin:
  def __init__(self, name, health, damage):
    self.name = name + "Goby" # Computed prop!
    self.health = health
    self.damage = damage

# Xaiver will be passed into the '__init__'
# method as the first argument (self)
Xavier = Goblin("Xavier", 5, 10)
print(Xavier.name) # XaiverGoby
print(Xaviier.health) # 5
print(Xavier.damage) # 10

 

我們一樣在 __init__() 帶入 self 參數,代表物件實體本身。接著陸續帶入其他屬性參數,完成賦值即可。當然如果有些屬性是透過其他屬性計算出來的,也直接在 __init__() 中定義。

但這又引發了另一個問題,如果某些屬性就剛好是每個物件實體都相同,該怎麼辦?只能在建立物件時重複帶入參數嗎?


實體變數 vs. class 變數

前面我們都是透過 self 來宣告變數,由於 self 代表物件實體本身,因此這些綁定 self 的變數,自然而然會跟著物件實體。

倘若我們是在 class 的範圍內宣告變數,而非 __int__() 建構方法的話,該變數在不同的物件實體仍會保持相同值。不過還是能以 <class_name.<attribute> 來變更變數值。

class Bear:
    mood = "happy"  # This is a class variable

    def change_mood(self, new_mood):
        self.mood = new_mood  # This creates an instance variable

# Create two bear instances
bear1 = Bear()
bear2 = Bear()

# Access the class variable
print(Bear.mood)  # "happy"
print(bear1.mood)  # "happy"

# Modify using the instance
bear1.change_mood("grumpy")

# Now:
print(bear1.mood)  # "grumpy" (instance variable)
print(Bear.mood)  # "happy" (class variable still unchanged)
print(bear2.mood)  # "happy" (still uses the class variable)

封裝與抽象化

OOP 這種將屬性和行為包裹在一個物件當中的設計模式,背後蘊含了兩大原則:

  • 封裝 (encapsulation):將相關的公開 (public) 和隱私 (private) 資料包成一個群組
  • 抽象化 (abstraction):將複雜的邏輯藏起來,提供簡易使用的介面 (interface)。我們只需要知道車子怎麼開,不用理解引擎如何運作。

兩者乍看之下非常相似,而事實上封裝和抽象化的確只是著重面向不同而已。

因為有封裝,我們才能夠實現抽象化!

這邊也值得注意,封裝所提到的公開和隱私資料,和我們一般認知中 個資、密碼、機敏資料 的資安概念不太一樣。封裝的意義在於更有效地管理程式碼,而非讓系統儲存的資料更加安全。在這層含義上,我們可以將封裝想像成未上鎖的檔案櫃,裡面的資料夾排列得井井有條,但知道檔案櫃所在位置的人都能自由查找資料。

唉,不對啊,如果是這樣,那還分什麼公開和隱私資料?

再想像一下,假設今天有甲、乙兩個開發團隊協作。甲團隊設計了一組 API,方便自己和乙團隊開發對應功能。如果乙團隊當中有人無心或刻意要攪亂一池春水,隨便亂調整背後邏輯的變數或函式,那大家就準備一起包包收拾滾回家去了。

因此在一般開發協作,甚或函式庫開源的情境下,我們需要將資料分成公開、隱私變數,以免天下秩序大亂。

接著底下會紀錄 Python 如何實現公開、隱私變數。爆個雷,不外乎兩個字:信任


公開與隱私,一場信任所建立的遊戲

預設上所有在 class 宣告的屬性和方法都是公開的。也就是說任何人用 . 運算子即可提取資料。

你可能會想說,只要在屬性或方法前面加上 privatenon-public 之類的關鍵字,資料就會神奇地貼上隱私標籤對吧?

我以前也是這麼以為的,但可惜事情沒有想像中那麼簡單。

由於 Python 是動態語言 (dynamic language),且由直譯器 (interpreter) 逐行執行程式碼,因此不會在執行前強制檢查變數的存取權限,這使得 Python 無法像 Golang、Rust 等靜態語言那樣,透過編譯 (compilation) 時的機制來嚴格限制屬性的可視性。

因此 **Python 很吃重開發者之間約定俗成的慣例,也就是命名方式**來實現資料公開與隱私。

 

用下底線 _ 來搭建信任的橋樑吧

我們用以下範例來看看 Python 所謂的「慣例」長什麼模樣:

class User:
    def __init__(self, name):
        self._name = name  # Convention: "this should be private"
        self.__password = "secret"  # Name mangling, not true private

user = User("Xavier")
print(user._name)  # Allowed, but conventionally discouraged
print(user.__password)  # AttributeError
print(user._User__password)  # Bypasses name mangling!

單一下底線 _

  • 純慣例,代表該屬性或方法僅供 class 內使用。
  • 注意這只是慣例,所以就算在 class 外部仍舊能夠提取資料。

雙底線 __

  • 除了慣例之外,雙底線還會觸發 name mangling,也就是 Python 會自動改變該屬性、方法的名稱。
  • 由於名稱被更改了,所以能預防該屬性、方法被刻意繼承到其他子 class 當中。
  • 名稱更改的模式並非秘密,就是多加上 class 名稱罷了,所以能施以小計提取資料

額外加映~前後雙底線 __keyword__

  • 這些是 Python 內建的特殊方法,像是常用的 __repr____init__等等。

雙底線是滿常見的隱私命名方式,如果希望其他子 class 繼承,可以改用單底線,或是保留雙底線然後建立 get_something() 之類的 getter 方法。總歸一句話,一切都源自於信任兩個字,所以和團隊或合作對象是先溝通清楚才為上策。