设计模式 with Python 5:单例模式

设计模式 with Python 5:单例模式

单例模式是一个相对简单的模式,但极为重要。

在我过往的工作经验中,这个模式相当实用,换言之,你可能会在项目中频繁看到,或者使用这个模式,但就像介绍前几个模式时候说的那样,我们需要保持清醒,不能为了使用模式而使用,而是要谨慎评估是否应该使用。当然这并不容易,而且往往比实现具体的设计模式更难。

好了,闲话少说,我们直接来看单例模式。

单例模式

所谓的单例模式就是为了确保只生成一个类实例的而生的模式

这在使用访问修饰符的编程语言中很简单,只需要合适地使用访问修饰符和一个类方法即可:

package pattern5.java;

public class Single {
    private static Single instance;

    private Single() {
    }

    public static Single getInstance() {
        if (instance == null) {
            instance = new Single();
        }
        return instance;
    }

    public void showMe() {
        System.out.print("This is a single pattern test");
    }
}

这里是用Java实现了一个单例。

实现单例的关键在于程序运行时的唯一Single类的实例由其静态变量instance所持有,同时,外部程序只能通过调用类静态方法getInstance()获取这个实例,且无法通过new Single()的方式创建实例,为了确保这一点,我们特意将Single的构造方法的访问修饰符设定为private,也就是说除了Single类自己,其它类都不能直接访问Single的构造方法,自然也就不能通过new关键字创建Single的实例。

还有一点需要说明,就是在getInstance()方法中我们通过检测instance变量是否已经初始化为Single实例来“实时”创建唯一实例,这样做的好处显而易见:将确实的实例化押后。如果这个类的实例化很消耗资源,且在程序运行一开始并不需要使用Single实例,则将不会触发Single实例化,当然也不会影响性能。当然,这个设计并非单例模式所必须的,你完全可以在类属性instance定义的时候直接初始化,然后在getInstance中直接返回该引用,当然这样做就没有相应的好处。

现在我们编写一个测试类进行测试:

需要注意的是测试单例必须是在另外一个类中测试,一开始我是直接在Single类中的main方法进行测试,结果发现依然可以通过new创建实例,差点以为是长时间没接触Java已经出现了某些新特性导致单例不是这么创建的了,大脑短路一会后才意识到在Single中测试单例是一种愚蠢的行为。

package pattern5.java;

public class SingleTest {
    public static void main(String[] args) {
        // Single single = new Single();
        // Exception in thread "main" java.lang.Error: Unresolved compilation problem: 
        // The constructor Single() is not visible

        // at pattern5.java.SingleTest.main(SingleTest.java:5)    
        Single single = Single.getInstance();
        single.showMe();
        // This is a single pattern test
    }
}

从输出可以看到,使用new是无法创建Single的实例的,所有Single的实例都是使用getInstance获取,而且它们事实上都是指向的同一个实例的引用,所以称为“单例”。

关于单例的UML图这里就不绘制了,因为只有一个类,并无太大意义。

下面我们探讨如何在Python中实现单例模式。

当然,我们可以依葫芦画瓢,按着Java的单例模式来抄,但是有一个致命的问题是Python解决不了的,就是Python并不存在访问修饰符,虽然我们可以用双下划线__XXX的方式定义伪私有属性,或者干脆使用属性描述符来定义一个调用受限的属性,但是这些都不能用于初始化方法__init__,相应的,我们抄出来的方案自然也无法阻止别人通过single=Single()的方式创建新实例。

但另一方面,如果换一个思路,用纯Python的方式思考,单例模式不就是要保证别人不能创建新实例嘛,事实上Python和Java的实例创建方式存在很大不同,这点从Python坚持没有使用new或者类似的关键字来表示实例创建就能看出,在Python中,single=Single()这段代码仅仅表明通过一个可执行对象Single创建了一个实例,并赋值给single,而具体Single是怎么实现的,或者干脆可能不是一个类,这些Python都不关心,仅仅需要保证Single是一个可执行对象即可。

而具体到Single作为一个类应用于single=Single(),这里会调用其__new__方法,而该方法也相当灵活,并不需要保证一定要返回一个Single实例(虽然一般来说会那样),如果我们需要,甚至可以返回其它任何东西,包括None,这显然和死板的Java是完全不同的运作方式,而这一点恰恰是我们需要利用的:

