设计模式-单例(Singleton)

设计模式-单例(Singleton)

单例(Singleton)

单例是一种创建型设计模式,让你能够保证一个类只有一个实例,并提供一个访问该实例的全局节点。

单例模式(Singleton)是一种非常简单且容易理解的设计模式。顾名思义,单例即单一的实例,确切地讲就是指在某个系统中只存在一个实例,同时提供集中、统一的访问接口,以使系统行为保持协调一致。singleton一词在逻辑学中指“有且仅有一个元素的集合”,这非常恰当地概括了单例的概念,也就是“一个类仅有一个实例”。

现实场景

  • 孤独的太阳
    盘古开天,造日月星辰。从“夸父逐日”到“后羿射日”,太阳对于我们的先祖一直具有着神秘的色彩与非凡的意义。随着科学的不断发展,我们逐渐揭开了太阳系的神秘面纱。我们可以把太阳系看作一个庞大的系统,其中有各种各样的对象存在,丰富多彩的实例造就了系统的美好。这个系统里的某些实例是唯一的,如我们赖以生存的恒星太阳。
宇宙中太阳是唯一的


与其他行星或卫星不同的是,太阳是太阳系内唯一的恒星实例,它持续提供给地球充足的阳光与能量,离开它地球就不会有今天的勃勃生机,但倘若天上有9个太阳,那么将会带来一场灾难。太阳东升西落,循环往复,不多不少仅此一例。

需求

假设我们需要为客户端实现一个时钟功能,并且在任意页面或组件可以调用。实现起来比较简单,在构造函数里调用计时器函数setInterval即可,并且对每次计时进行记录。

export class Singleton {
  /** 当前计数 */
  private _count = 0;

  /** 初始化计时器 */
  constructor() {
    setInterval(() => {
      this._count++;
    }, 1000);
  }

  get count(): number {
    return this._count;
  }
}

问题

如果调用者过多,会在内存中存在多个计时器,但其实现的功能几乎完全一致,从而会浪费资源,严重者会影响程序性能。而且计时器应该是全局保持一致的计时,但是每个页面创建计时器时机不同导致大家无法统一计时,比如先进入首页,过了一会在去联系我们页面,两个页面初始化时间不一致进而导致创建计时器时机不一致,自然就无法同步计时了。

// 首页需要计时器
const homeTime = new Singleton();
// 1s后
console.log(homeTime.count);
// 1

// 5s后-----

// 关于页需要计时器
const aboutTime = new Singleton();
console.log(aboutTime.count);
// 0
console.log(homeTime.count);
// 5

// 其他页面需要计时器
// ...

解决方案

既然功能一致,我们无须对每次调用都生成一个具体的实例,只需第一次调用的时候生成一个实例,后续调用直接返回已存在的实例即可。这样以来,无论多少次调用,全局只有一个计时器实例。从而达到共享。

代码(TypeScript)

export class Singleton {
  /** 私有实例,外界无法直接访问 */
  private static instance: Singleton = new Singleton();

  /** 计时次数 */
  public count = 0;

  /**
   * 将构造函数设置为外部不可见
   * 初次调用开始计时
   */
  private constructor() {
    setInterval(() => {
      this.count++;
    }, 1000);
  }

  /** 返回实例的静态方法 */
  public static getInstance(): Singleton {
    return Singleton.instance;
  }
}

如代码所示,我们将计时器的构造方法设为private,使其私有化,如此一来该类就被完全封闭了起来,实例化工作完全归属于内部事务,任何外部类都无权干预。

继续通过private关键字确保实例的私有性、不可见性和不可访问性;而static关键字确保类的静态性,将实例放入内存里的静态区,在类加载的时候就初始化了,它与类同在,也就是说它是与类同时期且早于内存堆中的对象实例化的,该实例在内存中永生,内存垃圾收集器(Garbage Collector,GC)也不会对其进行回收。这就是单例实现方式之一的“饿汉模式”(eagerinitialization),即在初始阶段就主动进行实例化,并时刻保持一种渴求的状态,无论此单例是否有人使用。

通过静态方法getInstance()来获取太阳的单例对象,同时将其设置为public以暴露给外部使用

测试

import { Singleton } from "../src/Singleton";
/** 睡眠函数 */
const sleep = () =>
  new Promise((resolve) => setTimeout(() => resolve(true), 1000));
