网站首页> 文章专栏> 单例模式实现方式比较
单例模式实现方式比较
编辑时间:2024-04-11 13:31:52 作者:loop 0条评论

单例模式实现方式比较


单例设计模式

单例设计模式的适用情况

一般情况下,单例设计模式在任何编程语言中都是一项糟糕的技术。这是由于一个一般意义上的带有状态的单例对象实际上等价于一个或一组全局变量。

单例仅在少数情况下不会对程序的可维护性造成破坏:

  • 单例是空的
  • 单例对象是 const
  • 在程序的整个生命周期中单例对象的状态只在初始化时改变一次
  • 程序仅从单例对象读取其状态却从不写入
  • 程序仅向单例对象写入其状态却从不读取

  • 如用于自动调用外部库中的全局 initquit 函数的 RAII 封装器

  • 如单例缓存了当前操作系统的版本号
  • 如其用于管理一个线程池,仅在程序需要用到线程池时才按需初始化
  • 如其用于维护一个全局的随机数生成器
  • 如其用于维护一个全局的日志记录系统

在低耦合的模块化程序设计中单例设计模式往往能够被归类为上述五种情况之一。单例模式仅作为前提或仅作为结果时能够降低程序的耦合度,但其不因该被用作连接外部模块间因果关系的渠道,否则其反而会增加程序的耦合度

如果一个单例对象被用于从某个模块中接收信息,籍此创造某种隐含前提,随后又被另一个模块读取,那么这种单例的设计就是糟糕的。因为后者相当于隐式地假设前者已经完成了某个特定操作,既假设某个全局变量处于其所期望的某个特定状态。这无异于假设两个不同模块中的两个具体调用存在不可逆的时序关系,过度依赖这种设计在重构时必定会导致程序爆炸

事实上这种连接程序中的全局因果关系,同时又常常仅会创建一个的特殊对象,往往应由操作系统直接提供。它们往往又恰好是各个操作系统中最复杂的内核对象,如 epoll IOCPio_uring

换言之,在程序中使用单例来管理全局状态或在全局中传递信息,保证多线程健壮性的前提下,其设计复杂程度恐怕和从零设计一套 epoll IOCPio_uring 不相上下。但这种复杂性常常被低估。

规范的单例设计导致困难的初始化控制

前文我们阐述了糟糕的单例设计是什么样的,既它们的状态总是在变化,这种变化又被外部所依赖。因此,生命周期贯穿始终的、行为具有一致性的,或状态具有无关性的单例,就是不会额外引入耦合的规范单例设计。

那么由此不难推导出一个结论,规范的单例设计往往会导致所有单例对象都集中在 main 函数执行前初始化。这是由于程序的主要生命周期位于 main 函数内部,因此这种提前初始化的设计使得程序的主体部分能够无责任地假设在其自身生命周期内,所有单例都必定是存活的,且其自身无需关心这些单例的具体状态。

这就是当单例之间存在依赖关系时其初始化顺序的控制问题显得如此常见且棘手的原因,尤其当希望尽可能保证绝大部分单例对象为 const 时,static storage duration 对象的构造顺序更难以得到显式控制,这主要是由于 C++ 没有直接提供这种情况下的控制手段造成的。


单例惰性初始化技术的本质

虽然惰性地初始化单例是一种可行的手段,但如果要保证所有单例的生命周期都能够覆盖 main 函数,则使得我们往往需要将某些单例实例进行 static 局部声明,而另一些又需要进行 全局声明。其中前者为被依赖对象,后者为不被依赖的最终引用者。

如果要严格地描述这一依赖关系的话,我们可以把所有单例的依赖关系描述为一个有向无环图,图中的边由被依赖对象指向其引用者。只需要对该有向无环图做拓扑排序,我们就可以得到这些单例对象的初始化顺序。其中入度为 0 的节点先初始化,出度为 0 的节点后初始化。

