[Effective C++读书笔记]004_条款04_确定对象被使用前已先被初始化

序言

   看书是一件百味陈杂的事。看小说,心思专注时,你能从别人的故事里流出自己的眼泪;看历史,仰望星空时,你能从演变的沧海桑田里发现现实的美好。看技术书籍,自己编示例程序时,你能发现自己离大牛专家的距离有多远,有时简直是天壤之别,甚至感觉自己这辈子都无法企及那样的高度。然而,我们仍不能停下自己的脚步,因为身边的人都在往那个高度攀登着,因为高处的风景,至少,或许更加美好。

本文主题

   好了,废话不多说,今天我们来看《Effective C++》中的条款04:

条款04:确定对象被使用前已先被初始化

            Make sure that objects are initialized before they're used

   在确保对象在使用前已先被初始化这一条款的编码实践中,作者为我们总结了三条经验,它们分别是:

1.  手工初始化内置类型对象
2.  构造函数最好使用成员初值列,而不要在构造函数内使用赋值操作,其排列次序应和他们在类中声明的次序相同
3.  用local static对象替换non-local static对象,以避免“跨编译单元的初始化次序”问题

   下面我们就一条一条的来解读作者的这三条经验。

1. 手工初始化内置类型对象

    在解读这条经验之前,我们先来看一段简单的程序让大家对“对象初始化先行”的重要性有一个认识。

 1 #include <iostream>
 2 using namespace std;
 3 
 4 int main(){
 5 
 6     int x;
 7     cout<<x<<endl;
 8 
 9     system("pause");
10     return 0;
11 }

   问题:上面的代码输出什么?

Windows:   不定值
  Linux:   不定值
   Unix:   0

   看,多么危险的一件事。什么?看不出危险,好吧!试想,假如变量x里面是你的银行存款。1. 银行采用Windows做服务器,假如x的值是198812282,你会因掠夺银行财富而进某某监狱;2. 银行用了Unix系统,你会忍气吞声吗?当然,这只是一个玩笑,不过透过这个玩笑,让我们对变量的初始化先行的重要性有了更深的认识。

   为什么变量要初始化呢?初始化和不初始化有什么区别呢?一切高级语言的本质都是汇编码,我们究其本质,来看下面的代码片段:

1   int x;                                                       |  int x = 0;                                                     
2   cout<<x<<endl;                                               |  cout<<x<<endl;
1 mov         eax,dword ptr [__imp_std::endl (10D2040h)]         |   mov         eax,dword ptr [__imp_std::endl (0CC2040h)] 
2 mov         ecx,dword ptr [esp]                                |   mov         ecx,dword ptr [__imp_std::cout (0CC2044h)] 
3 push        eax                                                |   push        eax  
4 push        ecx                                                |   push        0                                                 
5 mov         ecx,dword ptr [__imp_std::cout (10D2044h)]         |   
6 call        dword ptr [__imp_std::basic_ostream<char,std...    |   call        dword ptr [__imp_std::basic_ostream<char,std...
7 mov ecx,eax | mov ecx,eax 8 call dword ptr [__imp_std::basic_ostream<char,std... | call dword ptr [__imp_std::basic_ostream<char,std...

    从汇编码中,我们可以看出:未初始化变量的程序会将栈顶指针esp指向的未知值放到寄存器ecx里,然后压栈供后面的cout使用;初始化变量的程序则将数值0压栈,供后面的cout使用。

    由于main也是一个函数,它内部分配的所有变量都是存储在栈里的,而倘若栈里的值没有被初始化,那么就会得到一块“脏数据”,然后运行结果就成了不可期待的了,这是非常危险的。

    因此,上面的内置类型对象x在声明之后,就应该被手动的初始化"x = 0"。

2. 构造函数最好使用成员初值列,而不要在构造函数内使用赋值操作,其排列次序应和他们在类中声明的次序相同

    内置类型的对象我们是可以手动初始化的,而其外的任何其他对象就需要构造函数出场了。我们仍然以一个示例程序作为开始,然后展开讨论。

 1 #include <iostream>
 2 #include <string>
 3 using namespace std;
 4 
 5 class MyType{
 6 };
 7 
 8 class A{
 9 public:
10     A(){                                                     
11         this->m_IntValue = 0;                                
12         this->m_StrValue = "";                               
13         this->m_MyType = 0;                                  
14     }                                                        
15 private:
16     int m_IntValue;
17     string m_StrValue;
18     MyType *m_MyType;
19 };
20 
21 int main(){
22     A a;
23     return 0;
24 }

    你是不是这样写构造函数的呢?我也是这样写构造函数的,至少在阅读这个条款之前。但是今天的条款04认为这样的写法欠妥,为什么呢?来看看作者的理由:

①. C++规定,对象的成员变量初始化动作发生在进入构造函数本体之前

②. 上面写法是赋值,而不是初始化,对于对象,赋值操作的效率要比初始化低

   ①. C++规定,对象的成员变量初始化动作发生在进入构造函数本体之前

        这一点比较坑爹,它说了“C++规定”,我找了好多资料都没提到这一点。先不管这些,我们按照他说的来改造我们的构造函数,于是我们的类A变成了下面的样子:

1 class A{
2 public:
3     A():m_IntValue(0), m_StrValue(""),m_MyType(0){             
4     }                                                          
5 private:
6     int m_IntValue;
7     string m_StrValue;
8     MyType *m_MyType;
9 };

   关于上面的修改,以下内容援自《Effective C++》侯捷译第三版来进行说明:

   这个构造函数和上一个的最终结果相同,但通常效率较高。基于赋值的那个版本首先调用default构造函数为成员对象设初值,然后立刻再对它们赋予新值。default构造函数的一切作为因此浪费了。成员初值列(member initialization list)的做法避免了这一问题,因为初值列中针对各个成员变量而设的实参,被拿去作为各成员变量之构造函数的实参。

②. 上面写法是赋值,而不是初始化,对于对象,赋值操作的效率要比初始化低

   我并不相信,所以写了一个测试程序,看看到底效率有没有差异,测试代码如下:

1     int i = 0;
2     DWORD start_time = GetTickCount();
3     {
4         for(i = 10; i < 10000; i++){
5             A a;
6         }
7     }
8     DWORD end_time = GetTickCount();
9     cout<<end_time - start_time<<endl;

   测试结果(Debug):

                 成员初值列初始化                  手动赋值初始化

i=1000     : 0                                       0

i=10000   : 16                                      62

i=100000 : 201                                     256

   上面的结果或许能支撑作者的观点吧!好吧,数据面前说话,我们姑且认为作者是对的。

3.  用local static对象替换non-local static对象,以避免“跨编译单元的初始化次序”问题

   这个问题要稍微复杂一点了,至少作者用了很大的篇幅来描述他的问题和观点。而我觉得理解这一点的核心就三点:

   ①. 理解什么是local static和non-local static

   ②. 理解什么是跨编译单元和初始化次序

   ③. 为什么将non-local static变成local static就能避免初始化次序问题

   ①. 理解什么是local static和non-local static

        理解二者最好的方法就是看一段代码:

 1 #include <iostream>
 2 using namespace std;
 3 
 4 // 该静态变量不再函数中,因此是non-local static的
 5 static int non_local_static_value = 10;
 6 
 7 void test(){
 8     // 该静态变量在函数中,因此是local static的
 9     static int local_static_value = 10;
10 }
11 
12 int main(){
13     return 0;
14 }

   这就不用细说了,不过需要提醒一下,local static的变量可以存在的位置有:class内、函数内、file作用域。non-local static的变量可以存在的位置:global、namespace内。

②. 理解什么是跨编译单元和初始化次序

 

   所谓编译单元,是指单独生成目标文件的那些源码,比如我们在配置编译文件时,经常看到Makefile里有OBJ=XXX的字样,这就是目标文件。而跨编译单元的意思是我在A目标文件里使用B目标文件中的变量。要跨编译单元访问,我们有一个关键字extern。示例代码如下:

1 class MyType{
2 };
3 extern MyType mt; // 这句代码告诉其他编译单元,mt这个全局变量你们是可以使用的

   所谓初始化序列,是指构造函数在为其成员变量初始化时的顺序。当然,这里并不仅仅指成员变量的初始化次序,还有全局的变量,静态变量等。这里关于次序有一个问题,试想,如果成员变量m_A的值需要由m_B计算而来,那么谁先初始化呢?当然是m_B了。类的构造函数是按照其成员变量声明次序来初始化的。但是跨编译单元的non-local static变量初始化是没有这样一个顺序的。那么我们就有了一个大问题了。

问题:因为没有严格的初始化次序规定,有极大的可能,A编译单元使用了还没有初始化的B编译单元里的non-local static变量。

③. 为什么将non-local static变成local static就能避免初始化次序问题

   在解释这一点之前,我们将作者给的解决方案的代码贴出来:

class MyType{
};
// 这里定义了全局函数来将non-local static的变量变为local static
MyType&  GetMT(){
    static MyType mt;
    return mt;
}

    这样做的根据是什么呢?那是因为C++有下面这样一条规定:

C++保证,函数内的local static对象会在“该函数被调用期间”“首次遇上该对象之定义式”时被初始化。

 总结

   1. 对内置类型,如int、double、float、char等,声明变量后,要手工初始化对象
   2. 对内置类型对象以外的对象,要用构造函数初始化,最好使用成员初值列,而不要在构造函数内使用赋值操作,其排列次序应和他们在类中声明的次序相同
   3. 对跨编译单元的对象,要用全局函数将non-local static变为local static对象

posted @ 2012-10-19 18:28  邵贤军  阅读(1032)  评论(0编辑  收藏  举报