65.9K
CodeProject 正在变化。 阅读更多。
Home

C# 中的单例设计模式:第 1 部分

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.33/5 (11投票s)

2018年1月8日

CPOL

11分钟阅读

viewsIcon

46786

downloadIcon

615

本文以最简洁易懂的方式介绍了单例模式。文章还将讨论静态类以及单例设计模式与静态类之间的区别。

目录

引言

我一直想写一篇关于 C# 中单例设计模式的文章。尽管网上已经有很多关于单例设计模式的文章,但我会尝试以最简洁易懂的方式来介绍这个主题。文章还将讨论静态类以及单例设计模式与静态类之间的区别。这是一个两部分的教程系列,用于学习单例。以下是下一篇文章的链接。

单例设计模式 - 第二部分

模式本身

单例设计模式是一种创建型设计模式。设计模式有不同的类别,其中创建型类别涉及实例的创建和初始化。这种模式有助于程序员编写更灵活的代码,允许根据各种情况创建对象,例如单例、工厂、抽象工厂等。涵盖设计模式类型超出了本文的范围,因此我们专注于单例设计模式。单例设计模式表示或允许开发人员编写代码,其中只创建一个类的实例,并且线程或其他进程应引用该类的单个实例。在某些时候,我们可能需要在代码中只使用一个类的实例,如果其他类试图创建该类的对象,那么已经实例化的对象将被共享给该类。一个非常常见且合适的例子是日志写入类。我们可能需要维护一个单一文件来写入我们 .NET 应用程序来自各种客户端(如移动客户端、Web 客户端或任何 Windows 应用程序客户端)的请求日志。在这种情况下,应该有一个类负责将日志写入单个文件。由于请求同时来自多个客户端,因此需要一种机制,一次只记录一个请求。此外,其他请求不应被遗漏,而应被仔细记录,以便在记录时,这些请求不会覆盖或冲突已经记录的请求。为了实现这一点,我们可以通过遵循单例模式的准则来使一个类成为单例,其中一个线程安全的单例对象将在所有请求之间共享,我们将在本文中通过实际示例详细讨论这一点。

单例的优点

在深入实际实现之前,让我们先重点介绍一下单例类或模式的优点。从请求日志记录示例中我们可以得出第一个优点是,单例可以处理对共享资源的并发访问,这意味着如果我们同时与多个客户端共享一个资源,那么应该妥善处理。在我们的例子中,日志文件是共享资源,而单例确保每个客户端都能访问它,而不会发生死锁或冲突。第二个优点是,它只允许一个负责共享资源的类的实例,该实例在受控状态下被所有客户端或应用程序共享。

准则

每个模式都基于某些准则,在实现模式时应遵循这些准则。这些准则有助于我们构建健壮的模式,并且每个准则在我们创建单例类时都有其重要性。在 C# 中实现单例设计模式时,请始终遵循以下准则。

  1. 检查实现时,只创建一个类的实例,并且应该只有一个点可以创建实例。
  2. 单例类的构造函数应该是私有的,以便任何类都不能直接实例化单例类。
  3. 应该有一个静态属性/方法来负责单例类的实例化,并且该属性应该在应用程序之间共享,并专门负责返回单例实例。
  4. C# 单例类应该是密封的,以便任何其他类都不能继承它。当我们处理嵌套类结构时,这很有用。稍后在实现单例时,我们将讨论这种情况。

基本单例实现

理论讲了很多,现在让我们实际实现单例模式。我们将逐步进行。

  1. 打开 Visual Studio,创建一个名为 Singleton 的控制台应用程序(您可以选择任何喜欢的名称)。

  2. 添加一个名为 Singleton 的类,并将以下代码添加到其中以记录请求消息。目前,我们实际上并未将日志记录到文件中,而是在控制台上显示消息。

Singleton.cs

using static System.Console;

namespace Singleton
{
  class Singleton
  {
    public void LogMessage(string message)
    {
      WriteLine("Message " + message);
    }
  }
}

我们还在类中添加了一个公共构造函数,其中包含一个变量来保存该单例类创建对象的计数器。每当创建 Singleton 类的实例时,我们都会递增 instanceCounter 变量,因此递增和打印它的最佳位置是构造函数。

using static System.Console;

namespace Singleton
{
  class Singleton
  {
    static int instanceCounter = 0;
    public Singleton()
    {
      instanceCounter++;
      WriteLine("Instances created " + instanceCounter);
    }
    public void LogMessage(string message)
    {
      WriteLine("Message " + message);
    }
  }
}