对于同时混合采用 static 局部声明全局声明 的单例初始化顺序控制技术,如果希望单例对象都在 main 函数执行前完成初始化,则上述出度为 0 的节点就都必须被定义为 全局变量,而其他所有出度大于 0 的节点择必须都被定义为 static 局部变量。由于依赖方会在引用被依赖对象时惰性地完成其初始化,同时 C++ 保证 static 局部变量 仅在执行流跨过其声明位置时被构造一次,所以这种设计的本质就是递归拓扑排序

如果将所有的单例都设计为 static 局部变量 惰性初始化模式,则首次调用时这些单例才会被初始化,这种设计不能保证单例的生命周期完全覆盖 main 函数,但依旧能够允许程序主体无责任地假设自己使用时单例已都被构造完毕。但这么做有一些潜在问题,例如一但某个单例的构造代码较重(如建立连接池或预加载资源等),则将给调用方造成非预期且不可控的延迟。

对于同时混合采用 static 局部声明全局声明 的单例初始化顺序控制技术:

  • 优点
    • 除非依赖成环,否则单例之间的初始化顺序可以根据其依赖关系自动调整
  • 缺点
    • 由于拓扑排序不一定有唯一解,所以对于复杂有向无环图初始化顺序不确定
    • 优点的成立前提是惰性与积极初始化策略的分配与有向无环图拓扑结构相符
    • 一但实现错误则可能造成对未初始化对象的引用,引发 undefined behavior

一但程序的行为与设计不符,比如某个 static 局部声明 的单例对象没有被任何其他单例依赖,或者程序没有运行进入触发该依赖的分支,则该惰性单例就被遗漏了。由于其没有被其他单例递归构造,所以这可能导致其初始化过程被延迟到 main 函数生命周期内。

上述 undefined behavior 往往由间接引用出度为 0全局声明 单例所引发。比如某个单例在初始化时调用了某个全局函数,而该全局函数又通过一些调用间接引用了某个 全局声明 的单例。这种 bug 的实质是由于全局函数本不应该具有生命周期,但它对 全局声明 单例的引用却使得它隐式地继承了该单例的生命周期。

需要注意的是 undefined behavior 并不一定会马上造成 failure,所以这类错误可能在程序中传播,形成难以调试的 bug。

例如有如下单例依赖关系,其中单例 B 通过全局函数 [S] 间接引用 C:

explicit: A -> B
explicit: A -> C
explicit: B -> D
implicit: C -> [S] -> B

如果我们遗漏了这种间接依赖关系,则按照出度是否为 0,会将 C D 设置为 全局变量A B 设置为惰性 static 局部变量

C    D
^    ^
|    |
A -> B

得到三种可能的拓扑排序结果,其中两种违反了隐含依赖关系。出现 BC 就是错误的,因为 CB 之间存在隐含时序关系。

explicit:
ABCD [√]
ACBD [√]
ABDC [√]

explicit + implicit:
ABCD [×]
ACBD [√]
ABDC [×]

改进手段

一种改进思路是,将所有单例对象都设计为惰性初始化的,同时编写一个全局强制初始化器 enforce,以保证所有单例对象都能够在 main 函数执行前完成初始化。这一手段将原先分散的单例初始化时间点进行了逻辑上的集中,还可以在中心化的 enforce lambda 内显式地指定推荐的单例的初始化顺序。

代码示例:

#include <iostream>
#include <concepts>

struct A;
struct B;
struct C;
struct D;

template<typename _T>
requires std::same_as<_T, A> || std::same_as<_T, B> || std::same_as<_T, C> || std::same_as<_T, D>
auto& singleton(){static _T e; return e;}

int S();

