从源码角度告诉你为什么Java和Python可以用枚举类实现单例模式

432 阅读16分钟

封面

前言

月消费过万,扬言驾照比项目经历对于找程序员工作的人来说更重要,热衷于资助秦始皇上大学的编码大婶瞎目喵先生曾经对枚举类发出暴言:枚举类真的让编程变得更简单了吗?

暴言.png

笔者很难不站在道德制高点做出如下评判:是的,不仅变简单了,甚至还能拿来实现单例模式。

至于他提到的“reload一个模块时Enum不方便”,这个问题我们留到最后再讲。

如果你是经常用C#之类编程语言的朋友,谈到单例模式首先想到的应该是饿汉式和懒汉式,这两种方式都是通过类的静态变量来实现的。

但是正如笔者上文所说,在Java、Python语言中,还有一种更好的方式来实现单例模式,那就是枚举类。

很多朋友对枚举实现单例模式的方式并不陌生,但是对于为什么枚举类可以实现单例模式,却很少有人深入探讨。

这次我们就主要拿Java和Python作为例子,来探讨一下为什么这类语言的枚举类可以实现单例模式,在其他语言中却不行。

用枚举类实现单例模式

废话不多说,先看一下用枚举类实现单例模式的代码。

举一个很经典的场景,假设办公室中只有一台打印机,我们不可能让每个员工都有一个打印机,这时候就可以用单例模式来实现。

Java的代码如下:

enum Printer {
    INSTANCE;

    Printer() {
        System.out.println("Printer instance created");
    }

    /**
     * 打印文件
     */
    public void print(String user) {
        System.out.println(user + " is printing");
    }
}

这样,我们就可以通过Printer.INSTANCE来获取唯一的打印机实例。

public class Program {
    public static void main(String[] args) {
        var threads = new ArrayList<Thread>();
        // 创建三个线程,模拟三个用户打印文件
        for (int i = 0; i < 3; i++) {
            int finalI = i;
            var thread = new Thread(() -> {
                var instance = Printer.INSTANCE;
                instance.print(finalI + "");
            });
            threads.add(thread);
            thread.start();
        }

        // 等待所有线程执行完成
        for (var thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                System.out.println(e.getMessage());
            }
        }
    }
}

运行结果可能如下:

Printer instance created
0 is printing
1 is printing
2 is printing

Python的代码如下(记住这里我单独为Printer开了个模块,待会要考):

from enum import Enum


class Printer(Enum):
    INSTANCE = 1
    _instance = None

    @property
    def instance(self):
        if self._instance is None:
            self._instance = Printer.INSTANCE
        return self._instance

    def print(self, user):
        print(f"{user} is printing")

使用方法也很简单,直接引入后调用即可:

from threading import Thread
from Printer import Printer


# 3个线程同时打印
for i in range(3):
    t = Thread(target=Printer.INSTANCE.instance.print, args=(f"user{i}",))
    t.start()
    t.join()

运行结果可能如下:

user0 is printing
user1 is printing
user2 is printing

如果你背过求职八股文,那么你可能知道枚举类实现单例模式的优点:

  1. 线程安全,多线程情况下实例也只会在被首次访问时创建一次
  2. 防止反序列化创建新的对象
  3. 防止反射创建新的对象
  4. 简洁

但八股文也就仅是提出这几个优点,最多提了句“JVM会怎么做”便浅尝辄止,Python这边甚至没有八股文讲用Enum实现单例。在这种情况下你能真正明白且记得为什么线程安全且能防止反序列化和反射吗?

故接下来我们必须得深入分析其底层原理。

枚举类实现单例模式的本质原理

先拿Java来举例吧。

我们不妨先观察刚刚写好的代码。

我们定义了只有一个INSTANCE的枚举类,在枚举类中,我专门写了一个构造函数,用来打印一句话。

Printer() {
    System.out.println("Printer instance created");
}

我们在构造函数前添加一个private修饰符,看看会发生什么。

图1.png

IDEA居然提示我们,修饰符'private'对于枚举构造函数是冗余的。

