Python 全攻略第六節 Python 物件導向程式設計

Python 物件導向概念簡介

前言

這篇筆記的內容及大綱,主要是按照 Wilson 老師分享的簡報檔中針對 OOP 第六節內容的安排順序及範圍所撰寫的。

其實說實話,儘管 Python 本身也是 OOP 家族中的一員,但因為 Python 動態型別的特性且沒有存取限制以及追求易讀性的目標,因此並不很刻意在 OOP 上著墨。

這也是為何坊間上關於以 Python OOP 或 Design Pattern 為主題的書並不太多的原因。因為對比如 C# 或 Java 等語言,OOP 能做到藉由一些方法來限制或規範程式碼的走向,但因為 Python 動態型別及自由存取的特性,一切都破功了。

比方說,你可以使用屬性的技巧,來限制一些類別內部變數的讀取方式,然而 Python 是動態型別,也沒有存取限制,即便能利用 OOP 的屬性概念,定義了某個變數的讀取方式,然而要繞過這樣方式來讀取這個變數,對於 Python 的 Programmer 實在是太輕而易舉。

這也是 Wilson 老師在這節課程中說的,即便你定義了某個類別的屬性,為私有屬性,不應該被類別外的物件讀取到,然而對 Python 而言,實際上並不會限制你直接讀取這些屬性,因此這些以下底線為前導標示的所謂私有變數,仍然可以被用一些方式讀取出來。

因此學過 OOP 的人,更應該有意識的去遵守一些規則或約定俗成的觀念,避免因為這樣的暴力存取,造成程式未能依照開發者所設計的方向去運行,進而造成例外的發生,甚至導致程式執行的中斷。

在老師前面的教學影片中,大家應該都已經熟知老師的做法,除了依照簡報檔的課綱講述,然而所有的範例其實都是老師在 vscode 上隨意的編寫及示範,因此這篇筆記的範例部分,是我自己另外依照每個小節上的概念,藉由 Copilot 的輔助,所編寫出來的,主要是藉由範例讓大家了解這些 OOP 相關的概念,是如何在 Python 程式碼被實踐出來,其實 OOP 的整體觀念及細節仍有很多(甚至都出成書了)。

第六章充其量只是一個緒論,就是讓同學對 OOP 先有一個概觀的觀念,如果對 OOP 有興趣的,可以參考一下的一些推薦書目及課程:

  1. Python Object-Oriented Programming : Build robust and maintainable object-oriented Python applications and libraries, 4/e:
    這好像是目前 Packet Publishing 最新版的 Python OOP 的書,對了,這本書有很多範例,想看 Python OOP 範例的人,可以到它的 github 來找。

  2. Python 3: Deep Dive (Part 4 - OOP) @ Udemy:
    這是 Dr. Fred Baptiste 所開設的一系列進階學習 Python 的課程,其中的 Part4 就是在講 OOP。Python 3: Deep Dive 系列也是論壇版主 Sky 兄大力推薦共學的課程之一。

什麼是物件導向程式設計

物件導向程式設計(OOP)是一種透過將相關屬性和行為捆綁到單一物件中來建構程式的方法。就像許多其他程式語言一樣,Python 也具有 OOP 功能。 Python中有很多內建類,我們也可以創建自己的類型。

而要使用物件導向程式設計就必須對類別(Class)及物件(Object)等有一些基本的了解,包含了:

  • 類別(Class)
  • 物件(Object)
  • 屬性(Attribute)
  • 建構式(Constructor): __init__()
  • 方法(Method)

在進入筆記之前,提供一個我之前分享過的網路前輩所製作的 Python OOP 心智圖,提供大家參考,如下所示:

(我之前的分享文在這裡,原始出處則在這裡,其中有一個心智圖就是關於 Python 的物件導向程式設計)

類別和物件

類別是一個程式碼模板,用於創建物件。物件是類別的實例。當我們創建一個類別時,我們實際上創建了一個新的資料類型。這個新類型可以用來創建該類別的物件。這些物件將具有類別定義的屬性和行為。

  • 類別:

    • 類別是用於創建物件的程式碼模板。有時 classes 也被視為 type,兩者是同義字。基本上,我們可以將「類別」視為我們可以自己創建的新資料類型,就像內建的 Python 數字、字串、集合、列表、字典和許多其他資料類型一樣。

    • 透過 classes 類別範本將物件實例化創建出來 → 物件即類別的實例

    • 既然物件是透過類別創建出來的,那物件的靜態屬性 – 狀態 及 動態屬性 – 功能及行為,也是類別的基本構成物

      • object <—> classes
      • state <—> property
      • behavior <—> function、method
    • 那物件是透過將類別實例化創建出來,那類別又是如何產生出來?

      • In python → 類別是透過 type metaclass 產生出來的
    • 簡單來說,類別就是物件(Object)的藍圖(blueprint)。就像要生產一部汽車時,都會有設計圖,藉此可以知道此類汽車會有哪些特性及功能,類別(Class)就類似設計圖,會定義未來產生物件(Object)時所擁有的屬性(Attribute)及方法(Method)。

  • 類別的創建:

    • In python → 使用 class 關鍵字(來定義) → 藉由此 class 關鍵字,python 編譯器會將其識別為 class。
    • 透過 class callable → MyClass() 來創建該類別的實例物件(Instance Objects)
      • 當實例物件被創建出來,會自動提供該物件,具備來源類別所具有的屬性(靜態-狀態)及方法(動態-行為)
    • 透過 class.__name__ 取得該類別的具名
    • 透過 object 的 type → 取得該實例物件的來源類別 – type(obj) → source class
    • 透過 isinstance(MyClass, type) → 驗證該物件是否隸屬於某個來源類別的類別實例化的物件
  • 物件:

    • 物件是什麼?
      • 物件是容器:
        • 承載資料 → state 狀態、attributes 屬性
        • 承載物件函式 → behavior 行為、methods 方法
      • 例如: my_car 一輛車的物件會包含哪些?
        • 狀態(靜態屬性): 如品牌,比如 Ferrari、型號,如 599xx model、出廠年份,如 2010 等
        • 行為(動態屬性):如 accelerate 加速、brake 煞車、steer 轉向等行為
