正像以前我说过的,我已经不下五次“以为”我理解数组了,然而今天又一次发现自己无知。
初学C的时候我把数组当成指针看。明白了一些实现机制后,我开始把数组当成一个特殊的变量; 我开始察看汇编剖析原理,理解逐步深入。 可是,明白得越多,就会发现越多的特例。 最终 —— 我陷入了看似任意且繁杂的规则的迷宫。
还是的回到定义上来。C/C++的数组保证两件事:
1 数组是由同类型成员组成的构造对象,占有一块连续的内存。
2 它的名称表示它的首地址。
仅此而已,除此之外 —— 任何内容都不属于数组的定义。
所以,我们知道,当我们需要数组的时候,我们定义就是了。需要静态数组? C在静态区给你一个数组。 需要自动数组? C在栈上给你一个数组。它还会自动给你初始化:
char str [] = "It's convenient to use arrays";
于是栈上有了我们需要的、不折不扣刚好30字节的空间,当我们用str索引他的时候,他已经有需要的内容了。 这就是C/C++。他选取了最精确的定义,而隐藏繁杂的后台。一切都有可能改变,唯有概念不变。
ANSI C++98太厚了, 一时还没法学会在里面找到自己要东西。下面是我的一些总结:
1 首先谈谈函数内定义的普通数组,即auto数组。 auto 数组的的确确实在栈上建立的,且(可以认为)在定义的位置初始化。 所以,若你希望用一个数组实现一个颇大的正弦速查表,推荐还是改用指针 —— 系统切切实实会在栈上初始化一个结结实实的巨大 auto 数组。
auto数组在离开作用域后,其内存就被回收了 —— 按照C的说法,你不应该继续用它了。这的确就是我们编写程序需要用到的所有知识 —— 不过,若想刨根问底,稍候,我也会谈到这是一个怎么样的内部过程。
2 static 数组是怎么回事呢?
C和C++中有很多 static 。 可以说,每种 static 都不是一回事。
static 数组都存储在静态数据域中。这里要细谈的是局部 static 数组的初始化:
局部static对象当你第一次用的时候初始化;主程序结束时,所有初始化过的局部static 对象按和构造相反的顺序析构 —— 然后全局对象才开始析构。
下面是一个我曾研究过很长时间的一个经典问题,在此剖析一下:
char* getHello(){
char str[] = "hello world";
return str;
}
int main(){
std::cout << getHello() << std::endl;
}
这是一个错误的程序,他试图返回 auto 数组,结果是未定义的。虽然在VC7 Release 和DevC4.9下都没问题,但是在VC7 Debug模式下就会输出乱码。
那么返回auto数组问题到底在哪里呢? 微观的看看整个指令流程罢:
我希望大家都明白 asm/C/C++ 中函数调用是怎么一回事。函数使用一个统一的栈保存返回地址、参数和 auto 变量。 调用时往栈里堆东西, 返回时则宣布自己的东西都不要了。 正因为 auto 变量是栈上临时分配的, 所以 C/C++ 才支持递归调用 —— 若每个变量都在数据区静态引用, 那么每递归一层,原来的东西就刷掉啦~~~
首先, getHello 已经知道自己要分配12字节的栈空间给 str。所以getHello 函数开始就把空间留好了 —— 他可能这么做:保存当前栈顶 esp 到 ebp, 并让栈顶 esp 减12(堆栈一般是向“下”增长的)。 str知道自己相对 ebp的偏移, 这都是编译期就算好了的。假如 str 不需要初始化,那么实际上定义它不会有任何额外开销。
当执行 char str[] = "hello world" 的时候, 函数必须把栈上一片区域正确初始化。于是编译器(如VC)可以这么实现 —— 他预先在数据区放置字串 hello world/0, 然后把它拷到栈 ebp + 对应偏移的位置上。
最后,函数返回 str 的首地址 —— 这是栈上的地址 —— 并且恢复栈顶 esp 到调用前的位置 —— 于是这段字串就在栈顶之外了。
现在的问题是,为什么会出现乱码呢? 虽然栈顶 恢复了,但栈上的 "hello world" 仍然没有被删除阿!
这有点像物理学的测不准原理: 你为了测量一个量,就必须改变它。道理类似 —— 为了输出这个 "hello world", 我们调用了 ostream:: operator<<。 这个函数同样可能(也可能不)使用堆栈。 假如需要的临时变量覆盖了 "hello world" ,就只能看见乱码了。
用下面的做法,在调用函数前复制那个悬着的字符串,就可以看见正确内容了:
int main (){
char str[100], *pStr = str; // buf
char *p = getHello();
// copy p to buf
while( *p )
*pStr++ = *p++;
*pStr = 0;
std::cout<< str << std::endl;
}
更好的做法是, 直接用一个 const char 引用那个数据区的字串并返回:
const char* getHello(){
const char* str = "hello world";
return str;
}
另外说一句, C/C++规定,字符串 "hello world" 的类型是 char 数组,存储类型为 static 只读。 为了与C兼容, 你可以 char* str = "hello world", 但是这不安全。修改其内容会导致未定义行为 —— 事实上,除了古老的在保护模式下执行的 TC 以外, VC和 GCC 中下面行为都会最晚在下一次访问的时候运行时崩溃:
char * str = "hello world";
str[0] = 'H'; // 未定义行为!如果是VC Debug模式,在这里就已经挂了
cout<< str << endl; // 一般来说会在这里挂掉
还有一个常见问题是,两个字符串常量是否会合并:
char * str1 = "hello world";
char * str2 = "hello world";
cout<< boolalpha << ( str1 == str2 ) << endl; // 是否为 true 与实现相关
C++规定,上面两个相同的字符串是否合并取决于实现。具体说,我的简单测试代码在VC 7.1 环境下,选择“完全优化”、“不优化”时他们没有合并,而“最大化速度”和“最小化大小”时则合并了。 GCC 3.4.2 下则都合并了。实际上,当多个obj 参与连接的情况下,合并的难度会更大。
从数据区直接拷贝内存镜像的确是一种高效的初始化技巧 —— 如果你用 static 修饰数组,甚至可以直接用数据区的原始数据。 然而事情并不总是这么顺人心。在OOP中,我们面临一个麻烦:有些对象必须调用构造函数。
如下面一个例子:
class A{
int _i;
public:
explicit A( int n ):_i(n) { cout<<"A("<< _i << ")"<<endl; }
A( const A& rhs ): _i( rhs._i ) { cout<< "A(const A&"<< _i << " )"<<endl; }
~A( ){ cout<<"~A("<< _i << ")"<<endl; }
A& operator= ( const A& rhs ){ _i = rhs._i; cout<<"A::operator=( const A&"<< _i << " )" <<endl; }
};
int main(){
A a[] = { A(0), A(1), A(2) };
// 当然,去掉构造函数A::A( int ) 前的 explicit就可以写成:
// A a[] = { 0, 1, 2 };
}
结果并不是三个拷贝构造函数,而是三个普通的构造函数。察看汇编发现,系统以 a[0] a[1] a[2] 地址为 this 分别调用三次构造函数,而和静态区没有任何关系。(不过若是用A a[] = { 0, 1, 2 }; 则会从静态区索引0 / 1/ 2作为参数 )
假如我们有一个局部静态数组, 其中包含的是有构造函数的对象:
static A a[] = { A(0), A(1), A(2) };
那么它和一般对象数组在实现上有绝对的区别:
static int nums[] = { 0, 1, 2 };
后者其实什么都没有作。nums直接表示数据区串 0 1 2 的首地址。而前者,他的数据被分配在数据区,必须以成员对应地址为 this 调用三次 A( int )。
局部静态对象还有更多奇特的属性。
我们知道, C++有这么一句话: 对象的析构遵从和构造相反的顺序。
全局对象在调用 main 之前初始化, 在退出main之后析构。 局部静态对象则在“某个时刻”初始化一次且仅一次;若他初始化过,就必须且只能在退出时析构。
那么在何时初始化呢? 唯一的答案是:“在第一次运行到其定义的时候”。 因为构造函数往往有参数 —— 系统不可能在 main 开始之前就确定所有参数。
这种“运行时”在局部构造,最后又要求在全局析构的模式让我开始好奇 —— 系统如何知道他构造了,它又是如何析构的呢?
下面的例子测试两个全局变量何时构造,又是按什么顺序析构的:
void func(){
int i;
cin>>i;
if( i )
static A a( 0 );
cin>>i;
if( i )
static A a( 1 );
}
int main(){
func();
func();
cout<<"-------------"<<endl;
}
这个测试中, 流程是否经过 A( 0 ) 和 A( 1 ) 取决于两次调用 func() 的用户四次输入。
1 输入 0 0 0 0 , 结果:
-------------
没有任何对象被构造,也没有任何对象被析构。
2 当输入 1 1 1 1 时,结果:
1
A(0);
1
A(1);
1
1
-------------
~A(1);
~A(0);
每个静态局部对象只构造了一次,并且在退出后逆序析构。 你可以试试 0 1 1 0 这样的输入,C++仍然知道如何正确逆序析构。
简直像魔术一样! C++ 到底如何做到这个的呢? 察看汇编可以看见VC的实现:
1 他对每个局部 static 对象都定义了一个相关的静态变量, 监视其是否初始化过。
2 他把初始化过的 static 对象丢到一个全局栈中, 以便程序结束时析构。