describe("单例模式", () => {
  it("共享计时结果", async () => {
    /** 创建实例1 */
    const timer = Singleton.getInstance();
    /** 延迟一秒 */
    await sleep();
    /** 创建实例2 */
    const timer1 = Singleton.getInstance();
    /** 两个实例计时内容是否一致 */
    expect(timer.count).toBe(timer1.count);
  });
});

如代码所示,外部只要调用静态方法getInstance()就可以得到实例了,并且不管谁得到,或是得到几次,得到的都是同一个实例,这样就确保了整个系统有且只有一个实例。即节省了内存,也共享了数据。


单元测试

大道至简

单例有很多实现的方式,常见的是“懒汉模式”和“饿汉模式”两者区别在于初始化实例时机不同,相比“懒汉模式”,其实在大多数情况下我们通常会更多地使用“饿汉模式”,原因在于这个单例迟早是要被实例化占用内存的,延迟懒加载的意义并不大,在很多语言的多线程操作需要加锁解锁,这反而是一种资源浪费,同步更是会降低CPU的利用率,使用不当的话反而会带来不必要的风险。越简单的包容性越强,而越复杂的反而越容易出错。

结构


单例模式结构图

单例(Sin­gle­ton)

  • 该类声明了一个名为get­Instance获取实例的静态方法来返回其所属类的一个相同实例。
  • 单例的构造函数必须对客户端(Client)代码隐藏。调用获取实例方法必须是获取单例对象的唯一方式。

除了“饿汉”与“懒汉”这2种单例模式,其实还有其他的实现方式。但万变不离其宗,它们统统都是由这2种模式发展、衍生而来的。比如Spring框架中的IoC容器很好地帮我们托管了业务对象,如此我们就不必再亲自动手去实例化这些对象了,而在默认情况下我们使用的正是框架提供的“单例模式”。诚然,究其代码实现当然不止如此简单,但我们应该追本溯源,抓住其本质的部分,理解其核心的设计思想,再针对不同的应用场景做出相应的调整与变动,结合实践举一反三。

适用场景

  • 如果程序中的某个类对于所有客户端只有一个可用的实例,可以使用单例模式。
    单例模式禁止通过除特殊构建方法以外的任何方式来创建自身类的对象。该方法可以创建一个新对象,但如果该对象已经被创建,则返回已有的对象。
  • 如果你需要更加严格地控制全局变量,可以使用单例模式
    单例模式与全局变量不同,它保证类只存在一个实例。除了单例类自己以外,无法通过任何方式替换缓存的实例

请注意,你可以随时调整限制并设定生成单例实例的数量,只需修改获取实例方法,即 getInstance 中的代码即可实现

实现方式

  1. 在类中添加一个私有静态成员变量用于保存单例实例
  2. 声明一个公有静态构建方法用于获取单例实例
  3. 在静态方法中实现"延迟初始化。该方法会在首次被调用时创建一个新对象,并将其存储在静态成员变量中。此后该方法每次被调用时都返回该实例
  4. 将类的构造函数设为私有。类的静态方法仍能调用构造函数,但是其他对象不能调用
  5. 检查客户端代码,将对单例的构造函数的调用替换为对其静态构建方法的调用

优缺点

  • 你可以保证一个类只有一个实例。
  • 你获得了一个指向该实例的全局访问节点。
  • 仅在首次请求单例对象时对其进行初始化。
  • 违反了单一职责原则。该模式同时解决了两个问题。
  • 单例模式可能掩盖不良设计,比如程序各组件之间相互了解过多等。
  • 该模式在多线程环境下需要进行特殊处理,避免多个线程多次创建单例对象。
  • 单例的客户端代码单元测试可能会比较困难,因为许多测试框架以基于继承的方式创建模拟对象。
  • 由于单例类的构造函数是私有的,而且绝大部分语言无法重写静态方法,所以你需要想出仔细考虑模拟单例的方法。要么干脆不编写测试代码,或者不使用单例模式。

对比其他模式

  • 外观类通常可以转换为单例类,因为在大部分情况下一个外观对象就足够了。
  • 如果你能将对象的所有共享状态简化为一个享元对象,那么享元就和单例类似了。但这两个模式有两个根本性的不同。
  • 只会有一个单例实体,但是享元类可以有多个实体,各实体的内在状态也可以不同。
  • 单例对象可以是可变的。享元对象是不可变的。
  • 抽象工厂、生成器和原型都可以用单例来实现。

参考资料

深入设计模式

秒懂设计模式

编辑于 2022-09-29 18:59