相信读者朋友们朋友不难发现以下两点:

  1. 枚举类居然是引用类型?
  2. 枚举类的构造函数默认是private的?

接下来我们针对这两点展开讨论。

枚举类是引用类型

如果你读过我的上一篇文章《一篇文章让你重新认识值、引用和闭包》,我提到过,enum是一种值类型数据,但这种说法其实是不准确的。

在绝大部分编程语言当中,枚举类确实是值类型数据,但在Java、Python这类语言中,枚举类是引用类型数据。

Python就不用说了,你声明的时候写的就是class 类名(Enum),这就是一个类,很难不是引用类型数据。

对于Java这块,我们不妨在main方法当中使用反射来验证一下:

public class Program {
    public static void main(String[] args) {
        Printer instance = Printer.INSTANCE;
        System.out.println(instance.getClass().isPrimitive());
    }
}

运行后,输出的结果是false,这证明了枚举类是引用类型数据。

不妨再举个反例,比如我们现在在C#当中定义一个枚举类,然后用反射判断一下枚举类的类型:

internal static class Program
{
    private static void Main()
    {
        const Printer printer = Printer.Instance;
        Console.WriteLine(printer.GetType().IsValueType);
    }
}

internal enum Printer
{
    Instance,
}

执行后,输出的结果是True

此外,如果你想要在枚举类中像Java那样定义方法,那么你可能会看到如下报错:

图2.png

这证明了在C#中,枚举类是值类型数据,且不支持直接在枚举类中定义方法。

当然,你可能会说,在C#当中,我可能可以使用扩展方法为枚举类添加方法,或者在rust当中,我可以使用impl块为枚举类添加方法,变相地为enum这种值类型数据添加方法是可以实现的。

比如:

internal static class Program
{
    private static void Print(this Printer printer)
    {
        Console.WriteLine("Print");
    }
    
    private static void Main()
    {
        const Printer printer = Printer.Instance;
        
        // 输出为Print,说明枚举类的方法可以被调用
        printer.Print();
    }
}

这里就要引出第二个问题,在C#和rust等语言中,即便可以添加方法的枚举类,在使用时真正做到了全局性的唯一化吗?

笔者rust水平极差,这里只拿C#举例,就在刚才的代码基础上做修改吧(欢迎大家在评论区科普为什么rust不可以用枚举类实现单例模式):

internal static class Program
{
    private static void Main()
    {
        const Printer printer = Printer.Instance;

        // 使用反射创建新的枚举实例
        var printerType = typeof(Printer);
        var newPrinter = (Printer)(Activator.CreateInstance(printerType) ?? throw new InvalidOperationException());

        // 使用ReferenceEquals方法判断两个枚举实例是否是同一个实例
        Console.WriteLine(ReferenceEquals(printer, newPrinter));

        // 使用反序列化创建新的枚举实例
        var json = JsonSerializer.Serialize(printer);
        newPrinter = JsonSerializer.Deserialize<Printer>(json);
        
        // 同理,判断两个枚举实例是否是同一个实例
        Console.WriteLine(ReferenceEquals(printer, newPrinter));
    }
}

这里我们通过反射创建了一个新的枚举实例,然后使用ReferenceEquals方法判断两个枚举实例是否是同一个实例。接着又用反序列化创建了一个新的枚举实例,再次判断两个枚举实例是否是同一个实例。

运行后,输出的结果是False,这证明了在C#中,枚举类的实例并不是全局性的唯一化。

那么,Java和Python是如何做到枚举类的实例全局性唯一化的呢?

枚举类被创建时的唯一性

先聊聊Java如何保证枚举类实例创建时的唯一性。

我们执行javap -p指令对编译好的Printer.class文件进行反编译,看看实际编译后的枚举类是怎么样的:

javap -p Printer.class
Compiled from "Program.java"
final class Printer extends java.lang.Enum<Printer> {
  public static final Printer INSTANCE;
  private static final Printer[] $VALUES;
  public static Printer[] values();
  public static Printer valueOf(java.lang.String);
  private Printer();
  public void print(java.lang.String);
  private static Printer[] $values();
  static {};
}

