這一節的標題是
4.6 Inheritance and Polymorphism
因為方格子標題字數限制,所以沒完整顯現
接下來,我們會藉由繼承(inheritance)和多型(polymorphism)這兩個物件導向程式設計的技術,來製作更多樣化、更有趣的粒子系統。
假設我們要寫程式製作一張電子賀卡,賀卡上面要撒滿紫色、粉紅色、星形、方形、快速翻轉、一閃一閃閃個不停等各式各樣五彩繽紛的碎紙花。這麼多具有不同外觀和行為的碎紙花,全都要一股腦兒顯示在螢幕上。
顯然現在我們面對的是一個由一個個碎紙花所構成的粒子系統。
面對這樣一個含有許多不同外觀和行為的粒子所構成的粒子系統,在設計Particle
類別時,或許可以用不同的變數來存放粒子的顏色、形狀、行為等資料。有了這些變數之後,也許在製作實例(instance)時,可以利用亂數來決定這些變數的初始值,讓製作出來的Particle
實例,具有各種不同的顏色、外觀、行為。不過,這種把所有用來產生各式各樣粒子的程式碼,全都給塞在同一個類別裡頭的做法,雖然可以達到目的,但如果不同粒子之間的差異很大,卻會讓程式碼非常的雜亂。
既然上面提到的做法不怎麼樣,那或許可以把一種粒子寫成一個類別,像這樣:
class HappyConfetti: ...
class FunConfetti: ...
class WackyConfetti: ...
然後在Emitter
類別的__init__()
中,利用亂數來決定要用哪一個類別來製作粒子,並把製作出來的粒子放進粒子系統中,像這樣:
class Emitter:
def __init__(self, num):
particles = []
for i in range(num):
r = random.random()
if r < 0.33:
self.particles.append(HappyConfetti())
elif r < 0.67:
self.particles.append(FunConfetti())
else:
self.particles.append(WackyConfetti())
到目前為止,一切都很順利,沒什麼問題,反正需要什麼樣的粒子,就寫個那種粒子的類別就是了。當然啦,在寫的時候,也不需要每行都重打,不同種類的粒子之間再怎麼不同,總有一些程式碼是一樣的,複製、貼上就可以了。不過,話雖這麼說,但寫程式的時候,刻苦耐勞、埋頭苦幹,可絕不是一種美德;寫程式的時候應該要時時刻刻想到,有沒有什麼更好的寫法,可以更有效率地達到目的。在這裡,我們應該要自問的是:我們真的要在不同的「confetti
」類別間,不斷地複製、貼上一大堆的程式碼嗎?
不管粒子再怎麼不同,他們的類別的程式碼中,總會有許多部分是一樣的;像是記錄位置、速度、加速度的變數,還有更新狀態的update()
方法等,在不同粒子的類別程式碼中,這部分的程式碼都是一樣的。既然如此,那有沒有什麼好辦法,可以省去這一大堆的複製、貼上工作呢?
繼承!繼承這個技術,就可以讓我們擺脫不斷複製、貼上的惡夢!利用繼承的方式,在設計新的類別時,就可以直接讓新的類別具備現有類別中的屬性和方法,而不需重複撰寫程式碼。這樣子,我們就可以專注於設計新的類別特有的功能。
利用繼承的方式,可以讓我們在設計新的粒子類別時省下不少功夫。不過,在面對一大堆不同種類的粒子時,先前所設計的Emitter
類別,還能不能用呢?
先前在設計Emitter
類別時,我們是把所有的粒子都存放在list
中進行處理,而那些粒子都是同一類的粒子,處理方法都一樣。但是,現在我們要處理的粒子,卻有著不同的種類,那我們怎麼知道list
中放的是哪種?而又該用哪個方法來處理?
這是一個挺嚴肅的問題,畢竟現在我們有許多不同種類的粒子,如果不知道要處理的是哪種粒子,那又怎麼會知道哪個方法適用?
那難不成要為每一種粒子準備一個list
,然後每個list
裡頭,就只放一種粒子?
這樣子做,雖然很明確可以知道放在list
中的粒子是哪一種,然後找到合用的方法來處理,但實在是很不優。我們想要的做法,是就只用一個list
來存放所有各式各樣不同類型的粒子,而且在處理的過程中,可以不用去辨識粒子的種類。這時候,就是多型這個技術派上用場的時候了。多型這個技術,就可以讓我們用同樣的方法來處理不同類型的物件。
知道問題之所在之後,接下來,我們會更詳細地來看看,關於繼承和多型這兩個技術的內容,並且利用這兩個技術來製作一個粒子系統。
要說明繼承這個技術,動物的世界是個很好的例子。
動物世界中有貓、狗、猴子、松鼠、麻雀等等各式各樣的動物。我們先從狗開始。狗有年齡,會吃東西、會睡覺、會叫,所以狗的類別可以設計成這樣:
class Dog:
def __init__(self):
self.age = 0
def eat(self):
print("Yum!")
def sleep(self):
print("Zzzzzz")
def bark(self):
print("WOOF!")
接下來是貓。貓有年齡,會吃東西、會睡覺、會叫,所以貓的類別可以設計成:
class Cat:
def __init__(self):
self.age = 0
def eat(self):
print("Yum!")
def sleep(self):
print("Zzzzzz")
def meow(self):
print("MEOW!")
再來還有許許多多不同的動物,都要這樣一個一個的寫。其實,如果仔細看一下這些程式碼就會發現,大部分都是一模一樣的。即使用複製、貼上的方式可以省下不少功夫,但一直不斷地重複同樣的動作,實在是讓人厭煩又沒有效率。那有沒有什麼其他比較好的方法呢?當然有!用繼承的方式就可以了。利用繼承,就可以讓一個類別具有其他類別的屬性和方法。
首先,既然所有的動物都有年齡,而且都會吃東西、睡覺,那我們就寫個包含這些屬性和方法的Animal
類別;這樣子,任何一種動物都可以用Animal
類別來描述。當然啦,總有些特性不是每種動物都一樣,就像狗是汪汪叫,而貓是喵喵叫。這時候,我們就可以這樣來描述狗和貓:
在繼承時,被繼承的類別叫做「父類別(superclass)」,而繼承別人的,則叫做「子類別(subclass)」;子類別會具有父類別的屬性和方法,同時也可以擁有自己的屬性和方法。所以,既然根據先前的描述,狗和貓都具有動物的每一種特性,那就讓Dog
類別和Cat
類別分別都繼承Animal
類別,這樣子對Animal
類別而言,Dog
和Cat
這兩個類別,就是它的子類別;而對Dog
和Cat
這兩個類別而言,Animal
就是它們的父類別。因此,這麼一繼承下來,Dog
和Cat
這兩個子類別,就會具有Animal
這個父類別的屬性和方法了,不需要重複的寫那麼多的程式碼。根據這樣子的做法,最後Animal
、Dog
、Cat
這三個類別會長這樣:
class Animal:
def __init__(self):
self.age = 0
def eat(self):
print("Yum!")
def sleep(self):
print("Zzzzzz")
class Dog(Animal): # Dog繼承Animal
def bark(self):
print("WOOF!")
class Cat(Animal): # Cat繼承Animal
def meow(self):
print("MEOW!")
執行下面的程式
dog = Dog()
print(dog.age)
dog.eat()
dog.bark()
會得到這樣的結果:
0
Yum!
WOOF!
這樣子的結果,看起來挺不賴的,透過繼承的方式,Dog
和Cat
這兩個類別的程式碼減少了很多。不過,如果Dog
類別中,除了年齡之外,也需要有毛色這個屬性,那該怎麼辦呢?這時候,就要用到super()
這個方法了。
繼承時有個規則,就是當子類別的方法和父類別的方法有一樣的名稱時,父類別的方法會被子類別的方法給覆蓋掉,而在子類別中消失。所以,如果為了讓Dog
類別也有毛色這個屬性,而把__init__()
寫出來,像這樣:
class Dog(Animal):
def __init__(self):
# 用亂數來決定毛色
r = random.randint(0, 255)
g = random.randint(0, 255)
b = random.randint(0, 255)
self.hair_color = (r, g, b)
def bark(self):
print("WOOF!")
那Dog
類別的__init__()
就會覆蓋掉Animal
類別的__init__()
,而使得Dog
類別沒有繼承到Animal
類別__init__()
中的任何東西,導致Dog
類別不具有age
這個屬性。那要怎麼把被覆蓋掉的東西拿回來呢?就用super()
這個函數。
super()
這個函數的功用,就是用來把父類別的方法拿來繼承。所以,在Dog
類別的__init__()
中加入一行,改成這樣:
def __init__(self):
super().__init__() # 繼承父類別的__init__()方法
# 用亂數來決定毛色
r = random.randint(0, 255)
g = random.randint(0, 255)
b = random.randint(0, 255)
self.hair_color = (r, g, b)
就可以讓Dog
類別在新增hair_color
這個屬性時,也同時能夠繼承來自Animal
類別__init__()
內的age
屬性。
在先前的寫法中,狗狗吃東西的方式和其他動物都一樣。那如果狗狗跟其他動物不一樣,有牠自己獨特的吃東西方式時,程式要怎麼寫呢?其實很簡單,就在Dog
類別中,把狗狗吃東西的方式寫出來就可以了,像這樣:
class Dog(Animal):
def __init__(self):
super().__init__() # 繼承父類別的__init__()方法
# 用亂數來決定毛色
r = random.randint(0, 255)
g = random.randint(0, 255)
b = random.randint(0, 255)
self.hair_color = (r, g, b)
# 子類別的方法會覆蓋掉父類別的方法
def eat(self):
print("Woof! Woof! Slurp.")
def bark(self):
print("WOOF!")
因為子類別Dog
的eat()
方法會覆蓋掉父類別Animal
的eat()
方法,所以狗狗就不會有和其他動物一樣的吃東西方式了。當然啦,如果想要讓狗狗吃東西的時候,既有和其他動物一樣的方式,又有自己獨特的方法,那就用super()
把Animal
類別的eat()
方法繼承過來就可以了,像這樣:
def eat(self):
super().eat() # 繼承Animal類別的eat()方法
print("Woof!!!")
這時候如果執行
dog = Dog()
dog.eat()
就會得到
Yum!
Woof!!!
利用繼承的方式,我們可以造出一大堆動物的類別。假設我們已經設計好Dog
、Cat
、Duck
、Chicken
這些類別,如果現在有滿園子饑腸轆轆的狗、貓、鴨子、雞在吃東西,那程式寫起來會長什麼樣子呢?
第一步,當然是把這些動物給生出來:
dogs = [Dog() for i in range(10)]
cats = [Cat() for i in range(15)]
ducks = [Duck() for i in range(6)]
chickens = [Chicken() for i in range(98)]
然後讓牠們吃東西:
for dog in dogs:
dog.eat()
for cat in cats:
cat.eat()
for duck in ducks:
duck.eat()
for chicken in chickens:
chicken.eat()
程式這樣寫沒什麼問題,有四種動物,我們就用四個迴圈來讓牠們吃東西。不過,如果有幾十種動物呢?那豈不是得寫幾十個迴圈?其實也不用這麼麻煩,利用多型這個技術就可以了。
先前是把四種動物分別放在四個list
中,現在把牠們全部集中,放在一個叫做kingdom
的list
中:
kingdom = []
for i in range(10):
kingdom.append(Dog())
for i in range(15):
kingdom.append(Cat())
for i in range(6):
kingdom.append(Duck())
for i in range(98):
kingdom.append(Chicken())
接下來,用一個迴圈,讓牠們一隻一隻開始吃東西:
for animal in kingdom:
animal.eat()
雖然在kingdom
這個list
裡頭所放的,是不同種類的物件,但python
可以識別出當前處理的是哪一種,並自動幫我們找到其對應的eat()
方法。這就是多型的妙用之所在!
利用繼承和多型,我們可以很容易就讓原本只噴射圓形粒子的發射器,可以同時噴射出圓形和方形粒子,而且方形粒子還可以邊飛邊旋轉。
因為圓形粒子是由Particle
類別所製作出來的,而既然方形粒子和圓形粒子之間的不同點,就只在形狀和邊飛邊旋轉這兩樣而已,所以在設計方形粒子的類別時,可以利用繼承Particle
類別的方式,來讓方形粒子具備基本所需的屬性、方法,然後再加入方形粒子獨有的屬性、方法就可以了。
設計好的方形粒子類別如下:
class Confetti(Particle):
def __init__(self, x, y, mass):
super().__init__(x, y, mass)
# 加入方形粒子獨有的屬性,用來計算粒子的旋轉角度
self.width, self.height = self.screen.get_size()
def show(self):
# 使用具透明度的白色把confetti所在的surface清空
self.surface.fill((255, 255, 255, 0))
# 畫出具有透明度的confetti,並根據confetti剩下的壽命長短來決定透明度
rect = pygame.Rect(0, 0, self.size, self.size)
color = (0, 0, 0, self.lifespan)
pygame.draw.rect(self.surface, color, rect)
angle = 4*180*self.position.x/self.width # 旋轉角度
rotated_surface = pygame.transform.rotate(self.surface, -angle)
rect_new = rotated_surface.get_rect(center=self.position)
# 把confetti所在的surface貼到最後要顯示的畫面上
self.screen.blit(rotated_surface, rect_new)
在Confetti
類別中,我們重新設計了show()
方法,覆蓋掉繼承自Particle
類別的show()
方法。這樣子,方形粒子就能以自己的形狀和飛行方式,顯現在畫面上。
在計算方形粒子的旋轉角度時,只簡單地使用粒子的x軸座標值來換算。換算公式為:
旋轉角度 = 4π (粒子的x軸座標值) / 畫面寬度
當然啦,也可以用比較複雜一些的方式,像第三章中提到的角速度、角加速度,來設計旋轉角度的計算公式。
Exercise 4.7
讓角加速度的大小等於水平方向的速度大小,也就是
angular_acceleration = velocity.x
然後就可以算出角速度和旋轉的角度。
angular_velocity += angular_acceleration
angle += angular_velocity
有了Particle
和Confetti
這兩個類別之後,還需要把先前設計的Emitter
類別中的add_particle()
方法改造一下,才能讓發射器能噴射出方形和圓形兩種粒子。改造後的add_particle()
方法程式碼如下:
def add_particle(self):
r = random.random()
if r < 0.5:
self.particles.append(Particle(self.origin.x, self.origin.y, self.mass))
else:
self.particles.append(Confetti(self.origin.x, self.origin.y, self.mass))
在設計Emitter
類別時,不管是圓形或方形粒子,我們都把它放在particles
這個list
中,並在run()
這個方法中,藉由多型技術,在不需區分是哪種粒子的情況下,以相同的方式來處理從particles
這個list
中取出的物件。
下面這個例子模擬的,就是發射器噴射出方形和圓形粒子的情況。
Example 4.5: A Particle System with Inheritance and Polymorphism
# python version 3.10.9
import random
import sys
import pygame # version 2.3.0
pygame.init()
pygame.display.set_caption("Example 4.5: A Particle System with Inheritance and Polymorphism")
WHITE = (255, 255, 255)
screen_size = 640, 360
screen = pygame.display.set_mode(screen_size)
FPS = 60
frame_rate = pygame.time.Clock()
gravity = pygame.Vector2(0, 0.05)
emitter = Emitter(320, 50, 1)
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
screen.fill(WHITE)
emitter.add_particle()
emitter.run()
pygame.display.update()
frame_rate.tick(FPS)
Exercise 4.8
除了橢圓、方形兩種粒子外,多加一種快速旋轉、飛得比較遠的橢圓形粒子。
class Ellipse(Particle):
def __init__(self, x, y, mass):
super().__init__(x, y, mass)
self.width, self.height = self.screen.get_size()
self.velocity = pygame.Vector2(random.uniform(-3, 3), random.uniform(-2, 0))
self.angle = 0
def show(self):
# 使用具透明度的白色把ellipse所在的surface清空
self.surface.fill((255, 255, 255, 0))
# 畫出具有透明度的ellipse,並根據ellipse剩下的壽命長短來決定透明度
rect = pygame.Rect(0, 0, self.size, self.size/2)
color = (0, 0, 0, self.lifespan)
pygame.draw.ellipse(self.surface, color, rect)
self.angle += 10*self.velocity.x # 旋轉角度
rotated_surface = pygame.transform.rotate(self.surface, -self.angle)
rect_new = rotated_surface.get_rect(center=self.position)
# 把ellipse所在的surface貼到最後要顯示的畫面上
self.screen.blit(rotated_surface, rect_new)
修改Emitter
類別的add_particle()
方法,讓三種粒子出現的機率一樣。
def add_particle(self):
match random.randint(1, 3):
case 1:
self.particles.append(Particle(self.origin.x, self.origin.y, self.mass))
case 2:
self.particles.append(Confetti(self.origin.x, self.origin.y, self.mass))
case 3:
self.particles.append(Ellipse(self.origin.x, self.origin.y, self.mass))
執行結果