c++编程习惯三(确保对象在使用之前已经被初始化)

在使用c语言时,我么也因该养成对变量初始化的习惯,这个往往比较容易操作,但在c++中这好像有点反复无常。

比如:

int  x;

在某些时候x会被初始化为0,但在有些语境中却不保证。如果这么写:

class Poo
{
    int x,y;
};
...
Poo p;

p的成员有时候会被初始化为0,有时候不会。那么可能你不会觉得未初始化对象有什么影响,但是我要说的是,这颇为重要。

读取未初始化的值会导致不明确行为。在某些情况下,仅仅是读取未初始化的值,就可能会让你的程序停止运行,更可能是读入一些半随机的bits,污染了正在读取动作的那个对象,最终导致一些不可预知的错误,以及很多不愉快的测试过程。

解决办法:

在c++中我们有一些方法来解决这个问题,但是比较复杂。

我们知道c++中有时候使用c语言的代码,初始化会招致运行期的成本,那么就不保证发生初始化,一旦进入了c++部分,这时规则就发生了变化。比如,数组(array)不保证其内容被初始化,而vector(来自STL即c++)却有此保证。

 

我们表面上似乎无法决定这个状态,最好的处理办法就是:永远在使用对象之前先将它初始化。对于无任何成员的内置类型,必须手动完成此事。例如:

int x = 0;//对int进行手动初始化
const char* text = "hello world";//对指针进行手动初始化

double d;
std::cin >> d;//读取input stream的方式完成初始化

如果不是内置类型,初始化的责任就落在了构造函数身上,这个很好办,确保每一个构造函数将对象的每一个成员初始化。

这个规则虽然很容易实现,但是我们要注意,不要混淆了赋值和初始化。例如:

class Phone{...};
class Person
{
public:
    Person();
private:
    std::string theName;
    std::string theAddress;
    std::list<Phone> thePhones;
    int num;
};
Person::Person(const std::string& name,const std::string& address,const std::list<Phone>& phones)
{
    //这些都是赋值而非初始化,或者我们可以称之为伪初始化
    theName=name;
    theAddress=address;
    thePhones=phones;
    num=0;
}

c++规定,对象的成员变量初始化动作发生未进入构造函数本体之前。上面的例子并没有被初始化,而都是被赋值,初始化的发生时间更早,发生于这些成员的默认构造函数被自动调用之时(比进入Person构造函数的本体的时间更早)。但是对于num不为真,因为它属于内置类型,不保证一定在赋值动作之前获得初值(也就是并不能保证在你看到的赋值动作之前完成对内置类型的初始化)。

那么我们应该如何做呢:

Person::Person(const std::string& name,const std::string& address,const std::list<Phone>& phones):theName(name),theAddress(address),thePhones(phones),num(0)
{}

这个构造函数和上述结果一样,但是效率较高。基于赋值的那个版本,首先调用默认构造函数为theName,theAddress和thePhones设初值,然后立刻再对它们赋予新值。默认构造函数的一切作为因此浪费了。成员初始化列表的做法避免了这一问题,因为初值列中针对各个成员变量而设的实参,被拿去作为各成员变量之构造函数的实参(这句话有点绕,多读几遍就理解了)。本例中调用的是copy构造。

对大多数类型而言,比起先调用默认构造函数再调用赋值操作,只调用一次copy构造函数是比较高效的,有时甚至高效很多。对于内置类型对象如num,其初始化和赋值的成本相同,为了统一,尽量都使用初值列。同样的道理,甚至你想要默认构造一个成员变量你也可以使用初值列:

Person::Person():theName(),theAddress(),thePhones(),num(0)//记得将num初始化
{}

由于编译器会为用户自定义类型的成员变量自动调用默认构造函数——如果成员变量在成员初值列没有指定初值的话,所以我们给出一个规则:总是在初值列中列出所有的成员变量,以免还得记住哪些成员变量可以无需初值。比如,num属于内置类型,如果把它遗漏,它就没有初值,就会出现未初始化的现象,这当然是我们所不愿看到的。

注意:有些情况下即使面对的成员变量属于内置类型,也一定得使用初值列。如果成员变量是const或者引用时,就只能使用初值列而不能使用赋值。为了避免这些问题,我们只要总是使用初值列就可以了,这样又比较高效。

 

许多的class拥有多个构造函数,每个构造函数有自己的成员初值列。如果这种class存在很多的成员变量或者基类,多份成员初值列的存在就会导致重复和无聊的工作(对于程序员最枯燥的就是重复的代码)。这时候我们可以合理的在初值列中遗漏一些“赋值表现和初始化一样的”成员变量,改用他们的赋值操作,将其写成函数(通常是private),供所有的构造函数调用。这种做法在“成员变量的初值是由文件或数据库读入”时特别有用。然而比起经由赋值操作完成的伪初始化,通过成员初值列完成的真正初始化往往更加可取。

 

在c++中有着十分固定的“成员初始化次序”,也就是初始化次序总是相同的:基类更早于派生类,而class成员变量总是以其声明次序被初始化。我们上面例子中,theName成员总是被最先初始化,然后是theAddress,以此类推。就算在初值列中,你打乱了次序(而且这样也是合法的),也不会有任何影响。为了避免代码出现晦涩的错误(比如定义array时的大小,如果你顺序写错了,在别人看起来是错误的但是却依然可以通过),所以还是按照顺序写比较合理。

 

