3.1 继承构造函数
类别:类作者
C++中的自定义类型——类,是C++面向对象的基石。类具有可派生性,派生类可以自动获得基类的成员变量和接口(虚函数和纯虚函数,这里我们指的都是public派生)。不过基类的非虚函数则无法再被派生类使用了。这条规则对于类中最为特别的构造函数也不例外,如果派生类要使用基类的构造函数,通常需要在构造函数中显式声明。比如下面的例子:
struct A{A(int i){}};
struct B:A{B(int i):A(i){}};
B派生于A,B又在构造函数中调用A的构造函数,从而完成构造函数的“传递”。这在C++代码中非常常见。当然,这样的设计有一定的好处,尤其是B中有成员的时候。如代码清单3-1所示的例子。
代码清单3-1
struct A{A(int i){}};
struct B:A{
B(int i):A(i),d(i){}
int d;
};
//编译选项:g++ -c 3-1-1.cpp
在代码清单3-1中我们看到,派生于结构体A的结构体B拥有一个成员变量d,那么在B的构造函数B(int i)中,我们可以在初始化其基类A的同时初始化成员d。从这个意义上讲,这样的构造函数设计也算是非常合理的。
不过合情合理并不等于合用,有的时候,我们的基类可能拥有数量众多的不同版本的构造函数——这样的情况并不少见,我们在2.7节中就曾经看到过这样的例子。那么倘若基类中有大量的构造函数,而派生类却只有一些成员函数时,那么对于派生类而言,其构造就等同于构造基类。这时候问题就来了,在派生类中我们写的构造函数完完全全就是为了构造基类。那么为了遵从于语法规则,我们还需要写很多的“透传”的构造函数。我们可以看看下面这个例子,如代码清单3-2所示。
代码清单3-2
struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//…
};
struct B:A{
B(int i):A(i){}
B(double d,int i):A(d,i){}
B(float f,int i,const char*c):A(f,i,c){}
//…
virtual void ExtraInterface(){}
};
//编译选项:g++ -c 3-1-2.cpp
在代码清单3-2中,我们的基类A有很多的构造函数的版本,而继承于A的派生类B实际上只是添加了一个接口ExtraInterface。那么如果我们在构造B的时候想要拥有A这样多的构造方法的话,就必须一一“透传”各个接口。这无疑是相当不方便的。
事实上,在C++中已经有了一个好用的规则,就是如果派生类要使用基类的成员函数的话,可以通过using声明(using-declaration)来完成。我们可以看看下面这个例子,如代码清单3-3所示。
代码清单3-3
include <iostream>
using namespace std;
struct Base{
void f(double i){cout<<"Base:"<<i<<endl;}
};
struct Derived:Base{
using Base::f;
void f(int i){cout<<"Derived:"<<i<<endl;}
};
int main(){
Base b;
b.f(4.5);//Base:4.5
Derived d;
d.f(4.5);//Base:4.5
}
//编译选项:g++3-1-3.cpp
在代码清单3-3中,我们的基类Base和派生类Derived声明了同名的函数f,不过在派生类中的版本跟基类有所不同。派生类中的f函数接受int类型为参数,而基类中接受double类型的参数。这里我们使用了using声明,声明派生类Derived也使用基类版本的函数f。这样一来,派生类中实际就拥有了两个f函数的版本。可以看到,我们在main函数中分别定义了Base变量b和Derived变量d,并传入浮点字面常量4.5,结果都会调用到基类的接受double为参数的版本。
在C++11中,这个想法被扩展到了构造函数上。子类可以通过使用using声明来声明继承基类的构造函数。那我们要改造代码清单3-2所示的例子就非常容易了,如代码清单3-4所示。
代码清单3-4
struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//…
};
struct B:A{
using A::A;//继承构造函数
//…
virtual void ExtraInterface(){}
};
这里我们通过using A::A的声明,把基类中的构造函数悉数继承到派生类B中。这样我们在代码清单3-2中的“透传”构造函数就不再需要了。而且更为精巧的是,C++11标准继承构造函数被设计为跟派生类中的各种类默认函数(默认构造、析构、拷贝构造等)一样,是隐式声明的。这意味着如果一个继承构造函数不被相关代码使用,编译器不会为其产生真正的函数代码。这无疑比“透传”方案总是生成派生类的各种构造函数更加节省目标代码空间。
不过继承构造函数只会初始化基类中成员变量,对于派生类中的成员变量,则无能为力。不过配合我们2.7节中的类成员的初始化表达式,为派生类成员变量设定一个默认值还是没有问题的。
在代码清单3-5中我们就同时使用了继承构造函数和成员变量初始化两个C++11的特性。这样就可以解决一些继承构造函数无法初始化的派生类成员问题。如果这样仍然无法满足需求的话,程序员只能自己来实现一个构造函数,以达到基类和成员变量都能够初始化的目的。
代码清单3-5
struct A{
A(int i){}
A(double d,int i){}
A(float f,int i,const char*c){}
//…
};
struct B:A{
using A::A;
int d{0};
};
int main(){
B b(356);//b.d被初始化为0
}
有的时候,基类构造函数的参数会有默认值。对于继承构造函数来讲,参数的默认值是不会被继承的。事实上,默认值会导致基类产生多个构造函数的版本,这些函数版本都会被派生类继承。比如代码清单3-6所示的这个例子。
代码清单3-6
struct A{
A(int a=3,double=2.4){}
}
struct B:A{
using A::A;
};
可以看到,在代码清单3-6中,我们的基类的构造函数A(int a=3,double=2.4)有一个接受两个参数的构造函数,且两个参数均有默认值。那么A到底有多少个可能的构造函数的版本呢?
事实上,B可能从A中继承来的候选继承构造函数有如下一些:
❑A(int=3,double=2.4);这是使用两个参数的情况。
❑A(int=3);这是减掉一个参数的情况。
❑A(const A&);这是默认的复制构造函数。
❑A();这是不使用参数的情况。
相应地,B中的构造函数将会包括以下一些:
❑B(int,double);这是一个继承构造函数。
❑B(int);这是减少掉一个参数的继承构造函数。
❑B(const B&);这是复制构造函数,这不是继承来的。
❑B();这是不包含参数的默认构造函数。
可以看见,参数默认值会导致多个构造函数版本的产生,因此程序员在使用有参数默认值的构造函数的基类的时候,必须小心。
而有的时候,我们还会遇到继承构造函数“冲突”的情况。这通常发生在派生类拥有多个基类的时候。多个基类中的部分构造函数可能导致派生类中的继承构造函数的函数名、参数(有的时候,我们也称其为函数签名)都相同,那么继承类中的冲突的继承构造函数将导致不合法的派生类代码,如代码清单3-7所示。
代码清单3-7
struct A{A(int){}};
struct B{B(int){}};
struct C:A,B{
using A::A;
using B::B;
};
在代码清单3-7中,A和B的构造函数会导致C中重复定义相同类型的继承构造函数。这种情况下,可以通过显式定义继承类的冲突的构造函数,阻止隐式生成相应的继承构造函数来解决冲突。比如:
struct C:A,B{
using A::A;
using B::B;
C(int){}
};
其中的构造函数C(int)就很好地解决了代码清单3-7中继承构造函数的冲突问题。
另外我们还需要了解的一些规则是,如果基类的构造函数被声明为私有成员函数,或者派生类是从基类中虚继承的,那么就不能够在派生类中声明继承构造函数。此外,如果一旦使用了继承构造函数,编译器就不会再为派生类生成默认构造函数了,那么形如代码清单3-8中这样的情况,程序员就必须注意继承构造函数没有包含一个无参数的版本。在本例中,变量b的定义应该是不能够通过编译的。
代码清单3-8
struct A{A(int){}};
struct B:A{using A::A;};
B b;//B没有默认构造函数
在我们编写本书的时候,还没有编译器实现了继承构造函数这个特性,所以本节中代码清单3-4至代码清单3-8的例子都仅供读者参考,因为我们并没有实际编译过。但是编译器对继承构造函数的支持应该很快就要完成了,比如g++就计划在4.8版本中提供支持。可能本书出版的时候,读者就已经可以进行实验了。