my_car.brand = "Ferrari"
my_car.purchase_price = 1_600_000
my_car.accelerate(10)
my_car.steer(-15)
  • 創建物件:
    • 該如何創建物件這樣的容器?
      • 如何定義及設定狀態(靜態屬性);又如何來定義及設定其功能或行為(動態屬性)

Note: 許多的程式語言,如 C++、Java 或 Python 等都是基於物件類別的程式語言

  • 再談物件:
    • 物件是透過 classes 實例化產生出來,這代表:
      • classes 本身和物件一樣,也具備行為特質,這意味 class 是 callable ~
      • 經由 callable 被呼叫 → MyClass(),會返回該 class 的 instance → object 物件
        • 而物件的 type 就是指向該物件被實例化的來源類別
          • my_obj = MyClass() → type(my_obj) —> MyClass
# 汽車類別
class Cars:
    # 建構式
    def __init__(self, color, seat):
        self.color = color  # 顏色屬性
        self.seat = seat  # 座位屬性
    # 方法(Method)
    def drive(self):
        print(f"My car is {self.color} and {self.seat} seats.")

要創建一個類別,首先會有class關鍵字,接著自定類別名稱,最後加上冒號。類別名稱的命名原則習慣上使用Pascal命名法,也就是每個單字字首大寫,不得使用空白或底線分隔單字,如下範例:

#範例一
class Cars:
    pass

#範例二
class MyCars:
    pass
  • 物件:
    就是透過類別(Class)實際建立的實體(Instance),就像實際生產出來的汽車(例如下面範例裡的 car1 與 car2)。

類別(Class)與物件(Object)的關係就像汽車設計圖與汽車實體。而建立物件(Object)的語法如下:

'''
建立物件的語法,
等號左邊是類別建立實體的名稱,
等號右邊則是類別的名稱加上括號,
括號內則是建構式的必要參數
'''

instance_name = Class_name(*args, **kwargs);

例如:

# 建立物件
car1 = Cars('red', 4)
car2 = Cars('blue', 2)

上面範例中的 car1 與 car2 即是透過 Cars 類別 (Class) 建構式所創建的類別物件 (Object),或成為類別實體或實例(Class Instance)。

此外 Python 也提供了一個函式isinstance() 來判斷類別 (Class) 與物件 (Object) 的關係,語法如下:

# 判斷物件是否為某類別的實例
print(isinstance(car1, Cars)) # output: True
print(isinstance(car2, Cars)) # output: True

類別的屬性和方法

類別可以包含屬性和方法。分別可看作類別的靜態(屬性)與動態特徵(方法)。

類別屬性

類別屬性主要可被拆分為兩種:

  • Class variables : 該類別數的屬性變數主要被宣告在 class 中,但是在__init__ function 外定義。

  • Instance variables : 每個物件的該屬性變數不同,在宣告該物件的時候給予它相對應的值。主要在__init__ 建構式裡定義。

假若該變數對於該類別的所有物件皆相同,那此種變數,我們稱作為 class variable 或者是 static variable,並且該變數被所有的物件共享。

當我們需要讀取 class 中的屬性時,應該如何做呢?

  • 其實 python 有一個內部函式 → getattr function,可以用來讀取這些屬性,例如: getattr(MyClass, ‘language’) → ‘python’

    • 如果 gettattr 要取的屬性名稱不存在 → AttributeError exception

    • 或是你也可以指定一個 default 值,當屬性不存在時,就返回給定的預設值,而不拋出例外 → getattr(MyClass, ‘x’, ‘N/A’) —> ‘N/A’

    • 替代性的,你也可以使用 class.xxx 來讀取 xxx 的屬性值,但要注意的使用點符號來讀取 class 屬性時,無法指定預設值,因此當屬性名稱不存在時,會直接拋出 AttributeError 例外。

那當我們需要從物件中設定屬性時,又該如何來做呢?

  • 透過 setattr function 來設定 class 屬性

    • 語法: setattr(object_symbol, attribute_name, attribute_value)

      • 例如: setattr(MyClass, ‘version’, ‘3.7’)
    • 也可以使用 class.xxx = ‘aaa’ 這樣的語法,使用點符號連結來設定屬性

    • 而不論透過上述哪一種方式,都能夠修改 class 內部的屬性 → 屬性是可變的(mutable)

    • 當屬性發生改變時…

      • 當屬性被透過 setattr 改變時 → class.__dict__ 也會跟著改變

那如果當我們設定的屬性不存在時,會如何呢?

  • 由於 python 是一個動態語言,通常即便是使用(at runtime)中的 class 也一樣能被改變,因此當我們設定一個在目標 class 不存在的屬性時,就會讓這個目標 class 因此產生一個新屬性(被設定的不存在屬性)