from typing import Any


class Single:
    def __new__(cls) -> Any:
        if not hasattr(cls, "__instance"):
            setattr(cls, "__instance", super().__new__(cls))
        return getattr(cls, "__instance")

    def showMe(self):
        print("this is a single pattern test")


single1 = Single()
single2 = Single()
print(single1)
print(single2)
print(single1 is single2)
# <__main__.Single object at 0x0000018A9D61A4C0>
# <__main__.Single object at 0x0000018A9D61A4C0>
# True

这里的__new__起到了Java代码中的getInstance()的作用,更妙的是在任何地方使用xxx = Single()都会调用__new__进而得到指向同一个Single实例的引用。

当然,这并非在Python中实现单例的唯一方式,其它方式包括但不限于:使用元类,类修饰器等。如果想了解可以阅读Python中的单例模式的几种实现方式的及优化,但个人以为其中几个实现并不严谨,严格来说并不算真正的“单例”。

如果我们讨论的范畴仅仅限于单线程的情况的话,本文就应该到此结束,然而事实往往并非如此美好,多线程这头怪兽往往会把事情弄的一团糟。

多线程下的单例

Python的多线程因为机制的原因(全局线程锁),实质上多线程的代码对单例模式的影响是相对小的(除非人为在单例的生成过程加入阻塞),所以下面的演示代码主要以Java为例进行说明。

为了复现多线程下之前的设计可能会产生的问题,这里对Single类进行修改:

package pattern5.java;
public class SingleV2 {
    private static SingleV2 instance;

    private SingleV2() {
    }
    
    public static SingleV2 getInstance() {
        if (instance == null) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
            instance = new SingleV2();
        }
        return instance;
    }

    public void showMe() {
        System.out.print("This is a single pattern test");
    }
}

getInstance中执行instance == null的判断后,我们人为加入一个线程阻塞Thread.sleep(1000),这样就会在多个线程调用时候产生线程切换,让其它线程执行,并且也同样在这里阻塞。这样就会产生一个问题,在多线程下,是有可能同时有超过一个线程经过instance == null的判断进入if块,并且创建SingleV2的实例后赋值给类变量并且返回,在这种情况下自然会产生超过一个SingleV2实例。

通过测试代码我们可以进行验证:

package pattern5.java;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class SingleV2Test implements Callable<SingleV2> {
    public static void main(String[] args) {
        FutureTask<SingleV2>  t1 = SingleV2Test.startNewThread();
        FutureTask<SingleV2>  t2 = SingleV2Test.startNewThread();
        SingleV2 s1 = null;
        SingleV2 s2 = null;
        try {
            s1 = t1.get();
            s2 = t2.get();
        } catch (InterruptedException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (ExecutionException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
        if(s1 != null && s1 == s2){
            System.out.println("create same single instance");
        }
        else if(s1 != null && s1 != s2){
            System.out.println("create another single instance");
        }
        else{
            ;
        }
    }

    public static FutureTask<SingleV2> startNewThread(){
        SingleV2Test test = new SingleV2Test();
        FutureTask<SingleV2> task = new FutureTask<>(test);
        Thread thread = new Thread(task);
        thread.start();
        return task;
    }


    public SingleV2Test() {
    }

    @Override
    public SingleV2 call() throws Exception {
        // TODO Auto-generated method stub
        return SingleV2.getInstance();
    }
}

输出的结果是create another single instance,这表明生成了两个不同的SingleV2实例。

如果将SingleV2中的阻塞代码Thread.sleep()注释掉,会输出create same single instance,但这并不意味着没有阻塞的SingleV2是线程安全的,因为Java的多线程机制和Python不同,不存在全局线程锁,所以是否线程安全并不能依赖于代码中是否有阻塞出现,因为理论上CPU是可以在任意的两行代码之间进行线程切换的,甚至根本就是不同的核心同时执行不同的线程。

解决的方法可以很简单,比如:

    public static synchronized SingleV3 getInstance() {
        if (instance == null) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
            instance = new SingleV3();
        }
        return instance;
    }

只需要给getInstance加上synchronized即可。

测试代码与SingleV2的测试代码完全一致,完整代码见Github仓库的SingleV3.javaSingleV3Test.java

现在不存在之前所说的问题了,但是带来一个新问题,所有调用getInstance()的地方都会进行同步检查,也就是说本来单例已经初始化了,只需要调用getInstance获取单例,完全不需要进行instance == null检查的情况下,也必须进行同步检查,结果多个线程中同时只有一个线程同时可以使用getInstance(),其它线程只能干等着。这当然是很糟糕的。

既然给整个方法加锁很糟糕,会极大影响性能,那自然而然的,缩小锁影响的代码的范围就是个不错的选择,这往往也是多线程性能优化的常用方式。

package pattern5.java;

public class SingleV4 {
    private volatile static SingleV4 instance;

    private SingleV4() {
    }

    public static SingleV4 getInstance() {
        if (instance == null) {
            synchronized (SingleV4.class) {
                if (instance == null) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        // TODO Auto-generated catch block
                        e.printStackTrace();
                    }
                    instance = new SingleV4();
                }
            }
        }
        return instance;
    }

    public void showMe() {
        System.out.print("This is a single pattern test");
    }
}