不难看出,枚举类在编译后会被编译成一个继承自java.lang.Enum的类,所有的内部枚举实例都是使用static final修饰的,且这个类的构造函数是私有的。

那么这个私有的构造函数是如何被调用的呢?

在控制台中执行javap -c命令,可以看到如下结果:

javap -c Printer.class
Compiled from "Program.java"
final class Printer extends java.lang.Enum<Printer> {
  public static final Printer INSTANCE;

  // 省略values和valueOf方法

  public void print(java.lang.String);
    Code:
       0: getstatic     #26                 // Field java/lang/System.out:Ljava/io/PrintStream;
       3: aload_1
       4: invokedynamic #40,  0             // InvokeDynamic #0:makeConcatWithConstants:(Ljava/lang/String;)Ljava/lang/String;
       9: invokevirtual #34                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      12: return

  static {};
    Code:
       0: new           #1                  // class Printer
       3: dup
       4: ldc           #44                 // String INSTANCE
       6: iconst_0
       7: invokespecial #45                 // Method "<init>":(Ljava/lang/String;I)V
      10: putstatic     #3                  // Field INSTANCE:LPrinter;
      13: invokestatic  #46                 // Method $values:()[LPrinter;
      16: putstatic     #7                  // Field $VALUES:[LPrinter;
      19: return
}

观察上述控制台输出,我们可以发现,枚举类在被访问时会执行一个static代码块,用来初始化枚举类的实例。

在上面的static代码块中,序号7的指令invokespecial会调用<init>方法,这个方法是枚举类的构造函数。

这证明了JVM会在加载枚举类的时候,调用枚举类的构造函数,来初始化枚举类的实例。从而使得我们第一次访问枚举类的时候,枚举类的实例会被创建,且JVM保证了这个实例的唯一性即只会创建一次。

Python这边的实现方法不太一致。

Python这边的枚举类在被实例化时,由于Enum类的元类是EnumMeta,因此会先执行枚举类的元类EnumMeta__new__方法,然后在实例化内部每一个枚举实例时,会调用枚举类自己的__new__方法。

我们不妨戳开EnumMeta__new__方法源码看看,可以看到如下代码:

# 省略了部分代码
# If another member with the same value was already defined, the
# new member becomes an alias to the existing one.
for name, canonical_member in enum_class._member_map_.items():
    if canonical_member._value_ == enum_member._value_:
        enum_member = canonical_member
        break
# 省略了部分代码

这段代码的意思是,我们会先在全局的枚举类字典_member_map_中查找是否已经存在了相同值的枚举实例,如果存在,则将新的枚举实例指向已存在的枚举实例。

在这种情况下,不管我们访问了多少次枚举类,枚举类的实例都只会被创建一次,且保证了实例的唯一性,包括后续我们会讲到的反序列化和反射的情况。因为在反射和反序列化的情况下,创建实例的过程也是通过__new__方法来实现的。

防止反射和反序列化创建新的对象

现在又把视角转回到Java这边。

虽然Python通过了很巧妙的办法解决了枚举类问题,但对Java这边来说,只是在创建时保证唯一性是远远不够的,像刚才的C#代码一样,如果我们能用反射或反序列化创建新的枚举实例,那么枚举类的实例就不再唯一了。

很多八股文是这么说的:java在JVM层面有额外的保护机制,使得我们无法通过反射调用枚举类的构造函数。

我们通过代码来验证这个观点。

首先,我们来看看反射创建枚举实例的情况:

public class Program {
    public static void main(String[] args) {
        try {
            // 获取 Printer 枚举类的构造函数
            Constructor<Printer> constructor = Printer.class.getDeclaredConstructor(String.class, int.class);
            // 设置构造函数为可访问
            constructor.setAccessible(true);
            // 尝试通过反射调用构造函数
            Printer printer = constructor.newInstance("INSTANCE", 1);
        } catch (Exception e) {
            // 捕获并打印异常
            System.out.println("获取构造函数失败:" + e);
        }
    }
}