struct A{
    A(){std::cout << __PRETTY_FUNCTION__ << std::endl;}
    ~A(){std::cout << __PRETTY_FUNCTION__ << std::endl;}
    int method(){return 0;}
};
struct B{
    B(): explicit_v(singleton<A>().method()), implicit_v(S()){
        std::cout << __PRETTY_FUNCTION__ << std::endl;
    }
    ~B(){std::cout << __PRETTY_FUNCTION__ << std::endl;}
    int method(){return 0;}
    int explicit_v;
    int implicit_v;
};
struct C{
    C(): explicit_v(singleton<A>().method()){
        std::cout << __PRETTY_FUNCTION__ << std::endl;
    }
    ~C(){std::cout << __PRETTY_FUNCTION__ << std::endl;}
    int method(){return 0;}
    int explicit_v;
};
struct D{
    D(): explicit_v(singleton<B>().method()){
        std::cout << __PRETTY_FUNCTION__ << std::endl;
    }
    ~D(){std::cout << __PRETTY_FUNCTION__ << std::endl;}
    int explicit_v;
};

int S(){return singleton<C>().method();}

namespace{
    inline auto enforce = []{
        singleton<A>();
        singleton<B>();
        singleton<C>();
        singleton<D>();

        return []{};
    }();
}

int main(int _argc, char* _argv[]){
    std::cout << __PRETTY_FUNCTION__ << std::endl;

    return 0;
}

output:

A::A()
C::C()
B::B()
D::D()
int main(int, char**)
D::~D()
B::~B()
C::~C()
A::~A()

改进结果

  • 改良
    • 消除了 undefined behavior,因为所有单例都改为了惰性实例化
    • 显式引用所有单例,所以不会有单例被漏掉从而导致延迟初始化
  • 缺陷
    • 显式指定的初始化顺序 ABCD 依旧被隐式地改成了 ACBD

事实上上述改进相当于在有向无环图中建立了出度为 0 的节点 enforce,同时令所有其他节点指向它。籍此迫使其他所有节点的出度全部大于 0,由于出度大于 0 的节点都是惰性的,所以现在整个程序中就只需要 enforce 一个非惰性节点了。

需要注意的是 singleton<_T>() 模板函数的约束是必要的,否则传入 Aconst A 将形成两个不同的单例对象。

至于那个缺陷,很难评价这种自动调整到底是破坏了程序的明确性,还是加强了其鲁棒性。


使用单例模板管理单例

实际上我们可以为所有单例设计一个模板类,用于统一管理程序中全部单例对象的生命周期。我们可以显式地使用模板参数定义这些单例的初始化顺序,让单例管理器保证这些单例以我们指定的顺序构造,并以相反的顺序析构。

同时我们希望单例管理器满足如下性质:

  • 所有单例对象都在 main 函数执行前构造完毕
  • 所有单例对象都在 main 函数返回后进行析构
  • 程序中所有单例对象的引用都通过管理器类进行访问
  • 管理器对象本身是单例
  • 管理器对象本身无状态
  • 管理器模板实例化为具体类型时不需要获得单例的完整类型定义

除最后一条性质外,都是上文提到过的设计原则。最后一条性质也是必要的,这是由于如果不满足该性质,我们就不得不在只访问某个单例时候被迫引入所有无关的单例头文件。

示例代码

singleton.h:

#ifndef __PATTERNS_SINGLETON_H__
#define __PATTERNS_SINGLETON_H__

#include <optional>
#include <array>
#include <type_traits>

namespace patterns
{

    namespace tool
    {

        template<typename _Type>
        concept plug_c = std::is_class_v<_Type> && std::is_reference_v<_Type> == false;

        template<typename _Type, typename... _Types>
        concept member_c = (std::same_as<_Type, _Types> || ...);

        template<typename, std::size_t>
        struct element{};

        template<typename _T, std::size_t _i>
        static consteval void select(element<_T, _i>) noexcept{}

        template<typename... _Types>
        struct unique;

        template<typename... _Types, std::size_t... _index>
        struct unique<std::index_sequence<_index...>, _Types...> : element<_Types, _index>...{};

