iT邦幫忙

4

[不做怎麼知道系列之Android開發者的30天後端養成故事 Day10] - 你/妳SOLID了嗎? #什麼是SOLID Principles #減少debug時間 #高品質程式

Sam 2020-02-14 14:05:421701 瀏覽

https://ithelp.ithome.com.tw/upload/images/20200214/20124548fAwRCK44Iw.png

哈囉,我們又見面了,今天我們不實作電商網站,來看看 SOLID Principles 說了些什麼,為什麼可以透過遵守 SOLID 來達到 高品質程式碼 呢 ?,類此構 !

今天參考的文件是 solid.python,裡面用簡潔明瞭的方式,並且搭配 Python 的範例,來解釋 SOLID Principles 的核心概念,全篇提到的作者都是寫 solid.python 的 作者

SOLID 是啥 ?

相信你看到這裡,一定還是不知道在幹嘛 XD,直接往下看吧。

Single Responsibility Principle (SRP)

單一責任,從字面上來理解,就是一個物件 (class),就應該負責一項責任就好,是不是聽完有種「喔,所以呢 ?」的感覺,那直接來看個例子吧~

如果我們想創立一個動物的類別,只有一個 name 的屬性,然後可以儲存到 DB。

class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

    def save(self, animal: Animal):
        pass

上面這段 code 來自 SRP | solid.python

先花十秒看一下 code,感覺一下,10,9,8,1,好,有感覺到什麼嗎 ?

乍看之下沒什麼問題,用 name 當作參數到 constructor 創建 Animal,然後還能取得 Animal 的名字,然後再存到 DB,蠻合理也蠻好用的。

但是,假設今天我把 DB 從 SQLite(Relational) 換成 MongoDB(NoSQL),是一個完全不同的儲存實作方式,那麼我是不是就要來 Animal 這個物件改 save() 這個方法 ? 聽起來好像也還好吧,那如果有十個類似的 class,如果再大一點的專案,一百個類似的 class 怎麼辦 ? 加班生活直接 online。

還有另一點是,仔細想想,在 Animal 這樣的一個物件裡,有 save() 的方法好像有點奇怪 ?,這件事應該是跟 DB 相關的吧,怎麼會直接隸屬在 Animal 底下呢 ?

那麼肯定是有解決方法的吧 !?

我第一個想到的方法是,那就把儲存的方式獨立出來囉,我可以實作出 SQLiteMongoDB 兩種時儲存方法,再獨立出抽象介面,讓所有類似的 class 都透過抽象界面來儲存資料,那麼當我下次要新增另一種資料庫的時候,我所有類似 Animal 的 class 都不用動,只要新增新資料庫的實作方法,再改動抽象介面就好。這樣做的話,可以讓 Animal 負責 Animal 相關的屬性及功能,而跟資料儲存相關的實作,就給 DB 去負責就好,是不是聽起來有比原本的方法好呢 ?

而這位作者提供的方法如下,和我的想法類似,是用 AnimalDB 來處理資料庫儲存的功能、Animal 本身持有一個 DB 的實例 (instance),Animal 本身的 save() 實際上是 call DB 實例的 save(),如果要實作不同的 DB 的話,也可以再抽象一層,讓 AnimalDB 去 call SqliteDBsave()MongoDBsave() 之類的。

class AnimalDB:
    def get_animal(self, id) -> Animal:
        pass

    def save(self, animal: Animal):
        pass


class Animal:
    def __init__(self, name: str):
        self.name = name
        self.db = AnimalDB()

    def get_name(self):
        return self.name

    def get(self, id):
        return self.db.get_animal(id)
    
    def save(self):
        self.db.save(animal=self)

上面這段 code 來自 SRP | solid.python

透過以上講的解法,就可以將原本 Animal class 處理儲存細節的這個功能,給分開出來到另一個專門負責儲存細節的 class,達到一個 class 就負責它該負責的目標,以上就是 Single Responsibility Principle 的概念,對於程式的理解邏輯上可以更加清晰,同時,也 減少牽一髮動全身的慘況,讚讚。順口提一下,上面這段 code 也就是 Facade Pattern (念法是 ㄈㄜ˙ 薩的,是法文,參考 How to Pronounce Facade) 的展現。

Open/Closed Principles (OCP)

開放封閉原則,那麼是開放什麼、封閉什麼呢?

  • 開放 擴充 的可能性
  • 封閉 修改 的可能性

