C++ Nifty Counter惯用法; 为什么使用它?

30

我最近发现了 Nifty Counter Idiom。我的理解是它用于在标准库中实现全局变量,例如 cout、cerr 等等。由于专家们选择了它,我认为这是一种非常强大的技术。

我想了解它与使用 Meyer Singleton 等类似东西相比的优势。

例如,可以在头文件中直接写:

inline Stream& getStream() { static Stream s; return s; }
static Stream& stream = getStream();
优点是您不必担心引用计数、放置新对象或拥有两个类,即代码更加简单。既然不是这样做的,我相信这其中一定有原因:
  1. 无法保证在共享和静态库中具有单个全局对象吗?似乎ODR应该保证只能有一个静态变量。
  2. 是否存在某种性能成本?在我的代码和Nifty Counter中,似乎都是通过跟随一个引用来访问对象。
  3. 是否存在某些情况下引用计数实际上是有用的?似乎它仍然会导致对象在包含头文件时被构造,并在程序结束时被销毁,就像Meyer Singleton一样。
  4. 答案是否涉及手动dlopen打开某些东西?我对此没有太多经验。

编辑:在阅读Yakk的答案时,我被提示编写了以下代码片段,并将其添加到原始问题中作为一个快速演示。这是一个非常简单的示例,演示了如何使用Meyer Singleton + 全局引用在main之前初始化: http://coliru.stacked-crooked.com/a/a7f0c8f33ba42b7f


1
你是指 static Stream s; 吗? - Barry
你有动态初始化,这可能会引入顺序问题。Nifty计数器被初始化为零,这发生在其他所有操作之前 - Bo Persson
你是不是想把那个本地流变成静态的?现在你正在返回一个对本地变量的引用,使用它会很危险。 - dgel
@MSalters,整个程序只会实例化一个流吗? - dgel
@T.C. “before magic statics” 有道理。你提供的链接非常有信息量,尽管它似乎没有直接与 nifty counter 进行比较;从我所了解的情况来看,它基本上是在讨论全局静态变量是否应该存在,还是只有函数。我错过了什么吗? - Nir Friedman
显示剩余4条评论
4个回答

11

静态的本地/Meyer单例+静态全局引用(你的解决方案)几乎等同于nifty计数器。