        template<typename... _Types>
        concept unique_c = requires(tool::unique<std::index_sequence_for<_Types...>, _Types...> _e){
            (tool::select<_Types>(_e), ...);
        };

        enum struct phase_e : std::size_t{init, query, quit};

    }

    template<tool::plug_c... _Plugs>
    requires tool::unique_c<_Plugs...>
    struct singleton final{
    private:
        static constexpr std::size_t size = sizeof...(_Plugs);
        static constexpr bool noexcept_init = (std::is_nothrow_constructible_v<_Plugs> && ...);
        static constexpr bool noexcept_quit = (std::is_nothrow_destructible_v<_Plugs> && ...);

    public:
        template<tool::member_c<_Plugs...> _Plug>
        static _Plug& plug() noexcept{
            if(auto ptr = singleton::pointer<_Plug>())
                return *ptr.value();
            else
                return singleton::get<_Plug, tool::phase_e::query>().value();
        }

        static constexpr auto instance(){[[maybe_unused]] const auto &dummy = self; return []{};}

        // for executable to inject into library. 
        static void inject(void (*_dynamic)(std::size_t, void*)) noexcept{
            std::array<void*, size> array{reinterpret_cast<void*>(&plug<_Plugs>())...};
            _dynamic(size, reinterpret_cast<void*>(&array));
        }

        // for dynamic library to reference to the singletons from host. 
        static void reference(std::size_t _size, void* _array) noexcept{
            const std::array<void*, size> &array = *reinterpret_cast<std::array<void*, size>*>(_array);
            [&array]<std::size_t... _index>(std::index_sequence<_index...>){
                (singleton::pointer(reinterpret_cast<_Plugs*>(array[_index])), ...);
            }(std::make_index_sequence<size>());
        }

    private:
        singleton() noexcept(noexcept_init){
            (singleton::get<_Plugs, tool::phase_e::init>(), ...);
        };

        ~singleton() noexcept(noexcept_quit){
            char dummy{}; ((singleton::get<_Plugs, tool::phase_e::quit>(), dummy) = ...);
        };

        singleton(const singleton&) = delete;
        singleton& operator=(const singleton&) = delete;

    private:
        template<typename _Plug>
        static std::optional<_Plug>& storage(){
            static std::optional<_Plug> data;
            return data;
        }

        template<typename _Plug>
        static std::optional<_Plug*> pointer(_Plug* _ptr = nullptr){
            static std::optional<_Plug*> data = _ptr ? std::optional<_Plug*>{_ptr} : std::optional<_Plug*>{};
            return data;
        }

        template<typename _Plug, tool::phase_e _phase_e>
        static std::optional<_Plug>& get(){
            if constexpr(_phase_e == tool::phase_e::init)
                singleton::storage<_Plug>().emplace();
            if constexpr(_phase_e == tool::phase_e::quit)
                singleton::storage<_Plug>().reset();
            return singleton::storage<_Plug>();
        }

    private:
        static const singleton& local(){
            static singleton data;
            return data;
        }

    private:
        static const singleton &self;
    };

    template<tool::plug_c... _Plugs>
    requires tool::unique_c<_Plugs...>
    const singleton<_Plugs...>& singleton<_Plugs...>::self = singleton::local();

}

#endif /*__PATTERNS_SINGLETON_H__*/ 

使用例:

#include <iostream>
#include "singleton.h"

struct foo;
struct bar;

struct foo final{
    foo();
    ~foo();
    void method() const;
};

using singleton = patterns::singleton<bar, const foo>;

// ill formed: does not compile. 
// using error_no_reference = patterns::singleton<bar&, const foo>;
// using error_no_duplication = patterns::singleton<bar, foo, bar>;

// wrong order: throw 'std::bad_optional_access'. 
// using singleton = patterns::singleton<const foo, bar>;