那 class 的 state 狀態是存放在哪裡呢?

  • 基本上是以字典的形式被儲存下來 → __dict__

    • 可使用 class.__dict__ 來查詢,會得到這個 class 所有的屬性名稱及其當下的屬性值

屬性可以被讀取,可以被更新,更可以被新增,那麼屬性可以被刪除嗎?

  • 答案是可以的,可以使用 delattr 來刪除

    • 語法: delattr(obj_symbol, attribute_name)

      • 例如: delattr(MyClass, ‘version’)
    • 或是直接使用 del 這個關鍵詞

      • 例如: del MyClass.version

我們知道透過 class.__dict__ 可以(代表)接取這個 class 的整個命名空間

  • class.__dict__ 會返回一個 mappingproxy object,mappingproxy 也是一個容器,上頭承載的是這個 class 的所有屬性及方法

    • 由於 __dict__ 裡面就包含了這個 class 的所有屬性

      • 因此我們也可以透過 class.__dict__[‘xxx’] 這樣語法來取得 class 屬性 xxx 的屬性值

        • __dict__ 其實返回的不是一個 dict,但其仍具備 key 的特性,且 key 都是 hash map

        • __dict__ 並不一定能把 class 的屬性都包含進來,在一些情況下,有可能會有屬性無法從這裡來讀取

        • 例如 __xxx__ 等 python 自動產生的屬性就無法在 __dict__ 下顯示,比方說: __name__

class Student:
    
    # Class Variables 類別屬性
    course = 'Python Programming'
    
    def __init__(self, age, sex, name):
        # Instance Variables 物件屬性
        self.age = age
        self.sex = sex
        self.name = name
      
stu_1 = Student(26, 'man', 'Benny')
stu_2 = Student(25, 'girl', 'Lily')

print(stu_1.course) # output(stu_1): Python Programming
print(stu_2.course) # output(stu_2): Python Programming

類別屬性屬於類,因此我們可以直接從類別本身取得屬性。類別屬性可以透過以下任一方式存取:

  • self.__class__.attribute(方法定義內)

  • objectname.attribute(方法定義之外)

  • classname.attribute(盡量不要在方法定義中使用它)

在方法內部,當引用類別本身時,盡量使用 self.__class__ 。避免對類別名稱進行硬編碼。

類別的建構式 __init__()

前面我們提到,在 Python 中,我們可以透過 Class 來創建物件,這主要是講類別中的可呼叫屬性。

Classes are Callable: 類別是可被呼叫的!

  • 當使用 class 關鍵字進行宣告,Python 的編譯器在看到 class 關鍵字時,會自動幫這個 class 增加行為(動態屬性)能力:
    • 使其為 callable,而 callable 返回的會是這個 class 的 instance
    • 設定這個 instance 的 type 就是這個來源 class。

讓我們來解構 → 當我們對一個類別進行一 個實例化作業,來將物件初始化時,究竟發生哪些事?

class MyClass:
    def __init__(self, version):
        self.version = version

obj = MyClass('3.7')
  • 首先 Python 會創建一個新的實例物件(instance object),並建立這個物件的命名空間

  • 其次呼叫這個物件的 __init__(self, version) 方法 → 由於當前的命名空間仍是空的,而在找不到物件的 __init__ 方法時,Python 會從這個物件的所屬類別中去找尋類別中的有沒有這個 __init__ 函式,當找到後,就會將物件的 __init__方法繫結綁定到類別的 __init__ 函式(基於將物件作為第一個參數傳入而綁定到物件之上) → MyClass.__init__(obj, ‘3.7’)

  • 而在執行物件的 __init__ 方法後,物件的屬性 version 其值會被修改,因此這個 version 屬性及其值 ‘3.7’ 都會被記錄到物件的命名空間上

  • 其中 MyClass.__init__(obj, ‘3.7’) 中的 obj,一般我們會以 self 來取代

在物件導向的方法中,__init__() 方法是 C++、Java 和 JavaScript 建構函式的 Python 等效項。每次從類別建立物件時都會呼叫 __init__() 函數。 __init__() 方法讓類別初始化物件的屬性,沒有其他用途。它僅在類別中使用。

我們可利用視覺化 Python 編譯器 – Python Tutor: Visualize code in Python 來觀察 __init__() 方法的呼叫過程,如下圖所示:

Class Create Instance Process

class Student:
    def __init__(self, age, sex, name):
        self.age = age
        self.sex = sex
        self.name = name
        
    def fullname(self):
        print(f'{self.name}')
        
stu_1 = Student(26, 'male', 'Chris')
stu_1.fullname() # output: Chris

print(stu_1.__dict__) # output: {'age': 26, 'sex': 'male', 'name': 'Chris'}

類別的方法

方法是類別物件的行為。

方法(Method)又可分為:

  • 實體方法(Instance Method)
  • 類別方法(Class Method)
  • 靜態方法(Static Method)

其中實體方法是最常見的方法,它是一個可以訪問實例屬性的方法。實體方法的第一個參數通常是 self,它代表了實例本身。

定義方法 (Method) 和函式 (Function) 的語法很像,都是 def 關鍵字開頭,接著自訂名稱,但是方法 (Method) 和建構式 (Constructor) 一樣至少要有一個 self 參數,語法如下:

# 定義一個 person 類別
class person:
    # 類別變數,記錄所有的 person 實例
    people = []

    # 初始化方法,設定 name 和 age 屬性
    def __init__(self, name, age):
        self.name = name
        self.age = age
        # 將新建的 person 實例加入到類別變數 people 中
        person.people.append(self)

    # instance method,返回實例的 name 和 age
    def introduce(self):
        return f"我叫 {self.name},我 {self.age} 歲。"

    # class method,返回類別變數 people 的長度,即 person 的數量
    @classmethod
    def count(cls):
        return f"目前有 {len(cls.people)} 個人。"

    # static method,返回一個歡迎詞
    @staticmethod
    def welcome():
        return "歡迎使用 Copilot!"

# 定義一個 student 類別,繼承自 person 類別
class student(person):
    # 初始化方法,設定 name、age 和 grade 屬性
    def __init__(self, name, age, grade):
        # 調用父類別的初始化方法
        super().__init__(name, age)
        self.grade = grade

    # instance method,返回實例的 grade
    def get_grade(self):
        return f"我是 {self.grade} 年級的學生。"

    # class method,返回 student 的數量,即 person 類別變數 people 中 student 的數量
    @classmethod
    def count(cls):
        # 使用 list comprehension 篩選出 student 實例
        students = [p for p in cls.people if isinstance(p, student)]
        return f"目前有 {len(students)} 個學生。"

# 定義一個 teacher 類別,繼承自 person 類別
class teacher(person):
    # 初始化方法,設定 name、age 和 subject 屬性
    def __init__(self, name, age, subject):
        # 調用父類別的初始化方法
        super().__init__(name, age)
        self.subject = subject

    # instance method,返回實例的 subject
    def get_subject(self):
        return f"我教 {self.subject}。"

    # class method,返回 teacher 的數量,即 person 類別變數 people 中 teacher 的數量
    @classmethod
    def count(cls):
        # 使用 list comprehension 篩選出 teacher 實例
        teachers = [p for p in cls.people if isinstance(p, teacher)]
        return f"目前有 {len(teachers)} 個老師。"

# 建立一些 person、student 和 teacher 的實例
p1 = person("小明", 18)
p2 = person("小華", 20)
s1 = student("小美", 15, 9)
s2 = student("小強", 16, 10)
t1 = teacher("李老師", 30, "數學")
t2 = teacher("王老師", 35, "英文")

# 調用各種方法
print(p1.introduce()) # instance method,調用實例的方法
print(person.count()) # class method,調用類別的方法
print(person.welcome()) # static method,調用類別的方法
print(s1.introduce()) # instance method,調用實例的方法,繼承自父類別
print(s1.get_grade()) # instance method,調用實例的方法,定義在子類別
print(student.count()) # class method,調用類別的方法,覆寫父類別的方法
print(t1.introduce()) # instance method,調用實例的方法,繼承自父類別
print(t1.get_subject()) # instance method,調用實例的方法,定義在子類別
print(teacher.count()) # class method,調用類別的方法,覆寫父類別的方法

方法是「屬於」物件的函數;該方法的第一個參數將被稱為 self(按照慣例)。而在其他程式語言中,這個詞被稱為 this。

  • 實例方法或物件方法 (Instance Method):
    Instance Method 是定義在類別中的普通方法。
    它的第一個參數是 self,表示實例本身。
    instance method 可以訪問和修改實例的屬性,也可以訪問和修改類別的屬性。
    instance method 可以被子類別繼承,也可以被子類別覆寫。

  • 類別方法 (Class Method) :
    class method 是用 @classmethod 裝飾器或 classmethod() 函數來定義的方法,它的第一個參數是 cls,表示類別本身。

class method 可以訪問和修改類別的屬性,但不能訪問和修改實例的屬性。
class method 可以被子類別繼承,也可以被子類別覆寫。
當你今天已經實例化物件後,但是想改動整個類別的屬性,或是你單純想改動整個類別的屬性,就可以使用 class method 。
使用的方式是在 function 前面加上 @classmethod 並在函式中給予cls的參數(cls 表示 class 本身)。

與靜態方法相比,使用類別方法有什麼好處?
好吧,如果我們更改了類別名,那麼函數內的硬編碼類別名稱將不再起作用。如果我們硬編碼類別名,我們將必須手動更改方法代碼中的所有類別名稱。

  • 靜態方法 (Static Method) :
    static method 是用 @staticmethod 裝飾器或 staticmethod() 函數來定義的方法。
    它沒有任何參數,表示它不需要訪問實例或類別的屬性。
    static method 只是一個與類別相關的普通函數,它可以被子類別繼承,也可以被子類別覆寫。

假如今天你的 function 是因為一些邏輯的關聯,所以把它放在某個類別中,但使用這個 function 的時候,並不會用到該類別的任何屬性,便可以使用 staticmethod。

舉例來說如有一個學生類別中,其類別方法中有包含一個是否為假日的函數,但這個函數並不會使用到學生的任何屬性,僅會使用到當天的日期,這時就可使用靜態方法來定義。

為什麼我們使用類別屬性、靜態方法和類別方法?

現在,我們想談談──為什麼我們使用類別屬性、靜態方法和類別方法?
好吧,如果我們根本不使用它們,那也 100% 沒問題。

然而,假設我們可以將所有這些實例共有的和共享的屬性和方法放入類別中。
在這種情況下,我們可以避免許多相同的資料或方法佔用我們的實體記憶體槽。

