专栏简介:本专栏主要面向C++初学者,解释C++的一些基本概念和基础语言特性,涉及C++标准库的用法,面向对象特性,泛型特性高级用法。通过使用标准库中定义的抽象设施,使你更加适应高级程序设计技术。希望对读者有帮助!
目录
- 7.5构造函数再探
- 构造函数初始值列表
- 构造函数的初始值有时必不可少
- 成员初始化的顺序
- 默认实参和构造函数
- 委托构造函数
- 默认构造函数的作用
- 使用默认构造函数
- 隐式的类类型转换
- 只允许一步类类型转换
- 类类型转换不是总有效
- 抑制构造函数定义的隐式转换
- explicit构造函数只能用于直接初始化
- 为转换显式地使用构造函数
- 标准库中含有显式构造函数的类
- 聚合类
- 字面值常量类
- constexpr构造函数
7.5构造函数再探
对于任何C++的类来说,构造函数都是其中重要的组成部分。本节继续介绍构造函数的其他功能,并对之前已经介绍的内容进行一些更深入的讨论。
构造函数初始值列表
当我们定义变量时习惯于立即对其进行初始化,而非先定义、再赋值:
string foo=“HelloNorld1“j//定义并初始化
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;
这段代码和我们在237页的原始定义效果是相同的:当构造函数完成后,数据成员的值相同。区别是原米的版本初始化了它的数据成员,而这个版本是对数据成员执行了赋值操作。这一区别到底会有什么深层次的影响完全依赖于数据成员的类型。
构造函数的初始值有时必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。例如:
class ConstRef{
public:
ConstRef(int ii);
private:int i;
const int c;
int& rii;
};
和其他常量对象或者引用一样,成员ci和ri都必须被初始化。因此,如果我们没有为它们提供构造函数初始值的话将引发错误:
//错误:ci和r必须被初始化
ConstRef::ConstRef(inti3)
{ //赋值,
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:
//未定义的:士在了之前被初始化
X(int val):j(val), i(j){}
在此例中,从构造函数初始值的形式上来看仿佛是先用val初始化了j,然后再用j初始化i。实际上,i先被初始化,因此这个初始值的效果是试图使用未定义的值j初始化i!有的编译器具备一项比较友好的功能,即当构造函数初始值列表中的数据成员顺序与这些成员声明的顺序不符时会生成一条警告信息。
最好令构造函数初始值的顺序与成员声明的顺序保持一致。而且如果可能的话,尽量避免使用树些成员初始化其他成员。
如果可能的话,最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其他成员。这样的好处是我们可以不必考虑成员的初始化顺序。例如,X的构造函敬如果写成如下的形式效果会更好:
X(int val);i(val),j(val){}
在这个版本中,i和j初始化的顺序就没什么影响了。
默认实参和构造函数
Sales_data默认构造函数的行为与只接受一个string实参的构造函数差不多。唯一的区别是接受string实参的构造函数使用这个实参初始化bookNo,而默认构造函数(隐式地)使用string的默认构造函数初始化bookNo。我们可以把它们重写成一个使用默认实参的构造函数:
class Sales_data{
public:
//定义默认构造函数,令其与只接变一个string实参的构造函数功能相同
Sales_data(std::string s8=""):bookNo(s){}
//其他构造函数与之前一致
Sales_data(std::string s,unsigned cnt,double rev):
bookNo(s),units_sold(cnt),revenue(revs*cnt){}
Sales_data(std::istream&is){ read(is,*this);}
//其他成员与之前的版本一致
};
当没有给定实参,或者给定了一个string实参时,两个版本的类创建了相同的对象。因为我们不提供实参也能调用上述的构造函数,所以该构造函数实际上为我们的类提供了默认构造函数。
值得注意的是,我们不应该为Sales_data接受三个实参的构造函数提供默认值。因为如果用户为售出书籍的数量提供了一个非零的值,则我们就会期望用户同时提供这些书籍的售出价格。
委托构造函数
C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数围(delegating constructor)。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其他构造函数。
和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个晶一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
举个例子,我们使用委托构造函数重写Sales_data类,重写后的形式如下所示:
class Sales_data{
public:
//非委托构造函数使用对应的实参初始化成员
Sales_data(std::string&,unsigned cnt,double price):
bookNo(s),units_sold(cnt),revenue(cnt*price){}
//其余构造函数全都委托给另一个构造函数
Sales_data():Sales_data("",0,0){}
Sales_data(std::string&):Sales_qata(s,0,0){}
Sales_data(std::istream&is):Sales_data()
{
read(is,*this);
//其他成员与之前的版本一致
}
}
在这个Sales_data类中,除了一个构造函数外其他的都委托了它们的工作。第一个构造函数接受三个实参,使用这些实参初始化数据成员,然后结束工作。我们定义默认构造函数令其使用三参数的构造函数完成初始化过程,它也无须执行其他任务,这一点从空的构造函数体能看得出来。接受一个stzing的构造函数同样委托给了三参数的版本。
接受istreamg的构造函数也是委托构造函数,它委托给了默认构造函数,默认构造函数又接着委托给三参数构造函数。当这些受委托的构造函数执行完后,接着执行istreamg构造函数体的内容。它的构造函数体调用read函数读取给定的istream。
当一个构造函数委托给另一个构造函数时,受委托的构造函数的初始值列表和函数体被依次执行。在Sales_data类中,受委托的构造函数体恰好是空的。假如函数体包含有代码的话,将先执行这些代码,然后控制权才会交还给委托者的函数体。
默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时。
值初始化在以下情况下发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
- 当我们不使用初始值定义一个局部静态变量时。
- 当我们通过书写形如“()的表达式显式地请求值初始化时,其中7是类型名(vector的一个构造函数兮接受一个实参用于说明vector大小),它就是使用一个这种形式的实参来对它的元素初始化器进行值初始化。
类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。
不那么明显的一种情况是类的某些数据成员缺少默认构造函数:
class NoDefault{
public:
NoDefault(conststd::string&);
//还有其他成员,但是没有其他构造函数了
}
struct A{
//黯认情况下mymem是public的
NoDefault my_mem;
};
A a;//错误:不能为合成构造函敏
struct B{
B(){} //错误:b_member没有初始值
NoDefault b_member;
}
在实际中,如果定义了其它构造函敷,那么最好也提供一个默认构造函数
使用默认构造函数
下面的obj的声明可以正常编译通过:
Sales_data obj(); // 正确:定义了一个函数而非对象
if(obj.isbn()== Primer_5th_ed.isbn()) //错误:obj是一个出数
但当我们试图使用obj时,编译器将报错,提示我们不能对函数使用成员访问运算符。问题在于,尽管我们想声明一个默认初始化的对象,obj实际的含义却是一个不接受任何参数的函数并且其返回值是Sales_data类型的对象。
如果想定义一个使用默认构造函数进行初始化的对象,正确的方法是去掉对象名之后的空的括号对:
正确:obj是个默认初始化的对象
Sales_data obj;
对于C++的新手程序员来说有一种常犯的错误,它们试图以如下的形式声明一个用默认构造函敏初始化的对象:
Sales_data obj(); //错误:声明了一个函数而非对象
Sales_data obj2; //正确:obj2是一个对象而非函数
隐式的类类型转换
C++语言在内置类型之间定义了几种自动转换规则。同样的,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。
能通过一个实参调用的构造函数定义了一条从构造函数的参数类型向类类型隐式转换的规则。
在Sales_data类中,接受string的构造函数和接受istream的构造函数分别定义了从这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,我们可以使用string或者istream作为替代:
string null_book ="9~999~99999~9";
//构造一个临肘的Sales_data对象
//该对象的units_sold和revenue等于0,bookNo等于null_book
tem.combine(nul1l_book);
在这里我们用一个string实参调用了Sales_data的combine成员。该调用是合法的,编译器用给定的string自动创建了一个Sales_data对象。新生成的这个(临时)Sales_data对象被传递给combine。因为combine的参数是一个常量引用,所以我们可以给该参数传递一个临时量。
只允许一步类类型转换
编译器只会自动地执行一步类型转换。例如,因为下面的代码隐式地使用了两种转换规则,所以它是错误的:
//错误:需要用户定义的两种转换:
//(1)把“9-999-99999-9“转换成string
//(2)再把这个(临时的)string转换成Sales_data
item.combine("9-999-99999-9");
如果我们想完成上述调用,可以显式地把字符串转换成string或者Sales_data对象:
//正确:显式地转换成string,隐式地转换成Sales_data
tem.combine(string("9-999-99999~9"));
//正确:隐式地转换成string,显式地转换成Sales_data
tem.combine(Sales_data("9~-999~99999~9"));
类类型转换不是总有效
是否需要从string到Sales_data的转换依赖于我们对用户使用该转换的看法。在此例中,这种转换可能是对的。null_book中的string可能表示了一个不存在的ISBN编号。
//另一个是从istream到Sales_data的转换:
// 使用 istream构造函数创建一个函数传递给combine
item.combine(cin);
这段代码隐式地把cin转换成Sales_data,这个转换执行了接受一个istream的Sales_data构造函数.该构造函数通过读取标准输入创建了一个(临时的)Sales_data对象,随后将得到的对象传递给combine。
Sales_data对象是个临时量,一旦combine完成我们就不能再访问它了。实际上,我们构建了一个对象,先将它的值加到item中,随后将其丢弃。
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:
class Sales_data{
public:
Sales_data()=default;
Sales_data(conststd::stringg&S,unsigned n,double p):
bookNo(s),units_sold(n),revenue(P*n){}
explicit Sales_data(conststd::string&s):bookNo(s){}
explicit Sales_data(std::istream&);
//其他成员与之前的版本一致
此时,没有任何构造函数能用于隐式地创建Sales_data对象,之前的两种用法都无法通过编译:
item.combine(null_book);//错误:string构造函数是explicit的
item.combine(cin);//错误:istream构造函数是explicit的
关键字explicit只对一个实参的构造函数有效。需要多个实参的构造函数不能用执行隐式转换,所以无须将这些构造函数指定为exp1icit的。只能在类内声明构造函数时使用explicit关键字,在类外部定义时不应重复:
//错误;explicit关键守只允许出现在类内的构造函数声明处
explicit Sales_data::Sales_data(istream& is)
{
read(ts,*this);
}
explicit构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用exp1icit构造函数:
Sales_data item1(nul1_book);//正确;直接初始化
//错误:不能将explicit构造函数用于指贝形式的初始化过程
Sales_data item2=null_book;
当我们用explicit关键字声明构造函数时,它将只能以直接初始化的形式使用(参见3.2.1节,第76页)。而且,编译器将不会在自动转换过程中使用该构造函数。
为转换显式地使用构造函数
尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:
//正确:实参是一卜显式构造的Salesdata对象
item.combine(Sales_data(null_book));
//正确:stattc_cast可以使用explicit的构造函数
item.combine(static_cast<Sales_data>(cin));
在第一个调用中,我们直接使用sales_data的构造函数,该调用通过接受string的构造函数创建了一个临时的Sales_data对象。在第二个调用中,我们使用static_cast执行了显式的而非隐式的转换。其中,static_cast使用istream构造丽数创建了一个临时的Sales_data对象。
标准库中含有显式构造函数的类
我们用过的一些标准库中的类含有单参数的构造函数:
- 接受一个单参数的const char*的string构造函数不是explicit的。
- 接受一个容量参数的vector构造函数是explicit的。
聚合类
聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public的。
- 没有定义任何构造函数。
- 没有类内初始值。
- 没有基类,也没有virtual函数。
例如,下面的类是一个聚合类:
struct Data{
int ival;
string s;
}
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化棣合类的数据成员:
```cpp
//vall.ival=0;val1.s=string(“Rnnan)
Data val1 = {0,"Rnnan"};
初始值的顺序必须与声明的顺序一致,也就是说,第一个成员的初始值要放在第一个,然后是第二个,以此类推。下面的例子是错误的:
//错误:不能使用“Rnna“初始化ival,也不能使用1024初始化s
Data val2={"Rnna",1024}
与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。值得注意的是,显式地初始化类的对象的成员存在三个明显的缺点:
- 要求类的所有成员都是public的。
- 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
- 添加或删除一个成员之后,所有的初始化语句都需要更新。
字面值常量类
中我们提到过constexpr函数的参数和返回值必须是宇面值类型。除了算术类型、引用和指针外,桅些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型。
- 类必须至少含有一个constexpr构造函数。
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于桅种类类型,则初始值必须使用成员自己的constexpr构造函数。
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
constexpr构造函数
尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。
constexpr构造函数可以声明成=default的形式。否则,constexpr构造函就必须即符合构造函数的要求(意味着不能包含返回语句),又符合constexpr有的唯一可执行语句就是返回语句。综合这两点可知,constexpr构造函数体一般来说应该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了:
class Debug{
public:
constexpr Debug(bool b = true):hw(b),io(b),other(b){}
constexpr Debug(boolh,bool,boolo): hw(h),io(i),other(o){}
constexpr bool any(){return hw || io || other;}
void set_io(bool b){ io = b;}
void set_hw(bool b){ hw = b;}
void set_other(bool b){ hw = b;}
private:
bool hw;//硬件错误,而非IO错误
bool io;//IO错误
bool other;//其他错误
}
constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式。
constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型:
constexpr Debug io_sub(false,true,false); //调试IO
if(io_sub.any()) //等价于if(true)
cerr<<"print appropriate error messages"<<endl;
constexpr Debug prod(false); //无调试
if(prod.any()) //等价于if(false)
cerr << "print an error message"<<endl;