int main(int _argc, char* _argv[])
{
    std::cout << __PRETTY_FUNCTION__ << std::endl;
    singleton::plug<const foo>().method();

    return 0;
}

struct bar final{
    bar();
    ~bar();
    void hello();
};

static_assert(std::is_empty_v<singleton>);

// enforce an ODR-use on 'singleton::self'. 
namespace{inline const auto touch = singleton::instance();}

// multiple definitions are ok. 
namespace{inline const auto robust = singleton::instance();}

foo::foo(){std::cout << __PRETTY_FUNCTION__ << ": ", singleton::plug<bar>().hello();}
foo::~foo(){std::cout << __PRETTY_FUNCTION__ << ": ", singleton::plug<bar>().hello();}
void foo::method() const{std::cout << __PRETTY_FUNCTION__ << std::endl;}

bar::bar(){std::cout << __PRETTY_FUNCTION__ << std::endl;}
bar::~bar(){std::cout << __PRETTY_FUNCTION__ << std::endl;}
void bar::hello(){std::cout << __PRETTY_FUNCTION__ << std::endl;}

output:

bar::bar()
foo::foo(): void bar::hello()
int main(int, char**)
void foo::method() const
foo::~foo(): void bar::hello()
bar::~bar()

工作原理

concepts:

  • patterns::tool::plug_c<_T>
    • 用于约束单例类型为类,且不为引用
  • patterns::tool::member_c<_T, _Ts...>
    • 用于约束访问的类型包含在所有单例类型内
  • patterns::tool::unique_c<_Ts...>
    • 用于约束管理器模板类所管理的单例类型无重复

patterns::singleton<_Ts...>:

管理器模板类内部采用 static 局部变量 方式保证管理器自身无状态。内部维护的单例对象的生命周期采用 std::optaional<_T> 进行显式控制。

管理器对象本身是一个空对象,其构造与析构函数以 RAII 方式控制上述 std::optaional<_T> 内保存的实际单例对象的生命周期。

  • singleton& patterns::singleton::local()
    • 该函数通过内部 static 对象创建单例管理器的全局唯一对象。
  • const singleton& singleton::self
    • 该引用具有 static 存储周期,由于 local() 为其初始化代码,所以 local() 的调用时机被限制在了 main 函数执行前。
  • const auto singleton::instance()
    • 使用时 singleton::instance() 的调用一般会被单独放置在一个编译单元内,该编译单元需要包含所有单例类型的头文件。这么做能够避免污染其他编译单元,允许它们仅按需包含自己所要访问的单例类的头文件,而对于其他单例类则只需声明无需定义。
    • 该函数运行时什么都不做,仅用于在编译期触发 selfODR-use,令其模板实例化。
  • patterns::singleton::plug<_P>()
    • 单例管理器类的单例对象访问接口,返回引用。
    • 如果整个程序没有任何编译单元调用过 singleton::instance(),那么在试图引用任意单例对象时,管理器都会抛出 std::bad_optional_access 异常。
    • 示例代码 中,bar 类型的定义被放在了 main 函数之后,所以 main 函数中的 singleton::plug<const foo>().method(); 语句被编译时,编译器只能看到 foo 类型的定义,但看不到 bar 类型的定义。这得益于此时 singleton::self 从未被 ODR-use 过,因此编译器不需要看到 bar 类型的定义。
  • patterns::singleton::singleton()
  • patterns::singleton::~singleton()
    • 构造函数中 , 运算符从左向右求值,析构函数中 = 运算符从右向左求值。利用这个技巧就能够保证各个单例对象的生命周期能够得到显式的控制,确保 LIFO
  • patterns::singleton::inject(void (*_dynamic)(std::size_t, void*))
    • executable 调用,用于向由 dynamic library 导出的 宿主单例引用注入接口 进行运行时依赖注入。
    • 一般其实际调用语句会被置于 instance() 同一个编译单元内,封装后再向外提供接口,以规避其在调用上下文中进行模板展开时需要看到所有单例类型定义的问题。
  • patterns::singleton::reference(std::size_t _size, void* _array)
    • 宿主单例引用注入接口 的实现。
    • dynamic library 封装后导出,由 executable 调用,用于在运行时接收由宿主注入到自身的单例引用。