簡而言之,這可以節省內存。

繼承

繼承允許我們定義一個類,該類別繼承另一個類別的所有方法和屬性。
父類是被繼承的類,也稱為基底類。子類別是從另一個類別繼承的類,也稱為衍生類別。

super() 方法傳回一個代理物件(超類別的臨時物件),它允許我們使用 super 這個關鍵詞來繼承父類別上所有的屬性及方法。
透過在子類別的 __init__ 建構式中,加入 super().__init__() 就能讓子類別能輕鬆繼承存取父類別下的所有屬性和方法。
如以下範例:

# 父類別 -- Person
class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def introduce(self):
        print(f"我叫 {self.name},我 {self.age} 歲。")


# 子類別 -- Student
class Student(Person):
    def __init__(self, name, age, grade):
        super().__init__(name, age)
        self.grade = grade

    def get_grade(self):
        print(f"我是 {self.grade} 年級的學生。")


# 子類別 -- Teacher
class Teacher(Person):
    def __init__(self, name, age, subject):
        super().__init__(name, age)
        self.subject = subject

    def get_subject(self):
        print(f"我教 {self.subject}。")


# 測試
stu_1 = Student("小美", 15, 9)
stu_1.introduce() # output: 我叫 小美,我 15 歲。
stu_1.get_grade() # output: 我是 9 年級的學生。
tea_1 = Teacher("李老師", 30, "數學")
tea_1.introduce() # output: 我叫 李老師,我 30 歲。
tea_1.get_subject() # output: 我教 數學。

多重繼承

相較於 Python,在 Java 和 JavaScript 中,不允許多重繼承。而在 C++ 中,多重繼承則和 Python 一樣,同樣是允許的,但是它非常複雜;人們都盡可能避免去使用它。

Python 支持多重繼承。
多重繼承是指一個類別可以從多個父類別繼承屬性和方法。
在 Python 中,我們可以通過列出所有父類別的名稱來實現多重繼承。
然而如果繼承圖很大,事情就會變得複雜。

多重繼承如以下示意圖:

A 繼承 B、C 和 D,B;
而 B 則繼承了 E 和 F;
D 則繼承了 G;

這形成了複雜的繼承圖譜。
而這樣複雜的繼承圖譜,就會讓我們不好判斷子類別中使用的父類別方法是來自哪個父類別。
也因此,Python 提供了一個方法解析順序(MRO)來解決這個問題。

classDiagram
	E <|-- B
    	F <|-- B
        	G <|-- D
            	B <|-- A
                	C <|-- A
                    	D <|-- A       
class A:
    def method(self):
        print("A")

class B(A):
    def method(self):
        print("B")

class C(A):
    def method(self):
        print("C")

class D(B, C):
    pass

class E(A):
    def method(self):
        print("E")

class F(A):
    def method(self):
        print("F")

class G(D, E, F):
    pass

g = G()
g.method() # output: B

多重繼承與方法解析順序(MRO)

方法解析順序(MRO)的基本原理採用深度優先的圖遍歷演算法。透過應用該演算法,我們將得到:
A、B、E、F、C、D、G
這是在幫助我們從一個多重繼承類別中查找方法的順序。
如果無法確定MRO,我們可以使用Python內建:
class.mro()
class.__mro__
來弄清楚順序。

例如:

print(G.mro())
# output: [<class '__main__.G'>, <class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.E'>, <class '__main__.F'>, <class '__main__.A'>, <class 'object'>]

為什麼我們要用這種演算法來做 MRO?

好吧,該演算法只是確保每個類別都被訪問一次。然而,這種邏輯並不能讓你的程式碼更容易閱讀或維護。

有些人堅持認為多重繼承是個壞主意。許多任務也可以在沒有多重繼承的情況下完成(例如 Java)。

然而,透過使用多重繼承,我們確實得到了一些好處——它使我們能夠避免建立深刻的繼承關係。

【補充】C3 線性化

此外,傳統上,當我們進行多重繼承時,就會出現菱形問題。考慮一下:

classDiagram
    A <|-- B
            A <|-- C
                        B <|-- D
                                        C <|-- D

透過應用深度優先遍歷,我們知道方法解析順序將是:
D、B、A、C、A

然而,我們在訂單中得到了重複的 A;很直觀的是,A 在 C 之前。在先前的 Python 版本中,MRO 就是這個順序。 Python 3 使用一種稱為「C3 線性化演算法」的新演算法。

我們不會花時間學習這是什麼(因為這超出了我們課程的範圍);然而,這種 C3 線性化確實解決了這兩個問題;我們沒有重複的 A,並且 C 在 A 之前。

使用 C3 線性化,菱形問題的 MRO 將是:
D、B、C、A

如有興趣,請從 C3 linearization - Wikipedia 閱讀有關 C3 線性化的內容。

私有屬性和方法

在類別的上下文中,私有意味著屬性或方法僅對類別成員可用,而不對類別外部可用。

定義私有屬性和方法時,只需在前面加雙下劃線,末尾加雙下劃線即可;那麼,該屬性或方法將變為私有。

在 Python 中,依照慣例,任何以單一底線開頭的屬性或方法也是私有的。
但這樣的慣例只是約定俗成,並不受 Python 所監管,這意味著它們仍然是公共的。
(如果我們閱讀 Keras 的源代碼,那麼我們可以看到這一點。)

