Design Pattern (2) - Design Principles (設計原則)

您可於此 design_pattern repo 下載 Design Pattern 系列程式碼。

Design Principle

Design Principle 是用來幫助我們改善物件導向設計的建議,幫助我們設計出更好的軟體。

SOLID 物件導向程式設計基本五大原則

Single Responsibility Principle (SRP) 單一職責原則

物件應該僅具有一種單一功能,應只會有一個理由去改變此物件

e.g. 我們要做登入頁面功能,我們會這樣寫

  • class LoginViewController {
        func loginToServer(account: String, password: String, callback: Result<String, Error>) {
    //        Alamofire... { callback() }
    //        Volley... { callback() }
        }
    
        func saveToDB(account: String, password: String) {
            // sql.save()...
        }
    
        func deleteFromDB(account: String) {
            // sql.delete()
        }
    }
    
  • class LoginActivity {
        fun loginToServer(account: String, password: String, callback:  model.Result<String, Error>) {
    //        Alamofire... { callback() }
    //        Volley... { callback() }
        }
    
        fun saveToDB(account: String, password: String) {
            // sql.save()...
        }
    
        fun deleteFromDB(account: String) {
            // sql.delete()
        }
    }
    

依照單一職責原則,我們應該要將 API 及 DB 的功能分開,修改如下

  • class ServerApiRequestService {
        func login(account: String, password: String, callback: Result<String, Error>) {
    //        Alamofire... { callback() }
    //        Volley... { callback() }
        }
    }
    
    class DBService {
        func save(account: String, password: String) {
    //        sql.save()
        }
    
        func delete(account: String) {
    //        sql.delete()
        }
    }
    
    class LoginViewControllerSRP {
        var apiRequestService: ServerApiRequestService? = nil
        var dbService: DBService? = nil
    
        func loginToServer(account: String, password: String, callback: Result<String, Error>) {
            apiRequestService?.login(account: account, password: password, callback: callback)
        }
    
        func saveToDB(account: String, password: String) {
            dbService?.save(account: account, password: password)
        }
    
        func deleteFromDB(account: String) {
            dbService?.delete(account: account)
        }
    }
    
  • class ServerApiRequestService {
        fun login(account: String, password: String, callback: model.Result<String, Error>) {
    //        Alamofire... { callback() }
    //        Volley... { callback() }
        }
    }
    
    class DBService {
        fun save(account: String, password: String) {
    //        sql.save()
        }
    
        fun delete(account: String) {
    //        sql.delete()
        }
    }
    
    class LoginActivitySRP {
        var apiRequestService: ServerApiRequestService? = null
        var dbService: DBService? = null
    
        fun loginToServer(account: String, password: String, callback: model.Result<String, Error>) {
            apiRequestService?.login(account, password, callback)
        }
    
        fun saveToDB(account: String, password: String) {
            dbService?.save(account, password)
        }
    
        fun deleteFromDB(account: String) {
            dbService?.delete(account)
        }
    }
    

有些文章會說 save, delete function 也須拆開在不同 class(DeleteDBService, SaveDBService)處理, 因為 save. delete 是不同職責修改項目, 不應動到另一個 class, 但我認為這樣 Over Design 反而不好維護, 拆分職責應適當不過度

Open Closed Principle (OCP) 開放封閉原則

對於擴充開放,對於修改封閉