请注意,我们此处没有使用任何单例准则,首先我们将尝试从主方法(例如,在 Program.cs 类中)调用此方法。因此,转到 Program.cs 类并编写以下代码,通过创建该类的对象来调用单例类的 LogMessage 方法。我们假设有两个客户端或两个类(Manager 类和 Employee 类)正在创建 Singleton 类的对象来记录其请求消息。我们将这些实例命名为“fromManager”和“fromEmployee”。

Program.cs

using static System.Console;
namespace Singleton
{
  class Program
  {
    static void Main(string[] args)
    {
      Singleton fromManager = new Singleton();
      fromManager.LogMessage("Request Message from Manager");

      Singleton fromEmployee = new Singleton();
      fromEmployee.LogMessage("Request Message from Employee");

      ReadLine();
    }
  }
}

当我们运行应用程序时,我们会看到以下输出

在这里,我们看到我们创建了两个名为 Singleton 的类的实例,并且两个对象分别调用了方法。第一次创建 fromManager 实例时,计数器增加了 1,第二次创建 fromEmployee 实例时,计数器再次增加了 1,总共是 2。请注意,我们尚未实现单例准则,我们的目标是限制这种多对象创建。

  1. 如果我们再次参考准则,它说单例类的所有构造函数都应该是私有的,并且应该有一个属性或方法负责提供该单例类的实例。所以,让我们试试。将 Singleton 类中的公共构造函数更改为私有,如下所示:
        private Singleton()
        {
          instanceCounter++;
          WriteLine("Instances created " + instanceCounter );
        }

    但是当我们回到 Program.cs 类中创建实例的地方时,我们会看到如下错误,这是一个编译时错误,在此时编译程序时也会出现。

它说构造函数无法访问,这是因为我们将其设为私有。所以,让我们将提供对象的职责委托给 Singleton 类中的一个静态属性。在 Singleton 类中添加一个新属性,该属性使用后备字段来返回对象。

以下是代码

using static System.Console;

namespace Singleton
{
  class Singleton
  {
    static int instanceCounter = 0;
    private static Singleton singleInstance = null;
    private Singleton()
    {
      instanceCounter++;
      WriteLine("Instances created " + instanceCounter );
    }

    public static Singleton SingleInstance
    {
      get
      {
        if (singleInstance == null)
        {
          singleInstance = new Singleton();
        }
        return singleInstance;
      }
    }
    public void LogMessage(string message)
    {
      WriteLine("Message " + message);
    }
  }
}

如果我们查看上面的代码,我们只是创建了一个名为 singleInstance 的后备字段和一个名为 SingleInstance 的公共静态属性。每当访问此属性时,它都会使用新的 Singleton 实例实例化后备字段并将其返回给客户端,但不是每次都返回,为了确保这一点,我们检查如果 singleInstance 为 null,则仅返回新实例,而不是每次访问属性时都返回。在所有其他时间,它应该返回最初创建的同一实例。由于此类的构造函数现在是私有的,因此只能从类成员内部访问,而不能从类外部访问。现在转到 program.cs 类并访问此属性以获取实例,因为由于私有构造函数,我们现在无法直接创建 Singleton 实例。

以下是代码

using static System.Console;
namespace Singleton
{
  class Program
  {
    static void Main(string[] args)
    {
      Singleton fromManager = Singleton.SingleInstance;
      fromManager.LogMessage("Request Message from Manager");

      Singleton fromEmployee = Singleton.SingleInstance;
      fromEmployee.LogMessage("Request Message from Employee");

      ReadLine();
    }
  }
}

现在,当我们运行应用程序时,我们会得到以下输出。

这清楚地表明只创建了 Singleton 类的一个实例,但我们的方法由两个调用者分别调用。这是因为第一次创建实例时,但在 fromEmployee 访问该属性时,返回了已创建的对象。

所以,毫无疑问,我们实现了单例设计模式,并改变了这个 Singleton 类的对象创建策略,但这只是单例设计模式的一个非常基本的操作,它仍然没有处理死锁情况以及多线程环境中的类访问。让我们看看如何使这个类也线程安全。在继续之前,请将 singleton 类设置为密封。稍后我们将讨论为什么我们将其设置为密封。

线程安全的单例实现

问题

我们的基本级别实现仅适用于单线程系统,因为我们的实例创建是延迟初始化的,这意味着我们只在 SingleInstance 属性被调用时创建实例。假设存在两个线程同时尝试访问属性的情况,在这种情况下,可能出现两个线程同时命中 null 检查并获得访问新实例创建的机会,因为它们发现实例变量仍然为 null。让我们在当前的 Singleton 实现中测试此场景。

转到 Program.cs 类,创建两个名为 LogEmployeeRequestLogManagersRequest 的方法,并将两个实例的日志记录代码移动到这些方法中,如下所示。