关于volatile关键字可以阅读volatile关键字作用及原理浅析

这里对getInstance的改造使用的方案被称为"双重检查加锁",加锁当然指的是synchronized (SingleV4.class)的方式加同步锁,限制进程访问。双重检查指的是锁的内外都存在instance == nullif判断,内部的if很好理解,本来我们加锁就是为了避免同时有一个以上的线程同时进入if,自然要将其包含在锁的代码块内部,外部的if其用途也很明了:尽量避免不必要的线程进入锁检查环节。关于这一点,你可以将外层if去除后进行思考,那样的情形下所有线程都必须经过锁检查,无论是否instance已经被初始化,在这种情况下其实和给整个方法加锁是没有本质区别的,所以当然要通过外层添加一层if检查来进行规避。这样在instance已经被初始化的情况下所有线程都会直接返回instance,不需要进行锁检查。

除此之外,其实还可以通过直接在定义instance的时候直接初始化的方式彻底规避上面遇到的问题:

package pattern5.java;

public class SingleV5 {
    private static SingleV5 instance = new SingleV5();

    private SingleV5() {
    }

    public static SingleV5 getInstance() {
        return instance;
    }

    public void showMe() {
        System.out.print("This is a single pattern test");
    }
}

这种方式简单粗暴,可能有人会觉得这种解决方案很“low”,但其实计算机领域很多解决方式都很“low”,重要的其实并不是是否“low”,而是是否能解决问题。对于这种方案,我们可以称其为“急切实例化”。

现在我们对比一下上面介绍的几种多线程下的单例方案:

  • 同步getInstance()
    • 优点:容易实现。
    • 缺点:性能较差。
  • 急切实例化
    • 优点:容易实现。
    • 缺点:形式上比较“low”,与经典的单例模式样式不同,且不具备延后实例化的能力。
  • 双重检查加锁
    • 优点:平衡了对性能的影响的同时具备延后实例化的能力。
    • 缺点:不容易实现。

综上所述,每一种方案都具有不同的优缺点,所以可以视具体情况灵活使用。

最后说一下为什么这里没有讨论Python多线程下的单例模式实现。

之前已经说过了,Python的多线程机制和Java有很大不同,因为其具有全局线程锁,事实上Python一直在单个线程下进行运作,无论你编写的是否为多线程程序,除非遇到I/O阻塞,在遇到阻塞后才会切换到其它线程执行(至少在CPython解释器下如此)。

在这种前提下,只有同时出现你的程序为多线程,且单例模式中的单例类实例创建过程中出现阻塞,这两种条件同时满足才会出现我们之前讨论的问题,进而你可能需要按照前边Java的解决方案实现一个类似的Python版本。

在我看来这种情况出现的概率并不高,而且Python并不是很重视多线程,其中一个旁证就是同样是为了处理并发而引入的包,通过异步方式解决问题的asyncio就比通过多线程解决问题的futures包使用范围更广。

或许在JPython之类的线程安全的解释器下Python程序可能会需要切实考虑此类问题,如果有人了解相关问题,欢迎留言讨论。

单例的应用

单例的应用还是挺广泛的,所有系统中需要存在的单一的控制、管理模块都值得考虑是否应当使用单例来实现。

在我的工作经验中,曾经用单例实现过系统配置模块,消息队列等。

关于单例模式的讨论就到这里了,这是一个相当实用的设计模式,希望大家能喜欢,谢谢阅读。

参考文献:

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值