簡單來說,就是我已經寫好可以跑的 code 了,那麼我要新增一個功能的話,就是在不更動我原本寫好的部分,只要另外寫我需要擴充的功能就好,來看看下面的栗子

現在要在動物的類別上,加上一個動物叫聲的方法。

class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

animals = [
    Animal('lion'),
    Animal('mouse')
]

def animal_sound(animals: list):
    for animal in animals:
        if animal.name == 'lion':
            print('roar')

        elif animal.name == 'mouse':
            print('squeak')

animal_sound(animals)

上面這段 code 來自 OCP | solid.python

一樣花個十秒看看有沒有什麼"異味"。

乍看之下好像也是沒問題是吧 ? 那麼如果要新增一種動物以及它的叫聲呢 ? 就要再來這邊新增一個 if animal.name == 'new animal name',可是在 OCP 這個原則,希望我們要 新增 的話,不要更改到舊的 code,同時這也是一位重視效率的 programmer 會希望的一件事情,讓我們的程式保有一個 可擴充 的彈性,不要寫死,也能再次 避免牽一髮動全身的窘境,只要專注在新功能的邏輯上即可。

那麼有建議的解法嗎 ?

有der,可以利用 繼承(inherit)重寫(override) 每個動物叫聲的實作。

class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

		# abstract method
    def make_sound(self):
        pass


"""
python 的繼承是
在 class name 旁邊加小括號
"""
class Lion(Animal):
		# override make_sound() method
    def make_sound(self):
        return 'roar'


class Mouse(Animal):
    def make_sound(self):
        return 'squeak'


class Snake(Animal):
    def make_sound(self):
        return 'hiss'


def animal_sound(animals: list):
    for animal in animals:
		"""
		取用只需要同樣的介面
		好處是即使新增不同的動物
		這邊也不用改
		"""
        print(animal.make_sound())

animal_sound(animals)

上面這段 code 來自 OCP | solid.python

補充,有個很容易搞混的名詞解釋問題:Override、Overwrite、Overload

Liskov Substitution Principle (LSP)

里氏替換原則,想必是個叫做 Liskov 的人想出來的囉,替換是替換什麼呢 ?

一句話來說,就是 子類(Child Class/Sub Class) 能夠替換掉 父類(Parent Class/Super Class),其中 能替換 的意思包含「父類有的 method,子類也要有一樣的 method,而且 method 參數也必須相同」,先看看範例吧。

現在要算動物有幾隻腳。

def animal_leg_count(animals: list):
    for animal in animals:
        if isinstance(animal, Lion):
            print(lion_leg_count(animal))
        elif isinstance(animal, Mouse):
            print(mouse_leg_count(animal))
        elif isinstance(animal, Pigeon):
            print(pigeon_leg_count(animal))
        
animal_leg_count(animals)

上面這段 code 來自 LSP | solid.python

上面這段需要先判斷動物是什麼類別,才呼叫對應的數腳的方法,首先,這樣的寫法犯了 Open/Closed 原則,每次新增一個新的動物時,都還要來這邊加個 elif,第二是明明都是動物,這些動物都可以被數出有幾隻腳,為什麼不呼叫一樣的數腳方法,最後再導到不同數腳實作方法呢 ? 這就是 LSP 所講的,再來看看可以怎麼改善吧。

解法

class Animal:
	def leg_count(self):
		pass

class Lion(Animal):
	def leg_count(self):
		pass

class Mouse(Animal):
	def leg_count(self):
		pass

class Pigeon(Animal):
	def leg_count(self):
		pass

def animal_leg_count(animals: list):
    for animal in animals:
		"""
		不用檢查動物是什麼動物
		只要每一種動物都有實作
		自己的 leg_count() 即可
		"""
        print(animal.leg_count())
        
animal_leg_count(animals)

上面這段 code 來自 LSP | solid.python

值得注意的點是,Python 沒有像 Java 的 InterfaceAbstract 關鍵字,強制子類一定要 override 父類的 method,所以在實作 LSP 時,必須要自己多加注意,否則程式很容易出錯。

可以參考 (2017) Force child class to override parent's methods,強制子類一定要 override 父類的 method

Interface Segregation Principle (ISP)

介面隔離原則,從字面上看起來,應該是跟 介面 還有 隔離 相關吧 XD,那麼是要對介面做什麼隔離呢? 簡單來說,就是介面中的 methods 會隨著功能增長,越加越多而肥大,然而介面中的某些 methods,對於實作這個介面的 class 來說,可能不完全需要實作全部的 method,覺得越講越饒口,直接看範例吧。