现在,让我们尝试使用 Parallel.Invoke 方法并行调用这些方法,如下所示。此方法可以并行调用多个方法,因此我们将遇到两个方法同时声明 Singleton 实例的情况。Parallel 是 System.Threading.Tasks 命名空间下的类。

以下是代码

using System.Threading.Tasks;
using static System.Console;
namespace Singleton
{
  class Program
  {
    static void Main(string[] args)
    {
      Parallel.Invoke(() => LogManagerRequest(), () => LogEmployeeRequest());
      ReadLine();
    }

    private static void LogManagerRequest()
    {
      Singleton fromManager = Singleton.SingleInstance;
      fromManager.LogMessage("Request Message from Manager");
    }
    private static void LogEmployeeRequest()
    {
      Singleton fromEmployee = Singleton.SingleInstance;
      fromEmployee.LogMessage("Request Message from Employee");
    }
  }
}

现在,当我们运行应用程序时,我们会得到以下输出

上面的输出清楚地表明我们最终创建了 Singleton 类的两个实例,因为我们的构造函数被调用了两次。这是因为两个方法同时执行。现在,为了克服这种情况,我们可以进一步增强我们的 Singleton 类。

解决方案

克服这种情况的一种方法是使用锁。如果任何线程尝试访问实例,我们可以锁定一个对象,在这种情况下,其他线程会等待直到锁被释放。让我们来实现这一点。按以下方式更新您的 Singleton 类。

以下是代码

using static System.Console;

namespace Singleton
{
  sealed class Singleton
  {
    static int instanceCounter = 0;
    private static Singleton singleInstance = null;
    private static readonly object lockObject = new object();
    private Singleton()
    {
      instanceCounter++;
      WriteLine("Instances created " + instanceCounter );
    }

    public static Singleton SingleInstance
    {
      get
      {
        lock (lockObject)
        {
          if (singleInstance == null)
          {
            singleInstance = new Singleton();
          }

        }
        return singleInstance;
      }
    }
    public void LogMessage(string message)
    {
      WriteLine("Message " + message);
    }
  }
}

在上述源代码中,我们创建了一个私有的静态只读对象类型变量并对其进行了初始化。然后在 SingleInstance 属性中,我们将实例创建的代码包装在锁中,以便一次只有一个线程可以进入代码,而其他线程会等待直到第一个线程完成其执行。现在,让我们运行应用程序并检查输出。编译并运行。

所以,现在只创建了 Singleton 类的一个实例,这意味着我们的锁工作正常。当前实现仍然存在一个问题。问题在于,每次访问 SingleInstance 属性时都会调用我们的锁对象代码,这可能会对应用程序产生巨大的性能影响,因为当我们需要在应用程序中获得良好性能时,锁非常昂贵。因此,我们可以通过将锁包装在一个条件之下,只有当 singleInstance 后备字段为 null 时才能访问它,从而限制这种每次都访问锁代码。因此,我们属性的代码如下所示:

public static Singleton SingleInstance
    {
      get
      {
        if (singleInstance == null)
        {
          lock (lockObject)
          {
            if (singleInstance == null)
            {
              singleInstance = new Singleton();
            }

          }
        }
        return singleInstance;
      }
    }

现在,当我们运行应用程序时,锁代码不会每次都执行,而只会在第一次访问时执行,因为第二次它不会发现 singleInstance 字段为 null。完整的类代码如下。

using static System.Console;

namespace Singleton
{
  sealed class Singleton
  {
    static int instanceCounter = 0;
    private static Singleton singleInstance = null;
    private static readonly object lockObject = new object();
    private Singleton()
    {
      instanceCounter++;
      WriteLine("Instances created " + instanceCounter );
    }

    public static Singleton SingleInstance
    {
      get
      {
        if (singleInstance == null)
        {
          lock (lockObject)
          {
            if (singleInstance == null)
            {
              singleInstance = new Singleton();
            }

          }
        }
        return singleInstance;
      }
    }
    public void LogMessage(string message)
    {
      WriteLine("Message " + message);
    }
  }
}

我们将这种空实例检查锁定称为 **“双重检查锁定”**,这通常在面试中被问到。在下一篇文章中,我们将看到如何摆脱这种双重检查锁定,同时仍保持我们的单例功能完整和线程安全。

结论

在本文中,我们讨论了什么是单例设计模式,何时需要它以及它解决了什么问题。我们还讨论了如何一步步创建基本的单例类,以及如何通过锁定来增强该类以实现线程安全,并通过双重检查锁定来实现高性能。为了避免文章过长,我将学习单例主题分为两部分。请参考本文的下一部分,其中我们将讨论延迟初始化、饿汉初始化、如何在不使用双重检查锁的情况下创建单例,以及为什么将单例类设置为密封类以及单例与静态类之间的区别。下一篇文章>>

© . All rights reserved.