面试中的Singleton

引子

“请写一个Singleton。”面试官微笑着和我说。

“这可真简单。”我心里想着,并在白板上写下了下面的Singleton实现:

“那请你讲解一下该实现的各组成。”面试官的脸上仍然带着微笑。

“首先要说的就是Singleton的构造函数。由于Singleton限制其类型实例有且只能有一个,因此我们应通过将构造函数设置为非公有来保证其不会被用户代码随意创建。而在类型实例访问函数中,我们通过局部静态变量达到实例仅有一个的要求。另外,通过该静态变量,我们可以将该实例的创建延迟到实例访问函数被调用时才执行,以提高程序的启动速度。”

保护

“说得不错,而且更可贵的是你能注意到对构造函数进行保护。毕竟中间件代码需要非常严谨才能防止用户代码的误用。那么,除了构造函数以外,我们还需要对哪些组成进行保护?”

“还需要保护的有拷贝构造函数,析构函数以及赋值运算符。或许,我们还需要考虑取址运算符。这是因为编译器会在需要的时候为这些成员创建一个默认的实现。”

“那你能详细说一下编译器会在什么情况下创建默认实现,以及创建这些默认实现的原因吗?”面试官继续问道。

“在这些成员没有被声明的情况下,编译器将使用一系列默认行为:对实例的构造就是分配一部分内存,而不对该部分内存做任何事情;对实例的拷贝也仅仅是将原实例中的内存按位拷贝到新实例中;而赋值运算符也是对类型实例所拥有的各信息进行拷贝。而在某些情况下,这些默认行为不再满足条件,那么编译器将尝试根据已有信息创建这些成员的默认实现。这些影响因素可以分为几种:类型所提供的相应成员,类型中的虚函数以及类型的虚基类。”

“就以构造函数为例,如果当前类型的成员或基类提供了由用户定义的构造函数,那么仅进行内存拷贝可能已经不是正确的行为。这是因为该成员的构造函数可能包含了成员初始化,成员函数调用等众多执行逻辑。此时编译器就需要为这个类型生成一个默认构造函数,以执行对成员或基类构造函数的调用。另外,如果一个类型声明了一个虚函数,那么编译器仍需要生成一个构造函数,以初始化指向该虚函数表的指针。如果一个类型的各个派生类中拥有一个虚基类,那么编译器同样需要生成构造函数,以初始化该虚基类的位置。这些情况同样需要在拷贝构造函数中考虑:如果一个类型的成员变量拥有一个拷贝构造函数,或者其基类拥有一个拷贝构造函数,位拷贝就不再满足要求了,因为拷贝构造函数内可能执行了某些并不是位拷贝的逻辑。同时如果一个类型声明了虚函数,拷贝构造函数需要根据目标类型初始化虚函数表指针。如基类实例经过拷贝后,其虚函数表指针不应指向派生类的虚函数表。同理,如果一个类型的各个派生类中拥有一个虚派生,那么编译器也应为其生成拷贝构造函数,以正确设置各个虚基类的偏移。”

“当然,析构函数的情况则略为简单一些:只需要调用其成员的析构函数以及基类的析构函数即可,而不需要再考虑对虚基类偏移的设置及虚函数表指针的设置。”

“在这些默认实现中,类型实例的各个原生类型成员并没有得到初始化的机会。但是这一般被认为是软件开发人员的责任,而不是编译器的责任。”说完这些,我长出一口气,心里也暗自庆幸曾经研究过该部分内容。

“你刚才提到需要考虑保护取址运算符,是吗?我想知道。”

“好的。首先要声明的是,几乎所有的人都会认为对取址运算符的重载是邪恶的。甚至说,boost为了防止该行为所产生的错误更是提供了addressof()函数。而另一方面,我们需要讨论用户为什么要用取址运算符。Singleton所返回的常常是一个引用,对引用进行取址将得到相应类型的指针。而从语法上来说,引用和指针的最大区别在于是否可以被delete关键字删除以及是否可以为NULL。但是Singleton返回一个引用也就表示其生存期由非用户代码所管理。因此使用取址运算符获得指针后又用delete关键字删除Singleton所返回的实例明显是一个用户错误。综上所述,通过将取址运算符设置为私有没有多少意义。”

重用

“好的,现在我们换个话题。如果我现在有几个类型都需要实现为Singleton,那我应怎样使用你所编写的这段代码呢?”

