我可以在用 C++ 构造静态本地时访问它吗?

Can I access a static local while it is being constructed in C++?

C++ 标准保证在首次使用时实例化静态局部变量。但是,我想知道如果我在构造静态本地对象时访问它会发生什么。我假设这是UB。 但是在以下情况下避免这种情况的最佳做法是什么?

问题情况

Meyers Singleton 模式在静态 getInstance() 方法中使用静态局部来在第一次使用时构造对象。现在,如果构造函数(直接或间接)再次调用 getInstance(),我们将面临 静态初始化尚未完成的情况。这是一个说明问题情况的最小示例:

class StaticLocal {
private:
    StaticLocal() {
        // Indirectly calls getInstance()
        parseConfig();
    }
    StaticLocal(const StaticLocal&) = delete;
    StaticLocal &operator=(const StaticLocal &) = delete;

    void parseConfig() {
        int d = StaticLocal::getInstance()->getData();
    }
    int getData() {
        return 1;
    }

public:
    static StaticLocal *getInstance() {
        static StaticLocal inst_;
        return &inst_;
    }

    void doIt() {};
};

int main()
{
    StaticLocal::getInstance()->doIt();
    return 0;
}

在 VS2010 中,这没有问题,但 VS2015 死锁。

对于这种简单、简化的情况,显而易见的解决方案是直接调用 getData(),而无需再次调用 getInstance()。但是,在更复杂的场景下(如我的实际情况),这个方案并不可行。

正在尝试解决方案

如果我们更改 getInstance() 方法以像这样处理静态局部指针(从而放弃 Meyers Singleton 模式):

static StaticLocal *getInstance() {
    static StaticLocal *inst_ = nullptr;
    if (!inst_) inst_ = new StaticLocal;
    return inst_;
}

很明显我们得到了无限递归。 inst_ 在第一次调用时是 nullptr,所以我们用 new StaticLocal 调用构造函数。此时,inst_ 仍然是 nullptr,因为它只会在 构造函数完成。但是,构造函数会再次调用getInstance(),在inst_中找到一个nullptr,从而再次调用构造函数。一次又一次,...

一个可能的解决方案是将构造函数的主体移动到 getInstance():

StaticLocal() { /* do nothing */ }

static StaticLocal *getInstance() {
    static StaticLocal *inst_ = nullptr;
    if (!inst_) {
        inst_ = new StaticLocal;
        inst_->parseConfig();
    }
    return inst_;
}

这行得通。但是,我对这种情况并不满意,因为构造函数应该 construct 一个 complete object。这种情况是否可以破例值得商榷,因为它是单例。但是,我不喜欢它。

但更重要的是,如果 class 有一个非平凡的析构函数怎么办?

~StaticLocal() { /* Important Cleanup */ }

在上述情况下,永远不会调用析构函数。我们松开了 RAII,因此松开了 C++ 的一个重要区别特征!我们处在一个像 Java 或 C#...

这样的世界

所以我们可以用某种智能指针包装我们的单例:

static StaticLocal *getInstance() {
    static std::unique_ptr<StaticLocal> inst_;
    if (!inst_) {
        inst_.reset(new StaticLocal);
        inst_->parseConfig();
    }
    return inst_.get();
}

这将在程序退出时正确调用析构函数。但它迫使我们使析构函数 public.

在这一点上,我觉得我正在做编译器的工作...

回到原来的问题

这种情况真的是未定义行为吗?或者它是 VS2015 中的编译器错误?

这种情况的最佳解决方案是什么,最好是不删除完整的构造函数和 RAII?

这会导致 c++ 11 standard 出现未定义的行为。相关部分是 6.7:

If control enters the declaration concurrently while the variable is being initialized, the concurrent execution shall wait for completion of the initialization. If control re-enters the declaration recursively while the variable is being initialized, the behavior is undefined.

标准示例如下:

int foo(int i) {
    static int s = foo(2*i); // recursive call - undefined
    return i+1;
}

您正面临死锁,因为 MSVC 插入了互斥量 lock/unlock 以使静态变量初始化线程安全。一旦你递归调用它,你就会在同一个线程中锁定同一个互斥体两次,这会导致死锁。