然而,儘管 Python 沒有限制存取,我們仍可以直接存取該屬性,但我們仍然應該將其視為私有變數;盡量不要訪問它。

例如:

# 定義一個 BankAccount 類別,模擬銀行帳戶的功能
class BankAccount:
    # 初始化方法,設定 name 和 balance 屬性
    def __init__(self, name, balance):
        self.name = name
        # 使用 __ 來定義私有屬性,表示餘額不應該被外部訪問或修改
        self.__balance = balance

    # 定義一個私有方法,用來檢查金額是否有效
    def __check_amount(self, amount):
        # 金額必須是數字,且不能為負
        if isinstance(amount, (int, float)) and amount >= 0:
            return True
        else:
            print("金額無效")
            return False

    # 定義一個存款方法,使用私有方法來檢查金額,並修改私有屬性
    def deposit(self, amount):
        if self.__check_amount(amount):
            self.__balance += amount
            print(f"{self.name} 存入 {amount} 元,餘額為 {self.__balance} 元")

    # 定義一個提款方法,使用私有方法來檢查金額,並修改私有屬性
    def withdraw(self, amount):
        if self.__check_amount(amount):
            if amount <= self.__balance:
                self.__balance -= amount
                print(f"{self.name} 提出 {amount} 元,餘額為 {self.__balance} 元")
            else:
                print("餘額不足")

    # 定義一個查詢餘額的方法,返回私有屬性的值
    def get_balance(self):
        return self.__balance

# 建立一個 BankAccount 的實例
account = BankAccount("小明", 1000)

# 調用存款方法
account.deposit(500)

# 調用提款方法
account.withdraw(200)

# 調用查詢餘額的方法
print(account.get_balance())

# 嘗試直接訪問或修改私有屬性
print(account.__balance)
# 會報錯 --> AttributeError: 'BankAccount' object has no attribute '__balance'
# 因為私有屬性只存在於類別的內部,Instance 無法直接訪問或修改

account.__balance = 0 
# 不會影響實際的餘額,只是在 Instance 下新增了一個 Instance 的新屬性 
# 注意,這與 Class 的私有屬性是不一樣的!!!

print(account.get_balance()) # 餘額仍然是 1300

【補充】Python 中的 OOP 風格

傳統上,OOP 設計強調訊息隱藏;這稱為“封裝”。物件的所有屬性都應盡可能私有。封裝的目的是防止更改物件的屬性,因為這可能會導致意外錯誤。

當我們確實需要存取物件的屬性時,我們通常定義稱為 getter 和 setter 的公共方法。傳統上,getter 和 setter 存取屬性並執行一些中間件工作,例如更改單位或檢查最大值和最小值。

然而,在 Python 中,getter 和 setter 不被認為是 Pythonic!

那麼,我們可能會有一個問題,如何防止外人隨意存取和改變物件的內部屬性呢?
在Python社區,有句名言是「我們都是同意的成年人」。
這意味著我們彼此信任;如果有人想製造混亂,他們就必須為自己的決定負責。
這就是為什麼我們看到單底線和雙底線都可以用於私有屬性。

Python OOP 和傳統 OOP 之間的差異凸顯了 Python 和其他程式語言的概念的不同。
傳統的 OOP 強調錯誤預防和資訊隱藏;Python 則將簡潔和易讀性擺在首位,並不會刻意的隱藏資訊或預防錯誤。

@property 屬性裝飾器

有時,當我們存取或指派物件的屬性時,我們不想直接存取或指派它。例如,當我們必須將單位從攝氏度更改為華氏度。

當我們需要檢查賦值是否在某個範圍內。傳統上,這是由其他程式語言中的 getter 和 setter 完成的。

@property 裝飾器是 Python 中的內建裝飾器,有助於輕鬆定義虛擬屬性。

例如:

# 定義一個 Circle 類別,表示圓形
class Circle:
    # 初始化方法,設定 radius 屬性
    def __init__(self, radius):
        self._radius = radius

    # 使用 @property 裝飾器,定義一個 radius 方法,作為圓形的半徑屬性
    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, radius):
        if radius <= 0:
            raise ValueError("半徑必須大於 0")
        self._radius = radius

    # 使用 @property 裝飾器,定義一個 area 方法,作為圓形的面積屬性
    @property
    def area(self):
        # 使用 math 模組的 pi 常數
        import math
        # 面積等於半徑平方乘以 pi
        return self.radius ** 2 * math.pi

    # 使用 @property 裝飾器,定義一個 perimeter 方法,作為圓形的周長屬性
    @property
    def perimeter(self):
        # 使用 math 模組的 pi 常數
        import math
        # 周長等於半徑乘以 2 乘以 pi
        return self.radius * 2 * math.pi

# 建立一個 Circle 的實例,半徑為 5
c = Circle(5)
# 直接通過方法名來訪問屬性,不需要加括號
print("圓形的面積是:", c.area) # 圓形的面積是: 78.53981633974483
print("圓形的周長是:", c.perimeter) # 圓形的周長是: 31.41592653589793

在上面這個範例中,我們定義了一個 Circle 類別,它有一個 radius 屬性,表示圓形的半徑。
radius 屬性也能使用 @property 來改寫,這讓我們可以多一層管制,來確保半徑的值是正確的。
此外在這個範例中,我們亦可使用 @property 裝飾器,來將 area 和 perimeter 兩個方法定義為圓形的面積和周長屬性。
這樣,我們就可以直接通過方法名來訪問這些屬性,不需要在方法名後加括號。
這可以讓程式碼更簡潔,也可以保護屬性的封裝性,避免被外部修改。