刚刚还在洋洋自得的我恍然大悟:这个Singleton实现是无法重用的。没办法,只好一边想一边说:“一般来说,较为流行的重用方法一共有三种:组合、派生以及模板。首先可以想到的是,对Singleton的重用仅仅是对Instance()函数的重用,因此通过从Singleton派生以继承该函数的实现是一个很好的选择。而Instance()函数如果能根据实际类型更改返回类型则更好了。因此奇异递归模板(CRTP,The Curiously Recurring Template Pattern)模式则是一个非常好的选择。”于是我在白板上飞快地写下了下面的代码:

同时我也在白板上写下了对该Singleton实现进行重用的方法:

“在需要重用该Singleton实现时,我们仅仅需要从Singleton派生并将Singleton的泛型参数设置为该类型即可。”

生存期管理

“我看你在实现中使用了静态变量,那你是否能介绍一下上面Singleton实现中有关生存期的一些特征吗?毕竟生存期管理也是编程中的一个重要话题。”面试官提出了下一个问题。

“嗯,让我想一想。我认为对Singleton的生存期特性的讨论需要分为两个方面:Singleton内使用的静态变量的生存期以及Singleton外在用户代码中所表现的生存期。Singleton内使用的静态变量是一个局部静态变量,因此只有在Singleton的Instance()函数被调用时其才会被创建,从而拥有了延迟初始化(Lazy)的效果,提高了程序的启动性能。同时该实例将生存至程序执行完毕。而就Singleton的用户代码而言,其生存期贯穿于整个程序生命周期,从程序启动开始直到程序执行完毕。当然,Singleton在生存期上的一个缺陷就是创建和析构时的不确定性。由于Singleton实例会在Instance()函数被访问时被创建,因此在某处新添加的一处对Singleton的访问将可能导致Singleton的生存期发生变化。如果其依赖于其它组成,如另一个Singleton,那么对它们的生存期进行管理将成为一个灾难。甚至可以说,还不如不用Singleton,而使用明确的实例生存期管理。”

“很好,你能提到程序初始化及关闭时单件的构造及析构顺序的不确定可能导致致命的错误这一情况。可以说,这是通过局部静态变量实现Singleton的一个重要缺点。而对于你所提到的多个Singleton之间相互关联所导致的生存期管理问题,你是否有解决该问题的方法呢?”

我突然间意识到自己给自己出了一个难题:“有,我们可以将Singleton的实现更改为使用全局静态变量,并将这些全局静态变量在文件中按照特定顺序排序即可。”

“但是这样的话,静态变量将使用eager initialization的方式完成初始化,可能会对性能影响较大。其实,我想听你说的是,对于具有关联的两个Singleton,对它们进行使用的代码常常局限在同一区域内。该问题的一个解决方法常常是将对它们进行使用的管理逻辑实现为Singleton,而在内部逻辑中对它们进行明确的生存期管理。但不用担心,因为这个答案也过于经验之谈。那么下一个问题,你既然提到了全局静态变量能解决这个问题,那是否可以讲解一下全局静态变量的生命周期是怎样的呢?”

“编译器会在程序的main()函数执行之前插入一段代码,用来初始化全局变量。当然,静态变量也包含在内。该过程被称为静态初始化。”

“嗯,很好。使用全局静态变量实现Singleton的确会对性能造成一定影响。但是你是否注意到它也有一定的优点呢?”

见我许久没有回答,面试官主动帮我解了围:“是线程安全性。由于在静态初始化时用户代码还没有来得及执行,因此其常常处于单线程环境下,从而保证了Singleton真的只有一个实例。当然,这并不是一个好的解决方法。所以,我们来谈谈Singleton的多线程实现吧。”

多线程

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

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

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

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

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

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

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

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

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

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

“这样就可能导致其中一个线程在完成了内存分配后就被切换到另一线程,而另一线程对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++功底已经很好了。我相信,我们会在不久的将来成为同事。”

笔者注:这本是Writing Patterns Line by Line的一篇文章,但最后想想,写模式的人太多了,我还是省省吧。。。

下一篇回归WPF,环境刚好。可能中间穿插些别的内容,比如HTML5,JS,安全等等。

头一次写小品文,不知道效果是不是好。因为这种文章的特点是知识点分散,而且隐藏在文章的每一句话中。。。好处就是写起来轻松,呵呵。。。

2 6 收藏 1 评论

相关文章

可能感兴趣的话题



直接登录
最新评论
  • 不好意思,我是来泼冷水的...
    很明显,文中的面试官和面试者都没看过Effctive C++ 3rd. 其中,第四个item详细讲述了为什么要使用局部静态变量.:在不同编译单元中静态对象的初始化顺序是未定义的.

跳到底部
返回顶部