假設有些動物會走、會飛、會跳。

class IAnimal:
	def walk(self):
		raise NotImplementError
	def fly(self):
		raise NotImplementError
	def jump(self):
		raise NotImplementError

raise NotImplementError,可以迫使每個實作 IAnimal 介面的 class 們,都必須要實作 method 才可以,不然會有 NotImplementError,這個方法也就是 LSP 最後提到強迫子類一定要 override 父類的 method 的方法之一。

class Dock(IAnimal):
	def walk(self):
		pass
	def fly(self):
		pass
	def jump(self):
		pass
	
class Dog(IAnimal):
	def walk(self):
		pass
	def fly(self):
		pass
	def jump(self):
		pass

...

可是,並不是每個動物都一定同時會走、會飛又會跳啊,但是我又想要強迫子類一定要實作這些 method,該怎麼辦呢?

還有一種情況,就算是每一種動物都同時會走、會飛又會跳,現在假設已經有十種動物都符合這條件,如果突然又發現,原來他們不是同時都會走、會飛又會跳,甚至還會游泳,那我在 IAnimal 新增一個 def swim(self),其他已經有實作 IAnimal 的子類,也就需要各自實作 swim() 才可以。現在只有十種需要個別實作 swim(),大腿捏一下把它做完還能忍受,那規模放大到五十、一百個,光是用想像的,就會覺得瞬間想放棄新增功能 XD。

class IAnimal:
	def walk(self):
		raise NotImplementError
	def fly(self):
		raise NotImplementError
	def jump(self):
		raise NotImplementError
	# 新增游泳功能
	def swim(self):
		raise NotImplementError
class Dock(IAnimal):
	def walk(self):
		pass
	def fly(self):
		pass
	def jump(self):
		pass
	# 每個子類都要跟著實作
	def swim(self):
		pass
	
class Dog(IAnimal):
	def walk(self):
		pass
	def fly(self):
		pass
	def jump(self):
		pass
	# 才新增兩個 class 我就想放棄了
	def swim(self):
		pass

...

相信看到這裡,你一定會覺得「乾,誰會寫出這麼白癡的程式碼阿」,別不信邪,你同事、你同學甚至你自己,也會不知不覺寫出這樣的 code,我先承認,我就寫過這樣的 code ...

當初我在實作一個血壓血糖紀錄系統,需要從 Android 端,上傳血壓血糖資料到雲端儲存,因為同事後端設計的關係,舒張壓、收縮壓、心律、血糖值必須分別透過不同 API 上傳,所以我就開了一個介面來讓 Retrofit 使用,每個值都必須要有 POST 和 GET 兩個 method,這個介面包含了 8 個 method,代表每個實作這個介面的地方都需要實作這 8 個 method,可是明明上傳血糖的地方,我只需要血糖,卻必須要連血壓的一起實作;這故事還沒完,後來需要新增一個「今天有沒有吃藥」的功能,我在介面一樣多開了 POST 和 GET 兩個 method,這時候我就必須硬著頭皮,在上傳血壓的地方也要實作吃藥的方法、在上傳血糖的地方也要實作吃藥的方法,更好笑的是,我還要在上傳吃藥的地方也實作血壓、血糖的方法,明明不需要,卻還是要放一個空的在那邊,那個時候我就覺得我自己是白癡 ...

所以,痛過才知道 SOLID 的重要性,不要不信邪 XD。