运行后,输出的结果是获取构造函数失败:java.lang.IllegalArgumentException: Cannot reflectively create enum objects,这证明了在Java中,反射创建枚举实例是不被允许的。

为什么不被允许呢?归根结底是Constructor类做了特殊处理。我们不妨看看Constructor类的源码:

public class Constructor<T> extends Executable {
    // 省略了部分代码

    @CallerSensitive
    @ForceInline // to ensure Reflection.getCallerClass optimization
    public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
        Class<?> caller = override ? null : Reflection.getCallerClass();
        return newInstanceWithCaller(initargs, !override, caller);
    }

    /* package-private */
    T newInstanceWithCaller(Object[] args, boolean checkAccess, Class<?> caller)
        throws InstantiationException, IllegalAccessException,
               InvocationTargetException
    {
        if (checkAccess)
            checkAccess(caller, clazz, clazz, modifiers);

        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");

        ConstructorAccessor ca = constructorAccessor;   // read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(args);
        return inst;
    }

    // 省略了部分代码
}

重点观察newInstanceWithCaller方法的第二个if判断if ((clazz.getModifiers() & Modifier.ENUM) != 0),这里会判断当前类是否是枚举类,如果是枚举类,则会抛出异常IllegalArgumentException("Cannot reflectively create enum objects"),这就是为什么反射创建枚举实例是不被允许的原因。

接下来,我们来看看反序列化创建枚举实例的情况:

public class Program {
    public static void main(String[] args) {
        try {
            // 将枚举实例序列化到文件
            ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("printer.ser"));
            out.writeObject(Printer.INSTANCE);
            out.close();

            // 从文件中读取枚举实例
            ObjectInputStream in = new ObjectInputStream(new FileInputStream("printer.txt"));
            Printer printer = (Printer) in.readObject();
            in.close();

            // 比较反序列化的实例与 Printer.INSTANCE 是否相同
            System.out.println("是否相同: " + (deserializedPrinter == Printer.INSTANCE));
        } catch (Exception e) {
            // 捕获并打印异常
            System.out.println("反序列化失败:" + e);
        }
    }
}

// 这里实现了Serializable接口,使得枚举类可以被序列化
enum Printer implements Serializable {
    // 省略其他代码
}

上述的代码思路很简单,我们将枚举实例序列化到文件printer.ser中,然后再从文件中读取枚举实例,实现了反序列化。最后,我们将反序列化的实例和枚举类的实例进行比较,看看是否相同。

输出结果不用猜也知道是true,这证明了在Java中,反序列化创建的枚举实例被进行了特殊处理,使得其和枚举类的实例是相同的。

在执行反序列化时涉及到的类和方法有很多,挨个戳开找有点复杂。

方便起见,我把总体路径梳理一下:

  1. ObjectInputStream在反序列化时,会调用readObject方法,这个方法会调用readEnum方法。
  2. readEnum方法会调用Enum.valueOf方法,这个方法保证了反序列化的枚举实例和枚举类的实例是相同的。

现在我们就来看看这个Enum.valueOf方法的源码:

public static <T extends Enum<T>> T valueOf(Class<T> enumClass, String name) {
    T result = enumClass.enumConstantDirectory().get(name);
    if (result != null)
        return result;
    if (name == null)
        throw new NullPointerException("Name is null");
    throw new IllegalArgumentException(
        "No enum constant " + enumClass.getCanonicalName() + "." + name);
}

这个方法的关键点在于enumClass.enumConstantDirectory().get(name),这个方法会从枚举类的字典中获取枚举实例,如果获取不到,则会抛出异常,反之则返回枚举实例,这就保证了反序列化的枚举实例和枚举类的实例是相同的。

不难看出,Java在这里的处理算法和Python的如出一辙,都是通过从全局的枚举类字典中获取枚举实例,保证了反序列化的枚举实例和枚举类的实例是相同的。

换句话说,只要保证维护一个全局的枚举类字典,那么不管是反射还是反序列化,都可以保证枚举实例的唯一性。

