设计模式
设计模式#
设计模式简介&分类#
设计模式是软件开发中常用的一种设计思想,用于解决特定的问题。根据设计模式的分类方式不同,可以有不同的模式数量。
按照常用的分类方式,设计模式可以分为三类:
创建型模式(Creational Patterns):用于创建对象的模式,包括简单工厂模式、工厂方法模式、抽象工厂模式、建造者模式、原型模式和单例模式等。
结构型模式(Structural Patterns):用于描述如何组合类和对象以形成更大的结构,包括适配器模式、桥接模式、组合模式、装饰器模式、外观模式、享元模式和代理模式等。
行为型模式(Behavioral Patterns):用于描述对象之间的通信方式和职责划分,包括责任链模式、命令模式、解释器模式、迭代器模式、中介者模式、备忘录模式、观察者模式、状态模式、策略模式、模板方法模式和访问者模式等。
各个设计模式解决什么问题#
简单工程模式#
简单工厂模式(Simple Factory Pattern)是一种创建型模式,它提供了一种创建对象的统一接口,可以根据不同的参数返回不同类的实例对象,而客户端无需了解这些具体的类。
简单工厂模式一般用于解决以下问题:
需要创建的对象较少:如果只需要创建少量的对象,使用简单工厂模式可以减少代码的复杂性。
客户端不需要知道创建的具体对象:简单工厂模式将创建对象的逻辑封装在工厂类中,客户端只需要调用工厂方法即可获得所需的对象,无需知道具体的创建过程。
代码复用:如果多个客户端需要创建相同的对象,使用简单工厂模式可以避免代码的重复。
需要注意的是,简单工厂模式只适用于创建对象较少的场景,如果需要创建的对象较多,则可以考虑使用工厂方法模式或抽象工厂模式。此外,简单工厂模式也有其缺点,例如增加新的对象类型需要修改工厂类的代码,违反了开闭原则,因此在设计时需要谨慎使用。
1 | package main |
在上面的代码中,我们定义了 Product 接口和两个实现了该接口的具体产品 ProductA 和 ProductB。然后定义了 Factory 工厂类,其中的 CreateProduct 方法根据传入的参数返回不同的产品实例。最后在 main 函数中,我们使用工厂类创建了两个不同的产品并调用其 Show 方法展示出不同的效果。
工厂方法模式#
工厂方法模式(Factory Method Pattern)是一种创建型模式,它将对象的创建延迟到子类中来实现。工厂方法模式定义了一个用于创建对象的接口,让子类决定将哪一个类实例化。这样做的好处是将对象的创建和使用分离,客户端无需关心对象的创建细节,只需要关心如何使用对象即可。
工厂方法模式一般用于解决以下问题:
需要创建复杂对象:如果需要创建的对象比较复杂,需要进行一系列的初始化操作,使用工厂方法模式可以将这些初始化操作封装到具体的工厂子类中,让客户端更加简单地创建对象。
客户端不需要知道创建的具体对象:工厂方法模式将对象的创建封装在工厂子类中,客户端只需要通过工厂接口创建对象即可,无需知道具体的创建过程。
代码复用:如果多个客户端需要创建相同的对象,使用工厂方法模式可以避免代码的重复。
需要注意的是,工厂方法模式需要为每个产品定义一个具体的工厂子类,如果需要创建的产品很多,会导致类的数量增多,影响代码的可维护性,因此在使用时需要权衡设计的复杂度和代码的可维护性。同时,工厂方法模式也存在一定的开销,因为需要为每个产品定义一个工厂类,可能会导致类的数量增多,影响应用程序的性能。
1 | package main |
在上面的代码中,我们定义了 Product 接口和两个实现了该接口的具体产品 ProductA 和 ProductB。然后定义了 Factory 工厂接口和两个实现了该接口的具体工厂 FactoryA 和 FactoryB,每个工厂实现了自己的 CreateProduct 方法,用于创建对应的产品实例。最后在 main 函数中,我们使用不同的工厂创建了不同的产品并调用其 Show 方法展示出不同的效果。
抽象工厂模式#
抽象工厂模式(Abstract Factory Pattern)是一种创建型模式,它提供了一种创建一系列相关或相互依赖对象的接口,而无需指定它们具体的类。
抽象工厂模式一般用于解决以下问题:
需要创建一系列相关或相互依赖的对象:如果需要创建的一系列对象具有相关性或者相互依赖,使用抽象工厂模式可以避免对象之间的耦合度过高。
系统需要独立于它的产品的创建、组合和表示:抽象工厂模式通过封装产品的创建过程,使得系统与具体产品的实现相分离,从而可以方便地替换产品系列,提高系统的灵活性和可维护性。
需要注意的是,抽象工厂模式一般适用于产品族(即相关联的产品组合)的创建,而不是单一产品的创建。此外,抽象工厂模式的缺点是扩展产品族比较困难,因为需要修改抽象工厂接口及其所有实现类,增加系统的复杂度和维护难度。
1 | package main |
在上面这个示例中,我们定义了两个产品族:Pizza 和 Beverage。Pizza 产品族包含 CheesePizza 和 PepperoniPizza 两种产品,而 Beverage 产品族包含 Coke 和 Sprite 两种产品。
我们还定义了一个抽象工厂接口 PizzaAndBeverageFactory,其中包含两个方法 CreatePizza() 和 CreateBeverage(),分别用于创建 Pizza 和 Beverage 产品。
最后,我们定义了两个具体的工厂类 PizzaAndCokeFactory 和 PizzaAndSpriteFactory,它们分别实现了 PizzaAndBeverageFactory 接口,并实现了 CreatePizza() 和 CreateBeverage() 方法,用于创建具体的产品。
在 main 函数中,我们首先使用 PizzaAndCokeFactory 工厂创建一个 CheesePizza 产品和一个 Coke 产品,然后使用 PizzaAndSpriteFactory 工厂创建一个 PepperoniPizza 产品和一个 Sprite 产品。最后,我们分别调用了这些产品的方法,输出了它们各自的信息。
需要注意的是,如果需要添加一种新的产品族(比如 Dessert 产品族),则需要新增一个新的抽象产品接口 Dessert,以及对应的具体产品类和抽象工厂接口及其实现类。这也是抽象工厂模式的一种缺陷,即扩展产品族比较困难,容易引起类层次结构的复杂度。
建造者模式#
建造者模式(Builder Pattern)主要解决的是对象的创建过程过于复杂而导致代码难以维护的问题。当需要创建一个包含多个组成部分的复杂对象时,如果直接在客户端代码中一步步创建每个部分并组装起来,将会导致代码冗长、可读性差、可维护性差等问题。
建造者模式通过将对象的构建过程抽象出来并将其封装在一个独立的 Builder 类中,使得客户端代码只需要关注需要创建的对象的类型和配置参数,而不需要关心其具体的构建过程。这样可以使得代码更加简洁、易于维护,同时也方便了对象的复用和扩展。
建造者模式通常涉及到以下几个角色:
产品(Product):需要被创建的复杂对象。
抽象建造者(Builder):定义了对象的构建过程,以及组装对象的方法。
具体建造者(ConcreteBuilder):实现了抽象建造者的接口,负责具体的对象构建和组装过程。
导演者(Director):负责组装各个部件,控制构建过程的顺序和逻辑,最终返回完整的对象。
下面是一个简单的 Golang 示例代码,用于创建一份汉堡套餐:
1 | // 产品:汉堡套餐 |
在这个示例中,我们首先定义了一个产品 Meal,表示汉堡套餐,包含三个部分:Burger、Fries 和 Drink。然后定义了一个抽象建造者 MealBuilder,其中包含了三个方法 BuildBurger()、BuildFries() 和 BuildDrink(),用于构建不同部分的汉堡套餐,以及一个 GetMeal() 方法,用于返回最终构建完成的汉堡套餐。
接下来,我们定义了具体建造者 CheeseburgerMealBuilder,它实现了 MealBuilder 接口,并负责具体的对象构建和组装过程。在这个例子中,我们使用 CheeseburgerMealBuilder 来创建一份含有 Cheeseburger、Fries 和 Coke 的汉堡套餐。
最后,我们定义了一个导演者 Waiter,它持有一个 MealBuilder 对象,并负责组装各个部件,控制构建过程的顺序和逻辑,最终返回完整的对象。
在 main 函数中,我们首先创建了一个 CheeseburgerMealBuilder 对象和一个 Waiter 对象,然后将 CheeseburgerMealBuilder 对象传给 Waiter 对象。最后,我们通过调用 Waiter 对象的 Construct() 方法来构建汉堡套餐,并输出其内容。可以看到,通过建造者模式,我们可以将对象的构建过程抽象出来并将其与具体的对象实现分离开来,从而更加灵活地构建对象。这使得我们可以通过调整建造者的组装方式,来构建出不同的产品对象,而无需修改产品的具体实现。
原型模式#
原型模式是一种创建型设计模式,它允许通过复制现有对象来创建新对象,而无需向客户端暴露创建逻辑。它可以用于创建那些成本较高的对象,或者那些需要经过复杂计算才能得到的对象。
使用原型模式时,我们首先需要创建一个原型对象,它是我们要创建新对象的基础。然后,我们可以通过复制原型对象来创建新的对象,而不必重新创建这些对象。这个过程通常由一个原型管理器来控制,它维护了一个原型对象的注册表,客户端可以从中获取需要的原型对象,并通过复制来创建新的对象。
原型模式的主要优点是,它能够大幅度减少对象创建的开销,提高了对象创建的效率。同时,它也避免了客户端和创建逻辑之间的耦合,使得客户端无需关心对象的创建过程。此外,由于原型对象通常是通过复制来创建新对象的,因此可以保证新对象与原型对象具有相同的属性和行为。
下面是一个使用 Golang 实现的原型模式的示例代码:
1 | // 原型接口:定义了复制自身的方法 |
在这个示例中,我们首先定义了一个原型接口 Prototype,它包含了一个 Clone() 方法,用于复制自身来创建新对象。然后,我们定义了一个具体原型 ConcretePrototype,它实现了 Prototype 接口,并可以复制自身来创建新对象。
接下来,我们定义了一个原型管理器 PrototypeManager,它维护了一个原型对象的注册表,客户端可以从中获取需要的原型对象,并通过复制来创建新的对象。其中,Register() 方法用于注册原型对象,Unregister() 方法用于注销原型对象,Get() 方法用于获取原型对象。
在 main() 函数中,我们首先创建了一个原型管理器 manager,并注册了一个原型对象 prototype。然后,我们通过原型管理器获取原型对象,并通过复制来创建新对象,最后输出了新对象的属性值。
总的来说,原型模式主要解决的是创建成本高、创建过程复杂的对象的问题,通过复制现有对象来创建新对象,可以大幅度减少对象创建的开销,提高对象创建的效率,同时也避免了客户端和创建逻辑之间的耦合。
单例模式#
单例模式主要解决的是在系统中保证一个类只有一个实例,并且提供全局访问点,让其他对象可以通过该实例来访问该类的功能的问题。使用单例模式可以避免系统中出现多个相同的实例,减少系统内存占用和资源消耗,同时也方便了对实例的控制和管理。
在单例模式中,一般有两种方式来实现单例:
饿汉式单例:在类加载时就创建单例实例,并提供全局访问点,保证了单例的唯一性和全局可访问性。
懒汉式单例:在第一次调用时才创建单例实例,并提供全局访问点,同时需要处理多线程环境下的并发访问问题,保证了单例的唯一性和全局可访问性。
在实际开发中,单例模式常用于工具类、配置类、日志类、缓存类等。
当涉及到单例模式时,golang 的实现方式与其他面向对象语言有所不同,因为 golang 中没有类的概念,因此也没有类的静态成员变量和构造函数。但是,golang 中的包级别变量和初始化函数可以被用来实现饿汉式单例和懒汉式单例。
下面分别给出饿汉式单例和懒汉式单例的 golang 实现示例代码:
饿汉式单例:
1 | package singleton |
在上面的代码中,我们首先定义了一个 Singleton 结构体,并在全局范围内创建了一个名为 instance 的包级别变量,该变量在定义时就已经进行了初始化。我们还定义了一个名为 GetInstance 的函数,用于获取 instance 变量的值。由于 instance 变量在定义时就已经进行了初始化,因此可以保证 GetInstance 函数始终返回同一个 Singleton 实例。
懒汉式单例:
1 | package singleton |
在上面的代码中,我们定义了一个 Singleton 结构体,并在全局范围内创建了一个名为 instance 的包级别变量。我们还定义了一个名为 GetInstance 的函数,用于获取 instance 变量的值。由于 instance 变量没有在定义时进行初始化,因此在第一次调用 GetInstance 函数时,会执行 once.Do 方法中的代码块,创建一个新的 Singleton 实例并赋值给 instance 变量。由于 once.Do 方法只会执行一次,因此可以保证 GetInstance 函数始终返回同一个 Singleton 实例。此外,我们还使用了 sync 包中的 Once 类型来保证在多线程环境下的并发访问问题。
适配器模式#
适配器模式(Adapter Pattern)主要是为了解决两个已有接口之间的兼容性问题而提出的。
在实际开发中,我们常常需要使用一些已有的类或者组件,但是这些已有的类或组件可能不符合我们当前的需求。如果我们需要使用这些类或组件中的某些方法或功能,但是它们的接口与我们当前的需求不兼容,那么我们可以使用适配器模式来解决这个问题。
适配器模式定义了一个适配器类,这个类实现了我们需要的目标接口,并且持有一个已有接口的引用,在适配器类中实现目标接口的方法时,通过调用已有接口的方法来实现目标接口的功能,从而让已有接口能够被我们的应用程序所使用。
使用适配器模式可以有效地解决两个不兼容接口之间的兼容性问题,使得我们的应用程序能够更加灵活地使用已有的类或组件,同时又不需要修改已有的类或组件的代码。适配器模式可以降低代码的耦合度,提高代码的可维护性和可复用性。
1 | // Target 是目标接口,定义了目标接口的方法 |
在上面的代码中,我们定义了一个 Target 接口,其中定义了目标接口的方法 Request()。接着,我们定义了一个 Adaptee 结构体,其中的 SpecificRequest() 方法与目标接口的方法不兼容。
为了实现适配器模式,我们创建了一个 Adapter 结构体,并实现了目标接口的方法 Request(),在 Request() 方法中通过调用已有接口的方法 SpecificRequest() 来实现目标接口的功能。在 Adapter 的构造函数 NewAdapter() 中,我们传入已有接口的实例,创建一个适配器的实例并返回。
在 main() 函数中,我们创建了一个已有接口的实例 adaptee,并将其作为参数传入 NewAdapter() 函数中,创建一个适配器的实例 target。接着,我们调用目标接口的方法 Request(),实际上是通过已有接口的方法 SpecificRequest() 来实现的。
桥接模式#
桥接模式主要是为了解决在多维度变化中,如何有效地组合不同维度上的变化,而又不至于使得类的数量急剧增加的问题。当一个类有多个变化维度时,如果采用传统的继承方式,就需要定义多个子类来处理各种变化的组合,这样会导致类的数量急剧增加,给系统带来很大的复杂度和维护成本。
桥接模式的解决思路是将一个类的多个维度上的变化分离出来,使它们可以独立地变化,然后再通过组合的方式将它们组合起来,从而减少类的数量。这种思路和“组合优于继承”的设计原则是一致的。
桥接模式将类的一个维度上的变化抽象为一个接口,称为“实现类接口”,然后在该维度上定义一个“抽象类”,它包含一个指向实现类接口的引用,通过这个引用可以调用实现类的方法。在另一个维度上定义一个“具体类”,它继承自抽象类,并实现了抽象类中的方法,从而完成对另一个维度上的变化的支持。这样,我们就可以通过组合的方式将多个维度上的变化组合起来,而又不需要定义大量的子类。
桥接模式的优点是可以有效地将多个维度上的变化分离出来,使得系统更加灵活,同时也减少了类的数量,提高了系统的可维护性和可扩展性。其缺点是需要设计和实现更多的类和接口,增加了系统的复杂度。
总之,桥接模式主要是为了解决在多维度变化中,如何有效地组合不同维度上的变化,而又不至于使得类的数量急剧增加的问题。
当一个类有多个变化维度时,可以使用桥接模式进行设计。下面是一个使用Golang实现的桥接模式示例:
1 | package main |
在上述示例中,implementor接口定义了实现类需要实现的方法operationImp(),具体实现类concreteImplementorA和concreteImplementorB分别实现了该接口的方法。抽象类abstractClass包含一个指向implementor接口的引用,通过这个引用可以调用实现类的方法。在客户端代码中,我们通过组合的方式将具体实现类和抽象类组合起来,从而实现多个维度上的变化组合。
组合模式#
组合模式主要解决的是对象结构的设计问题。当我们需要处理具有层次结构的对象,而且对于这些对象的处理需要一致时,可以使用组合模式。
组合模式可以使客户端对单个对象和组合对象的使用具有一致性,这也是它的优势所在。同时,组合模式也具有灵活性,因为可以通过简单的添加和删除节点来改变组合结构。
举个例子,比如我们需要处理一个组织机构,这个机构包含多个部门,而每个部门又可以包含多个员工,而且我们需要对这些员工进行统一的管理和处理。这种情况下,我们就可以使用组合模式,通过将部门和员工组合起来,从而实现对整个组织机构的管理和处理。
当我们使用组合模式时,通常会定义一个抽象组件(Component)类和一个具体组件类(Leaf),还有一个组合类(Composite)。下面是一个使用golang实现组合模式的代码示例:
1 | package main |
在上面的例子中,Component是一个抽象组件,它定义了两个方法:GetName()和Print()。Leaf是具体组件类,它实现了Component接口中的方法。Composite是组合类,它包含一个名称和一个子组件的列表。Composite也实现了Component接口中的方法,并且可以通过Add()和Remove()方法来添加或删除子组件。在main()函数中,我们创建了一个根组合对象,添加了两个叶子节点和一个组合节点,并通过Print()方法来打印整个组合对象。
装饰器模式#
装饰器模式主要解决以下两个问题:
1.动态地给一个对象添加一些额外的职责,而不会影响到其他对象。
2.避免使用继承的方式扩展对象的功能,因为继承会造成子类数量的爆炸,使得代码变得难以维护。
装饰器模式可以在运行时动态地为对象添加职责,通过包装一个已有的对象,来扩展其功能,而不是通过继承的方式。这样可以避免对原有代码的修改,并且可以灵活地组合各种功能。同时,由于装饰器模式是通过对象组合的方式来实现功能的扩展,因此可以避免类的继承关系过于复杂的问题。
当我们需要对一个对象进行功能扩展时,可以使用装饰器模式。以下是一个使用 Golang 实现装饰器模式的示例代码:
1 | // 定义组件接口 |
在上面的代码中,我们首先定义了一个 Component 接口和一个具体实现 ConcreteComponent。然后定义了一个 Decorator 抽象类,该类实现了 Component 接口,并且维护了一个 Component 对象。接着我们定义了两个具体的装饰器类 ConcreteDecoratorA 和 ConcreteDecoratorB,它们都继承自 Decorator,并且在实现 Operation() 方法时,先调用 component.Operation(),然后在其返回值前后添加一些修饰语。
最后,我们使用具体组件来创建一个装饰器 d1,然后使用 d1 再创建一个装饰器 d2。调用 d2.Operation() 方法时,会依次执行 ConcreteDecoratorB 和 ConcreteDecoratorA 的 Operation() 方法,最终输出带有修饰语的结果。这就是装饰器模式的运行方式。
外观模式#
外观模式(Facade Pattern)主要解决的问题是将复杂的系统或子系统进行封装,提供一个简单的接口或入口供客户端使用,从而简化客户端与系统之间的交互,降低系统间的耦合度。
举个例子,比如一个电脑系统,它由多个子系统组成,如CPU、内存、硬盘、显卡等等。如果每次使用电脑时,都需要直接与这些子系统进行交互,那么操作起来就会非常繁琐。而外观模式可以提供一个简单的接口,例如开机、关机、运行软件等,这样客户端就不需要直接与各个子系统打交道了,只需要通过这些简单的接口进行操作即可。
外观模式可以降低客户端的复杂度和使用难度,也可以方便地对系统进行修改和扩展,同时也可以提高系统的安全性和可靠性。
当我们需要对外提供一个简单的接口,来隐藏系统复杂的内部结构时,可以使用外观模式。下面是一个使用golang实现的外观模式示例:
首先定义一个外观类(facade),它封装了复杂系统的各个子系统,并提供了一个简单的接口给客户端使用:
1 | // Facade 外观类,封装了各个子系统,提供简单的接口给客户端使用 |
从输出结果可以看到,虽然客户端只调用了外观类的一个方法,但实际上内部封装了三个子系统的操作,并成功地输出了子系统的操作结果。这就是外观模式的作用,将系统的复杂度隐藏起来,提供一个简单的接口给客户端使用。
享元模式#
享元模式主要解决大量细粒度对象的共享问题,以减少内存的消耗。
在软件系统中,有时需要创建大量的对象,如果每个对象都包含相同或相似的状态信息,那么对象的内存消耗将会很大。享元模式通过共享这些相同或相似的状态信息来减少对象的数量,从而降低了内存的消耗。
通常情况下,享元模式将一个对象的状态分为内部状态和外部状态,内部状态是可以共享的,而外部状态是不可以共享的。通过共享内部状态,让系统中相同或相似的对象共享一个对象,从而减少了对象的数量。
例如,一个文本编辑器可能需要创建大量的字符对象,每个字符对象都包含字符的字体、颜色等属性信息,这些属性信息属于内部状态,可以共享。而每个字符对象在文本中的位置信息属于外部状态,不能共享。通过享元模式,可以将相同字体和颜色的字符对象共享一个对象,从而减少对象的数量。
另外,享元模式也可以用于缓存池、线程池等资源池的实现,以提高资源利用率和系统性能。
1 | // UserLoginInfo 用户登录信息 |
在这个例子中,UserLoginInfo 表示一个用户的登录信息,其中 username 和 password 是内部状态,可以共享,而 lastLoginTime 是外部状态,不能共享。
UserLoginInfoFactory 表示用户登录信息的工厂,用于管理登录信息对象的创建和共享。当需要获取一个用户登录信息时,先根据 username 和 password 生成一个唯一的 key,如果这个 key 对应的登录信息已经存在,则直接返回,否则创建一个新的登录信息对象,并将其加入到 loginInfos 中,以便后续使用。
代理模式#
代理模式主要解决两类问题:
1.对象访问的控制:代理模式可以在代理对象和实际对象之间加入一些控制逻辑,限制对实际对象的直接访问,从而实现对对象访问的控制。
2.远程对象的访问:代理模式可以在代理对象和远程对象之间建立一个代理,使得本地对象可以通过代理访问远程对象,从而实现远程方法调用。
举个例子,假设我们有一个需要控制访问权限的文件系统,可以使用代理模式来实现对文件的访问控制。具体代码实现可以参考下面的示例:
1 | // FileSystem 文件系统接口 |
在这个例子中,FileSystem 表示文件系统接口,FileSystemImpl 是文件系统的实现。FileSystemProxy 是文件系统的代理,用于实现对文件访问的控制。代理对象接收到请求后,先进行权限检查,只有具有管理员权限的用户才能执行读取或写入文件的操作。如果检查通过,则将请求转发给实际的文件系统对象进行处理。这样就实现了对文件访问的控制。