一旦你已经很小心地将“内置型成员变量”明确地加以初始化,而且也确保你的构造函数运用“成员初值列”初始化基类和成员变量,那还有唯一一件事需要操心,就是“不同编译单元内定义的non-local static对象”的初始化次序。

我们一点一点来分析。

static对象,其寿命从被构造出来直到程序结束为止,因此,栈(stack)和堆(heap-based)对象都被排除。这种对象包括:全局对象、定义域namespace作用域内的对象、在class内、在函数内以及在file作用域内被声明为static的对象。函数内的static对象叫做local对象(因为他们对于函数来说是local),其他static对象为non-local static对象。程序结束时会自动销毁,也就是它们的析构函数会在main函数结束时被自动调用。

然后呢,编译单元是指产出单一目标文件(.o文件)的那些源码,基本上它是单一源码文件加上其所含入的头文件。

现在我们所说的不同编译单元至少涉及两个源码文件,每一个内至少含有一个non-local static对象(也就是上面解释),真正的问题就是:如果某一编译单元内的某个non-local static对象的初始化动作用到了另一个编译单元中的non-local static对象,它所用到的这个对象可能未被初始化,因为c++对“定义于不同编译单元中的non-local static对象”的初始化次序并无明确定义。

难点:

下面通过一个例子来说明:

calss FileSystem{     //来自你的程序库
public:
    ...
    int numDisks() const;//众多成员函数之一
    ...
};

extern FileSystem tfs; //预备给客户使用的对象

这里预留的tfs对象绝不是一个普通的对象,如果你的客户在tfs对象还未构造完成之前就使用它就会出现很尴尬的错误。

假设现在客户建立一个class处理文件系统的目录,很自然就会用到我们的tfs对象:


class Directory{ //由客户建立
public:
    Directory (params);
    ...
};
Directory::Directory(params)
{
    ...
    int disks=tfs.numDisks();//使用tfs对象
    ...
}
//进一步万一客户创建一个Directory对象
Directory tempDir(params);//为临时文件而做出的目录

这时候,初始化次序的重要性显现出来:除非tfs在tempDir之前先被初始化,否则tempDir的构造函数会用到尚未初始化的tfs。但是tfs和tempDir是不同的人在不同的时间在不同的源码文件建立起来的,它们是定义于不同编译单元内的non-local static对象。如何能确定tfs会在tempDir之前先被初始化?

 

前面说过“c++对“定义于不同编译单元中的non-local static对象”的初始化次序并无明确定义“,这是因为:决定它们的初始化次序相当困难,非常困难,根本无解。

 

幸运的是一个小小的设计便可完全消除这个问题。我们要做的是:将每一个non-local static对象搬到自己的专属函数内(在函数内将该对象声明为static)。这些函数返回一个引用指向它所含的对象。然后用户调用这些函数,而不是直接涉及这些对象。换句话说non-local static对象被替换为local  static对象,可以看出来这是单例模式的一个常见手法。

 

这个手法的基础在于:c++保证,函数内的local static对象会在“该函数被调用期间”“首次遇上该对象的定义式”时被初始化。也就是说你在使用“函数调用(反回的引用指向local static对象)”替换直接访问non-local static对象,你就获得了这个保证,所获得的引用将指向一个初始化的对象。更好的一点是,如果从未调用non-local static对象的“仿真函数”,就绝不会引发构造和析构成本,真正的non-local static对象就会浪费资源了。

我们应该这样用:

class FileSystem{...}; //同前
FileSystem& tfs()
{
    static FileSystem fs;
    return fs;
}

class Directory{...}; //同前
Directory::Directory(params)
{
    ...
    int disks=tfs().numDisks;
    ...
}

Directory& tempDir()
{
    static Directory td;
    return td;
}

这么修改之后,他们使用函数返回的“指向static对象”的引用,而不是使用static对象本身。

注意:

这种结构下的函数往往十分单纯:第一行定义并初始化一个local static对象,第二行返回它。这样的单纯性使他们成为绝佳的inline函数。从另一个角度,这些函数内含static对象,使他们在多线程中带有不确定性。任何一种non-const static对象,不论是non-local static对象还是local static对象,在多线程环境中“等待某事发生”都会有麻烦。处理这个麻烦的一种做法就是,在程序还在单线程启动阶段,手工调用所有的这种函数,可以消除与初始化有关的“竞速形式”。

为避免在对象初始化之前过早地使用他们,需要做三件事。第一,手工初始化内置型对象,第二,使用初值列,最后,在“初始化次序不确定性”氛围下加强设计。

 

总结:

1、为内置型对象进行手工初始化,因为c++不保证初始化他们;

2、构造函数最好使用成员初值列(成员初始化列表),而不要在构造函数本体内使用赋值操作。初值列列出的成员变量,其排列次序应该和他们在class中的声明次序相同;

3、为免除“跨编译单元的初始化次序”问题,请用local static对象替换non-local static对象。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值