虽然 std::optional<_T> 对象本身也是 static 局部声明 的,但由于其初始化时什么也不做,所以并不会触发递归构造导致单例的构造顺序发生变化,因此我们就可以将所有单例的实例化延迟到 phase_e::init 分支内。由于管理器的构造函数执行结束前,所有 std::optional<_T> 就已构造完毕,此后管理器的构造函数才能返回,又因为对象的生命周期是构造函数彻底执行结束后才开始的,所以管理器是所有单例中最后构造完毕的。由于 static storage duration 对象生命周期 LIFO,所以管理器最先析构,这确保了此时所有 std::optional<_T> 都是存活的。事实上由于 std::optional<_T> 的构造顺序和单例对象的构造顺序是相同的,因此 ~singleton() 内即使什么也不做,程序的行为也和示例中相同。但为了语义明确,还是显式地进行了 phase_e::quit 分支的调用。

设计分析

  • 优点
    • 不需要区分惰性初始化与积极初始化
    • 所有单例对象的初始化顺序唯一确定
    • 抛出异常以规避 undefined behavior
    • 即使抛异常初始化顺序也不会被调整
    • 所有单例生命周期皆覆盖 main 函数
  • 缺点
    • 由于初始化顺序控制提前到编译期,所以不能自动拓扑排序
  • 跨二进制
    • 管理器类的存在让我们有机会顺便对跨二进制引用问题进行处理
    • 本文中虽没有实现,但这一技巧对于之前的惰性初始化同样成立

由于异常本身就是 failure,所以错误不会在程序中传播。

我们可以发现如果将单例的初始化顺序的控制提前到编译期,与原始的递归拓扑排序惰性初始化策略相比,二者优缺点几乎正好翻转了。

但与改良的递归拓扑排序初始化策略相比,二者的唯一区别仅仅在于对显式指定初始化顺序时的明确性与鲁棒性之间做出的不同取舍。

  • 单例管理器模板类的显式初始化顺序指定的思路类似于 assert,既程序编写时我们就假设我们对依赖关系的分析是正确的,如果实际依赖关系与设计不符,那程序就应该马上报错。
  • 改良的递归拓扑排序初始化策略的思路则是最大化程序的鲁棒性,如果我们在编写程序时对依赖关系做出的假设是错误的,则该策略会自动寻找到一个尽可能小的调整方案,并悄悄地执行该方案。

很难评价二者孰优孰劣,具体的策略选择可能跟实际需求相关。

综合示例

代码链接

  • 综合示例中包含跨二进制单例引用注入功能的使用例


    出自:purecpp.cn

    地址: www.purecpp.cn

    转载请注明出处!


来说两句吧
登录才能发表评论。
最新评论
Absolutely

purecpp

一个很酷的modern c++开源社区


[社区开源项目列表,点击前往]


purecpp社区自2015年创办以来,以“Newer is Better”为理念,相信新技术可以改变世界,一直致力于现代C++研究、应用和技术创新,期望通过现代C++的技术创新来提高企业生产力和效率。


社区坚持只发表原创技术文章,已经累计发表了一千多篇原创C++技术文章;


组织了十几场的C++沙龙和C++大会,有力地促进了国内外C++开发者之间的技术交流;


开源了十几个现代C++项目,被近百家公司所使用,有力地推动了现代C++在企业中的应用。


期待更多的C++爱好者能参与到社区C++社区的建设中来,一起为现代C++开源项目添砖加瓦,一起完善C++基础设施和生态圈。


微信公众号:purecpp, 社区邮箱: purecpp@163.com


友情链接