那麼解法呢 !! (敲碗

class IAnimal():
	def behavior(self):
		pass

class Dock(IAnimal):
	def behavior(self):
		pass

class Dog(IAnimal):
	def behavior(self):
		pass

其實就是把原本定得很細的 method,給它再抽象一層,不要把功能給寫死,實際作的事就交給十作的 class 去決定就好。

那麼我的血淚故事可以怎麼解決呢 ? 就把它分成 post()get() 就好惹,唉 ...。

Dependency Inversion Principle (DIP)

依賴反轉原則,什麼是依賴(Dependency)? 又要反轉? 你可以把 依賴 想像成,我每天早上都要喝一杯咖啡,那麼就是我 依賴 咖啡,那麼用程式的邏輯來解釋依賴,就是有一個 Person class 和 Coffee Class,直接看看程式碼。

class Person:
	def behavior_in_morning(self, coffee):
		print("Drink", coffee.name, ".")

class Coffee:
	def __init__(self, name):
		self.name = name

Person().behavior_in_morning(Coffee("City Cafe"))
# 輸出為 Drink City Cafe .

class SoyMilk:
	def __init__(self, name):
		self.name = name

這樣瞭解了依賴的關係了嗎 ? 其實再講得更簡單點,可以把 依賴 想像成 在你的 method 裡有用到某個物件就是一種依賴,不論是取得物件的屬性、修改物件的屬性,還是執行物件的功能等等,都是一種依賴,還有繼承與實作也是一種依賴。

那麼今天有另一個人它習慣早上喝豆漿呢 ? 我這個 Person class 就必須得修改,這樣就違反了 OCP,所以這樣不是個好設計。

解法

class Drink:
    def __init__(self, name):
        self.name = name

class SoyMilk(Drink):
    def __init__(self, name):
        self.name = name

class Coffee(Drink):
    def __init__(self, name):
        self.name = name

class Person:
    def behavior_in_morning(self, drink):
        print("Drink", drink.name, ".")

Person().behavior_in_morning(SoyMilk("IMei Sugarless"))
# 輸出為 Drink IMei Sugarless .

Person().behavior_in_morning(Coffee("City Coffee"))
# 輸出為 Drink City Coffee .

可以將再抽象一層 Drink class,然而 Personbehavior_in_morning 的參數改成 Drink,這樣身為一個人,就可以自由地選擇他早上想喝的飲料了,耶呼~

你或許有個疑問:「奇怪,你不是說依賴反轉嗎? 反轉在哪裡?」,這有點難理解,依照原文:

Dependency should be on abstractions not concretions

A. High-level modules
should not depend upon low-level modules. Both should depend upon abstractions.

B. Abstractions should not depend on details. Details should depend upon
abstractions.

直接翻譯的話,就是「高階不依賴低階,高階與低階都應該依賴抽象,抽象不依賴細節,細節應該依賴抽象」,簡單來說,就是 在需要有彈性的地方,不應該直接定死細節,而是需要再抽象一層

其實你會感覺這個範例,怎麼好像跟前面提到的每一個原則都適用阿 XD,沒錯,所以 一段好的程式碼,就是都符合 SOLID 五原則,但也不是說要無限的抽象下去。

像在 Person class 的 behavior_in_morning 就還可以再改善,本身這個 method 在命名上就已經很細節了,還定到時間點,再加上 早上的行為 有可能是 運動 或 工作,而我在範例的設計卻只有喝飲料,所以還能再深一層地抽象下去,可是 ! 如果當我的系統不需要這麼有彈性的話,那我其實不需要抽象到這麼多層,只要把 behavior_in_morning 改成 drink 並吃下 時間飲料 的參數,就可以了,如果我無限地抽象下去的話,就是 overdesign,你必須自己考量 擴充彈性開發效益,來決定你怎麼設計這支程式。

單日心得總結

好的,我第二次晚了一天發文 QQ,昨天下午有兩場專題分享會,晚上還有一場讀書會,原本想說不作進度,改發篇技術研究,應該會比較省時間,但其實沒有嗚嗚,昨天寫到 OCP 結束就已經晚上十一點半了,但又不想把 SOLID 拆成兩篇來發,只好,恩... 但我心念一轉,反正只要我的時間不要浪費掉就可以了,不用拘泥這種死規定,畢竟這個規定還是我自己設定的,不需要給自己綁死~

RS 建議,今天講的 SOLID 五個原則,最好多花點時間把它搞懂,並且把例子熟記起來,熟到你可以說到其中一個原則,就能直接把例子想出來或寫出來,可以幫你省了很多 debug、改 code 的時間,但有時候就是要痛過才會知道,沒有被嚴重的 bug 拖過時間,你就不會知道 SOLID 給的建議有多重要。

我是 RS,這是我的 不做怎麼知道系列 文章,我們 明天見。



1 則留言

0
心原一馬
iT邦研究生 5 級 ‧ 2020-02-14 15:57:31

邦友您好,謝謝你的分享,
範例清楚易懂,
這樣的寫程式思維給我很多啟發,
謝謝您~
/images/emoticon/emoticon07.gif/images/emoticon/emoticon07.gif/images/emoticon/emoticon07.gif

Sam iT邦新手 5 級 ‧ 2020-02-14 17:43:30 檢舉

Hi 心原一馬,感謝你的留言~
有看到你也很用心地在經營你的文章呢,一起加油 !

我要留言

立即登入留言