设计模式
约 4861 字大约 16 分钟
面试设计模式
2025-03-13
设计原则
单一职责原则
单一职责原则(Simple Responsibility Principle,SRP)是最简单的面向对象设计原则,它用于控制类的粒度大小。
一个对象应该只包含单一的职责,并且该职责被完整地封装在一个类中。
将类的粒度进行更进一步的划分,会更加清晰。包括我们以后在设计 Mapper、Service、Controller 等等,根据不同的业务进行划分,都可以采用单一职责原则,以它作为我们实现高内聚低耦合的指导方针。实际上我们的微服务也是参考了单一职责原则,每个微服务只应担负一个职责。
开闭原则
开闭原则(Open Close Principle)
软件实体应当对扩展开放,对修改关闭。
如:
public abstract class Coder {
public abstract void coding();
class JavaCoder extends Coder {
@Override
public void coding() {
System.out.println("Java");
}
}
class PHPCoder extends Coder {
@Override
public void coding() {
System.out.println("PHP");
}
}
class CppCoder extends Coder {
@Override
public void coding() {
System.out.println("CPlusPlus");
}
}
}
通过提供一个 Coder 抽象类,定义出编程的行为,但是不进行实现,而是开放给其他具体类型的程序员来实现,这样就可以根据不同的业务进行灵活扩展了,具有较好的延续性。
里氏替换原则
里氏替换原则(Liskov Substitution Principle)是对子类型的特别定义。
所有引用基类的地方必须能透明地使用其子类的对象。
简单的说就是,子类可以扩展父类的功能,但不能改变父类原有的功能:
- 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
- 子类可以增加自己特有的方法。
- 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松。
- 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或与父类一样。
依赖倒转原则
依赖倒转原则(Dependence lnversion Principle)也是我们一直在使用的,最明显的就是我们的Spring框架了。
高层模块不应依赖于底层模块,它们都应该依赖抽象。抽象不应依赖于细节,细节应该依赖于抽象。
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
}
static class UserMapper {
//CRUD...
}
static class UserServiceNew { //由于UserServiceNew发生变化,会直接影响到其他高层模块
UserMapper mapper = new UserMapper();
//业务代码....
}
static class UserController { //焯,干嘛改底层啊,我这又得重写了
UserService service = new UserService(); //哦豁,原来的不能用了
UserServiceNew serviceNew = new UserServiceNew(); //只能修改成新的了
//业务代码....
}
}
我们发现,我们的各个模块之间实际上是具有强关联的,一个模块是直接指定依赖于另一个模块,虽然这样结构清晰,但是底层模块的变动,会直接影响到其他依赖于它的高层模块,如果我们的项目变得很庞大,那么这样的修改将是一场灾难。
而有了Spring框架之后,我们的开发模式就发生了变化:
public class Main {
public static void main(String[] args) {
UserController controller = new UserController();
}
interface UserMapper {
//接口中只做CRUD方法定义
}
static class UserMapperImpl implements UserMapper {
//实现类完成CRUD具体实现
}
interface UserService {
//业务代码定义....
}
static class UserServiceImpl implements UserService {
@Resource //现在由Spring来为我们选择一个指定的实现类,然后注入,而不是由我们在类中硬编码进行指定
UserMapper mapper;
//业务代码具体实现
}
static class UserController {
@Resource
UserService service; //直接使用接口,就算你改实现,我也不需要再修改代码了
//业务代码....
}
}
通过使用接口,我们就可以将原有的强关联给弱化,我们只需要知道接口中定义了什么方法然后去使用即可,而具体的操作由接口的实现类来完成,并由Spring来为我们注入,而不是我们通过硬编码的方式去指定。
接口隔离原则
接口隔离原则(Interface Segregation Principle, ISP)实际上是对接口的细化。
客户端不应依赖那些它不需要的接口。
如一个获取设备信息的接口,有获取 cpu 的方法,有获取功率的方法。对于一台电脑,这两个方法都能用到;但是对于一台电风扇,根本用不到获取 cpu 的方法,这就违背了接口隔离原则。应该细化粒度,如分为智能设备信息接口和普通设备信息接口。
合成复用原则
合成复用原则(Composite Reuse Principle)的核心就是委派。
优先使用对象组合,而不是通过继承来达到复用的目的。
如:
class A {
public void connectDatabase() {
System.out.println("连接数据库操作");
}
}
class B extends A { // 直接通过继承的方式,得到A的数据库连接逻辑
public void test() {
System.out.println("B也需要连接数据库");
connectDatabase(); //直接调用父类方法
}
}
这样,使用继承的方式实现复用,会增加耦合度。一旦该功能不由 A 来实现,B 也要跟着修改继承的父类。而且通过继承子类会得到一些父类中的实现细节,比如某些字段或是方法,这样直接暴露给子类,并不安全。
因此,可以选择以下方式来实现复用:
class A {
public void connectDatabase() {
System.out.println("连接数据库操作");
}
}
class B { // 不进行继承,而是在用的时候给我一个A,当然也可以抽象成一个接口,更加灵活
public void test(A a) {
System.out.println("B也需要连接数据库");
a.connectDatabase(); // 在通过传入的对象A去执行
}
}
或是
class A {
public void connectDatabase() {
System.out.println("连接数据库操作");
}
}
class B {
A a;
public B(A a) { // 在构造时就指定好
this.a = a;
}
public void test() {
System.out.println("B也需要连接数据库");
a.connectDatabase(); // 也是通过对象A去执行
}
}
通过对象之间的组合,降低了类之间的耦合度,并且 A 的实现细节也不会之间被 B 得到,只管用就行。
迪米特法则
迪米特法则(Law of Demeter)又称最少知识原则,是对程序内部数据交互的限制。
每一个软件单位对其他单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。、
一个类/模块对其他的类/模块有越少的交互越好。
创建型设计模式
工厂方法模式
我们想要创建一个类的实现类时,如果用普通写法:
public abstract class Fruit {
private final String name;
public Fruit(String name) {
this.name = name;
}
@Override
String toString() {
return name + "@" + hashCode();
}
}
public class Apple extends Fruit {
public Apple() {
super("苹果");
}
}
public class Orange extends Fruit {
public Orange() {
super("橘子");
}
}
直接 new 来得到对象
public class Main {
public static void main(String[] args) {
Apple apple = new Apple();
System.out.println(apple);
}
}
但是这样写,如果创建对象的时候需要传入一些参数,要修改实现类时就会改动很多代码。根据迪米特法则,应该减少类之间的交互。因此,我们可以创建一个工厂,我们需要什么类,找工厂生成就行,于是有了下面的简单工厂模式。
简单工厂模式
简单工厂模式不是一个正式的设计模式,但它是工厂模式的基础。它使用一个单独的工厂类来创建不同的对象,根据传入的参数决定创建哪种类型的对象。
将对象封装到工厂中:
public class FruitFactory {
/**
* 这里就直接来一个静态方法根据指定类型进行创建
* @param type 水果类型
* @return 对应的水果对象
*/
public static Fruit getFruit(String type) {
switch (type) {
case "苹果":
return new Apple();
case "橘子":
return new Orange();
default:
return null;
}
}
}
然后通过工厂来创建对象:
public class Main {
public static void main(String[] args) {
Fruit fruit = FruitFactory.getFruit("橘子"); // 直接问工厂要,而不是自己去创建
System.out.println(fruit);
}
}
但这样还是有一些问题,根据开闭原则,一个软件实体,比如类、模块和函数应该对扩展开放,对修改关闭。如果我们现在需要新增一种水果,比如桃子,那么这时就得去修改工厂提供的工厂方法了,但是这样是不太符合开闭原则的,因为工厂实际上是针对于调用方提供的,所以我们应该尽可能对修改关闭。
工厂方法模式
针对上面的问题,改进出了工厂方法模式。它定义了一个创建对象的接口,但将具体的对象创建延迟到子类中。
核心思想:
- 将对象的创建与使用分离:客户端不需要知道具体创建哪个类的对象,只需要通过工厂接口获取对象。
- 扩展性:如果需要增加新的产品类型,只需要增加新的工厂子类,而不需要修改现有代码。
优点
- 解耦:将对象的创建与使用分离,客户端不需要关心具体的产品类。
- 扩展性:增加新的产品类型时,只需要增加新的工厂类,符合开闭原则。
- 可维护性:代码结构清晰,易于维护。
缺点
- 类的数量增加:每增加一个产品类型,都需要增加一个具体工厂类,可能会导致类的数量膨胀。
- 复杂性增加:对于简单的对象创建,使用工厂方法模式可能会显得过于复杂。
适用场景
- 需要灵活创建对象:当对象的创建过程比较复杂,或者需要根据条件动态创建对象时。
- 需要解耦:当需要将对象的创建与使用分离,避免客户端依赖具体类时。
- 需要扩展性:当系统需要支持多种产品类型,并且未来可能会增加新的产品类型时。
应用实例
- 汽车制造:你需要一辆汽车,只需从工厂提货,而不需要关心汽车的制造过程及其内部实现。
- Hibernate:更换数据库时,只需更改方言(Dialect)和数据库驱动(Driver),即可实现对不同数据库的切换。
public abstract class FruitFactory<T extends Fruit> { // 将水果工厂抽象为抽象类,添加泛型T由子类指定水果类型
public abstract T getFruit(); // 不同的水果工厂,通过此方法生产不同的水果
}
public class AppleFactory extends FruitFactory<Apple> { // 苹果工厂,直接返回Apple,一步到位
@Override
public Apple getFruit() {
return new Apple();
}
}
这样,我们就可以使用不同类型的工厂来生产不同类型的水果了,并且如果新增了水果类型,直接创建一个新的工厂类就行,不需要修改之前已经编写好的内容。
public class Main {
public static void main(String[] args) {
test(new AppleFactory()::getFruit); // 比如我们现在要吃一个苹果,那么就直接通过苹果工厂来获取苹果
}
// 此方法模拟吃掉一个水果
private static void test(Supplier<Fruit> supplier){
System.out.println(supplier.get() + " 被吃掉了,真好吃。");
}
}
抽象工厂模式
抽象工厂是围绕一个超级工厂创建其他工厂,该超级工厂又称为其他工厂的工厂。
在抽象工厂模式中,接口是负责创建一个相关对象的工厂,不需要显式指定它们的类。每个生成的工厂都能按照工厂模式提供对象。
抽象工厂模式提供了一种创建一系列相关或相互依赖对象的接口,而无需指定具体实现类。通过使用抽象工厂模式,可以将客户端与具体产品的创建过程解耦,使得客户端可以通过工厂接口来创建一族产品。
核心思想
- 提供一个接口:用于创建一组相关或依赖的对象,而不需要指定具体的类。
- 封装对象的创建:客户端只需要知道抽象工厂和抽象产品,不需要关心具体的实现。
结构
- 抽象工厂(Abstract Factory):定义创建一组产品的接口。
- 具体工厂(Concrete Factory):实现抽象工厂接口,创建具体的产品。
- 抽象产品(Abstract Product):定义产品的接口。
- 具体产品(Concrete Product):实现抽象产品接口的具体类。
优点
- 确保同一产品族的对象一起工作。
- 客户端不需要知道每个对象的具体类,简化了代码。
缺点
- 扩展产品族非常困难。增加一个新的产品族需要修改抽象工厂和所有具体工厂的代码,违背了开闭原则。
使用场景
- QQ 换皮肤时,整套皮肤一起更换。
- 创建跨平台应用时,生成不同操作系统的程序。
public class Phone {
}
public class Computer {
}
public class TV {
}
定义抽象工厂
public abstract class AbstractFactory {
public abstract Phone getPhone();
public abstract Phone getComputer();
public abstract Phone getTV();
}
定义具体工厂
class XiaoMiFactory extends AbstractFactory {
}
class AppleFactory extends AbstractFactory {
}
class HuaweiFactory extends AbstractFactory {
}
使用抽象工厂模式
public class Main {
public static void main(String[] args) {
AbstractFactory factory;
// 获取小米的产品
factory = new XiaoMiFactory();
Phone = factory.getPhone();
Computer = factory.getComputer();
// 获取华为的产品
factory = new HuaweiFactory();
...
}
}
建造者模式
如 StringBuilder
等 XXXBuilder 就属于建造者模式的思想,经常使用这样的类来创建需要的对象。
实际上是通过建造者来不断配置参数或内容,最后进行对象的构建。
相比直接去 new 一个新的对象,建造者模式的重心更加关注在如何完成每一步的配置,同时如果一个类的构造方法参数过多,我们通过建造者模式来创建这个对象,会更加优雅。
public class Student {
private int id;
private int age;
private String name;
private List<String> awards;
// 一律使用建造者来创建,不对外直接开放
private Student(int id, int age, String name, List<String> awards) {
...
}
public static StudentBuilder builder() { // 通过builder方法直接获取建造者
return new StudentBuilder();
}
public static class StudentBuilder { // 创建一个内部类
// Builder也需要将所有的参数都进行暂时保存,所以Student怎么定义的这里就怎么定义
int id;
int age;
String name;
List<String> awards;
public StudentBuilder id(int id) { // 直接调用建造者对应的方法,为对应的属性赋值
this.id = id;
return this; // 为了支持链式调用,这里直接返回建造者本身,下同
}
...
public StudentBuilder awards(String... awards) {
this.awards = Arrays.asList(awards);
return this;
}
public Student build() { // 最后我们只需要调用建造者提供的build方法即可根据我们的配置返回一个对象
return new Student(id, age, name, awards);
}
}
}
单例模式
有时在整个程序中,同一个类始终只会有一个对象来进行操作,如数据库连接类,只需要创建一个对象或使用静态方法进行操作即可,没必要创建多个对象。
饿汉式单例
public class Singleton {
private final static Singleton INSTANCE = new instance();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
当我们需要获取对象时,通过 getInstance()
方法来获取唯一的对象。
懒汉式单例
并不一开始就创建对象,而是在第一次使用的时候才创建。
public class Singleton {
private final static Singleton INSTANCE;
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton();
}
return INSTANCE;
}
}
这样做虽然能够节省一定资源,但是在多线程环境下可能会出现问题。
多线程环境下的问题 在多线程环境下,上述实现可能会导致以下问题:
- 竞态条件(Race Condition) 当多个线程同时调用 getInstance () 方法时,可能会同时进入 if (instance == null) 的判断。
如果此时 instance 为 null,多个线程都会执行 instance = new LazySingleton (),从而导致创建多个实例。
- 实例不一致 由于竞态条件的存在,不同线程可能会获取到不同的实例,这违反了单例模式的唯一性要求。
示例场景 假设有两个线程 T 1 和 T 2 同时调用 getInstance () 方法:
T 1 进入 getInstance () 方法,判断 instance == null 为 true,准备执行 instance = new LazySingleton ()。
在 T 1 执行 new LazySingleton () 之前,T 2 也进入 getInstance () 方法,判断 instance == null 也为 true。
T 1 和 T 2 都会执行 new LazySingleton (),从而创建两个不同的实例。
解决方法 为了解决懒汉式单例模式在多线程环境下的问题,可以采用以下方法:
- 加锁(Synchronized) 在 getInstance () 方法上加锁,确保同一时间只有一个线程可以执行创建实例的代码:
Java 复制 Public class LazySingleton { Private static LazySingleton instance;
private LazySingleton() {
// 私有构造函数
}
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
} 优点:简单直接,确保线程安全。
缺点:每次调用 getInstance () 方法时都会加锁,性能较差。
- 双重检查锁(Double-Checked Locking) 在加锁的基础上,通过双重检查来减少加锁的次数:
Java 复制 Public class LazySingleton { Private static volatile LazySingleton instance;
private LazySingleton() {
// 私有构造函数
}
public static LazySingleton getInstance() {
if (instance == null) {
synchronized (LazySingleton.class) {
if (instance == null) {
instance = new LazySingleton();
}
}
}
return instance;
}
} 优点:只有在第一次创建实例时加锁,后续调用不需要加锁,性能较好。
注意:instance 需要使用 volatile 关键字,禁止指令重排序,确保多线程环境下的可见性。
- 静态内部类(Initialization-on-demand Holder Idiom) 利用类加载机制来保证线程安全:
Java 复制 Public class LazySingleton { Private LazySingleton () { // 私有构造函数 }
private static class SingletonHolder {
private static final LazySingleton INSTANCE = new LazySingleton();
}
public static LazySingleton getInstance() {
return SingletonHolder.INSTANCE;
}
} 优点:线程安全,延迟加载,性能好。
原理:静态内部类在第一次被引用时才会加载,从而保证实例的唯一性。
- 枚举(Enum) 使用枚举实现单例模式:
Java 复制 Public enum LazySingleton { INSTANCE;
public void doSomething() {
// 业务方法
}
} 优点:线程安全,防止反射攻击,代码简洁。
缺点:不够灵活,无法延迟加载。
总结 懒汉式单例模式在多线程环境下可能会出现问题,主要是因为竞态条件导致创建多个实例。为了解决这个问题,可以采用以下方法:
加锁(synchronized)。
双重检查锁(Double-Checked Locking)。
静态内部类(Initialization-on-demand Holder Idiom)。
枚举(Enum)。
其中,静态内部类和枚举是实现单例模式的推荐方式,它们既能保证线程安全,又能实现延迟加载。
原型模式
原型模式使用原型示例指定待创建对象的类型,并且通过复制这个原型来创建新的对象。也就是说,原型对象作为模板,通过克隆操作,来产生更多的对象。
要注意浅拷贝和深拷贝的区别。对于引用类型,浅拷贝只能复制内层对象的地址,而深拷贝相当于使用其值创建一个新的引用对象。