2024-10-14|閱讀時間 ‧ 約 0 分鐘

The Nature of Code閱讀心得與Python實作:4.6 Inheritance and...

這一節的標題是
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來存放所有各式各樣不同類型的粒子,而且在處理的過程中,可以不用去辨識粒子的種類。這時候,就是多型這個技術派上用場的時候了。多型這個技術,就可以讓我們用同樣的方法來處理不同類型的物件。

知道問題之所在之後,接下來,我們會更詳細地來看看,關於繼承和多型這兩個技術的內容,並且利用這兩個技術來製作一個粒子系統。

Inheritance Basics

要說明繼承這個技術,動物的世界是個很好的例子。

動物世界中有貓、狗、猴子、松鼠、麻雀等等各式各樣的動物。我們先從狗開始。狗有年齡,會吃東西、會睡覺、會叫,所以狗的類別可以設計成這樣:

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類別而言,DogCat這兩個類別,就是它的子類別;而對DogCat這兩個類別而言,Animal就是它們的父類別。因此,這麼一繼承下來,DogCat這兩個子類別,就會具有Animal這個父類別的屬性和方法了,不需要重複的寫那麼多的程式碼。根據這樣子的做法,最後AnimalDogCat這三個類別會長這樣:

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!

這樣子的結果,看起來挺不賴的,透過繼承的方式,DogCat這兩個類別的程式碼減少了很多。不過,如果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!")

因為子類別Dogeat()方法會覆蓋掉父類別Animaleat()方法,所以狗狗就不會有和其他動物一樣的吃東西方式了。當然啦,如果想要讓狗狗吃東西的時候,既有和其他動物一樣的方式,又有自己獨特的方法,那就用super()Animal類別的eat()方法繼承過來就可以了,像這樣:

def eat(self):
super().eat() # 繼承Animal類別的eat()方法
print("Woof!!!")

這時候如果執行

dog = Dog()
dog.eat()

就會得到

Yum!
Woof!!!

Polymorphism Basics

利用繼承的方式,我們可以造出一大堆動物的類別。假設我們已經設計好DogCatDuckChicken這些類別,如果現在有滿園子饑腸轆轆的狗、貓、鴨子、雞在吃東西,那程式寫起來會長什麼樣子呢?

第一步,當然是把這些動物給生出來:

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中,現在把牠們全部集中,放在一個叫做kingdomlist中:

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()方法。這就是多型的妙用之所在!

Particles with Inheritance and Polymorphism

利用繼承和多型,我們可以很容易就讓原本只噴射圓形粒子的發射器,可以同時噴射出圓形和方形粒子,而且方形粒子還可以邊飛邊旋轉。

因為圓形粒子是由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

有了ParticleConfetti這兩個類別之後,還需要把先前設計的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))

執行結果


分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.