3.2 委派构造函数
类别:类作者
与继承构造函数类似的,委派构造函数也是C++11中对C++的构造函数的一项改进,其目的也是为了减少程序员书写构造函数的时间。通过委派其他构造函数,多构造函数的类编写将更加容易。
首先我们可以看看代码清单3-9中构造函数代码冗余的例子。
在代码清单3-9中,我们声明了一个Info的自定义类型。该类型拥有2个成员变量以及3个构造函数。这里的3个构造函数都声明了初始化列表来初始化成员type和name,并且都调用了相同的函数InitRest。可以看到,除了初始化列表有的不同,而其他的部分,3个构造函数基本上是相似的,因此其代码存在着很多重复。
读者可能会想到2.7节中我们对成员初始化的方法,那么我们用该方法来改写一下这个例子,如代码清单3-10所示。
在代码清单3-10中,我们在Info成员变量type和name声明的时候就地进行了初始化。可以看到,构造函数确实简单了不少,不过每个构造函数还是需要调用InitRest函数进行初始化。而现实编程中,构造函数中的代码还会更长,比如可能还需要调用一些基类的构造函数等。那能不能在一些构造函数中连InitRest都不用调用呢?
答案是肯定的,但前提是我们能够将一个构造函数设定为“基准版本”,比如本例中Info()版本的构造函数,而其他构造函数可以通过委派“基准版本”来进行初始化。按照这个想法,我们可能会如下编写构造函数:
Info() { InitRest(); }
Info(int i) { this->Info(); type = i; }
Info(char e) { this->Info(); name = e; }
这里我们通过this指针调用我们的“基准版本”的构造函数。不过可惜的是,一般的编译器都会阻止this->Info()的编译。原则上,编译器不允许在构造函数中调用构造函数,即使参数看起来并不相同。
当然,我们还可以开发出一个更具有“黑客精神”的版本:
Info() { InitRest(); }
Info(int i) { new (this) Info(); type = i; }
Info(char e) { new (this) Info(); name = e; }
这里我们使用了placement new来强制在本对象地址(this指针所指地址)上调用类的构造函数。这样一来,我们可以绕过编译器的检查,从而在2个构造函数中调用我们的“基准版本”。这种方法看起来不错,却是在已经初始化一部分的对象上再次调用构造函数,因此虽然针对这个简单的例子在我们的实验机上该做法是有效的,却是种危险的做法。
在C++11中,我们可以使用委派构造函数来达到期望的效果。更具体的,C++11中的委派构造函数是在构造函数的初始化列表位置进行构造的、委派的。我们可以看看代码清单3-11所示的这个例子。
可以看到,在代码清单3-11中,我们在Info(int)和Info(char)的初始化列表的位置,调用了“基准版本”的构造函数Info()。这里我们为了区分被调用者和调用者,称在初始化列表中调用“基准版本”的构造函数为委派构造函数(delegating constructor),而被调用的“基准版本”则为目标构造函数(target constructor)。在C++11中,所谓委派构造,就是指委派函数将构造的任务委派给了目标构造函数来完成这样一种类构造的方式。
当然,在代码清单3-11中,委派构造函数只能在函数体中为type、name等成员赋初值。这是由于委派构造函数不能有初始化列表造成的。在C++中,构造函数不能同时“委派”和使用初始化列表,所以如果委派构造函数要给变量赋初值,初始化代码必须放在函数体中。比如:
struct Rule1 {
int i;
Rule1(int a): i(a) {}
Rule1(): Rule1(40), i(1) {} // 无法通过编译
};
Rule1的委派构造函数Rule1()的写法就是非法的。我们不能在初始化列表中既初始化成员,又委托其他构造函数完成构造。
这样一来,代码清单3-11中的代码的初始化就不那么令人满意了,因为初始化列表的初始化方式总是先于构造函数完成的(实际在编译完成时就已经决定了)。这会可能致使程序员犯错(稍后解释)。不过我们可以稍微改造一下目标构造函数,使得委派构造函数依然可以在初始化列表中初始化所有成员,如代码清单3-12所示。
在代码清单3-12中,我们定义了一个私有的目标构造函数Info(int, char),这个构造函数接受两个参数,并将参数在初始化列表中初始化。而且由于这个目标构造函数的存在,我们可以不再需要InitRest函数了,而是将其代码都放入Info(int, char)中。这样一来,其他委派构造函数就可以委托该目标构造函数来完成构造。
事实上,在使用委派构造函数的时候,我们也建议程序员抽象出最为“通用”的行为做目标构造函数。这样做一来代码清晰,二来行为也更加正确。读者可以比较一下代码清单3-11和代码清单3-12中Info的定义,这里我们假设代码清单3-11、代码清单3-12中注释行的“其他初始化”位置的代码如下:
type += 1;
那么调用Info(int)版本的构造函数会得到不同的结果。比如如果做如下一个类型的声明:
Info f(3);
这个声明对代码清单3-11中的Info定义而言,会导致成员f.type的值为3,(因为Info(int)委托Info()初始化,后者调用InitRest将使得type的值为4。不过Info(int)函数体内又将type重写为3)。而依照代码清单3-12中的Info定义,f.type的值将最终为4。从代码编写者角度看,代码清单3-12中Info的行为会更加正确。这是由于在C++11中,目标构造函数的执行总是先于委派构造函数而造成的。因此避免目标构造函数和委托构造函数体中初始化同样的成员通常是必要的,否则则可能发生代码清单3-11错误。
而在构造函数比较多的时候,我们可能会拥有不止一个委派构造函数,而一些目标构造函数很可能也是委派构造函数,这样一来,我们就可能在委派构造函数中形成链状的委派构造关系,如代码清单3-13所示。
代码清单3-13所示就是这样一种链状委托构造,这里我们使Info()委托Info(int)进行构造,而Info(int)又委托Info(int, char)进行构造。在委托构造的链状关系中,有一点程序员必须注意,就是不能形成委托环(delegation cycle)。比如:
struct Rule2 {
int i, c;
Rule2(): Rule2(2) {}
Rule2(int i): Rule2('c') {}
Rule2(char c): Rule2(2) {}
};
Rule2定义中,Rule2()、Rule2(int)和Rule2(char)都依赖于别的构造函数,形成环委托构造关系。这样的代码通常会导致编译错误。
委派构造的一个很实际的应用就是使用构造模板函数产生目标构造函数,如代码清单3-14所示。
在代码清单3-14中,我们定义了一个构造函数模板。而通过两个委派构造函数的委托,构造函数模板会被实例化。T会分别被推导为vector::iterator和deque::iterator两种类型。这样一来,我们的TDConstructed类就可以很容易地接受多种容器对其进行初始化。这无疑比罗列不同类型的构造函数方便了很多。可以说,委托构造使得构造函数的泛型编程也成为了一种可能。
此外,在异常处理方面,如果在委派构造函数中使用try的话,那么从目标构造函数中产生的异常,都可以在委派构造函数中被捕捉到。我们可以看看代码清单3-15所示的例子。
在代码清单3-15中,我们在目标构造函数DCExcept(int, double)抛出了一个异常,并在委派构造函数DCExcept(int)中进行捕捉。编译运行该程序,我们在实验机上获得以下输出:
going to throw!
caught exception.
terminate called after throwing an instance of 'int'
Aborted
可以看到,由于在目标构造函数中抛出了异常,委派构造函数的函数体部分的代码并没有被执行。这样的设计是合理的,因为如果函数体依赖于目标构造函数构造的结果,那么当目标构造函数构造发生异常的情况下,还是不要执行委派构造函数函数体中的代码为好。
其实,在Java等一些面向对象的编程语言中,早已经支持了委派构造函数这样的功能。因此,相比于继承构造函数,委派构造函数的设计和实现都比较早。而通过成员的初始化、委派构造函数,以及继承构造函数,C++中的构造函数的书写将进一步简化,这对程序员尤其是库的编写者来说,无疑是有积极意义的。