C++中的数组和局部静态对象

时间:2022-01-12 02:28:30

   像以前我说过的,我已经不下五次“以为”我理解数组了,然而今天又一次发现自己无知。

    初学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 对象丢到一个全局栈中, 以便程序结束时析构。