我能在C++中访问正在构建的静态局部变量吗?

11

根据C++标准,静态局部变量保证在第一次使用时被实例化。然而,如果在构造过程中访问静态局部对象会发生什么我很好奇。我认为这是未定义的行为。但在下面这种情况下,有什么最佳实践可以避免这种情况呢?

问题情况

Meyers单例模式在静态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_;
}

这将有效。但是,作为构造函数应该构建一个完整对象,我对这种情况不太满意。这种情况是否可以作为一个例外是有争议的,因为它是单例模式。然而,我不喜欢它。

更重要的是,如果类有一个非平凡的析构函数呢?

~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的情况下,什么是最好的解决方案?


可能是这个:http://stackoverflow.com/questions/32079095/vs2015-c-static-initialization-crash-possible-bug?rq=1 - H. Guijt
1
parseConfig是一个成员函数,你可以写int d = getData(); - Marian Spanik
@H.Guijt 那个问题与错误的CLR/Subsystem设置有关,在调用main之前导致崩溃。在我的问题中,这不是问题(Debug,x86,Console App)。 - king_nak
@MarianSpanik 您是正确的。但是我在我的问题中提到,我的实际情况更加复杂,在parseConfig中使用的其他类调用了getData(和其他方法)。我可以重写代码,将StaticLocal指针/引用传递给该类的构造函数,但那将是相当大的工作... - king_nak
2
当某人试图访问尚未构建的对象时,该对象正在被构建。你希望发生什么? - n. m.
显示剩余2条评论
7个回答

14
这会导致c++ 11 standard中的未定义行为。相关章节是6.7:

如果在变量初始化期间并发地进入声明,则并发执行应等待初始化完成。如果在变量初始化期间递归地重新进入声明,则行为未定义。

标准中的示例如下:
int foo(int i) {
    static int s = foo(2*i); // recursive call - undefined
    return i+1;
}

您面临死锁问题,因为MSVC插入了互斥锁/解锁以使静态变量初始化线程安全。一旦您递归调用它,您在同一线程中锁定了相同的互斥锁两次,导致死锁。
这是llvm编译器内部实现静态初始化的方式。
我认为最好的解决方案是根本不使用单例模式。显著的开发人员群体倾向于认为单例模式是反模式。像您提到的问题确实很难调试,因为它发生在main函数之前。由于全局初始化顺序未定义,可能涉及多个翻译单元,因此编译器无法捕获此类错误。因此,当我在生产代码中遇到相同的问题时,我被迫删除所有单例模式。
如果你仍然认为单例是正确的选择,那么当单例对象在初始化期间拥有(例如将它们作为成员持有)所有调用GetInstance的类时,你需要以某种方式重新构造你的代码。将你的类视为拥有关系树,其中单例是根。如果子类需要,创建子类时传递对父类的引用。请保留HTML标签。

7
问题在于,在类内部,您应该使用"this"而不是调用getInstance,具体来说:
void parseConfig() {
    int d = StaticLocal::getInstance()->getData();
}

应该简单明了:

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

该对象是单例的,因为构造函数是私有的,用户无法构造任意数量的对象。假设整个类只有一个对象实例是一种糟糕的设计。某些情况下,有人可能会将单例的概念扩展到以下程度:

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

当这种情况发生时,如果类中没有对getInstance()的调用,则更新代码会更加容易。为什么会出现这样的变化呢?假设20年前你正在编写一个代表CPU的类。当然,系统中只有一个CPU,所以你将其设置为单例模式。然后,突然间,多核系统变得常见起来。虽然您仍希望CPU类的实例数与系统上的核心数相同,但在运行程序之前,您不知道给定系统上实际上有多少个核心。
故事的寓意是:使用this指针不仅可以避免递归调用getInstance(),还能为您的代码未来提供保障。

2

实际上,目前这段代码陷入了三重无限递归。因此它永远不会起作用。

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

为了让它工作,以上三种方法中的任何一种都必须妥协并走出恶性循环。你判断正确,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_;
    }   

这个很好用。


1
这对我来说是最好的平衡。最大的优点是析构函数可以保持私有,并且使用RAII来销毁单例。 - king_nak

1
这个问题的一个简单解决方法是分离职责,即将"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的实现移动到单独的编译单元中,静态局部变量的用户甚至不会知道StaticLocalData的存在。
很可能你会发现,你不需要将StaticLocalData的功能包装成单例模式。

1
所有版本的C++标准都有一个段落将此定义为未定义行为。在C++98中,第6.7节第4段。
引用:
实现可以在与命名空间作用域中具有静态存储期的对象静态初始化允许的条件下对其他本地具有静态存储期的对象进行早期初始化(3.6.2)。否则,这样的对象在首次通过其声明时进行初始化;这样的对象被认为在完成其初始化时已初始化。如果初始化通过抛出异常退出,则初始化不完整,因此将在下一次控制进入该声明时再次尝试。如果在初始化对象时控件重新进入声明(递归),则行为是未定义的。
随后的所有标准基本上都有相同的段落(唯一的区别是无关紧要的 - 例如用于交叉引用的部分编号等)。
您所做的是实现单例的构造函数,使其调用构造它的函数。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() 将导致异常,直到解析成功为止。一旦解析成功,对象将存在,并且不会再尝试使用它(它的地址将被简单地返回)。
如果调用者没有捕获异常,则简单的效果是程序将终止。如果调用者捕获了异常,则不应尝试使用指针。
 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
 }

所以,是的,你的方法引入了未定义的行为。但是标准中提供了使行为变得明确的基础,也可以作为解决方案的依据。

-1

查看如何在C++11中实现多线程安全的单例模式而不使用<mutex>

C++11中的单例声明是符合标准的线程安全的。在VS2015中,它可以通过互斥锁来实现。

因此,您最后的解决方案完全适用。

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,可能也存在于Windows中,作为标准库中的函数。

这不是线程安全的 - 在inst_被(安全地)初始化为nullptr之后,多个线程可能会进入if块并竞争构造“the”单例。 - dhaffey
是的,你说得对,抱歉我匆忙回答。我的答案修正为:static StaticLocal inst_; std::call_once(once_flag, [&inst_]() {inst_.parseConfig(); return &inst_;}) - Sergey_Ivanov

-1
关于dtor,我认为你不必担心。一旦你定义它,它就会在main()退出后自动调用。

这与问题有什么关系? - Barry

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