e.g. 我們常常會需要檢查使用者登入的帳密等等,我們來做一個檢查器吧

  • enum ValidatorType {
        case username
        case password
    }
    
    enum ValidationError: Error, Equatable {
        case isEmpty(errorMessage: String)
        case containsSpecialChar(errorMessage: String)
    
        static func == (lhs: Self, rhs: Self) -> Bool {
            switch (lhs, rhs) {
            case (.isEmpty(_), .isEmpty(_)):
                return true
            case (.containsSpecialChar(_), .containsSpecialChar(_)):
                return true
            default:
                return false
            }
        }
    }
    
    class Validator {
        func validated(_ value: String, validatorType: ValidatorType) throws -> String {
            switch validatorType {
            case .username:
                guard !value.isEmpty else {
                    throw ValidationError.isEmpty(errorMessage: "isEmpty")
                }
                guard !value.isContainsSpecialChars() else {
                    throw ValidationError.containsSpecialChar(errorMessage: "containsSpecialChar")
                }
            case .password:
                guard !value.isEmpty else {
                    throw ValidationError.isEmpty(errorMessage: "isEmpty")
                }
            }
            return value
        }
    }
    
  • enum class ValidatorType {
        Username,
        Password;
    }
    
    sealed class ValidationException: Exception() {
        class IsEmpty(val errorMessage: String): ValidationException()
        class ContainsSpecialChar(val errorMessage: String): ValidationException()
    
        override fun equals(other: Any?): Boolean {
            return when {
                this is IsEmpty && other is IsEmpty -> true
                this is ContainsSpecialChar && other is ContainsSpecialChar -> true
                else -> false
            }
        }
    }
    
    class Validator {
        @Throws(ValidationException::class)
        fun validated(value: String, validatorType: ValidatorType): String {
            when (validatorType) {
                ValidatorType.Username -> when {
                    value.isEmpty() -> throw ValidationException.IsEmpty("isEmpty")
                    value.isContainsSpecialChars() -> throw ValidationException.ContainsSpecialChar("containsSpecialChar")
                }
                ValidatorType.Password -> when {
                    value.isEmpty() -> throw ValidationException.IsEmpty("isEmpty")
                }
            }
            return value
        }
    }
    

但假如今天客戶想要增加 Email、Phone Number、Device Mac 等等的格式檢查,那我們必須修改到 Validator class 的程式碼,這樣會影響到其他程式碼,打破了 Open-Closed Principle,對於擴充開放,對於修改封閉,那我們可以怎麼改進,如下

  • protocol ValidatorConvertible {
        func validated(_ value: String) throws -> String
    }
    
    class UserNameValidator: ValidatorConvertible {
    
        func validated(_ value: String) throws -> String {
            guard !value.isEmpty else {
                throw ValidationError.isEmpty(errorMessage: "isEmpty")
            }
            guard !value.isContainsSpecialChars() else {
                throw ValidationError.containsSpecialChar(errorMessage: "containsSpecialChar")
            }
            return value
        }
    }
    
    class PasswordValidator: ValidatorConvertible {
    
        func validated(_ value: String) throws -> String {
            guard !value.isEmpty else {
                throw ValidationError.isEmpty(errorMessage: "isEmpty")
            }
            return value
        }
    }
    
  • interface ValidatorConvertible {
        @Throws(ValidationException::class)
        fun validated(value: String): String
    }
    
    class UserNameValidator: ValidatorConvertible {
    
        override fun validated(value: String): String {
            when {
                value.isEmpty() -> throw ValidationException.IsEmpty("isEmpty")
                value.isContainsSpecialChars() -> throw ValidationException.ContainsSpecialChar("containsSpecialChar")
            }
            return value
        }
    }
    
    class PasswordValidator: ValidatorConvertible {
    
        override fun validated(value: String): String {
            when {
                value.isEmpty() -> throw ValidationException.IsEmpty("isEmpty")
            }
            return value
        }
    }
    

如此要新增 Email、Phone Number、Device Mac 格式檢查,我們只需要新增相對應的檢查器即可 EmailValidator、PhoneNumberValidator 及 DeviceMacValidator,既不會影響其他程式碼(對修改封閉),也容易擴充新的檢查器(對擴充開放)

Liskov Substitution Principle (LSP) 里氏替換原則

程式中的物件應該是可以在不改變程式正確性的前提下被它的子類所替換的

e.g. 我們需要計算正方形及長方形的面積

  • class Rectangle {
        var height: Int
        var width: Int
    
        init(height: Int, weight: Int) {
            self.height = height
            self.width = weight
        }
    
        func getArea() -> String {
            return "\(height * width)"
        }
    }
    
    class Square: Rectangle {
        override func getArea() -> String {
            if height != width {
                return "長寬需一致"
            } else {
                return super.getArea()
            }
        }
    }
    
    let rectangle = Rectangle(height: 2, weight: 3)
    print("\(rectangle.getArea())")
    let square = Square(height: 2, weight: 3)
    print("\(square.getArea())")
    
  • open class Rectangle(protected val height: Int, protected val width: Int) {
    
        open fun getArea(): String {
            return "${height * width}"
        }
    }
    
    class Square(height: Int, width: Int) : Rectangle(height, width) {
        override fun getArea(): String {
            return if (height != width) {
                "長寬需一致"
            } else {
                super.getArea()
            }
        }
    }
    
    val rectangle = Rectangle(2, 3)
    println("${rectangle.getArea()}")
    val square = Square(2, 3)
    println("${square.getArea()}")
    

