1.构造函数
每个类分别定义来它的对象初始化方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫构造函数。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
构造函数的名字和类名相同。和其他函数不同的是,构造函数没有返回值;除此之外类似与其他函数,构造函数也有一个参数列表和一个函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
不同与其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。
合成的默认构造函数
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫默认构造函数,默认构造函数无须任何实参。
如果类没有显示的定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
编译器创建的这个构造函数又叫合成默认构造函数,将按照如下规则初始化类的数据成员:
如果存在类内的初始值,用它来初始化成员。
否则,默认初始化该成员。
某些类不能依赖于合成的默认构造函数
合成的默认构造函数只适合非常简单的类,对于一个普通的类来说,它必须定义自己的默认构造函数,原因有三:
1.编译器只有发现类不包含任何构造函数的情况下才会替我们生成一个默认构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。这条规则的依据是,如果一个类在某种情况下需要控制对象初始化,那么该类很可能在所有情况下都需要控制。
2.对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型的对象被默认初始化,则它的值将是未定义的。该准此同样适用于默认初始化的内置类型成员。因此含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户创建类的对象时可能得到未定义的值。
3.有的时候时候编译器不能为某些类合成默认构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类,我们必须自定义默认构造函数,否则类将没有可用的默认构造函数。
=default的含义
Test() = default;
首先声明一点,因为该构造函数不接受任何参数,所以它是一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。我们希望这个函数的作用完全等同于之前使用的合成默认构造函数。
在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面加上=default来要求编译器生成构造函数。其中,=default既可以和声明一起出现在类的内部,也可以定义出现在类的外部。和其他函数一样,如果=default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。
构造函数初始化列表
Test(const string &s): s1(s) {};
Test(const string &s, int a, int b): s1(s),a1(a) {};
这两个定义出现新的部分,即冒号以及冒号和花括号之间的代码,称为构造函数初始化列表。它负责为新创建的对象的一个或几个数据成员赋值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号扩起来(或者或花括号的)成员初始值。不同成员的初始值通过逗号分割开来。
构造函数的初始值有时必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化:
class ConstRef {
public:
ConstRef(int ii);
private:
int i;
const int cil
int &ri;
};
ConstRef::ConstRef(int i){
//赋值,不是初始化,初始化已经完成
i = ii; //正确
ci = ii; //错误,不能给const赋值
ri = i; //错误,ri没有被初始化
}
随着构造函数体一开始执行,初始化就完成了。我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值。因此该构造函数的正确形式应该是:
ConstRef::ConstRef(int i) : i(ii), ci(ii), ri(i) { }
在很多类中,初始化和赋值的区别事关底层效率问题:前者直接初始化数据成员,后者则先初始化再赋值。
除了效率问题外更重要的是,一些数据成员必须被初始化。建议养成使用构造函数初始值的习惯,这样能避免一些意想不到的编译错误,特别是含有需要构造函数初始值的成员时。
成员初始化的顺序
构造函数初始值列表只说明用于初始化成员的值,而不限定初始化的具体执行顺序。成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
一般来说,初始化的顺序没什么特别要求。不过如果一个成员是用另一个成员来初始化的,那么这两个成员的初始化顺序就很关键了:
class x {
int i;
int j;
public:
//未定义的:i在j之前被初始化
x(int val): j(val), i(j) { }
}
最好令构造函数初始值的顺序与成员声明的顺序保持一致,而且如果可能的话,尽量避免使用某些成员初始化其他成员。
委托构造函数
C++11新标准扩展了构造函数初始值的功能,使得我们可以定义所谓的委托构造函数。一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些或全部职责委托给了其他构造函数。
和其他构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数内,成员初始值列表只有一个唯一的入口,就是类名本身。和其他成员初始值一样,类名后面紧跟圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
class Test {
public:
Test(string a, int b, double c):a1(a), b1(b), c1(c) { }
Test(): Test("", 0, 0) { }
Test(string a): Test(a, 0, 0) { }
Test(long d): Test() { }
};
2.友元
类可以允许其他类型或者函数访问它的非公有成员,方法是令其他类或者函数称为它的友元。如果类想把一个函数声明成它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:
class Sales_data{
friend Sales_data add(const Sales_data &, const Sales_data &);
friend std::istream &read(std::istream &, Sales_data &);
friend std::ostream &print(std::ostream &, const Sales_data &);
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s),units_sold(n),revenue(p*n) { }
private:
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。一般来说,最好在类定义开始或结束前的位置几种声明友元。
类与友元之间的关系
某个类如果想访问另一个类的私有函数,则可以把这个类指定成它的友元:
class A {
friend class B;
private:
int i = 0;
};
class B {
public:
void test(){ printf("a.i=%d\n", a.i);}
private:
A a{};
};
友元的关系不存在传递性,也就是说,如果B有它自己的友元,则这些友元不能理所当然地具有访问A的特权。
令成员函数成为友元
除了令整个类成为友元,还可以只声明某个成员函数为友元:
class A;
class B {
public:
void test();
private:
A a{};
};
class A {
friend void A::test();
private:
int i = 0;
};
void B::test()
{
printf("a.i=%d\n", a.i);
}
要想令某个成员函数成员友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中:
首先定义B类,其中声明test函数,但是不定义它。在test函数使用A的成员之前必须先声明A。
接着定义A,包括对test的友元声明。
最好定义test,此时才可以使用A的私有成员。
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。
甚至就算类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句换说,即使我们仅仅是使用声明友元的类的成员调用该友元的函数,它也必须是被声明过的:
struct{
friend void f(){ } //友元函数可以定义在类的内部
X() { f(); } //错误,f还没有被声明
void g();
void h();
};
void X::g() {return f();} //错误,f还没有被声明
void f(); //声明那个定义在X中的函数
void X::h(){ return f();} //正确:现在f的声明在作用域中了
关于这段代码,最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意思上的声明。
3类的作用域
每个类都会有自己的作用域,在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员运算符来访问。对于类类型成员则使用作用域运算访问符。不论哪种情况,跟在运算符之后的名字都必须是类的成员。
一个类就是一个作用域的事实能够很好的解释为什么当我们在类的外部定义成员函数时必须同时提供类名和函数名。在类的外部,成员的名字被隐藏起来了。
一旦遇到类名,定义剩余的部分就在类的作用域内了,剩余的部分包括列表和函数体。结果就是我们可以直接使用类的其他成员而无须再次授权。
另一方面,函数的返回类型通常出现在函数名之前。因此当成员函数定义在类的外部时,返回类型使用的名字都位于类的作用域之外。这时,返回类型必须指明它是哪个类的成员。
名字查找与类的作用域
名字查找的过程如下:
首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
如果没有找到,继续查找外层作用域。
如果最终没有找到匹配的声明,则程序报错。
对于定义在类内部的成员函数来说,解析其中的名字的方式与上述的查找规则有所区别,不过在当前的这个例子中体现得不太明显。类的定义分两步处理:
首先,编译成员的声明。
直到类全部课件后才编译函数体。
按照这种两阶段的方式处理类可以简化代码的组织方式。因为成员函数体直到整个类可见后才会被处理,所以它能使用类中定义的任何名字。相反,如果函数的定义和成员的声明被同时处理,那么我们将不得不在成员函数中只使用那些已经出现的名字。
用于类成员声明的名字查找
这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找:
typedef double Money;
string bal;
class Account {
public:
Money balance() { return bal; }
private:
Money bal;
};
当编译器看到balance函数的声明语句时,它将在Account类的范围内寻找对Money的声明。编译器只考虑Account中在使用Money前出现的声明,因为没有找到匹配的成员,所以编译器会接着到Account的外层作用域中查找。在这个例子中,编译器会找到Money的typedef语句,该类型被用作balance函数的返回类型以及数据成员bal的类型。另一方面,balance函数体在整个类可见后才被处理,因此,该函数的return语句返回名为bal的成员,而非外层作用域的string对象。
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:
typedef double Money;
class Account {
public:
Money balance() { return bal; } //使用外层作用域的Money
private:
typedef double Money; //错误,不能重新定义Money
Money bal;
};
尽管重新定义类型名字是一种错误的行为,但是编译器并不为此负责。一些编译器仍然顺利通过这样的代码,而忽略代码有错的事实。
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
如果类内也没有找到该名字的声明,在成员函数定义之前的作用域内继续查找。
一般来说,不建议使用其他成员的名字作为某个函数的参数,不过为了更好地解释名字的解析过程,我们不妨在dummy_fcn函数中暂时违反一下这个约定:
int height;
class Screen {
public:
typedef std::string::size_type pos;
void dummy_fcn(pos hegiht){
cursor = width*height; //height是个参数
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};
当编译器处理dummy_fcn中的乘法表达式时,它首先在函数作用域内查找表达式中用到的名字。函数的参数位于函数作用域内,因此dummy_fcn函数体内用到的名字height指的是参数声明。
在此例中,height参数隐藏了同名的成员。如果想绕开上面的查找规则,应该将代码变为:
//不建议的写法:成员函数中的名字不应该隐藏同名的成员
void Screen::dummy_fcn(pos height) {
cursor = width * this->hegiht; //成员height
//另外一种表示该成员的方式
cursor = width * Screen::height; //成员height
}
其实最好的确保我们使用height成员的方法是给参数起个其他名字:
//建议写法:不要把成员名字作为参数或其他局部变量使用
void Screen::dummy_fcn(pos ht) {
cursor = width * hegiht; //成员height
}
类作用域之后,在外面的作用域中查找
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。在我们的例子中,名字height定义在外层作用域中,且位于Screen的定义之前。然而,外层作用域的对象被名为height 的成员隐藏掉了。因此,如果我们需要的是外层作用域中的名字,可以显示地通过作用域运算符来进行请求:
//不建议的写法:不要隐藏外层作用域中可能被用到的名字
void Screen::dummy_fcn(pos height) {
cursor = width * ::height; //外层的height
}
在文件中名字的出现出对其进行解析
当成员定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还需要考虑在函数定义之前的全局作用域中的声明:
int height;
class Screen {
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height = 0; //隐藏了外层作用域中的height
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var) {
//var:参数
//height:类的成员
//verify:全局函数
height = verify(var);
}
全局函数verify的声明在Screen类的定义之前是不可见的。然而,名字查找的第三步包括了成员函数出现之前的全局作用域。在此例中,verifty的声明位于setHeight的定义之前,因此可以被正常使用。
4.类的其他特性
可变数据成员
有时我们希望修改一个的某个数据成员,即使是一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到。
一个可变数据成员永远不会是const,即使它是const对象的成员。因此一个const成员函数可以改变一个可变的值:
class A {
public:
void test() const;
private:
mutable int i = 0;
};
void A::test() const
{
i = 1; //尽管test是一个const成员函数,但是仍然可以改变i的值
}
类数据成员的初始化
C++11新标准提供类内初始化,类内初始化必须使用=或者花括号的形式初始化。