其区别如下:

  1. 在你的解决方案中不需要.cpp文件。

  2. 从技术上讲,static Steam& 存在于每个编译单元中;但被引用的对象并不存在。由于在当前版本的C++中没有检测到这一点,因此在as-if情况下这种情况会消失。但是某些实现可能会创建该引用而不是省略它。

  3. 如果有人在创建static Stream&之前调用了getStream(),这将导致销毁顺序出现问题(流比预期被销毁得晚)。可以通过禁止这样做来避免这种情况。

  4. 标准要求在inline getStream中局部创建static Stream是线程安全的。检测这一点对于编译器来说是具有挑战性的,因此在你的解决方案中可能存在某些冗余的线程安全开销。nifty计数器并未明确支持线程安全;但这被认为是安全的,因为它在静态初始化阶段运行,在预期线程之前。

  5. 必须在每个编译单元中调用getStream()。只有在被证明它不会做任何事情时才可能被优化掉,这很困难。nifty计数器具有类似的代价,但操作可能更容易优化或在运行时成本上优化或不优化。(要确定这一点,需要检查使用各种编译器生成的汇编输出)

  6. 在C++11中引入了"magic statics"(没有竞争条件的静态局部变量)。在C++11之前,你的代码可能存在其他问题;我能想到的唯一一个问题是在静态初始化期间在另一个线程中直接调用getStream(),而这通常应该是被禁止的。

  • 在标准范围之外,你的版本会自动地并神奇地在每个动态链接的代码块(DLL、.so等)中创建一个新的单例。漂亮的计数器只会在cpp文件中创建单例。这可能使库编写者更加精细地控制意外生成新单例;他们可以将其放入动态库中,而不是生成重复的实例。

  • 有时避免有多个单例非常重要。


    1
    很抱歉出现了竞争条件 - 我已经修复了问题,这使得你提出的第一个问题已经过时了。 - MSalters
    漂亮计数器模式的主要区别和原因是共享库行为(在@Yakk答案中列出了7个) 。 - Amir Kirsh
    当共享库调用getStream时,它会在共享库上编译和链接,作为对其版本的getStream的调用,具有自己的静态变量,从而导致库的单独单例。由于流库想要避免这种情况,因此需要使用nifty计数器,其中静态计数器位于cpp中。当不存在dll和共享对象时,Meyer的单例可能是更好的选择,更易于编写和维护。 - Amir Kirsh
    @AmirKirsh 我提供的解决方案可以通过在头文件中仅声明(而不是定义)具有静态局部变量的函数进行修改。然后,在一个.cpp文件中定义该函数,该文件被编译为.so文件。在这种情况下,单例的用户链接到.so文件。每个翻译单元都有自己的引用副本,但每个引用都保证通过调用函数的确切相同副本(该副本仅存在于.so文件中)来初始化。 - Nir Friedman
    2
    @Yakk 我最近进行了这方面的实验,因为我计划写一篇博客文章。结果表明,即使在.so文件中使用,这也不会导致单例的多个副本(至少在Linux上)。这是因为当共享库被加载时,如果已经定义,则它对函数(getStream)的定义仅会被加载一次。因此,全局引用实际上将使用函数的现有定义及其现有的静态局部变量。 - Nir Friedman
    显示剩余10条评论

    5

    总结答案和评论:

    让我们比较三种不同的库选项,希望以全局 Singleton 的形式呈现,作为变量或通过 getter 函数:

    选项 1 - nifty counter pattern,允许使用一个全局变量,该变量具有以下特点:

    • 保证只创建一次
    • 保证在第一次使用之前创建
    • 保证在与创建此全局变量的库动态链接所有共享对象仅创建一次

    选项 2 - Meyers 单例模式与引用变量(如问题中所示):

    • 保证只创建一次
    • 保证在第一次使用之前创建

    但是,它将在共享对象中创建单例对象的副本,即使所有共享对象和主对象都与库动态链接。这是因为 Singleton 引用变量在头文件中声明为静态,并且必须在编译时准备好其初始化,无论在哪里使用它,包括在共享对象中,在编译时,在它们将要加载到的程序之前。


    选项 3 - Meyers 单例模式没有引用变量(调用 getter 来检索 Singleton 对象):

    • 保证只创建一次
    • 保证在第一次使用之前创建
    • 保证在与创建此 Singleton 的库动态链接所有共享对象仅创建一次

    但是,在此选项中没有全局变量或内联调用,每次检索 Singleton 的调用都是一个函数调用(可以在调用方缓存)。

    此选项将如下所示:

    // libA .h
    struct A {
        A();
    };
    
    A& getA();
    
    // some other header
    A global_a2 = getA();
    
    // main
    int main() {
        std::cerr << "main\n";
    }
    
    // libA .cpp - need to be dynamically linked! (same as libstdc++ is...)
    // thus the below shall be created only once in the process
    A& getA() {
        static A a;
        return a;
    } 
    
    A::A() { std::cerr << "construct A\n"; }
    

    在使用微软平台时请小心。事情可能不会按预期工作。 微软等了将近十年才实现 C++11 的核心库功能 带并发的动态初始化和销毁,这是有原因的。 - jww

    2
    您对Nifty Counter(又称Schwartz Counter)的效用/性能的所有问题基本上都在Maxim Egorushkin的回答中得到了解答(但也请参阅评论线程)。 现代C++中的全局变量 主要问题是存在一种权衡。当您使用Nifty Counter时,您的程序启动时间会稍慢(在大型项目中),因为所有这些计数器都必须在任何事情发生之前运行。而在Meyer的单例模式中,这种情况不会发生。
    但是,在Meyer的单例模式中,每次想要访问全局对象时,您都必须检查它是否为空,或者编译器会发出代码,在尝试访问任何内容之前检查静态变量是否已经构造。在Nifty Counter中,您已经拥有指针,可以直接使用,因为您可以假设在启动时已经进行了初始化。
    因此,Nifty Counter与Meyer的单例模式基本上是程序启动时间和运行时之间的权衡。

    我认为这不正确;在我上面展示的代码中,您通过stream引用访问单例,而不是调用getStream函数。 stream是一个已经初始化的简单引用,控制流不会通过getStream来访问stream,因此没有理由检查守卫变量以查看静态局部变量是否已经初始化。 - Nir Friedman
    @Chris Meyer的单例模式只在OP代码启动时被调用一次。因此,“每次访问全局对象时都必须检查”不适用。 - Yakk - Adam Nevraumont
    @NirFriedman:我猜你是对的 - 当你以那种方式做时,似乎可以兼顾两全其美。 - Chris Beck
    @ChrisBeck 是的,我非常困惑。我实际上认为Icy的答案是正确的,但出人意料的是C++对同一翻译单位中的静态局部变量做出了相当严格的保证。这是一个非常棘手的问题。 - Nir Friedman

    0

    根据您提供的解决方案,全局变量stream在静态初始化期间的某个时刻被分配,但是具体时间未指定。因此,在静态初始化期间从其他编译单元使用stream可能无法正常工作。Nifty计数器是一种保证全局变量(例如std::cout)即使在静态初始化期间也可用的方法。

    #include <iostream>
    
    struct use_std_out_in_ctor
    {
        use_std_out_in_ctor()
        {
            // std::cout guaranteed to be initialized even if this
            // ctor runs during static initialization
            std::cout << "Hello world" << std::endl;
        }
    };
    
    use_std_out_in_ctor global; // causes ctor to run during static initialization
    
    int main()
    {
        std::cout << "Did it print Hello world?" << std::endl;
    }
    

    全局变量 stream 在头文件中声明。要使用 stream,必须在代码中 #include 头文件。这意味着在你的翻译单元中,静态全局变量 stream 会在你编写的任何代码之前出现,这也就保证了在使用任何静态对象之前,stream 已经被初始化了。 - Nir Friedman

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接