上面的例子我們將正方形繼承長方形,但正方形的 getArea() 卻不符合長方形的結果,這就打破了 LSP。

  • 增加程式碼的健全度,在使用不同的子類別的時候,可以大幅度的保證彼此之間的相容性。只要父類別可以使用,基本上子類別也可以使用
  • 子類別如果要新增功能,獨立在父類別的功能之外,才不會在搬移到其他子類別的時候發生奇怪的問題,也可以將功能切分乾淨,區分職責

Interface Segregation Principle (ISP) 介面隔離原則

多個特定客戶端介面要好於一個寬泛用途的介面

e.g. 今天需要設計如何讓使用者操作車子

  • protocol Car {
        func startEngine()
        func stopEngine()
        func enableDebugMode()
    }
    
    class Driver: Car {
        func startEngine() {
            print("start engine")
        }
    
        func stopEngine() {
            print("stop engine")
        }
    
        func enableDebugMode() {
            print("enable debug mode")
        }
    }
    
    class Engineer: Car {
        func startEngine() {
            print("start engine")
        }
    
        func stopEngine() {
            print("stop engine")
        }
    
        func enableDebugMode() {
            print("enable debug mode")
        }
    }
    
  • interface Car {
        fun startEngine()
        fun stopEngine()
        fun enableDebugMode()
    }
    
    class Driver: Car {
        override fun startEngine() {
            println("start engine")
        }
    
        override fun stopEngine() {
            println("stop engine")
        }
    
        override fun enableDebugMode() {
            println("enable debug mode")
        }
    }
    
    class Engineer: Car {
        override fun startEngine() {
            println("start engine")
        }
    
        override fun stopEngine() {
            println("stop engine")
        }
    
        override fun enableDebugMode() {
            println("enable debug mode")
        }
    }
    

工程師可以開啟 DebugMode, 但駕駛使用者不應該可以開啟 DebugMode,因此我們來改變程式碼將 enableDebugMode() 隔離成獨立介面吧!

  • protocol Car1 {
        func startEngine()
        func stopEngine()
    }
    
    protocol Debuggable {
        func enableDebugMode()
    }
    
    class Driver1: Car1 {
        func startEngine() {
            print("start engine")
        }
    
        func stopEngine() {
            print("stop engine")
        }
    }
    
    class Engineer1: Car1, Debuggable {
        func startEngine() {
            print("start engine")
        }
    
        func stopEngine() {
            print("stop engine")
        }
    
        func enableDebugMode() {
            print("enable debug mode")
        }
    }
    
  • interface Car1 {
        fun startEngine()
        fun stopEngine()
    }
    
    interface Debuggable {
        fun enableDebugMode()
    }
    
    class Driver1: Car1 {
        override fun startEngine() {
            println("start engine")
        }
    
        override fun stopEngine() {
            println("stop engine")
        }
    }
    
    class Engineer1: Car1, Debuggable {
        override fun startEngine() {
            println("start engine")
        }
    
        override fun stopEngine() {
            println("stop engine")
        }
    
        override fun enableDebugMode() {
            println("enable debug mode")
        }
    }
    

如此就只有工程師能進入 DebugMode

Dependency Inversion Principle (DIP) 依賴反向原則

高階模組不應該依賴於低階模組,兩者都應該依賴抽象, 抽象不應該依賴細節,細節應該依賴抽象。

