18

我的具体问题是,在 C++ 中实现单例类时,以下两个代码在性能、附带问题或其他方面是否存在实质性差异:

class singleton
{
    // ...
    static singleton& getInstance()
    {
        // allocating on heap
        static singleton* pInstance = new singleton();
        return *pInstance;
    }
    // ...
};

还有这个:

class singleton
{
    // ...
    static singleton& getInstance()
    {
        // using static variable
        static singleton instance;
        return instance;
    }
    // ...
};


(请注意,基于堆的实现中的取消引用不应影响性能,因为 AFAIK 没有为取消引用生成额外的机器代码。与指针区分似乎只是语法问题。)

更新:

我有一些有趣的答案和评论,我试图在这里总结它们。(建议感兴趣的人阅读详细答案。)‎:

  • 在使用静态局部变量的单例中,类析构函数在进程终止时自动调用,而在动态分配的情况下,您必须在某个时候以某种方式管理对象销毁,例如通过使用智能指针:
    static singleton& getInstance() {
        static std::auto_ptr<singleton> instance (new singleton());
        return *instance.get(); 
    }
  • 使用动态分配的单例比静态单例变量“更懒惰”,因为在后一种情况下,单例对象所需的内存(总是?)在进程启动时保留(作为加载程序所需的整个内存的一部分) 并且只有单例构造函数的调用被推迟到getInstance()调用时。这可能sizeof(singleton)很重要。

  • 两者在 C++11 中都是线程安全的。但是对于早期版本的 C++,它是特定于实现的。

  • 动态分配情况使用一级间接访问单例对象,而在静态单例对象情况下,对象的直接地址是在编译时确定和硬编码的。


PS:我已经根据@TonyD 的回答更正了我在原始帖子中使用的术语。

4

2 回答 2

7
  • new版本显然需要在运行时分配内存,而非指针版本在编译时分配内存(但两者都需要执行相同的构造)

  • new版本不会在程序终止时调用对象的析构函数,但非new版本会:您可以使用智能指针来更正此问题

    • 你需要小心一些静态/命名空间范围对象的析构函数在其静态本地实例的析构函数运行后不会调用你的单例......如果你担心这一点,你可能应该阅读更多关于单例生命周期和管理它们的方法。Andrei Alexandrescu 的 Modern C++ Design 有一个非常易读的处理方法。
  • 在 C++03 下,是否线程安全是实现定义的。(我相信 GCC 往往是,而 Visual Studio 往往不会 - 评论确认/纠正赞赏。)

  • 在 C++11 下,它是安全的: 6.7.4 “如果在初始化变量时控制同时进入声明,则并发执行应等待初始化完成。” (无递归)。

讨论重新编译时与运行时分配和初始化

从您对摘要和一些评论的措辞方式来看,我怀疑您并没有完全理解静态变量的分配和初始化的一个微妙方面......

假设您的程序有 3 个本地静态 32 位ints -ab-c在不同的函数中:编译器可能会编译一个二进制文件,告诉操作系统加载程序为这些静态保留 3x32 位 = 12 字节的内存。编译器决定每个变量的偏移量:它可以a在数据段中的十六进制偏移量 1000 处,b在 1004 处,以及c在 1008。当程序执行时,操作系统加载程序不需要为每个单独分配内存 - 它只知道总共 12 个字节,它可能会或可能不会被专门要求进行 0 初始化,但它可能无论如何都想确保进程看不到其他用户程序的剩余内存内容。程序中的机器代码指令通常会硬编码偏移量 1000、1004、1008 以访问ab因此c在运行时不需要分配这些地址。

动态内存分配的不同之处在于指针(比如p_a, p_b, p_c)将在编译时被赋予地址,如前所述,但另外:

  • 必须在运行时找到指向的内存(每个abc)(通常是在静态函数第一次执行时,但编译器允许根据我对另一个答案的评论更早地执行它),并且
    • 如果操作系统当前为进程分配的内存太少而无法成功进行动态分配,则程序库将要求操作系统提供更多内存(例如使用sbreak())-出于安全原因,操作系统通常会清除这些内存
    • 分配给每个 的动态地址,a并且b必须c复制回指针p_a和。p_bp_c

这种动态方法显然更加复杂。

于 2013-02-25T08:42:12.307 回答
3

主要区别在于,使用本地static对象将在关闭程序时被销毁,而堆分配的对象将被丢弃而不会被销毁。

请注意,在 C++ 中,如果您在函数内声明一个静态变量,它将在您第一次进入作用域时被初始化,而不是在程序启动时(就像全局静态持续时间变量一样)。

总的来说,这些年来我从使用延迟初始化切换到显式控制初始化,因为程序启动和关闭是一个微妙的阶段,而且很难调试。如果您的课程没有做任何复杂的事情并且不能失败(例如,它只是一个注册表),那么即使是延迟初始化也可以……否则控制会为您节省很多问题。

在进入第一条指令之前main或执行最后一条指令之后崩溃的程序main更难调试。

使用单例的惰性构造的另一个问题是,如果您的代码是多线程的,您必须注意并发线程同时初始化单例的风险。在单线程上下文中进行初始化和关闭更简单。

自 C++11 以来,多线程代码中函数级静态实例初始化期间可能出现的竞争已经得到解决,当时该语言添加了官方多线程支持:对于正常情况,编译器会自动添加适当的同步保护,因此这不是问题C++11 或更高版本的代码。但是,如果函数中的静态初始化a调用函数b,反之亦然,如果两个函数第一次由不同的线程同时调用,则可能会出现死锁(仅当编译器使用单个互斥锁时,这不是问题所有静力学)。另请注意,不允许从静态对象的初始化代码中递归调用包含静态对象的函数。

于 2013-02-25T08:32:40.810 回答