@property 裝飾器的作用是將一個方法轉換為一個唯讀 (read only) 的屬性。
但如果我們想要修改或刪除這些屬性,我們還需要使用 @property.setter@property.deleter 兩個裝飾器,來定義 setter 和 deleter 方法。
這樣就可以實現屬性的讀寫及刪除功能。

強大的哈希函數

哈希(Hash)是一種將一個值轉換為另一個值的函數。
在 Python 中,有一個內建的 hash() 函數。例如,我們可以這樣做:
hash(“你今天過得怎麼樣?”)
然後它將返回 2279678017565837156。(某種整數)

或者,我們可以這樣做:
hash(‘How are you’)
然後它將返回4509308033369319486。(另一個整數)

在 Python 中,哈希函數(hash function)基本上會傳回一個固定大小的整數。

Python 中可以對什麼進行哈希處理?
Python 可雜湊資料型別包括整數、浮點數、字串、布林值、元組和 None。
其中元組如要 hashable,則元組中的所有元素都需要是 immutable 的型別物件所構成,以維持元組母體可以是 hashable。

大多數 Python 的不可變 (immutable) 內建物件都是 hashable;
可變 (mutable) 容器(例如列表或字典)不是;
不可變容器(例如元組)僅當其元素 hashable 時才是 hashable。
預設情況下,作為使用者定義類別實例的物件是 hashable。
但是我們可以透過實作 __hash__() 方法來定義雜湊值(Hash Value)。

以下資料型別是 unhashable:集合、列表、字典。

以下是一個好的哈希函數的一些關鍵特徵:

  • 一致性 (Consistent) - 每次我們給 hash() 哈希函數相同的輸入時,我們需要得到相同的輸出。
  • 均勻分布 (Distributed Evenly) - 輸入的微小變化應該導致輸出的巨大差異。這將減少哈希碰撞(Hash Collision)的數量。(哈希碰撞意味著 2 個不同的輸入產生相同的輸出。)
  • 不可逆 (Not invertible) - 出於安全目的,這個函數不應該是可逆的。

在這門課中,我們不會學習如何創建自己的哈希函數。這不是這裡的主要焦點。如果感興趣,您可以在算法和數據結構課程中了解更多關於哈希函數的信息。

注意,一個值的哈希只需要在Python的一次運行中保持相同。在 Python 3.3 中,實際上每次新運行 Python 時,它們會改變。

這是為了使猜測特定字符串將具有什麼哈希值更加困難,這對於 Web 應用等是一個重要的安全特性。

因此,哈希值不應該被永久存儲。
如果我們需要以永久方式使用哈希值,我們可以看看更“嚴肅”的哈希類型,加密哈希函數,如bcrypt、SHA-256、RSA算法、橢圓曲線加密。

哈希函數的概念在現實世界中被廣泛使用。例如:

  • 密碼在存儲到數據庫之前會被哈希。通過這種方式,即使有人黑入系統,他們也無法看到密碼。同時,由於哈希函數不可逆,他們也無法進行任何逆向工程。

  • 哈希表使用哈希函數。在Python中,字典和集合都實現了哈希表。因此,當在字典和集合中使用成員運算符進行值查找時,無論字典或集合的大小如何,速度都是恆定地快。(然而,列表查找時間取決於列表的大小。列表越大,所需時間越長。)

__hash__() 函數

正如我們之前提到的,任何實現了__hash__()函數的對象都可以用作字典的鍵。

實現 __hash__() 的一個簡單且正確的方法是使用鍵元組。我們可以直接返回鍵的哈希值。

此外,當您使用==運算符比較類的實例時,Python自動調用該類的 __eq__()方法。因此,在我們的類中,我們應該通過以下方式實現這個 __eq__()方法:

  1. 檢查類型是否匹配
  2. 檢查鍵()是否匹配

雙底線(Dunder)方法或魔法方法

Python中的 Dunder 或魔法方法(Magic Method)是方法名中有兩個前綴和後綴底線的方法。Dunder在這裡意味著“雙底線”。這些通常用於操作符重載。

除了我們學過的 __init__、__eq__、__hash__ 之外,以下列表所列舉的雙底線方法是我們可以在 Python 類中常見的一些雙底線方法:

  1. __repr__:當我們使用 repr() 函數時,它將調用 __repr__ 方法。

  2. __str__:當我們使用 print() 函數時,它將調用 __str__ 方法。

  3. __len__:當我們使用 len() 函數時,它將調用 __len__ 方法。

  4. __del__:當我們使用 del 關鍵字時,它將調用 __del__ 方法。

  5. __add__:當我們使用 + 運算符時,它將調用 __add__ 方法。

  6. __gt__:當我們使用 > 運算符時,它將調用 __gt__ 方法。

  7. __lt__:當我們使用 < 運算符時,它將調用 __lt__ 方法。

  8. __ge__:當我們使用 >= 運算符時,它將調用 __ge__ 方法。

  9. __le__:當我們使用 <= 運算符時,它將調用 __le__ 方法。