e.g. 設計一個能不同房間加入不同 IoT 設備的系統,可以新增刪除房間,例如客廳有智慧音箱、溫度控制器,廚房有煙霧偵測器等…

  • class Room {
        var no: Int
        var device: [String]
    
        init(no: Int, device: [String]) {
            self.no = no
            self.device = device
        }
    }
    
    class SQLiteService {
        func saveRoom(room: Room) {
            print("SQLiteService save")
        }
    
        func deleteRoom(no: Int) {
            print("SQLiteService delete")
        }
    }
    
    class RoomViewController {
        var sqlDBService: SQLiteService? = nil
    
        init(sqlDBService: SQLiteService) {
            sqlDBService
        }
    
        func saveRoomToDB(room: Room) {
            sqlDBService?.saveRoom(room: room)
        }
    
        func deleteRoomFromDB(no: Int) {
            sqlDBService?.deleteRoom(no: no)
        }
    }
    
    let roomVC = RoomViewController(sqlDBService: SQLiteService())
    let room = Room(no: 1, device: ["IPCam", "VDP"])
    roomVC.saveRoomToDB(room: room)
    roomVC.deleteRoomFromDB(no: room.no)
    
  • class Room {
        val no: Int
        val device: List<String>
    
        constructor(no: Int, device: List<String>) {
            this.no = no
            this.device = device
        }
    }
    
    class SQLiteService {
        fun saveRoom(room: Room) {
            println("SQLiteService save")
        }
    
        fun deleteRoom(no: Int) {
            println("SQLiteService delete")
        }
    }
    
    class RoomActivity {
        var sqlDBService: SQLiteService? = null
    
        constructor(sqlDBService: SQLiteService) {
            this.sqlDBService = sqlDBService
        }
    
        fun saveRoomToDB(room: Room) {
            sqlDBService?.saveRoom(room)
        }
    
        fun deleteRoomFromDB(no: Int) {
            sqlDBService?.deleteRoom(no)
        }
    }
    
    val roomVC = RoomActivity(SQLiteService())
    val room = Room(1, listOf("IPCam", "VDP"))
    roomVC.saveRoomToDB(room)
    roomVC.deleteRoomFromDB(room.no)
    

如果今天 SQLite 因某些問題(速度過慢等等…)因素,導致我們必須換成 CoreData 或其他 Database 呢? 你會發現我們無法抽換,但如果依賴於抽象編寫,程式碼就會非常好抽換及測試,下面讓我們修改一下程式碼

  • protocol DataBaseService {
        func saveRoom(room: Room)
        func deleteRoom(no: Int)
    }
    
    class SQLiteDBService: DataBaseService {
        func saveRoom(room: Room) {
            print("SQLiteDBService save")
        }
    
        func deleteRoom(no: Int) {
            print("SQLiteDBService delete")
        }
    }
    
    class CoreDataDBService: DataBaseService {
        func saveRoom(room: Room) {
            print("CoreDataDBService save")
        }
    
        func deleteRoom(no: Int) {
            print("CoreDataDBService delete")
        }
    }
    
    class MySQLDBService: DataBaseService {
        func saveRoom(room: Room) {
            print("MySQLDBService save")
        }
    
        func deleteRoom(no: Int) {
            print("MySQLDBService delete")
        }
    }
    
    class Room2ViewController {
        var databaseService: DataBaseService? = nil
    
        init(databaseService: DataBaseService) {
            self.databaseService = databaseService
        }
    
        func saveRoomToDB(room: Room) {
            databaseService?.saveRoom(room: room)
        }
    
        func deleteRoomFromDB(no: Int) {
            databaseService?.deleteRoom(no: no)
        }
    }
    
    let sqliteDB = SQLiteDBService()
    let coreDataDB = CoreDataDBService()
    let mysqlDB = MySQLDBService()
    
    let room2VC = Room2ViewController(databaseService: sqliteDB)
    let room2 = Room(no: 2, device: ["IPCam", "VDP"])
    
    // sql
    room2VC.saveRoomToDB(room: room2)
    room2VC.deleteRoomFromDB(no: room2.no)
    
    // coredata
    room2VC.databaseService = coreDataDB
    room2VC.saveRoomToDB(room: room2)
    room2VC.deleteRoomFromDB(no: room2.no)
    
    // mysql
    room2VC.databaseService = mysqlDB
    room2VC.saveRoomToDB(room: room2)
    room2VC.deleteRoomFromDB(no: room2.no)
    
  • interface DataBaseService {
        fun saveRoom(room: Room)
        fun deleteRoom(no: Int)
    }
    
    class SQLiteDBService: DataBaseService {
        override fun saveRoom(room: Room) {
            println("SQLiteDBService save")
        }
    
        override fun deleteRoom(no: Int) {
            println("SQLiteDBService delete")
        }
    }
    
    class CoreDataDBService: DataBaseService {
        override fun saveRoom(room: Room) {
            println("CoreDataDBService save")
        }
    
        override fun deleteRoom(no: Int) {
            println("CoreDataDBService delete")
        }
    }
    
    class MySQLDBService: DataBaseService {
        override fun saveRoom(room: Room) {
            println("MySQLDBService save")
        }
    
        override fun deleteRoom(no: Int) {
            println("MySQLDBService delete")
        }
    }
    
    class Room2Activity {
        var databaseService: DataBaseService? = null
    
        constructor(databaseService: DataBaseService) {
            this.databaseService = databaseService
        }
    
        fun saveRoomToDB(room: Room) {
            databaseService?.saveRoom(room)
        }
    
        fun deleteRoomFromDB(no: Int) {
            databaseService?.deleteRoom(no)
        }
    }
    
    val sqliteDB = SQLiteDBService()
    val coreDataDB = CoreDataDBService()
    val mysqlDB = MySQLDBService()
    
    val room2VC = Room2Activity(sqliteDB)
    val room2 = Room(2, listOf("IPCam", "VDP"))
    
    // sql
    room2VC.saveRoomToDB(room2)
    room2VC.deleteRoomFromDB(room2.no)
    
    // coredata
    room2VC.databaseService = coreDataDB
    room2VC.saveRoomToDB(room2)
    room2VC.deleteRoomFromDB(room2.no)
    
    // mysql
    room2VC.databaseService = mysqlDB
    room2VC.saveRoomToDB(room2)
    room2VC.deleteRoomFromDB(room2.no)
    

