C++构造函数

构造函数

每个类分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。

不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化的过程,对象才能真正取得其”常量“属性。因此,构造函数在const对象的构造函数过程中可以向其写值。

 

合成的默认构造函数

我们的Sales_data类并没有定义任何构造函数,可是之前使用了Sales_data对象的程序仍然可以正确地编译和允许。

Sales_data total;   //保存当前求和和结果的变量

Sales_data trans;   //保存下一条交易数据的变量

这时我们不仅要提问:total和trans是如何初始化的呢?

我们没有为这些对象提供初始值,因此我们知道它们执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数无须任何实参

如我们所见,默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。

编译器创建的构造函数又被称为合成的默认构造函数。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员。
  • 否则,默认初始化该成员。内置类型也会提供初始化,不会是未定义的

因为Sales_data为units_sold和revenue提供了初始值,所以合成的默认构造函数将使用这些值来初始化对象的成员;同时,它把bookNo默认初始化为一个空字符串。

 

某些类不能依赖于合成的默认构造函数

合成的默认构造函数只适合非常简单的类,比如现在定义的这个Sales_data版本,对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:第一个原因也是最容易理解的一个原因是编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这条规则的依据是,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。

只有当类没有声明任何构造函数时,编译器才会自动地生成默认构造函数。

第二个原因是对于某些类来说,合成的默认构造函数可能执行错误的操作。回忆我们之前介绍过的,如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类型应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。

如果类包含内置类型或者复合类型的成员,则只有当这些成员全部都被赋予了类内的初始值时,这个类才适合于使用合成的默认构造函数,不然使用合成的默认构造函数,这些成员将是未定义的。

第三个原因是有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认的构造函数,那么编译器将无法承受该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。

 

=default的含义

我们从解释默认构造函数的含义开始:

Sales_data()=default;

首先请明确一点:因为该构造函数不接受任何实参,所以它是一个默认构造函数,我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。

在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上=default来要求编译器生成构造函数。其中,=default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果=default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。

 

上面的默认构造函数之所以对Sales_data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表来初始化类的每个成员。

 

构造函数初始值列表

接下来介绍类中定义的另外两个构造函数:

Sales_data(const string &s):bookNO(s) {}

Sales_data(const string &s,unsigned n,double p):bookNo(s),units_sold(n),revenue(p*n) {}

这两个定义中出现了新的部分,即冒号和花括号之间的代码,其中花括号定义了(空的)函数体。我们把新出现的部分称为构造函数初始值列表,他负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。

含有三个参数的构造函数分别使用它的前两个参数初始化成员bookNo和units_sold,revenue的初始值则通过将售出图书总是和每本书的单价相乘计算得到。

只有一个string类型参数的构造函数使用这个string对象初始化bookNo,对于units_sold和revenue则没有显式初始化,当某个数据成员被构造函数初始值列表忽略是,它将以与合成默认构造函数相同的方式隐式初始化。在此例中,这样的成员使用类内初始值初始化,因此只接受一个string参数的构造函数等价于

//与上面定义的那个构造函数效果相同

Sales_data(const string &s):bookNo(s),units_sold(0),revenue(0) {}

通常情况下,构造函数使用类内初始值不失为一种好的选择,因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显示地初始化每个内置类型的成员。

 

如果不能使用类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

 

在类的外部定义构造函数

与其他几个构造函数不同,以istream为参数的构造函数需要执行一些实际的操作,在它的函数体内,调用了read函数以给数据成员赋以初值:

Sales_data::Sales_data(istream &is)

{

  read(is,*this);  //read函数的作用是从is中读取一条交易信息然后存入this对象中

}

构造函数没有返回类型,所以上述定义从我们指定的函数名字开始。和其他成员函数一样,当我们在类的外部定义构造函数时,必须指明该构造函数是哪个类的成员。因此Sales_data::Sales_data的含义是我们定义Sales_data类的成员,它的名字是Sales_data。又因为该成员的名字和类名相同,所以它好似一个构造函数。

这个构造函数没有构造函数初始值列表,或者讲的更准确一点,它的构造函数初始值列表是空的。尽管构造函数初始值列表是空的,但是由于执行了构造函数体,所以对象的成员仍然能被初始化。

没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化。对于Sales_data来说,这意味着一旦函数开始执行,则bookNo将被初始化成空string对象,而units_sold和revenue将是0.

 

拷贝、赋值和析构

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等。当我们使用赋值运算符时会发生对象的赋值操作。当对象不再存在时执行时销毁的操作,比如一个局部对象会在创建它的块结束时被销毁,当vector对象(或者数组)销毁时存储在其中的对象也会被销毁。

如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。

 

某些类不能依赖于合成的版本

尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。例如,管理动态内存的类通常不能依赖于上述操作的合成版本。

不过值得注意的是,很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的储存空间。使用vector或者string的类能避免分配和释放内存带来的复杂性。

进一步讲,如果类包含vector或者string成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector成员的对象执行拷贝或者赋值操作时,vector类会设法拷贝或赋值成员中的元素。当这样的对象被销毁时,将销毁vector对象,也就是依次销毁vector中的每一个元素。这一点与string是非常类似的。

 

构造函数再探

对于任何C++的类来说,构造函数都是其中重要的组成部分。前面已经介绍了构造函数的基础知识,后面将继续介绍一些其他的功能。

 

构造函数初始值列表

当我们定义变量时习惯于立即对其进行初始化,而非先定义、再赋值:

string foo="Hello World!";  //定义并初始化

string bar;   //默认初始化成空string对象

bar="Hello World!";   //为bar赋一个新值

就对象的数据成员而言,初始化和赋值也有类似的区别。如果没有在构造函数的初始值列表中显示地初始化成员,则该成员将在构造函数体之前执行默认初始化。例如:

复制代码
//Sales_data构造函数的一种写法,虽然合法但比较草率:没有使用构造函数初始值
//先执行了默认初始化,然后再进行赋值操作
Sales_data::Sales_data(const string &s,unsigned cnt,double price)
{
    bookNo=s;
    units_sold=cnt;
    revenue=cnt*price;
}
复制代码

这段代码和我们前面使用初始值列表的效果是相同的:当构造函数完成后,数据成员的值相同。区别是原来的版本初始化了它的数据成员,而这个版本是对数据成员执行了赋值操作。这一区别到底会有什么深层次的影响完全依赖于数据成员的类型。

 

构造函数的初始值有时必不可少

有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员时const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。例如:

复制代码
class ConstRef{
public:
    ConstRef(int ii);
private:
    int i;
    const int ci;
    int &ri;
};
复制代码

和其他常量对象或者引用一样,成员ci和ri都必须被初始化。因此,如果我们没有为它们提供构造函数初始值的话将引发错误:

复制代码
//错误:ci和ri必须初始化

ConstRef::ConstRef(int ii)
{
//赋值
    i=ii;  //正确
    ci=ii;   //错误:不能给const赋值
    ri=i;    //错误:ri没有初始化
}
复制代码

随着构造函数体一开始执行,初始化就完成了。我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值,因此该构造函数的正确形式应该是:
//正确:显式地初始化引用和const成员

  ConstRef::ConstRef(int ii): i(ii),ci(ii),ri(i) {}

如果成员时const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值(因为如果进行赋值操作,首先要进行一次默认初始化,这样要使用都类类型的默认构造函数)。

建议:使用构造函数初始值

在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者先初始化在赋值。
除了效率问题外更重要的是,一些数据成员必须初始化。

成员初始化的顺序

显然,在构造函数初始值中每个成员只能出现一次。否则,给同一个成员赋两个不同的初始值有什么意义呢?

成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先把初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。

一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了。

举个例子,考虑下面这个类:

class X{

  int i;

  int j;

public:

  //未定义的,i在j之前被初始化

  X(int val):j(val),i(j) {}

};

在此例中,从构造函数的形式上来仿佛是先用val初始化了j,然后再用j初始化i。实际上,i先把初始化,因此这个初始值的效果是试图使用未定义的j初始化i!

如果可能的话,最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其他成员。这样的好处是我们可以不必考虑成员的初始化顺序。例如,X的构造函数如果写成如下的形式效果会更好:

X(int val):j(val),i(val) {}

 

默认实参和构造函数

//定义默认构造函数,令其与只接受一个string实参的构造函功能相同

Sales_data() {}

等价于

Sales_data(string s=" "):bookNo(s) {}

当没有给定实参,或者给定了一个string实参时,两个版本的类创建了相同的对象。因为我们不提供实参也能调用上面含有一个参数的构造函数,所以该构造函数实际上为我们的类提供了默认构造函数。

如果一个构造函数为所有参数都提供了默认实参,则它实际上也定义了默认的构造函数。

 

委托构造函数

C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责给了其他构造函数。

和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另一个构造函数匹配。

举个例子,我们使用委托构造函数重写Sales_data类,重写后的形式如下所示:

复制代码
class Sales_data{
public:
    //非委托构造函数使用对应的实参初始化成员
    Sales_data(string s,unsigned cnt,double price):bookNo(s),units_sold(cnt),revenue(cnt*price) {}
    //其余构造函数全都是委托给另一个构造函数
    Sales_data():Sales_data(" ",0,0) {}
    Sales_data(string s):Sales_data(s,0,0)  {}
//如果要在类的外面定义构造函数,则初始值列表在定义时写出,而不是在类内声明时写出,委托构造函数也是这样,在定义时写
    Sales_data(istream &is):Sales_data()  {read(is,*this);}
    //其他成员与之前的版本一致
};
复制代码

在这个Sales_data类中,除了一个构造函数外其他都委托了它们的工作。第一个构造函数接受三个参数,使用这些实参初始化数据成员,然后结束工作。我们定义默认构造函数令其使用三参数的构造函数完成初始化过程,它也无须执行其他任务,这一点从空的构造函数体能看得出来。接受一个string的构造函数同样也委托给了三参数的版本。

接受istream&的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。当这些受委托的构造函数执行完后,接着执行istream&构造函数体的内容。它的构造函数体调用read函数读取给定的istream。

当一个构造函数委托另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在Sales_data类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。

 

默认构造函数的作用

当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:

  • 当我们在块作用域内不使用然后初始值定义一个非静态变量或者数组时。
  • 当一个类不是含有类类型的成员且使用合成的默认构造函数时。
  • 当类类型的成员没有在构造函数初始值列表中显式地初始化时。

值初始化在以下情况下发生:

  • 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
  • 当我们不使用初始值定义一个局部静态变量时。
  • 当我们通过书写形如T()的表达式显式地请求值初始化时,其中T是类型名。

 

在实际中,如果定义了其他构造函数,那么最好也提供一个默认的构造函数。

posted on 2018-02-22 15:59  AlanTu  阅读(629)  评论(0编辑  收藏  举报

导航