• 单例模式


    “首先请你写一个线程安全的Singleton实现。”

      我拿起笔,在白板上写下早已烂熟于心的多线程安全实现:

    template <typename T>
    class Singleton
    {
    public:
        static T& Instance()
        {
            if (m_pInstance == NULL)
            {
                Lock lock;
                if (m_pInstance == NULL)
                {
                    m_pInstance = new T();
                    atexit(Destroy);
                }
                return *m_pInstance;
            }
            return *m_pInstance;
        }
    
    protected:
        Singleton(void) {}
        ~Singleton(void) {}
    
    private:
        Singleton(const Singleton& rhs) {}
        Singleton& operator = (const Singleton& rhs) {}
    
        void Destroy()
        {
            if (m_pInstance != NULL)
                delete m_pInstance;
            m_pInstance = NULL;
        }
    
        static T* volatile m_pInstance;
    };
    
    template <typename T>
    T* Singleton<T>::m_pInstance = NULL;

    “写得很精彩。那你是否能逐行讲解一下你写的这个Singleton实现呢?”

      “好的。首先,我使用了一个指针记录创建的Singleton实例,而不再是局部静态变量。这是因为局部静态变量可能在多线程环境下出现问题。”

      “我想插一句话,为什么局部静态变量会在多线程环境下出现问题?”

      “这是由局部静态变量的实际实现所决定的。为了能满足局部静态变量只被初始化一次的需求,很多编译器会通过一个全局的标志位记录该静态变量是否已经被初始化的信息。那么,对静态变量进行初始化的伪码就变成下面这个样子:”。

    1 bool flag = false;
    2 if (!flag)
    3 {
    4     flag = true;
    5     staticVar = initStatic();
    6 }

      “那么在第一个线程执行完对flag的检查并进入if分支后,第二个线程将可能被启动,从而也进入if分支。这样,两个线程都将执行对静态变量的初始化。因此在这里,我使用了指针,并在对指针进行赋值之前使用锁保证在同一时间内只能有一个线程对指针进行初始化。同时基于性能的考虑,我们需要在每次访问实例之前检查指针是否已经经过初始化,以避免每次对Singleton的访问都需要请求对锁的控制权。”

      “同时,”我咽了口口水继续说,“因为new运算符的调用分为分配内存、调用构造函数以及为指针赋值三步,就像下面的构造函数调用:”

    1 SingletonInstance pInstance = new SingletonInstance();

      “这行代码会转化为以下形式:”

    1 SingletonInstance pHeap = __new(sizeof(SingletonInstance));
    2 pHeap->SingletonInstance::SingletonInstance();
    3 SingletonInstance pInstance = pHeap;

      “这样转换是因为在C++标准中规定,如果内存分配失败,或者构造函数没有成功执行, new运算符所返回的将是空。一般情况下,编译器不会轻易调整这三步的执行顺序,但是在满足特定条件时,如构造函数不会抛出异常等,编译器可能出于优化的目的将第一步和第三步合并为同一步:”

    1 SingletonInstance pInstance = __new(sizeof(SingletonInstance));
    2 pInstance->SingletonInstance::SingletonInstance();

      “这样就可能导致其中一个线程在完成了内存分配后就被切换到另一线程,而另一线程对Singleton的再次访问将由于pInstance已经赋值而越过if分支,从而返回一个不完整的对象。因此,我在这个实现中为静态成员指针添加了volatile关键字。该关键字的实际意义是由其修饰的变量可能会被意想不到地改变,因此每次对其所修饰的变量进行操作都需要从内存中取得它的实际值。它可以用来阻止编译器对指令顺序的调整。只是由于该关键字所提供的禁止重排代码是假定在单线程环境下的,因此并不能禁止多线程环境下的指令重排。”

      “最后来说说我对atexit()关键字的使用。在通过new关键字创建类型实例的时候,我们同时通过atexit()函数注册了释放该实例的函数,从而保证了这些实例能够在程序退出前正确地析构。该函数的特性也能保证后被创建的实例首先被析构。其实,对静态类型实例进行析构的过程与前面所提到的在main()函数执行之前插入静态初始化逻辑相对应。”

    引用还是指针

      “既然你在实现中使用了指针,为什么仍然在Instance()函数中返回引用呢?”面试官又抛出了新的问题。

      “这是因为Singleton返回的实例的生存期是由Singleton本身所决定的,而不是用户代码。我们知道,指针和引用在语法上的最大区别就是指针可以为NULL,并可以通过delete运算符删除指针所指的实例,而引用则不可以。由该语法区别引申出的语义区别之一就是这些实例的生存期意义:通过引用所返回的实例,生存期由非用户代码管理,而通过指针返回的实例,其可能在某个时间点没有被创建,或是可以被删除的。但是这两条Singleton都不满足,因此在这里,我使用指针,而不是引用。”

      “指针和引用除了你提到的这些之外,还有其它的区别吗?”

      “有的。指针和引用的区别主要存在于几个方面。从低层次向高层次上来说,分为编译器实现上的,语法上的以及语义上的区别。就编译器的实现来说,声明一个引用并没有为引用分配内存,而仅仅是为该变量赋予了一个别名。而声明一个指针则分配了内存。这种实现上的差异就导致了语法上的众多区别:对引用进行更改将导致其原本指向的实例被赋值,而对指针进行更改将导致其指向另一个实例;引用将永远指向一个类型实例,从而导致其不能为NULL,并由于该限制而导致了众多语法上的区别,如dynamic_cast对引用和指针在无法成功进行转化时的行为不一致。而就语义而言,前面所提到的生存期语义是一个区别,同时一个返回引用的函数常常保证其返回结果有效。一般来说,语义区别的根源常常是语法上的区别,因此上面的语义区别仅仅是列举了一些例子,而真正语义上的差别常常需要考虑它们的语境。”

      “你在前面说到了你的多线程内部实现使用了指针,而返回类型是引用。在编写过程中,你是否考虑了实例构造不成功的情况,如new运算符运行失败?”

      “是的。在和其它人进行讨论的过程中,大家对于这种问题有各自的理解。首先,对一个实例的构造将可能在两处抛出异常:new运算符的执行以及构造函数抛出的异常。对于new运算符,我想说的是几点。对于某些操作系统,例如Windows,其常常使用虚拟地址,因此其运行常常不受物理内存实际大小的限制。而对于构造函数中抛出的异常,我们有两种策略可以选择:在构造函数内对异常进行处理,以及在构造函数之外对异常进行处理。在构造函数内对异常进行处理可以保证类型实例处于一个有效的状态,但一般不是我们想要的实例状态。这样一个实例会导致后面对它的使用更为繁琐,例如需要更多的处理逻辑或再次导致程序执行异常。反过来,在构造函数之外对异常进行处理常常是更好的选择,因为软件开发人员可以根据产生异常时所构造的实例的状态将一定范围内的各个变量更改为合法的状态。举例来说,我们在一个函数中尝试创建一对相互关联的类型实例,那么在一个实例的构造函数抛出了异常时,我们不应该在构造函数里对该实例的状态进行维护,因为前一个实例的构造是按照后一个实例会正常创建来进行的。相对来说,放弃后一个实例,并将前一个实例删除是一个比较好的选择。”

      我在白板上比划了一下,继续说到:“我们知道,异常有两个非常明显的缺陷:效率,以及对代码的污染。在太小的粒度中使用异常,就会导致异常使用次数的增加,对于效率以及代码的整洁型都是伤害。同样地,对拷贝构造函数等组成常常需要使用类似的原则。”

      “反过来说,Singleton的使用也可以保持着这种原则。Singleton仅仅是一个包装好的全局实例,对其的创建如果一旦不成功,在较高层次上保持正常状态同样是一个较好的选择。”

    Anti-Patten

      “既然你提到了Singleton仅仅是一个包装好的全局变量,那你能说说它和全局变量的相同与不同么?”

      “单件可以说是全局变量的替代品。其拥有全局变量的众多特点:全局可见且贯穿应用程序的整个生命周期。除此之外,单件模式还拥有一些全局变量所不具有的性质:同一类型的对象实例只能有一个,同时适当的实现还拥有延迟初始化(Lazy)的功能,可以避免耗时的全局变量初始化所导致的启动速度不佳等问题。要说明的是,Singleton的最主要目的并不是作为一个全局变量使用,而是保证类型实例有且仅有一个。它所具有的全局访问特性仅仅是它的一个副作用。但正是这个副作用使它更类似于包装好的全局变量,从而允许各部分代码对其直接进行操作。软件开发人员需要通过仔细地阅读各部分对其进行操作的代码才能了解其真正的使用方式,而不能通过接口得到组件依赖性等信息。如果Singleton记录了程序的运行状态,那么该状态将是一个全局状态。各个组件对其进行操作的调用时序将变得十分重要,从而使各个组件之间存在着一种隐式的依赖。”

      “从语法上来讲,首先Singleton模式实际上将类型功能与类型实例个数限制的代码混合在了一起,违反了SRP。其次Singleton模式在Instance()函数中将创建一个确定的类型,从而禁止了通过多态提供另一种实现的可能。”

      “但是从系统的角度来讲,对Singleton的使用则是无法避免的:假设一个系统拥有成百上千个服务,那么对它们的传递将会成为系统的一个灾难。从微软所提供的众多类库上来看,其常常提供一种方式获得服务的函数,如GetService()等。另外一个可以减轻Singleton模式所带来不良影响的方法则是为Singleton模式提供无状态或状态关联很小的实现。”

      “也就是说,Singleton本身并不是一个非常差的模式,对其使用的关键在于何时使用它并正确的使用它。”

      面试官抬起手腕看了看时间:“好了,时间已经到了。你的C++功底已经很好了。我相信,我们会在不久的将来成为同事。”

  • 相关阅读:
    前端好用js库
    springmvc 配置之 mvc:default-servlet-handler
    springmvc配置之mvc:annotation-driven
    spring mvc 配置之 context:annotation-config vs component-scan
    React 学习笔记
    数据结构乱写
    字符串乱写
    HEOI2020 游记
    奇怪的基础容斥数学课件
    省选模拟104 题解
  • 原文地址:https://www.cnblogs.com/theCambrian/p/3929089.html
Copyright © 2020-2023  润新知