C#中单例模式的实现

作者: zengde 分类: 笔记 发布时间: 2023-05-16 10:03

一、引言

算是第一篇和设计模式正式相关的文。
相信写代码的都听说过设计模式,即使没有特意去学过。
而在这些设计模式之中,有一种很基础,也很常用的模式,就是单例模式(Singleton)。
那这篇文就来学习学习它。

二、单例模式

1. 单例模式

1.1. 场景

在一些场景下,在程序中需要一个特定的类,并且该类的数据需要能被所有其它对象访问。而在大多数情况下,系统中该类的数据也是唯一的。例如,界面上只能有一个鼠标指针,并且该鼠标指针能被所有程序访问。同样的还有,企业解决方案可以与管理到特定系统连接的单网关对象进行对接。

1.2. 问题产生

那么怎么去实现这样一个全局可用的对象实例,并且保证它只有一个实例被创建呢?

注意:这里对单例(singleton)的定义有意地比《设计模式:可重用面向对象软件的元素》[Gamma95]中的更窄。

1.3. 应当考虑的

在该场景下,当你考虑这个问题的解决方案时,必须注意并协调以下几点:
许多编程语言(如,VB 6.0或C )都支持全局范围内的对象定义。这些对象都驻留在命名空间(namespace)的根上,对程序中所有对象而言都是普遍可用的。这种方法为全局可访问性的问题提供了一个简单的解决方案,但没有解决单实例的需求。因为它不会阻止其他对象创建全局对象的其他实例。此外,其他面向对象语言,如VB .NET或C#,并不直接支持全局变量(所以也无法直接用这种方案)。

为了确保一个类只能存在一个实例,必须得控制实例化过程。这意味着你需要通过使用编程语言中固有的实例化机制(例如,使用new操作符)来防止其它对象创建类的实例。控制实例化的另一部分是提供一种中心机制,通过这种机制,所有对象都可以获得对单个实例的引用。

这段话也说明了设计模式的一些特点,它不是与编程语言强相关的,它不是一个函数,一个类,而是更接近一种实现机制、实现思路。你通过任何语言都可以去实现它。

1.4. 解决方案

单例模式通过以下几方面来提供一个全局且单一(唯一)的实例:

  • 让类创建单个实例。
  • 允许其他对象通过返回实例引用的类方法来访问该实例。这个类方法是全局可访问的。
  • 将类构造函数声明为private,为的是其他对象不能创建新实例。

下图展示了该模式的静态结构。该UML类图非常简单,因为Singleton由一个简单的类组成,该类持有对自身单个实例的引用。

该图显示了Singleton类包含一个public的static属性,该属性返回对Singleton类的单个实例的引用。(静态类图中, 表示public,下划线表示static)。右上角的1表示系统中任何时候都只能有一个该类的实例。因为Singleton的默认构造函数是私有的,系统中的任何其他对象都必须通过Instance属性访问Singleton对象。

Singleton模式通常被归类为习惯用法而不是模式,因为解决方案主要取决于你所使用的编程语言的特性(例如,类方法和静态初始化器)。就如这个模式所做的那样,将抽象概念从特定的实现中分离出来,可能会使Singleton的实现看起来非常简单。

2. C#中实现单例模式

2.1. 场景

现在,你构建了一个C#程序。你需要一个仅有一个实例的类,并且需要一个该实例的全局访问点(global point of access)。你想确保你的方案是高效的,并且它能利用了微软.NET CLR的特性。你可能还希望你的方案是线程安全的。

2.2. 实现策略

尽管单例模式是一种相对简单的模式,但它也有许多不同的实现,你需要根据实现的不同,做一些权衡与选择。下面介绍了一系列的实现策略,并讨论了它们的优缺点。

2.2.1. 单例(Singleton)

该单例设计模式的实现遵循了设计模式中提出的解决方案:
可重用的面向对象软件元素[Gamma95],修改它以使得C#中语言特性可用,例如属性的修改:

下面提到的设计模式,有时候是指一本书或一套准则。
至于[Gamma95]以及相似格式的标识符应该是这本书中提到的一种解决方案。

using System;

public class Singleton
{
   private static Singleton instance;

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null)
         {
            instance = new Singleton();
         }
         return instance;
      }
   }
}

 

该实现有两个主要优点:
因为实例是在instance属性方法中创建的,所以类可以执行额外的功能(例如,实例化一个子类),即使它可能引入一些不受欢迎的依赖项。

直到对象请求实例时才执行实例化;这样的方法称为懒汉实例化(lazy instantiation,也叫延迟、惰性实例化)。懒汉实例化避免了在程序启动时,实例化不必要的单例。

然而,这种实现主要缺点是它在多线程环境下是不安全的(即不是线程安全的)。如果独立的执行线程同时进入Instance属性方法,那么可能会创建多个Singleton对象的实例。每个线程都可以执行下面语句,并创建一个新的实例:

if (instance == null)