例如:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    def __repr__(self):
        return f"Circle({self.radius})"

    def __str__(self):
        return f"圓形的半徑是 {self.radius}"

    def __len__(self):
        return self.radius

    def __del__(self):
        print("圓形已被刪除")

    def __add__(self, other):
        return Circle(self.radius + other.radius)

    def __gt__(self, other):
        return self.radius > other.radius

    def __lt__(self, other):
        return self.radius < other.radius

    def __ge__(self, other):
        return self.radius >= other.radius

    def __le__(self, other):
        return self.radius <= other.radius

c1 = Circle(5)
c2 = Circle(10)

print(repr(c1)) # output: Circle(5)
print(str(c1)) # output: 圓形的半徑是 5
print(len(c1)) # output: 5
print(c1 + c2) # output: Circle(15)
print(c1 > c2) # output: False
print(c1 < c2) # output: True
print(c1 >= c2) # output: False
print(c1 <= c2) # output: True
del c1 # output: 圓形已被刪除
1個讚

Hi Chris,
在 類別方法與繼承 的範例裡面,有 Person、Student 和 Teacher,想問如果已經是父類別 (Person)的實體,能否在之後轉為子類別的實體。
如你的範例中,能否讓小明或小華的實體,最後轉型成 Teacher 或是 Student 的實體 ?
p1 = person(“小明”, 18)
p2 = person(“小華”, 20)
s1 = student(“小美”, 15, 9)
s2 = student(“小強”, 16, 10)
t1 = teacher(“李老師”, 30, “數學”)
t2 = teacher(“王老師”, 35, “英文”)

父類別 (Person)的實體,能否在之後轉為子類別的實體?

可以,以 Instnce 而言,其實創建的實體,就是一個變數,而 Python 是動態型別,所以你可以先 p1 = person(“小明”, 18) 然後再把 p1 = student(“小美”, 15, 9),只是這時 p1 這個 Instance 的 Type 已經從 Person 變成 Student,你可以在指定 p1 = student(“小美”, 15, 9) 的前後印出 print(type(p1)) 就可以知道~

那延伸這個想法來說,那 Class 能否互相繼承,當然是不行的,這會產生循環參考的問題。然而仔細的去思考,使用類別及繼承的目的是為了什麼? 其實就是讓我們能夠藉由 BaseClass 的建立,來減少重複的程式碼,比方說在不透過類別繼承方式,那麼如果有一個屬性需要在 Person Class、Student Class 及 Teacher Class 都出現,例如: Name、Gender、Age等,你就得每個 Class 都寫一次,這樣這些類別才能有這些屬性能夠使用。

不知道這樣是否有回答道你的問題~

補充一點,p1 = student(“小美”, 15, 9) 這個 “=” 等號符號,其實是 assign 賦值的意思,就是把右邊的物件指派給左邊的變數,因此基於 Python 動態型別的關係,你可以隨時指派不同的型別物件,給左邊的變數,而 Python 的編譯器,會依據右邊物件的型別,動態來調整左邊變數的型別。

感謝 Chris,
我的問題比較像是如下的情況,code 概略寫(不嚴謹)

  1. SchoolStudent 類別 有 name 屬性
  2. ClassStudent 類別 有 name 屬性,還有 班級 屬性
  3. ClassStudent繼承 SchoolStudent 相當於 ClassStudent(SchoolStudent):
  4. 情境是: 小明與小華都是學校新生,所以
    ss1 = SchoolStudent(‘小明’)
    ss2 = SchoolStudent(‘小華’)
  5. 待教務處校務會議後,才確認小明分在 “一年一班”,小華分在 “一年二班”
    有辦法將既有的實體(ss1 / ss2)直接轉型成子類別 (ClassStudent) 的實體,並賦予班級的參數設定嗎?
    有點像 cs1 = ClassStudent(ss1, ‘一年一班’)
    cs2 = ClassStudent(ss2, ‘一年二班’)
  6. 並非直接覆蓋原有實體,而是有點像父類別的實體,擴充屬性(ex,班級),變成了子類別的實體
  7. 原本一開始的實體的總個數並沒有新增或消滅,而是換成子類別的型態

不曉得具體該怎麼做 ?

其實就是簡單的 ClassStudent 子類別繼承 SchoolStudent 父類別的作法,做法如下:

class SchoolStudent:
    def __init__(self, name):
		# 共有的屬性,如學生姓名放在父類別
        self.name = name

class ClassStudent(SchoolStudent):
    def __init__(self, school_student, class_name):
		# 透過繼承所有子類別的 Instance 都會有共有的屬性 Student Name
        super().__init__(school_student.name)
		# 子類別 ClassStudent 只有 class_name 一個屬性
        self.class_name = class_name

    def __str__(self):
        '''
        透過 __str__ 複寫,來改變 print(instance) 的結果
		'''
        return f'{self.name} {self.class_name}'

# 小明與小華都是學校新生
ss1 = SchoolStudent('小明')
ss2 = SchoolStudent('小華')

# 待教務處校務會議後,確認小明分在 “一年一班”,小華分在 “一年二班”
cs1 = ClassStudent(ss1, '一年一班')
cs2 = ClassStudent(ss2, '一年二班')

print(cs1)  # 小明 一年一班
print(cs2)  # 小華 一年二班

希望有幫到你~

如你有參加第三屆Python全攻略共學,建議可以貼在 Discord 論壇上,這樣可以讓同在共學的人,第一時間可以看到問題與回答,可以激發更多討論。
基本上,我的回答只是基於我的見解所做的回覆,也不一定就完全正確,如有問題或其他見解,歡迎大家隨時提出,這樣才能充分體現共學的意義~