最后还是写一段Python代码,来验证一下Python的枚举类在反射和反序列化时是否也能保证枚举实例的唯一性:

import pickle
from Printer import Printer

printer = Printer.INSTANCE.instance

# 尝试反射破坏单例
try:
    constructor = getattr(Printer, "__new__")
    new_instance = constructor(Printer, None)
    print(id(new_instance) == id(printer))
except Exception as e:
    print(e)

# 尝试反序列化破坏单例
serialized_instance = pickle.dumps(Printer.INSTANCE.instance)
deserialized_instance = pickle.loads(serialized_instance)
print(id(deserialized_instance) == id(printer))

在反射部分,我们尝试获取到了Printer__new__方法,然后调用这个方法,看看是否能创建新的枚举实例。在反序列化部分,我们将枚举实例序列化到文件中,然后再从文件中读取枚举实例,看看是否能创建新的枚举实例。

不用多说,运行后输出的结果必然是:

True
True

这证明了Python的枚举类在反射和反序列化时也能保证枚举实例的唯一性。

破坏Python枚举类单例模式的特殊方法

正如编码大婶瞎目喵曾经提到的内容,Python在reload模块时,模块内所有的代码都会被重新读取执行,这里就导致了新生成的枚举实例和原先的并不一致。

我们不妨来验证一下这个观点(这就是我为什么只import Printer的原因):

import Printer
import importlib

printer = Printer.Printer.INSTANCE.instance

# 重新加载模块
importlib.reload(Printer)

new_printer = Printer.Printer.INSTANCE.instance

print(id(printer) == id(new_printer))

运行后,输出的结果是False,这证明了在Python中,重新加载模块时会重新生成枚举实例,导致了枚举实例的唯一性被破坏。

但这个问题并不是枚举类的问题,而是reload的问题。你闭着眼睛想都应该知道,重新执行模块内的代码肯定会让新生成的枚举实例和原先的并不一致。

即便你使用其他方法实现单例模式,比如饿汉式、懒汉式,只要你使用reload模块,都会导致单例模式的唯一性被破坏。

以及,笔者实在不清楚,为什么这位编码超级大婶会在Python中反复使用reload模块,这种操作在Python中是非常不推荐的。笔者能想到的使用场景无非就是在开发环境下用reload调试代码。

反复reload会产生如下这些问题:

  1. 性能问题:重新加载模块会导致模块内的所有代码重新执行,这会带来额外的性能开销。
  2. 状态丢失:模块中的全局变量、单例实例等状态会在重新加载时丢失,导致不一致性。
  3. 复杂性:频繁重新加载模块会增加代码的复杂性和维护难度,容易引发难以调试的问题。

与其说枚举类在Python当中如此鸡肋,不如说reload模块在Python中如此鸡肋,枚举类如果真的有这么大的问题,为什么大家仍旧热衷于使用枚举类呢?

笔者甚至开始怀疑,这位编码超级大婶真的懂编程吗?天天翘课出去逛街打街机,回过头来吹自己是编程领域超级大神,实在是幽默至极。

总结

总的来说,这次笔者通过带领大家阅读源码和反编译的方式,深入探讨了为什么Java和Python的枚举类实现单例模式的优点,这些内容都是目前互联网上绝大部分文章当中完全不会提到的。

这就是我经常说的那句话,我的观点虽然很激进,但绝对不会故意写垃圾文,更不可能是瞎目喵先生口中所谓的“误人子弟”。我更多时候会去探讨一些大伙没提到过或没做过的内容,甚至传递一些我所认可的编程思想和价值观。

发散思维地说,这里通过保存全局的枚举类字典的行为,是不是可以联想到其他的业务逻辑实现?比如说依赖注入、事件总线等等。实际上如果你读过SpringBoot的源码,你会发现SpringBoot的IoC容器就是通过保存全局的Bean字典来实现的。

平时在写代码的时候多思考下为什么,多考虑考虑能不能把这个思路应用到其他地方,这样才能真正提高自己的编程水平,更不会提出瞎目喵先生说出的这种低级问题。