构造函数(上)
为了便于说明构造函数存在的意义和用法,不妨假设我们正着手于某个或者某些class的设计。这里我们假想要设计这个两个class,一个是描述复数的class,即Complex.,虽然标准库中也存在复数类,但假设出于特殊需要我们要自己设计一个,它看起来或许是类似这样子:
class Complex
{
public:
... // others
private:
double _x; // 实部
double _y; // 虚部
};
此外我们要还设计一个class,它是一个用于存储int型数据的高级数组,我们知道C++中的数组功能单一,存在无法动态扩充、不够安全等问题,所以我们要进行更高阶的设计,它大概相当于标准库中的vector<int>.由于涉及到内存的动态分配,我们需要一个指针变量来存储分配所得的内存块地址,或许我们同时还需要一个表示当前数组元素个数的整型变量,所以它看起来是这样子:
class IntArray
{
public:
... // others
private:
int *_p;
int _size;
};
OK,我们现在开始看看构造函数可以为我们这两个class提供什么样的好处。
一、初始化,以及初始化的多种途径
引入构造函数一个很自然的目的就是用于在对象构建时对其进行初始化。没有被初始化的对象,其值将是任意的,如果对其进行不恰当的使用,可能会引起一定的危险。例如对于IntArray,假如不提供初始化,则对象在构造之后,_p和_size的值一般是不确定的,假如此时不小心对其进行数组读写操作,则几乎可以肯定它将访问到某个不该访问的区域,结局是出现内存保护错误,或者程序崩溃。对于复数类,情况则好一点:一个初始值不确定的复数至少不会那么直接地引起上述恐怖的错误;但我们仍有理由希望它们都有一个自动的初始化机制。当然,构造函数可以为我们实现这一点。
对于IntArray,可以令其在初始化时将_p设为空指针,将_size设为0(以下给出定义,略去声明)::
IntArray::IntArray()
{
_p = 0;
_size = 0;
}
对于复数类,则简单地将初始后的实、虚部设为0:
Complex::Complex()
{
_x = 0.0;
_y = 0.0;
}
现在我们的两个class都提供了方便安全的初始化功能。但再进一步,我们有时希望能够用一个具体的、指定的方式来初始化对象,例如,我们可能想构造出一个值为3+2i的复数变量,如果构造函数只提供简单的构造功能,则我们不得不写:
Complex c;
c.setX(3.0); // 设定实部
c.setY(2.0); // 设定虚部
呃,当然,这看起来没有太大的问题,至少它可以正常地工作。只是我们注意到在代码的第一行,定义了复数对象c,而这将调用构造函数,它分别将_x,_y设为0,然后我们又要再手动地将_x,_y分别改为3和2,多少有些愚蠢,不是么?即使不考虑效率的损失,看起来也颇不雅观。所幸我们可以使用带参数的构造函数来实现这一点:
Complex::Complex(double x, double y)
{
_x = x;
_y = y;
}
由于我们需要同时提供默认初始化方式和指定初始化方式,所以应当使用重载,保留原来的构造函数。我们可以像前面声明c那样使用默认的初始化方式,也可以使用指定初值的方式,像这样:
Complex d(3.0, 2.0);
此时将调用第二个版本的构造函数,构建出一个值为3+2i的复数对象d.
上面我们以两个重载的构造函数实现了两种不同的初始化方式,观察发现它们其实是很相像的,即都是为_x,_y进行赋值,区别仅在于所赋的值是默认给定还是特别指定。对于这一特点我们不难联想到这就是默认参数函数的特征,从而我们可以将上述两个构造函数合并成为一个具有默认参数的构造函数:
Complex::Complex(double x = 0.0, double y = 0.0)
{
_x = x;
_y = y;
}
除了观感更好之外,我们还“顺便”获得了一个好处:可以用一个实数来初始化复数:
Complex c(3.14);
由于虚部没有明确给出,所以它将被默认为0,这是符合实际意图的;从而它免去了我们再多写一个专门用一个实数参数初始化复数的构造函数。
当然,不是所有的情况下,类似这样“顺便”的功能都是好处:它当然也可能带来混乱或者不合实际的初始化,这时我们就有必要通过重载来具体限定初始化的途径。
类似地,对于IntArray,通过重载我们可以实现各种初始化方式,例如,通过指定_size来让其实现初始时就分配内存,更进一步,还可以再指定一个初始值,赋予数组中的每一个元素作为初始值;我们也可以用一个普通的C++整型数组(或者它的指定的某一部分)来初始化IntArray,等等。
二、具有“特别”构造函数的class
1.不存在默认构造方式的class
对于前述各版本的Complex类,我们都提供了它的“默认构造”的途径,即我们可以写形如:
Complex c;
的语句而不必须在对象名后面加上一对含有构造参数的括号。因为当我们没有对Complex提供任何构造函数,或者仅提供一个无实参的、形如Complex()的构造函数时,这也是我们声明Complex对象的唯一方式,到我们使用重载、乃至将它们统一成一个提供默认参数的构造函数时,这种写法仍然是有意义的。但假如我们仅仅提供一个构造函数,并且它需要指定参数,同时还不允许完全的默认参数,如
Complex(double x, double y);
这将意味着像Complex()这样的函数调用是没有意义的,也就是说,我们不能再写
Complex c; // 不存在Complex()这样的函数供调用,编译出错
而只能写类似于这样的语句。
Complex c(0.0,0.0); // 没问题,它调用Complex(double x, double y)
哦,看起来问题不算太大,我们只不过在声明对象的时候多写点东西就可以了。但让我们把注意力转移到C++的数组:假如我们需要声明一个Complex数组,应该怎么办呢?显然Complex仍然会要求我们对每个对象提供初始值,但对于数组,我们无法写
Complex a[20](0.0, 0.0); // 哈,出错了
同样,动态分配数组也不起作用:
Complex *p = new Complex[20](0.0, 0.0); // 哈哈,还是不行
我们只能承认,“不存在直接的方法,可以让没有提供默认构造方式的类对象使用数组。”呵,你可能要提出这个说法似乎在暗示还是存在某些隐涩的方法来实现Complex数组。那让我想一想,呃,或许你可以用C中的malloc()内存分配函数来实现它,由于它只是单纯地进行内存分配而不会调用构造函数,所以编译不会有问题。但用malloc()来为C++对象分配内存是很过时的方法,因而也不被推荐使用。如果你真的觉得迫切需要使用数组,那还是为你的类提供一个默认构造的接口吧。
此外,如果有一个类需要包含Complex对象作为它的成员,应该如何对其指定初始化参数呢?例如:
class A
{
public:
... // others
private:
... // others
Complex c; // 这里不允许写Complex c(0.0,0.0);
};
这里,C++专门为之提供了初始化方式,即“成员初始化列表”。它出现在构造函数定义的参数表后,以冒号与参数表分隔,如果有多个成员需要在参数表内初始化,则它们之间以逗号分隔。对于class A,为了对成员c进行初始化,我们可以这样定义构造函数:
A::A(... /* 参数表 */) : c(0.0, 0.0) // 在这里初始化c
{
... // 其它操作
}
当然,上面的(0.0,0.0)还可以改成包含其它参数、变量等等的参量,以实现对c值的指定。
除了用户定义的class对象外,C++中的内置基本类型也可以在成员初始化列表中完成初始化,例如前面的Complex构造函数也可以写成
Complex::Complex(double x = 0.0, double y = 0.0) : _x(x), _y(y)
{
}
那么,它和
Complex::Complex(double x = 0.0, double y = 0.0)
{
_x = x;
_y = y;
}
有什么区别呢?答案是没有区别。有些人,比如我,更喜欢把基本内置类型的初始化也弄进初始化列表中;而另一些人则倾向于只把class对象放进去,而把基本内转类型的初始化放在函数体中。你可以择好而从之。
如果构造函数既包含了成员初始化列表,也包含了函数体内的语句(包括初始化语句与非初始化语句),那么编译器则保证先将初始化列表的初始化完成,再执行函数体的语句。但对于成员初始化列表中各成员的初始化次序,则需要特别注意:该次序并不是由书写次序决定,而是由class声明中各成员的声明次序决定。如考虑前面的IntArray,假设我们要提供一个“以数组大小”初始化的构造函数,则它可能是这样子:
class IntArray
{
public:
IntArray(int size);
... // others
private:
int *_p;
int _size;
};
// 简单起见,假设用户提供的size值总大于0,以保证new int[size]总有意义
IntArray::IntArray(int size): _size(size), _p(new int[_size])
{
... // others
}
这里就引入了一个相当隐匿的错误:表面上看,我们在初始化列表中先初始化了_size,再以初始化后的_size作为动态数组的大小传给new表达式。但初始化列表中对象的初始顺序实际上是依据变量的声明顺序,而我们在IntArray的声明部分中先声明_p,尔后才声明_size,因而程序运行时将先会对_p进行初始化,而此时new表达式所用到的_size值是不确定的,可能是一个非常大的数,也可能非常小甚至是负值,从而严重违背了我们的本意。
避免出现上述错误当然可以有很多种方法,比如,直接一点地,可以交换_p与_size的声明次序,但这不是一个值得提倡的方法,毕竟我们潜意识中都会认为类的声明次序不会改变程序的语义,因而有可能在将来什么时候更动声明次序,而这将导致错误,并且难以捕捉。一个比较好的解决方案是将_size改为size,也就是在初始化阶段应当尽量降低成员变量之间的依赖性。
以上我们讨论了一个没有包含默认构造形式的类Complex,以及当另一个类将Complex作为成员变量时应当如何对其进行初始化,下面我们看看继承的情况:假如有一个class继承自Complex,根据关于继承的规定,编译器将在派生类的构造函数调用完成之后再调用基类,也就是Complex的构造函数。问题是我们应当如何将参数传给基类的构造函数?答案是类似的:通过成员初始化列表。下面是例子:
class SuperComplex: public Complex
{
public:
SuperComplex(... /* 参数表 */);
... // others
private:
... // others
};
SuperComplex::SuperComlex(... /* 参数表 */): Complex(0.0, 0.0)
{
... // others
}
显然继承的情况与前面包含的情况是很相似的,不同之处是将前面的对象名换成了这里的基类名,当然你也可以用其它变量值,比如SuperComplex的构造函数的参数值来代替Complex(0.0, 0.0)中的常数参数。
2. 私有构造函数
通常我们都将构造函数的声明置于public区段,假如我们将其放入private区段中会发生什么样的后果?没错,我也知道这将会使构造函数成为私有的,这意味着什么?
我们知道,当我们在程序中声明一个对象时,编译器为调用构造函数(如果有的话),而这个调用将通常是外部的,也就是说它不属于class对象本身的调用,假如构造函数是私有的,由于在class外部不允许访问私有成员,所以这将导致编译出错。
你于是说:“哈哈。”我们制造了一个似乎无法产生对象的class.哦,当然,对于class本身,我们还可以利用它的static公有成员,因为它们独立于class对象之外,我们不必产生对象也可以使用它们。嗯,看来我们还是为带有私有构造函数的类找到了一个存在的理由。不过我们不应当满足于此,因为看上去应当还有发掘的余地。
首先我们来认真看一下是不是真的无法创建出一个具有私有构造函数的类对象。“呃,可能未必。”你现在也许会这样说。这很好,让我们再来看看为什么,没错,因为构造函数被class私有化了,所以我们要创建出对象,就必须能够访问到class的私有域;但这一点“我们”是做不到的,那么,谁能做得到呢?class的成员可以做得到;但在我们建构出其对象之前,怎么能利用它的成员呢?噢,刚才我们刚刚提到了static公有成员,它是独立于class对象而存在的,当然,它也是公有的,“我们”可以访问得到。假如在某个static函数中创建了该class的对象,并以引用或者指针的形式将其返回(不可以以值的形式返回,想想为什么),我们就获得了这个对象的使用权。下面是例子:
class WonderfulClass
{
public:
static WonderfulClass* makeAnObject()
{
// 创建一个WonderfulClass对象并返回其指针
return (new WonderfulClass);
}
private:
WonderfulClass() { }
};
int main()
{
WonderfulClass *p = WonderfulClass::makeAnObject();
... // 使用*p
delete p; // Not neccesary here, but it's a good habit.
return 0;
}
嗯,这个例子使用了私有构造函数,但它运行得很好:makeAnObject()作为WonderfulClass的静态成员函数,尽心尽责地为我们创建对象:由于要跨函数传递并且不能使用值传递方式,所以我们选择在堆上创建对象,这样即使makeAnObject()退出,对象也不会随之蒸发掉,当然,使用完之后你可不要忘了手工将它清除。
回到前面的思路:除了公有的static成员可以帮助我们访问私有域外,还有没有其它可以利用的“东西”?
噢,你一定想到了使用友元,完全正确。可以使用该类的友元函数或者友元类创建其对象,这里就不举例了。
我们知道没有人会无聊到无缘无故把一个class设为私有,然后再写一个和上面一模一样的makeAnObject()来让它的用户体验一下奇特的感觉。我们也不太相信这只是由于C++的设计原因而导致的一个“顺便的”“特殊的”“无用的”边角功能。它应当是有实际用途的。
嗯,例如,我们想实现这样一个class:它至多只能存在一个,或者指定数量个的对象(还记得标准输入输出流库中那个独一无二的cout吗?),我们可以在class的私有域中添加一个static类型的计数器,它的初值置为0,然后再对makeAnObject()做点手脚:每次调用它时先检查计数器的值是否已经达到对象个数的上限值,如果是则产生错误,否则才new出新的对象,同时将计数器的值增1.最后,为了避免值复制时产生新的对象副本,除了将构造函数置为私有外,复制构造函数也要特别声明并置为私有。