抽象 - interface, protocol, abstract class

  • 依賴於抽象可以使我們的程式碼更加有彈性, 也更好抽換依賴物件
  • 養成多寫一層抽象成能使程式碼更好維護、測試
  • 抽象層能使我們非常容易的製作假物件快速測試程式邏輯

Encapsulate What Varies 封裝變化

找出程式中可能需要更動之處,把它們獨立出來,不要和那些不需要更動的程式碼混再一起。

假設今天要設計一間鬆餅店,可以訂購鬆餅

  • func orderPancake(type: String) {
        var pancake: Pancake?
    
        // Code that is varying
        switch type {
        case "classic":
            pancake = ClassicPancake()
        case "blueberry":
            pancake = BlueberryPancake()
        case "banana":
            pancake = BananaPancake()
    //    case "chocolate chip":
    //        pancake = ChocolateChipPancake()
        default:
            pancake = ClassicPancake()
        }
    
        // Important code that does not vary
        pancake?.cook()
        pancake?.plate()
        pancake?.addButter()
    }
    
  • fun orderPancake(type: String) {
    
        // Code that is varying
        val pancake: Pancake = when (type) {
            "classic" -> ClassicPancake()
            "blueberry" -> BlueberryPancake()
            "banana" -> BananaPancake()
    //        "chocolate chip" -> ChocolateChipPancake()
            else -> ClassicPancake()
        }
    
        // Important code that does not vary
        pancake.cook()
        pancake.plate()
        pancake.addButter()
    }
    

但老闆今天想增加新口味 ChocolateChip ,但 cook(), plate(), addButter() 這些程式並不需要修改,所以我們應該將會變化的程式碼抽出來封裝,減少對不需變動的程式碼產生影響。

  • public class SimplePancakeFactory {
        public class func createPancake(type: String) -> Pancake? {
            var pancake: Pancake?
    
            // Code that is varying
            switch type {
            case "classic":
                pancake = ClassicPancake()
            case "blueberry":
                pancake = BlueberryPancake()
            case "banana":
                pancake = BananaPancake()
            //    case "chocolate chip":
            //        pancake = ChocolateChipPancake()
            default:
                pancake = ClassicPancake()
            }
    
            return pancake
        }
    }
    
    func orderPancakeWithFactory(type: String) {
        let pancake = SimplePancakeFactory.createPancake(type: type)
    
        // Important code that does not vary
        pancake?.cook()
        pancake?.plate()
        pancake?.addButter()
    }
    
  • object SimplePancakeFactory {
        fun createPancake(type: String): Pancake {
            return when (type) {
                "classic" -> ClassicPancake()
                "blueberry" -> BlueberryPancake()
                "banana" -> BananaPancake()
    //        "chocolate chip" -> ChocolateChipPancake()
                else -> ClassicPancake()
            }
        }
    }
    
    fun orderPancakeWithFactory(type: String) {
        val pancake = SimplePancakeFactory.createPancake(type)
    
        // Important code that does not vary
        pancake.cook()
        pancake.plate()
        pancake.addButter()
    }
    

如此我們就可以隨時添加新口味且不會影響其他不會變動的程式碼。

Favor composition over inheritance 多用合成,少用繼承

HAS-A (composition) can be better than IS-A (inheritance)