This是llvm编译器内部静态初始化的实现方式

IMO 的最佳解决方案是根本不使用单例。相当多的开发人员倾向于认为 singleton is anti-pattern。像你提到的问题真的很难调试,因为它发生在 main 之前。因为全局初始化的顺序是未定义的。此外,可能涉及多个翻译单元,因此编译器不会捕获此类错误。所以,当我在生产代码中遇到同样的问题时,我不得不删除所有的单例。

如果您仍然认为单例是正确的方法,那么当您的单例对象拥有(例如,将它们作为成员持有)所有 类在单例初始化期间调用 GetInstance。将您的 类 想象成所有权树,其中单例是根。在创建子项时传递给父项的引用,如果子项需要的话。

解决这个问题的一个直接方法是分离责任,在这种情况下,“无论 StaticLocal 应该做什么”和“”读取配置数据"

class StaticLocal;

class StaticLocalData
{
private:
  friend StaticLocal;
  StaticLocalData()
  {
  }
  StaticLocalData(const StaticLocalData&) = delete;
  StaticLocalData& operator=(const StaticLocalData&) = delete;

  int getData()
  {
    return 1;
  }

public:
  static StaticLocalData* getInstance()
  {
    static StaticLocalData inst_;
    return &inst_;
  }
};

class StaticLocal
{
private:
  StaticLocal()
  {
    // Indirectly calls getInstance()
    parseConfig();
  }
  StaticLocal(const StaticLocal&) = delete;
  StaticLocal& operator=(const StaticLocal&) = delete;

  void parseConfig()
  {
    int d = StaticLocalData::getInstance()->getData();
  }

public:
  static StaticLocal* getInstance()
  {
    static StaticLocal inst_;
    return &inst_;
  }

  void doIt(){};
};

int main()
{
  StaticLocal::getInstance()->doIt();
  return 0;
}

这样一来StaticLocal就不会调用自己了,圈圈就断了

还有,你有清洁剂类。如果将 StaticLocal 的实现移动到单独的编译单元中,static local 的用户甚至不会知道 StaticLocalData 这个东西的存在。

很有可能您会发现您不需要将 StaticLocalData 的功能包装到 Singleton 中。

How to implement multithread safe singleton in C++11 without using <mutex>

c++11 中的单例声明按照标准是线程安全的。在VS2015中可能是通过mutex来实现的。

所以,你最后的解决方案完全适用

StaticLocal() { /* do nothing */ }

static StaticLocal *getInstance() {
   static StaticLocal inst_; 
   std::call_once(once_flag, [&inst_]() {inst_.parseConfig(); return &inst_;});
   return &inst_;
}

关于析构函数:您可以使用以下方式注册单例析构函数 int atexit(void (*function)(void));。这适用于 Linux,也可能存在于 Win 中,作为标准库中的函数。

dtor方面,我觉得你大可不必担心。一旦你定义了它,它就会在 main() 退出后被自动调用。

问题是在 class 中,您应该使用 "this" 而不是调用 getInstance,特别是:

void parseConfig() {
    int d = StaticLocal::getInstance()->getData();
}

应该只是:

void parseConfig() {
    int d = getData();
}

对象是单例,因为构造函数是私有的,因此用户不能构造任意数量的对象。假设永远只有一个对象实例,编写整个 class 是糟糕的设计。在某些时候,有人可能会像这样扩展单例的概念:

static StaticLocal *getInstance(int idx) {
    static StaticLocal inst_[3];
    if (idx < 0 || idx >= 3)
      throw // some error;
    return &inst_[idx];
}

发生这种情况时,如果在 class 期间没有调用 getInstance(),则更新代码会容易得多。

为什么会发生这样的变化?想象一下,你在 20 年前写了一个 class 来表示 CPU。当然系统中永远只有一个 CPU ,所以你让它成为一个单例。然后,突然间,多核系统变得司空见惯。您仍然只需要与系统中的内核一样多的 CPU class 实例,但是直到程序 运行 给定的内核上实际有多少个内核才知道系统.

