C++ 中的静态变量和单例模式

2019.11.14
SF-Zhou

C++ 中的静态变量是个有趣的精灵,可以通过它实现很多魔法。精灵强大,同时也危险,本文简单介绍一下静态变量的使用和特性,以及基于静态变量的单例模式实现。

1. Static VS. Global

静态变量中的静态 static 是一种存储类别说明符,其和变量的作用域共同控制变量中两个独立的属性: Storage Duration 和 Linkage(不确定怎么翻译)。下面摘录自文献 1

The storage class specifiers are a part of the decl-specifier-seq of a name's declaration syntax. Together with the scope of the name, they control two independent properties of the name: its storage duration and its linkage.

  1. static - static or thread storage duration and internal linkage.
  2. extern - static or thread storage duration and external linkage.

关于 internalexternal,这里举个例子🌰,下面的代码一共有三个文件:

// a.h
#pragma once
#include <cstdio>

class A {
 public:
  A(int v) { printf("A(%d) at %p\n", v, this); }
};

static A x(1);


// a.cpp
#include "a.h"
A y(2);


// main.cpp
#include <iostream>
#include "a.h"

extern A y;
int main() { printf("y at %p\n", &y); }


// Compile & Run
// g++ main.cpp a.cpp -std=c++11 -o a.out && ./a.out

其输出为:

A(1) at 0x10a73d010
A(1) at 0x10a73d011
A(2) at 0x10a73d012
y at 0x10a73d012

a.h 中的 x 使用 static 修饰,其仅仅在源码文件内部链接,所以 main.cppa.cpp 内部均有一份独立的 x,故其会初始化两次;而在 a.cpp 中定义的全局变量 y,会使用外部链接,且使用 extern 声明后,可以在任意一个 cpp 文件中访问。

理解 C++ 编译的过程的话,internalexternal 的区别就很容易理解了。简单来说全局变量会导出到符号表中,而静态全局变量不会。所以静态全局变量一般直接在 cpp 文件中定义和使用。特别的,如果在匿名命名空间中定义全局变量,其默认是 internal 的 Linkage,或者说就是静态全局变量。关于 Linkage 更详细的信息可以参考文献 1

2. Static Initialization

静态变量初始化中的静态指的是 Storage Duration,同样从文献1摘录一段:

static storage duration. The storage for the object is allocated when the program begins and deallocated when the program ends. Only one instance of the object exists. All objects declared at namespace scope (including global namespace) have this storage duration, plus those declared with static or extern. See Non-local variables and Static local variables for details on initialization of objects with this storage duration.

静态变量会在编译期确定空间大小,对于 POD 还会确定其值,并将其放入 .bss 段或者 .data 段。举个例子:

#include <iostream>

const int N = 1 << 20;
int a[N] = {0};
int b[N] = {1};

int main() {}

上面这段代码编译后的可执行文件大小约为 4MB,其中数组 a 会编译到 .bss 段,空间忽略不计;而数组 b 会编译到 .data 段,占用 4MB 空间。关于数据段可以参考维基百科

对于非 POD 的全局变量,其初始化会在 main 函数执行前完成。根据这点可以实现众多黑魔法,比如 gtest 中使用全局变量初始化实现所有测试用例的注册,之后在 main 函数中遍历所有测试用例逐一执行测试。但全局变量的初始化有一个严重的问题:C++ 标准下全局变量初始化的顺序是不可控的。如果全局变量 A 的初始化依赖全局变量 B,但是 B 还没有完成初始化,A 的初始化也势必会挂掉。关于这点 Standard C++ 的 Wiki 中有连续数个问题讨论如何解决,见文献 4。总结起来,可以通过以下几种技术解决静态变量初始化和析构顺序问题:

  1. 使用局部静态变量,使用函数返回该变量,首次使用时会自动完成初始化(C++ 11 会保证初始化有且仅有一次);
  2. 如果对析构的顺序有严格要求,并且不强制要求析构,可以选择不析构;
  3. 如果对析构的顺序有严格要求,并且强制要求析构,可以使用 Nifty Counter 引用计数。

3. Singleton Pattern

有了静态变量这个神器后,C++ 里实现单例模式就变得非常简单了:

template <class T>
class Singleton {
 private:
  Singleton(const Singleton &) = delete;
  Singleton(Singleton &&) = delete;

 public:
  Singleton() = default;
  static T &Instance() {
    static T obj;
    return obj;
  }
};

class A: public Singleton<A> {
  ...
};

int main() {
  // Use A::Instance()
}

References

  1. "Storage class specifiers", C++ Reference
  2. "C++ named requirements: PODType", C++ Reference
  3. "Data segment", Wikipedia
  4. "What’s the static initialization order fiasco (problem)?", Standard C++
  5. "More C++ Idioms/Nifty Counter", Wikibooks