盡量使用合成來取代繼承,並不是完全不使用繼承,而是多數情況下你應該考慮使用合成而不是繼承

假設今天要設計一間咖啡店,裡面有賣很多咖啡,我們可以這樣設計

但如果今天客人要加 Butter 及 Milk,我們又要定義一個新的 class CoffeeWithButterAndMilk 繼承 Coffee 我們會發現,隨著調味料種類越多,咖啡的組合也會越來越多,以及牛奶價格上漲,那所有包含牛奶的 Coffee 都必須修改

如果我們用合成取代繼承呢,從牛奶咖啡是(is-A)咖啡,變成咖啡有(has-A)各種調味料

你會發現使用合成取代繼承有下列好處

  1. 可以在 Run time 替換不同的調料物件
  2. 新增一種新調料只需新增一個對應的 class
  3. 沒有重複的程式碼
  4. 避免 class 數量爆炸性增加

再次強調不是完全不用繼承,而是”盡量”用合成取代繼承,像圖中例子 Mocha、Butter 及 Milk 也是有使用到繼承去繼承 Condiment

Loose Coupling 鬆耦合

將每個組件獨立開來,使部件之間的相互影響降低

再來看一個範例,今天要做一個 Weather App,他可以取得溫度並顯示在螢幕上。

你會發現 WeatherApp 與 LCDScreen 緊密耦合,今天如果老闆想改成在 Widget 或 LED 上來顯示,WeatherApp 的 screen 屬性及 display 方法都要修改,且不能在 Run time 任意替換。

我們改一下 UML 來將兩者之間做解耦

這樣不管老闆想改成什麼螢幕都能夠很輕易替換,因為 WeatherApp 依賴的是介面,不再是實體,善用抽象介面解耦兩個實體物件吧!

Program to Interfaces 基於介面編程

寫程式是針對介面而寫,而不是針對實踐方式而寫。

當你針對介面編寫,你會發現任何物件都變得非常好抽換,當你需要注入假資料測試,你可以作假物件實作此介面即可,當你在做 MVC MVP 等架構,只需要實作此介面就能輕易替換組件,當你開始已介面去思考,你會發現程式碼變得非常有彈性、且好擴充測試,Apple 甚至在 WWDC15 中提到 Swift 的 Protocol-Oriented Programming,代表介面(協議)思考的重要性,所以從現在起開始從介面思考吧!

來個例子,今天我們要設計一個基本網站,有一個 WebSystem 及 DB 來存讀資料

今天我們想在上 Production 前,將 CommercialDB 換成 TestDB 做測試,但目前 KillerWebSystem 的 db 型別為 CommercialDB 無法抽換

讓我們修改一下

寫一個 AbstractDB 的介面或抽象類別,讓 CommercialDB 及 TestDB 都實作此介面,這樣就能很輕鬆的在測試環境替換 DB

總結

在本篇文章中,我們深入探討了設計原則的重要性,並透過實際的例子,如何在不同環境下靈活切換數據庫實例,展示了這些原則在實際開發中的應用。通過引入 AbstractDB 介面,我們看到了如何將具體的數據庫實現(如 CommercialDB 和 TestDB)與系統的其他部分解耦,從而提高了程式碼的靈活性和可維護性。

這一過程不僅鞏固了我們對物件導向概念的理解,也為我們進一步探索設計模式鋪平了道路。設計原則是構建健壯、可擴展和靈活系統的基石,而設計模式則提供了一套解決特定設計問題的模板和最佳實踐。

隨著我們即將進入設計模式的探索,期待您能夠將這些原則與即將學習的模式結合起來,進一步提升您的軟件設計和開發能力。下一篇文章將帶您深入設計模式的世界,探索如何通過這些模式解決更加複雜的設計挑戰,敬請期待。

Object-Oriented Concepts -> Design Principle -> Design Pattern

參考

Note: 如果有任何建議、問題或不同想法,歡迎留言或寄信給我,可以一起討論進步成長 🙂




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • 💡 一台電腦操作多個 GitHub 帳號:最簡單快速的 SSH 設定方法
  • 🚀 如何使用 Excalidraw AI 快速生成專業級圖表,提升工作效率!
  • Setup Development Environment on a New macOS
  • Design Pattern (28) - Interpreter Pattern (解譯器模式)
  • Design Pattern (27) - Visitor Pattern (訪問者模式)