这个故事的寓意:使用 this 指针不仅可以避免递归调用 getInstance(),还可以在未来证明您的代码。

所有版本的 C++ 标准都有一段规定了这种未定义的行为。在 C++98 中,第 6.7 节第 4 段。

An implementation is permitted to perform early initialization of other local objects with static storage duration under the same conditions that an implementation is permitted to statically initialize an object with static storage duration in namespace scope (3.6.2). Otherwise such an object is initialized the first time control passes through its declaration; such an object is considered initialized upon the completion of its initialization. If the initialization exits by throwing an exception, the initialization is not complete, so it will be tried again the next time control enters the declaration. If control reenters the declaration (recursively) while the object is being initialized, the behavior is undefined.

所有后续标准基本上都有相同的段落(只有差异是无关紧要的 - 例如用于交叉引用的章节编号等)。

您所做的是实现单例的构造函数,以便它调用构造它的函数。 getInstance() 创建对象,构造函数(间接)调用 getInstance()。因此它与上面引述的最后一句话发生冲突,并引入了未定义的行为。

与任何递归的解决方案一样,要么重新实现递归,要么防止第一次调用和任何递归调用之间的干扰。

可以通过三种方式实现。

第一个,你说你不想,是构造一个对象,然后解析数据来初始化它(两阶段构造)。

二是先解析数据,只有解析出来的数据合法(即适合构造对象)才构造对象。

第三个是让构造函数处理解析(您正在尝试这样做)但是,如果解析的数据无效,则强制构造函数失败(您的代码不会这样做)。

第三个示例是单独保留 getInstance(),并重构构造函数,使其永远不会调用 getInstance()

static StaticLocalData* getInstance()
{
    static StaticLocalData inst_;
    return &inst_;
}

StaticLocalData::StaticLocalData()
{
    parseConfig();
}

void StaticLocalData::parseConfig()
{
     int data = getData();    // data can be any type you like

     if (IsValid(data))
     {
          //   this function is called from constructor so simply initialise
          //    members of the current object using data
     }
     else
     {
           //   okay, we're in the process of constructing our object, but
           //     the data is invalid.  The constructor needs to fail

           throw std::invalid_argument("Construction of static local data failed");
     }
}

在上面,IsValid()表示一个函数或表达式,用于检查解析的数据是否有效。

这种方法实际上利用了我在上面引用的标准段落中的倒数第二句。它的作用是确保重复调用 staticLocal::getInstance() 将一直导致异常,直到解析成功。一旦解析成功,该对象将存在,并且不会对其进行进一步的尝试(它的地址将被简单地返回)。

如果调用者没有catch异常,效果很简单——程序会terminate()。如果调用者catch异常,它不应该尝试使用指针。

 try
 {
       StaticLocal *thing = StaticLocal::getInstance();

       //  code using thing here will never be reached if an exception is thrown

 }
 catch (std::invalid_argument &e)
 {
       // thing does not exist here, so can't be used
       //     Worry about recovery, not trying to use thing
 }

所以,是的,您的方法引入了未定义的行为。但是使行为未定义的标准的相同部分也为解决方案提供了基础。

实际上,当前形式的此代码被卡在 3 路无限递归 中。因此它永远不会起作用。

getInstance() --> StaticLocal()
 ^                    |  
 |                    |  
 ----parseConfig() <---

要让它起作用,以上3种方法中的任何一种都必须妥协并从恶性循环中走出来。你猜对了,parseConfig()是最佳人选

假设构造函数的递归内容全部放入parseConfig(),非递归内容保留在构造函数中。然后您可以执行以下操作(仅相关代码):

    static StaticLocal *s_inst_ /* = nullptr */;  // <--- introduce a pointer

public:
    static StaticLocal *getInstance() {
      if(s_inst_ == nullptr)
      {   
        static StaticLocal inst_;  // <--- RAII
        s_inst_ = &inst_;  // <--- never `delete s_inst_`!
        s_inst_->parseConfig();  // <--- moved from constructor to here
      }   
      return s_inst_;
    }   

这很好用。