Learning Objectives
- 說明類別是藍圖、物件是依藍圖建立的實體;物件同時有資料(屬性)與行為(方法)
- 說明封裝:把資料和操作資料的方法包在一起,外界透過方法使用資料
- 說明繼承:子類別沿用父類別的屬性與方法,可以加上新的或改寫既有的
- 說明多型:同一個方法呼叫,不同類別的物件各自做出適合的反應
- 比較程序導向與物件導向兩種組織程式的方式
一百個遊戲角色要怎麼管
假設你在寫一款遊戲,有一百個角色,每個角色都有名字、血量、位置,還會移動、攻擊、補血。 只用上一課的變數和函式,你可能會這樣寫:
names = ["Amy", "Ben", ...] # 一百個名字
hps = [100, 80, ...] # 一百個血量,順序要和 names 對齊
def attack(i, j): ... # 用編號 i、j 找到兩個角色
資料散在好幾個串列裡,全靠「第幾個」對齊;有人不小心把某個血量改成負數,也沒有地方擋。 物件導向(object-oriented programming,OOP)換一個角度:把一個角色的資料(名字、血量)和它會做的事(移動、攻擊)包成一個物件(object)。
| 組織方式 | 程式的主角 | 例子 |
|---|---|---|
| 程序導向(procedural) | 步驟與函式:資料交給函式處理 | attack(角色1, 角色2) |
| 物件導向(object-oriented) | 物件:資料和操作它的方法放在一起 | 角色1.attack(角色2) |
兩種方式沒有絕對的好壞,大型程式常常混著用。物件導向的想法最早來自 1960 年代挪威計算中心的 Ole-Johan Dahl 與 Kristen Nygaard, 他們設計的 Simula 語言(Simula 67)提出了類別、子類別等概念,兩人在 2001 年因此獲得 ACM 圖靈獎。 今天的 Java、C++、Python、C#、JavaScript 等語言都支援物件導向。
類別與物件:藍圖和照藍圖做出來的東西
| 名詞 | 意思 | 比喻 |
|---|---|---|
| 類別(class) | 規定這類物件有哪些資料、會做哪些事 | 房子的設計圖、帳戶的申請表格 |
| 物件(object),也叫實例(instance) | 依類別建立出來的一個實體,有自己的資料 | 照設計圖蓋好的一棟房子、一個開好的帳戶 |
| 屬性(attribute) | 物件身上的資料 | 戶名、餘額 |
| 方法(method) | 物件會做的事,就是寫在類別裡的函式 | 存款、提款 |
同一張設計圖可以蓋很多棟房子,每一棟的住戶、裝潢各自不同;同一個類別也可以建立很多個物件,每個物件的屬性各自獨立。
在 Python 裡:
class Account:定義類別;類別裡用def寫方法__init__是建立物件時自動執行的初始化方法,負責設定屬性的初始值- 方法的第一個參數
self代表「這一次被操作的那個物件」:a.deposit(300)時self就是a 物件.屬性、物件.方法(...)用點號存取
一個類別,兩個帳戶
右邊會畫出類別 Account 和兩個帳戶物件,以及名稱 a、b 各自指向哪一個物件。注意:呼叫 a.deposit(300) 時 self 指向 a 的物件;呼叫 b.withdraw(800) 時 self 換成指向 b 的物件。withdraw 先檢查餘額,Ben 的提款被擋下來。
封裝:資料交給方法看管
把資料和操作資料的方法包在同一個物件裡,叫做封裝(encapsulation)。 它的重點不只是「放在一起」,而是外界透過方法使用資料,由方法負責守住規則:
- 帳戶的餘額不能變成負數,所以提款一律走
withdraw(),由它先檢查 - 使用的人只要知道「有
deposit、withdraw可以用」,不必知道餘額怎麼存、檢查怎麼寫 - 之後要加上「每筆交易都記錄下來」,只改類別裡的方法,用這個類別的程式都不必改
讓外界看不到、碰不到內部細節,叫做資訊隱藏(information hiding)。不同語言的嚴格程度不同:
| 語言 | 怎麼表示「這是內部資料」 |
|---|---|
| Java、C++、C# | 宣告成 private,類別外面的程式直接存取會無法編譯 |
| Python | 慣例在名稱前面加底線,例如 _balance,意思是「請不要直接改」;語言本身不強制 |
這和上一課的函式是同一個想法:把細節藏在介面後面,使用的人只需要知道怎麼呼叫。
想一想:類別、物件與封裝
關於類別與物件,下列敘述何者正確?
下面的程式會印出什麼?
class Counter:
def __init__(self):
self.n = 0
def add(self):
self.n = self.n + 1
c1 = Counter()
c2 = Counter()
c1.add()
c1.add()
c2.add()
print(c1.n, c2.n)
帳戶類別規定提款一律呼叫 withdraw(),由它檢查餘額夠不夠。這是哪一個物件導向的觀念?
繼承:在既有的類別上加東西
長方形、圓形、三角形都是「形狀」,都有名字、都能算面積、都能介紹自己。 如果每個類別都從頭寫一次「介紹自己」,又會回到複製貼上的老問題。
繼承(inheritance)讓新的類別沿用既有類別的屬性與方法:
| 名詞 | 意思 | 例子 |
|---|---|---|
| 父類別(parent class、superclass) | 被繼承的類別,放共同的部分 | Shape |
| 子類別(child class、subclass) | 繼承父類別,再加上自己特有的部分 | Rect(Shape) |
| 改寫(override) | 子類別重新定義父類別已有的方法 | Rect 自己的 area() |
呼叫物件的方法時,電腦先在物件自己的類別找,找不到再往父類別找。所以 Rect 沒寫 describe() 也能用,用的是 Shape 的;
而 describe() 裡呼叫的 self.area(),因為 self 是長方形,找到的是 Rect 改寫過的版本。
子類別和父類別是「是一種」(is-a)的關係:長方形是一種形狀。如果說不通(汽車「是一種」輪子?),就不適合用繼承。
繼承與改寫:長方形是一種形狀
看建立 Rect(3, 4) 時,super().__init__ 怎麼請父類別幫忙設定 name;再看 r.describe():Rect 沒有這個方法,執行的是 Shape 的;describe 裡的 self.area() 卻跳到 Rect 的 area。
多型:同一句話,各自做對的事
學校要通知期中考時間,有人用 Email,有人用簡訊,有人用 App 推播。 寫通知程式的人不想寫成一長串「如果是 Email 就…,如果是簡訊就…」,只想對每個管道說同一句話:「送出這則訊息」。
多型(polymorphism)就是這個意思:不同類別的物件提供同名的方法,呼叫的人用同一行程式 c.send(msg),
每個物件依自己的類別做出適合的反應。多型通常靠繼承加上改寫來實現:
Notifier(規定:都有 send)
┌──────────┼──────────┐
Email SMS Push
寄出信件 只傳摘要 推播通知
好處是新增一種管道不用改呼叫的程式:之後加一個 Line 類別,只要它也有 send,原本的迴圈一個字都不必動。
多型:一個迴圈,三種通知方式
迴圈裡只有一行 c.send(...)。仔細看每一圈執行箭頭跳到哪一個類別的 send:第一圈 c 是 Email 物件、第二圈是 SMS、第三圈是 Push。
想一想:繼承與多型
下面的程式會印出什麼?
class A:
def hi(self):
return "A"
def show(self):
print(self.hi())
class B(A):
def hi(self):
return "B"
B().show()
A().show()
遊戲裡有戰士、法師、弓箭手三個類別,都有 attack() 方法但攻擊方式不同。程式用一個迴圈對所有角色呼叫 attack(),每個角色各自用自己的方式攻擊。這主要是哪一個觀念?
實作:正方形是一種長方形
Rect 類別已經會算面積,請完成兩件事:(1) 補完 perimeter(),回傳周長 2 × (寬 + 高);(2) 完成子類別 Square 的 __init__:正方形是長寬相等的長方形,用 super().__init__(...) 把邊長交給 Rect 設定。Square 不必自己寫 area 和 perimeter,會從 Rect 繼承。
重點整理
- 類別是藍圖,物件是依藍圖建立的實體;物件有屬性(資料)與方法(行為),
self指向正在被操作的那個物件。 - 封裝:資料和操作資料的方法包在一起,外界透過方法使用,由方法守住規則;隱藏內部細節叫資訊隱藏。
- 繼承:子類別沿用父類別的屬性與方法,可以加上新的或改寫既有的;找方法時先找自己的類別,再往父類別找。
- 多型:同一個方法呼叫,不同類別的物件各自做出適合的反應;新增類別不必修改呼叫的程式。
- 程序導向以步驟與函式為主角,物件導向以物件為主角;兩者常混合使用。
下一課:學會了組織程式的方法,接下來比較同一個問題的不同解法:演算法要怎麼評估快慢?
想實際寫更多類別,可以接著上 Python 軌的物件導向單元;Java 軌則從頭到尾都以類別組織程式。