非局部变量初始化议题讨论
Roger (
roger2yi@gmail.com)
这篇文章集中讨论了非局部变量的一些初始化议题,包括非局部变量的定义;非局部变量初始化规则和现实中跨编译单元的初始化顺序依赖的几种解决方案。
文中的内容都源自一些C++书籍(BS的TCPL,Sutter的Exceptional系列),库的源代码和MSDN。
1.非局部变量的定义
非局部变量包括全局变量(Global),名字空间域变量(Namespace scope)和静态类成员(Static class member)。它们被称为非局部是相对于函数内部定义的局部变量而言。
下面的例子来自BS的TCPL:
class
X
{
static Table memtbl;
};
Table
tbl;
Table
X::memtbl;
namespace
Z
{
Table tbl2;
}
其中memtb1是静态类成员,tbl是全局变量,tbl2是名字空间域变量。
2.初始化规则
所有的全局(包括名字空间域内的)和静态变量(局部或者非局部)的存储区都是在程序启动的时候就会被事先分配好,初始化的过程是在之后才进行的。你可以想象这个过程跟内存转储刚好相反,运行时库分配一块跟执行文件的静态区段同样大小的内存作为静态存储区,并直接拷贝执行文件的静态区段到静态存储区中。
a) 在main函数被调用之前会被初始化。
b) 在同一个编译单元中的非局部变量按照它们被定义的顺序被初始化,注意不是按照声明的顺序。比如在上面的示例代码中,初始化顺序为tbl;memtbl;tbl2,虽然memtbl的声明在最前面。(反过来,自定义类型析构的顺序跟初始化的顺序正好相反)
c) 对于可以在编译期就确定初值的内建类型和枚举而言(用文字量赋值),其实它们并不参与后期的初始化过程,它们的初始化在拷贝执行文件静态区段到内存静态存储区这个过程中就完成了。因为链接器生成执行文件的时候已经为执行文件的静态区段正确赋值,所以简单的拷贝对它们来说就足够了。
d) 如果定义处没有显式的初始化调用式,则使用缺省的初始化方式(内建类型和枚举类型被初始化为0,自定义类型使用缺省构造函数)。
e) 不同编译单元的非局部变量的初始化顺序是无法确定的。
3.现实中跨编译单元的初始化顺序依赖的几种解决方案
虽然大部分C++书籍都会告诉你尽量避免使用非局部变量(甚至局部静态变量)作为共享数据,更应该避免跨编译单元的初始化顺序依赖(参看上面的2.e项次),但是现实中你总是无法避免的,特别是作为库的设计者的时候。一个最经典的例子莫过于标准库中的cin;cout和cerr,它们不但是名字空间域的变量(早期的版本更是全局的),更要求在所有其它的非局部变量初始化之前被初始化,在它们析构之后被析构(这意味着你可以在你的类的构造函数和析构函数使用cin;cout和cerr,而不用担心你的类在被使用来定义一个非局部对象的时候,cin;cout和cerr还没有被初始化或者提前被析构)
注:
反对的理由通常有增加耦合度;降低可重用性;名字污染;带来复杂的运行时状态;有线程安全问题;动态链接库导入难以保证期望的初始化顺序等等。所以在使用非局部变量的时候确保你了解它可能带来的副作用并匹配妥善的解决方法。
以下列举了3种方法,每种都有各自的优缺点,并附有选择上的讨论。
用函数返回函数内部的局部静态变量的引用
函数内部的局部静态变量的初始化规则是当某个执行线程将要经过该变量的定义位置时它会被先初始化。一般情况而言,你可以认为当这个函数在第一次被调用之前,它内部的局部静态变量会被先初始化(实际上会稍微复杂一些,可以参考TCPL)。
通过函数返回自身内部的局部静态变量的引用,你可以保证得到的是已经经过正确初始化的对象,而且标准保证了初始化过程中绝不会有多线程争用上的问题。
TCPL的例子:
int
& use_count()
{
static int uc = 0;
return uc;
}
void
f()
{
cout<<++use_count();
}
例子中函数use_count完全可以被当做一个非局部变量来使用,除了语法上的差异。应该说这个方法已经可以满足大部分顺序依赖上的需要,除非是必须像cin;cout或者cerr这样一定要使用变量语法。
如果你有cin;cout这样语法上的强制要求,就需要考虑下面的两种方法。
具体编译器支持的初始化优先级定义
C++虽然已经标准化,但实际上仍然是一门有着太多未定义行为而依赖于具体编译器实现的语言(Imperfect C++中有大量相关的讨论),所以标准虽然说不同编译单元的非局部变量的初始化顺序是无法确定的,但实际上一些编译器支持通过编译器指令来指定那些编译单元的非局部变量拥有更高的初始化优先级,它们应该首先被初始化。
注:
C++
的编译过程是各编译单元分别被编译,不存在编译顺序上的依赖,这跟
Java
不一样。所以在早期的编译器中,链接器所做的事情相对比较简单,只是将各编译单元编译后的二进制档链接在一起,解决之间符号引用的问题,最后生成本机代码。但是现代的编译器,链接器可以做到的事情更多,它可以同时考察全部编译单元的二进制档,以决定如何生成更优化的代码(全程序优化),同时也具备了决定那个编译单元中的非局部变量拥有更高的初始化优先级的能力。
例如VC 8.0编译器支持使用init_seq来决定初始化优先级(实际上我并不知道其它编译器的可能实现,只是猜测有可能会有类似的实现)。
下面是VS 2005自带的标准库中cout的具体实现,声明在iostream头文件中,定义在cout.cpp中。
iostream,删节了一些无关的部分
// iostream standard header for Microsoft
#pragma
once
#ifndef
_IOSTREAM_
#define
_IOSTREAM_
#ifndef
RC_INVOKED
#include
<istream>
#ifdef
_MSC_VER
#pragma
pack(push,_CRT_PACKING)
#pragma
warning(push,3)
#endif
/* _MSC_VER */
_STD_BEGIN
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 istream cin;
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 ostream cout;
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 ostream cerr;
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 ostream clog;
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 wistream wcin;
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 wostream wcout;
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 wostream wcerr;
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 wostream wclog;
_STD_END
#ifdef
_MSC_VER
#pragma
warning(pop)
#pragma
pack(pop)
#endif
/* _MSC_VER */
#endif
/* RC_INVOKED */
#endif
/* _IOSTREAM_ */
/*
* Copyright (c) 1992-2005 by P.J. Plauger. ALL RIGHTS RESERVED.
* Consult your license regarding permissions and restrictions.
V4.05:0009 */
cout.cpp,删节了一些无关的部分
// cout -- initialize standard output stream
#include
<fstream>
#include
<iostream>
#pragma
warning(disable: 4074)
#pragma init_seg(compiler)
static
std::_Init_locks initlocks;
_STD_BEGIN
// OBJECT DECLARATIONS
__PURE_APPDOMAIN_GLOBAL
static filebuf fout(_cpp_stdout);
__PURE_APPDOMAIN_GLOBAL
extern _CRTDATA2 ostream cout(&fout);
_STD_END
/*
* Copyright (c) 1992-2005 by P.J. Plauger. ALL RIGHTS RESERVED.
* Consult your license regarding permissions and restrictions.
V4.05:0009 */
注意加亮的“
#pragma init_seg(compiler)”,它用于告诉编译器cout这个编译单元的非局部变量拥有compiler级别的初始化优先级(也是最高的优先级)。
更多关于init_seg的使用方法请参考MSDN。
如果你的编译器不具备这样的能力,或者你希望你的程序完全遵循标准以支持多个不同的编译器(虽然很多书籍会告诉你完全遵循标准的程序具备多编译器支持的能力,但是实际上不见得每个编译器都是完全遵循标准的,这个其实就变成了一个悖论,所以即使你写的程序是完全标准的,也避免不了大量的编译脚本,预编译宏……),传统上,像cin;cout只能使用复杂的初始化器的实现。
注:
如果你遵循一些编程准则把编译器的警告级别调至最高并让编译器把警告视为错误,在使用
compiler
和
lib
优先级时你会得到一个警告,所以需要手动把它关掉“
#pragma warning(disable: 4074)
”。
使用初始化器和
placement new
所谓初始化器是指这样一类东西,它是一个静态的全局或者名字空间域变量(使用static修饰),这意味这它的名字没有外部链接;并且它的定义是放置在头文件中,所以每个包括这个头文件的编译单元都会有一个同名的初始化器对象(因为它没有外部链接,所以不会有一个外部链接名字具备多个定义而导致的歧义问题);最后它在构造函数中使用placement new来显式初始化真正共享的非局部对象,并且采用一个内部计数器来防止多次初始化;而这些非局部对象的定义处的构造函数实际上不做任何事情。
看例子可能更容易理解,下面的例子同样是一个cout的实现,来自微软的Platform SDK R2的标准库源码(为什么SDK里面的标准库实现和VS 2005里面的居然不一样,实在是令人相当困惑,不过看出来作者是同一个人,VS 2005里面的应该是更新性能更佳的版本,可能是专门为VC 8.0编译器而做的新版本)。
头文件iostream
// iostream standard header
#ifndef
_IOSTREAM_
#define
_IOSTREAM_
#include
<istream>
#ifdef
_MSC_VER
#pragma
pack(push,8)
#endif
/* _MSC_VER */
_STD_BEGIN
// OBJECTS
static ios_base::Init _Ios_init;
extern
_CRTIMP istream cin;
extern
_CRTIMP ostream cout;
extern
_CRTIMP ostream cerr, clog;
// CLASS _Winit
class
_CRTIMP _Winit {
public
:
_Winit();
~_Winit();
private
:
static int _Init_cnt;
};
// WIDE OBJECTS
static
_Winit _Wios_init;
extern
_CRTIMP wistream wcin;
extern
_CRTIMP wostream wcout, wcerr, wclog;
_STD_END
#ifdef
_MSC_VER
#pragma
pack(pop)
#endif
/* _MSC_VER */
#endif
/* _IOSTREAM_ */
/*
* Copyright (c) 1994 by P.J. Plauger. ALL RIGHTS RESERVED.
* Consult your license regarding permissions and restrictions.
*/
加亮的初始化器“
static ios_base::Init _Ios_init”,每个包含iostream头文件的编译单元都自动拥有一个_Ios_init静态对象。
cout的定义在iostream.cpp文件中:
// iostream -- ios::Init members, initialize standard streams
#include
<locale>
#include
<fstream>
#include
<istream> /* NOT <iostream> */
#include
<new>
_STD_BEGIN
// OBJECT DECLARATIONS
int
ios_base::Init::_Init_cnt = -1;
static
filebuf fin(_Noinit);
static
filebuf fout(_Noinit);
_CRTIMP2
istream cin(_Noinit);
_CRTIMP2 ostream cout(_Noinit);
static
filebuf ferr(_Noinit);
_CRTIMP2
ostream cerr(_Noinit);
_CRTIMP2
ostream clog(_Noinit);
_CRTIMP2 ios_base::Init::Init()
{ // initialize standard streams first time
bool doinit;
{_Lockit _Lk;
if (0 <= _Init_cnt)
++_Init_cnt, doinit = false;
else
_Init_cnt = 1, doinit = true; }
if (doinit)
{ // initialize standard streams
new (&fin) filebuf(stdin);
new (&fout) filebuf(stdout);
new (&cin) istream(&fin, true);
new (&cout) ostream(&fout, true);
cin.tie(&cout);
new (&ferr) filebuf(stderr);
new (&cerr) ostream(&ferr, true);
cerr.tie(&cout);
cerr.setf(ios_base::unitbuf);
new (&clog) ostream(&ferr, true);
clog.tie(&cout);
}
}
_CRTIMP2 ios_base::Init::~Init()
{ // flush standard streams last time
bool doflush;
{_Lockit _Lk;
if (--_Init_cnt == 0)
doflush = true;
else
doflush = false; }
if (doflush)
{ // flush standard streams
cout.flush();
cerr.flush();
clog.flush();
}
_STD_END
}
const
char _PJP_CPP_Copyright[] =
"Portions of this work are derived"
" from 'The Draft Standard C++ Library',/n"
"copyright (c) 1994-1995 by P.J. Plauger,"
" published by Prentice-Hall,/n"
"and are used with permission.";
/*
* Copyright (c) 1994 by P.J. Plauger. ALL RIGHTS RESERVED.
* Consult your license regarding permissions and restrictions.
*/
类ios_base::Init包含一个静态的成员变量_Init_cnt作为计数器,根据2.c,我们知道它在初始化开始之前就已经有一个正确的初始值“-1”。当它小于0时,Init的构造函数会执行对cout的初始化工作(如前所述,cout的存储区已经分配好,只是没有初始化,所以可以用placement new来手动初始化),当它开始有一个计数值的时候,对cout的初始化就会被跳过(为了防止多线程争用而导致多次初始化,要使用一个自旋锁来保证对_Init_cnt的访问是独占的)。最后在cout的定义处,它的构造函数其实不执行任何初始化工作,这一点相当重要,保证了cout不会被重复初始化。
看完上面的代码,是不是给人一种相当繁琐,过于精妙而让人有不安全的感觉,当然经过长时间的实践检验,上面的做法是相当安全的,已经考虑到方方面面可能影响的因素。不过,你仍然需要为每个使用cout的编译单元付出一个初始化器的空间开销和每次初始化检查的时间开销。
应该说,以上3种方法,第一种是首选,除非有语法上的强制要求,如果必须使用变量语法,在编译器支持而且没有跨编译器的需求的时候可以考虑第二种,如果所有条件都不满足,第三种方法就是你唯一的选择了。