有许多方法可以解决这个问题。其中一种是使用叫双重检查锁(double-check locking)的惯用法。然而,C#结合CLR(公共语言运行库)提供了一种静态初始化(static initialization)方法,它可以避免这些问题,而不需要开发者显式地编写线程安全代码。

事实上,假如没有研究过单例模式,我觉得这种方法已经很不错了。
public提供属性的外部访问。
static保证只有一份。
私有构造函数又使外界不能实例化它。
唯一缺点正如上面所说,它不是线程安全的。因为if(instance==null)这条语句不是原子的。
许多简单的场景下,我觉得这种写法完全够用。

2.2.2. 静态初始化

《设计模式[Gamma95]》避免静态初始化的原因之一是(说明该书中还是抵制这种静态初始化的方法的),C 规范在静态变量的初始化顺序上留下了一些模糊性。幸运的是,.NET框架通过对变量初始化的处理解决了这种模糊性。

public sealed class Singleton
{
   private static readonly Singleton instance = new Singleton();
   
   private Singleton(){}

   public static Singleton Instance
   {
      get 
      {
         return instance; 
      }
   }
}

 

在这种策略中,当类中任何成员第一次被引用时,实例会被创建。CLR负责变量初始化。该类被标记为sealed以防止派生,因为派生可能会添加实例。有关将类标记为sealed的优缺点的讨论,参考[Sells03]。此外,变量被标记为readonly,这意味着只能在静态初始化期间(此处所显示的)或在类构造函数中对其赋值。

这种实现与前面示例相似,不同之处在于它依赖于CLR来初始化变量。它仍然处理了Singleton模式试图解决的两个基本问题:全局访问(global access)和初始化控制(initialization control)。这个公有静态属性(public static property)提供了实例的全局访问点。同时因为构造函数是私有的(private),Singleton类无法在类之外被实例化;因此,该变量就是系统中唯一存在的实例。

因为Singleton实例是由私有静态成员变量引用的,所以直到对Instance属性的调用第一次引用该类时,实例化才会发生。因此,该解决方案是延迟实例化属性的一种实现形式,正如《单例的设计模式形式》那样。

这种方法唯一的潜在缺点是那你对实例化机制的控制较少。在《设计模式形式》中介绍到,你能够在实例化之前使用非默认的构造函数或执行其他任务。因为.NET框架在这种解决方案中执行了初始化,所以你无法进行这些选项。在大多数情况下,静态初始化是.NET中实现一个单例的首选方法

2.2.3. 多线程单例

静态初始化对于大多数情况是可行的。当程序必须延迟实例化、使用非默认构造函数或在实例化之前执行其他任务,和在多线程环境中工作时,你就需要不同的解决方案了。但是,也存在着不能依赖CLR来确保线程安全的情况,例如在静态初始化示例中。在这种情况下,你必须使用特定的语言功能来确保在多线程环境下只创建一个实例。一种比较创建的解决方案是使用双重检查锁(Double-Check Locking)[Lea99]来隔离线程,避免同时创建单例的新实例。

注意:CLR解决了在其他环境中常见的与使用Double-Check Locking相关的问题。但也存在锁被打破的情况,相关更多信息,可以查看该网址

下面实现只允许一个线程进入临界区(critical area),这是锁块标识的,在还没有创建Singleton的实例时:

using System;

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}

这种方法确保只创建一个实例,并且只在需要实例时创建。另外,将变量声明为volatile,以确保在访问实例变量之前完成对实例变量的赋值。最后,这种方法使用syncRoot实例来上锁,而不是锁住类型本身,以避免死锁。

这种双重检查锁的方法解决了线程并发性问题,同时避免了在每次调用Instance属性方法时使用独占锁。它还运行你延迟实例化,直到对象第一次被访问。事实上,程序很少使用这种实现。在大多数情况下,静态初始化方法就足矣。

2.3. 导致的一些问题

在C#中实现单例有以下好处和问题:

2.3.1. 好处

静态初始化方法是可行的,因为.NET框架显式地定义了静态变量初始化的方式和时间。

在“多线程单例”中描述的双重检查锁用法在CLR中得到了正确的实现。

2.3.2. 存在的问题

如果你的多线程程序需要显式初始化,则必须采取预防措施以避免线程问题。

2.4. 鸣谢

  • [Gamma95] Gamma, Helm, Johnson, and Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley, 1995.
  • [Lea99] Lea, Doug. Concurrent Programming in Java, Second Edition. Addison-Wesley, 1999.
  • [Sells03] Sells, Chris. “Sealed Sucks.”

三、结尾语

虽然是个基础的单例模式,但涉及的知识并不简单。
其中明显就涉及了平时业务代码中不太会遇到的操作系统(Operating System)这门课的并发相关知识。尤其是多线程单例中,锁的示例,如果OS零基础或对并发没个概念还真难啃下来。
不过若只是应用单例的话,就如文中所说,理解静态初始化那段代码,然后把你的类套进去(即把静态初始化的Singleton类名换成你自己的类)就行了。