第七章 类
在C++语言中,我们使用类定义自己的数据类型。通过定义新的类型来反映待解决问题中的各种概念。可以使我们更容易编写、调试和修改程序。
类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程:使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解工作细节。
7.1 定义抽象数据类型
7.1.1 设计Sales_data类
Sales_data的接口应该包含以下操作:
- 一个isbn成员函数,用于返回对象的ISBN编号
- 一个combine成员函数,用于将一个Sales_data对象加到另一个对象上
- 一个名为add的函数,执行两个Sales_data对象的加法
- 一个read函数,将数据从istream读入到Sales_data对象中
- 一个print函数,将Sales_data对象的值输出到ostream
使用改进的Sales_data类
Sales_data total;
if(read(cin, total)){
Sales_data trans;
while(read(cin, trans)){
if(total.isbn() == trans.isbn()){
total.combine(trans);
}else{
print(cout, total) << endl;
total = trans;
}
}
}
定义改进的Sales_data类
struct Sales_data{
std::string isbn()const{return bookNo;}
Sales_data combine(const Sales_data&);
double avg_price()const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// 非成员接口函数
Sales_data_add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
定义成员函数
尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。定义在类内部的函数是隐式的inline函数。
引入this
成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。
total.isbn();
// 伪代码
Sales_data::isbn(&total); // isbn();
因为this的目的总是指向“这个”对象,所以this是一个常量指针。
引入const成员函数
isbn函数的另外一个关键之处是紧随参数列表之后的const关键字,这里,const的作用是修改隐式this指针的类型。
C++语言允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称为常量成员函数(const member function)。
// 伪代码
std::string Sales_data::isbn(const Sales_data *const this){return this->isbn;}
类作用域和成员函数
编译器分两步处理类:首先编译成员的声明,然后才轮到成员的函数体(如果有的话)。因此,成员函数体可以随意使用类中的其它成员而无须在意这些成员出现的次序。
在类的外部定义成员函数
double Sales_data::avg_prices()const{
if(units_sold){
return revenue / units_sold;
}else{
return 0;
}
}
定义一个返回this对象的函数
Sales_data &Sales_data::combine(const Sales_data &rhs){
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
7.1.3 定义类相关的非成员函数
我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明与定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。
定义read和print函数
istream &read(istream &is, Sales_data &item){
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
ostream &print(ostream &os, const Sales_data &item){
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price;
return os;
}
因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。
定义add函数
Sales_data add(const Sales_data &lhs, const Sales_data &rhs){
Sales_data sum = lhs;
sum.combine(rhs);
return sum;
}
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
不同与其他成员函数,构造函数不能被声明为const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其"常量"属性。因此,构造函数在const对象的构造过程中可以向其写值。
合成的默认构造函数
类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫默认构造函数(default constructor)。默认构造函数无须任何实参。如果类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
编译器创建的构造函数又称为合成默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员。
- 否则,默认初始化该成员。
某些类不能依赖于合成的默认构造函数
对于一个普通的类来说,必须定义它自己的默认构造函数。有三个原因
- 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。
- 对于某些类来说,合成的默认构造函数可能执行错误的操作。如果类包含有内置类型或者符合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适用于使用合成的默认构造函数。
- 有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。
定义Sales_data的构造函数
struct Sales_data{
Sales_data() = default;
Sales_data(const std::string &s):bookNo(s){}
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n){}
Sales_data(std::istream &);
std::string isbn()const{return bookNo;}
Sales_data combine(const Sales_data&);
double avg_price()const;
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
}
=default的含义
Sales_data() = default;
在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上=default来要求编译器生成构造函数。其中,=default既可以和声明在一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果=default在类的内部,则默认构造函数是内联的;如果在类的外部,则该成员函数默认不是内联的。
构造函数初始值列表
Sales_data(const std::string &s):bookNo(s){}
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n){}
构造函数初始值列表(constructor initialize list)负责为新创建对象的一个或几个数据成员赋初值。
如果编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
在类的外部定义构造函数
Sales_data::Sales_data(std::istream &is){
read(is, *this);
}
7.1.5 拷贝、赋值和析构
当我们使用赋值运算符时会发生对象的赋值操作。当对象不再存在时执行销毁操作。如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员进行拷贝、赋值和销毁操作。
total = trans;
等价于
total.bookNo = trans.bookNo;
total.units_sold = trans_units_sold;
total.revenue = trans.revenue;
某些类不能依赖于合成的版本
当类需要分配对象之外的资源时,合成的版本常常会失效。
7.2 访问控制与封装
在C++语言中,我们使用访问说明符(access specifiers)加强类的封装性。
- 定义在public说明符之后的成员在整个程序内可被访问,public成员定义类的接口。
- 定义在private说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。
class Sales_data{
public:
Sales_data() = default;
Sales_data(const std::string &s):bookNo(s){}
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n){}
Sales_data(std::istream &);
std::string isbn()const{return bookNo;}
Sales_data combine(const Sales_data&);
private:
double avg_price()const
{return units_sold ? revenue/units_sold : 0;}
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
}
一个类可以包含0个多多个说明符。而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者达到类的尾处为止。
使用class或struct关键字
使用class和struct定义类的唯一的区别就是默认访问权限。出于同一的编程风格考虑,当我们希望定义的类的所有成员是public的时,使用struct;反之,如果希望成员是private的,使用class。
7.2.1 友元
类允许其他类或者函数访问它的公有成员,方法是令其他类或者函数成为它的友元(friend)。如果类想把一个函数作为它的友元,只需要增加一条以friend关键字开始的函数声明语句即可:
class Sales_data{
// 友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&);
friend std::istream &read(std::istream&, Sales_data&);
public:
Sales_data() = default;
Sales_data(const std::string &s):bookNo(s){}
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p*n){}
Sales_data(std::istream &);
std::string isbn()const{return bookNo;}
Sales_data combine(const Sales_data&);
private:
double avg_price()const
{return units_sold ? revenue/units_sold : 0;}
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
}
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istream &read(std::istream&, Sales_data&);
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。友元不是类的成员也不受它所在区域访问控制级别的约束。
一般来说,最好定义在类开始或结束前的位置集中声明友元
封装的益处
- 确保用户代码不会无意间破坏封装对象的状态
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。如果我们希望类的用户能够调用某个友元函数,那么我们就必须在友元声明之外再专门对函数进行一次声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置再同一个头文件中。
许多编译器并未强制限定友元函数必须在使用之前在类的外部声明
7.3 类的其他特性
7.3.1 类成员再探
定义一个类型成员
除了定义数据和函数成员之外,类还可以定义某种类型在类中的别名。由类定义的类型名字和其它成员一样存在访问限制,可以是public或者private中的一种。
class Screen{
public:
typedef std::string::size_type pos;
// using pos = std::string::size_type;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string counters
}
用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别,因此,类型成员通常出现在类开始的地方。
Screen类的成员函数
class Screen{
public:
typedef std::string::size_type pos;
Screen() = default;
Screen(pos ht, pos wd, char c): height(ht), width(wd), contents(ht * wd, c){}
char get() const{return contents[cursor];}
inline char get(pos ht, pos wd)const;
Screen &move(pos r, pos c);
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string counters
};
令成员作为内联函数
inline Screen &Screen::move(pos r, pos c){ // 在函数定义处指定inline
pos row = r * width;
cursor = row + c;
return *this;
}
char Screen::get(pos r, pos c)const{
pos row = r * width;
return contents[row + c];
}
虽然我们无须在声明和定义的地方同时说明inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline,这样可以使类更容易理解。
重载成员函数
和非成员函数一样,成员函数也可以重载,只要函数之间在参数的数量和/或类型上有所区别就行。成员函数的函数匹配过程同样与非成员函数非常类似。
可变数据成员
有时会发生这样一种情况,我们希望能修改类的某个数据成员,即便是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字达到这一点。
一个可变数据成员(mutable data member)永远不会是const,即使它是const对象成员。因此一个const成员函数可以改变一个可变成员的值。
class Screen{
public:
void some_member()const;
private:
mutable size_t access_ctr;
};
void Screen::some_member()const{
++ access_ctr;
}
类数据成员的初始值
class Window_mgr{
private:
// 默认情况下,一个Window_mgr包含一个标准尺寸的恐怖Screen
std::vector<Screen> screens{Screen(24, 80, ' ')};
}
当我们提供一个类内初始值时,必须以符号=或者花括号表示。
7.3.2 返回*this的成员函数
class Screen{
public:
Screen &set(char);
Screen &set(pos, pos, char);
};
inline Screen& Screen::set(char c){
content[cursor] = c;
return *this;
}
inline Screen& Screen::set(pos r, pos col, pos ch){
content[r * width + col] = ch;
return *this;
}
myScreen.move(4, 0).set('#');
假设返回值的类型是Screen,则在返回值上的操作是在副本上的操作,而不能改变myScreen的值。
从const成员函数返回*this
逻辑上说,display成员函数不需要改变对象的内容,因此我们令display为const成员。此时this变成指向const的指针,返回类型应该为const Screen&,因此我们不能把display嵌入到一组动作序列中去:
Screen myScreen;
myScreen.display(cout).set('*'); // 错误
基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const而重载函数的原因差不多。具体说来,因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。
class Screen{
public:
Screen &display(std::ostream &os){
do_display(os);
return *this;
}
const Screen &display(std::ostream &os)const{
do_display(os);
return *this;
}
private:
void do_display(std::ostream &os)const{
os << content;
}
};
Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // 调用非常量版本
blank.display(cout); // 调用常量版本
7.3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。
struct First{
int memi;
int getMem();
};
struct Second{
int memi;
int getMem();
};
First obj1;
Second obj2 = obj1; // 错误
我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我们也可以把类名跟在关键字class或struct后面:
Sales_data item1;
class Sales_data item1; // 两条等价的声明
类的声明
就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:
class Screen; // Screen类的声明
这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或返回类型的函数。
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明,否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须先被定义,然后才能引用或者指针访问其成员。毕竟,如果类未定义,编译器也就不清楚该类到底有哪些成员。
7.3.4 友元再探
类之间的友元关系
class Screen{
// Window_mgr的成员可以访问Screen类的私有部分
friend class Window_mgr;
}
如果一个类指定了友元类,则友元的成员函数可以访问此类包括非公有成员在内的所有成员。
class Window_mgr{
public:
using ScreenIndex = std::vector<Screen>::size_type;
void clear(ScreenIndex);
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
void Window_mgr::clear(ScreenIndex i){
Screen &s = screen[i];
s.contents = string(s.height * s.width, ' ');
}
友元不具有传递性
命令成员函数为友元
class Screen{
// Window_mgr::clear必须在Screen类之前被声明
friend void Window_mgr::clear(ScreenIndex);
};
在这个例子中,我们必须按照如下方式设计程序:
- 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen。
- 接下来定义Screen,包括对于clear的友元声明。
- 最后定义clear,此时它才可以使用Screen的成员。
函数重载和友元
尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明:
extern std::ostream& storeOn(std::ostream&, Screen &);
extern BitMap& storeOn(BitMap &, Screen &);
class Screen{
friend std::ostream& storeOn(std::ostream&, Screen &);
friend BitMap& storeOn(BitMap &, Screen &);
}
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而友元本身不一定真的声明在当前作用域中。
甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。
struct X{
friend void f(){/* 友元函数可以定义在类的内部*/}
X(){f();} // 错误:f还没有被声明
void g();
void h();
};
void X::g(){return f();} // 错误:f还没有被声明
void f(); // f声明
void X::h(){return f();} // 正确
7.4 类的作用域
每个类都会定义它自己的作用域。在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符运算。
Screen::pos ht = 24, wd = 80;
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get();
c = p->get();
作用域和定义在类外部的成员
void Window_mgr::clear(ScreenIndex i){
Screen &s = screens[i];
s.content = string(s.height * s.width, ' ');
}
因为编译器在处理参数列表之前已经明确我们当前正位于Window_mgr类的作用域中,所以不必再专门说明ScreenIndex是Window_mgr类定义的。另一方面,返回类型通常出现在函数名之前,因此当成员定义在类的外部时,返回类型中使用的名字都位于类的作用域之外。此时,返回类型必须指明它是哪个类的成员。
class Window_mgr{
public:
ScreenIndex addScreen(const Screen&);
};
Window_mgr::ScreenIndex Window_mgr::addScreen(const Screen &s){
screens.push_back(s);
return screens.size() - 1;
}
7.4.1 名字查找与类的作用域
在目前为止,我们编写的程序中,**名字查找(**name lookup)的过程比较直接了断:
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
- 如果没找到,继续查找外层作用域。
- 如果最终没找到匹配的声明,则程序报错。
对于定义在类内部的成员函数来说,解析其中名字的方式与上述的查找规则有所区别,不过在当前的这个例子中体现地不太明显,类的定义分两步处理:
- 首先,编译成员的声明
- 直到类全部可见后才编译函数体。
编译器处理完类中的全部声明后才会处理成员函数的定义
用于类成员声明的名字查找
这种两阶段的处理方式只适用于成员函数中使用的名字。声明中使用的名字,包括返回类型或者参数列表中使用的名字,都必须在使用前确保可见。如果某个成员的声明使用了类中尚未出现的名字,则编译器将会在定义该类的作用域中继续查找。
typedef double Money;
string bal;
class Account{
public:
Money balance(){return bal;}
private:
Money bal;
}
当编译器看到balance函数的声明语句时,它将在Account类的范围内寻找对Money的声明。编译器只考虑Account中在使用Money前出现的声明,因为没找到匹配的成员,所以编译器会接着到Account的外层作用域中查找。另一方面,balance函数体在整个类可见后才被处理,因此,该函数的return语句返回名为val的成员,而非外层作用域的string对象。
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字。
typedef double Money;
class Account{
public:
Money balance(){return bal;} // 外层定义的Money
private:
typedef double Money; // 错误,重复定义Money
Money bal;
// ...
};
即使Account中定义的Money类型与外层作用域一致,上述代码仍然是错误的。
尽管重新定义类型名字是一种错误的行为,但编译器并不为此负责。一些编译器将顺利通过这样的代码,而忽略代码有错的事实。
类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后
成员定义中的普通块作用域的名字查找
成员函数中使用的名字按照如下方式解析:
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没有找到该名字的声明,在成员函数定义之前的块作用域内继续查找。
一般来说,不建议使用其他成员的名字作为某个成员函数的参数。
// 这不是一段很好的代码
int height;
class Screen{
public:
typedef std::string::size_type pos;
void dummy_fcn(pos height){
cursor = width * height; // height指的是函数参数的height
// cursor = width * this->height;
// cursor = width * Screen::height;
}
private:
pos cursor = 0;
pos height = 0, width = 0;
};
类作用域之后,在外围的作用域中查找
如果编译器在函数和类的作用域中都没有找到名字,它将接着在外围的作用域中查找。
// 不建议的写法:不要隐藏外层作用域中可能被用到的名字
void Screen::dummy_fcn(pos height){
cursor = width * ::height;
}
在文件中名字出现处对其进行解析
当成员在定义在类的外部时,名字查找的第三步不仅要考虑类定义之前的全局作用域中的声明,还要考虑在成员函数定义之前的全局作用域中的声明。
int height;
class Screen{
public:
typedef std::string::size_type pos;
void setHeight(pos);
pos height = 0;
};
Screen::pos verify(Screen::pos);
void Screen::setHeight(pos var){
height = veriy(var);
}
全局函数verify的声明在Screen类定义之前是不可见的。然而,名字的第三步包括了成员函数出现之前的全局作用域,在此例中,verify的声明位于setHeight的定义之前,因此可以正常被使用。
7. 5 构造函数再探
7.5.1 构造函数初始值列表
构造函数的初始值有时必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但是并非总是这样。如果成员是const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。
class ConstRef{
public:
ConstRef(int ii);
private:
int i;
const int ci;
int &ri;
};
// 错误:ci和ri必须被初始化
ConstRef::ConstRef(int ii){
i = ii;
ci = ii; // 错误
ri = i; // 错误
}
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(ii){}
成员初始化的顺序
成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。
class X{
int i;
int j;
public:
X(int val): j(val), i(val){}// 先初始化i,然后j
};
如果可能的话,最好用构造函数的参数作为成员的初始值,而尽量避免使用同一个对象的其它成员。这样的好处使我们可以不必考虑成员的初始化顺序。
默认实参和构造函数
class Sales_data{
public:
// 定义默认构造函数,令其与只接受一个string实参的构造函数功能相同
Sales_data(std::string s = ""): bookNo(s){}
// 其它构造函数与之前一致
Sales_data(std::string s, unsigned cnt, double rev):
bookNo(s), units_sold(cnt), revenue(rev * cnt){}
Sales_data(std::istream &is){read(is, *this);}
// 其它成员与之前的版本一致
};
如果一个构造函数为所有参数提供了默认实参,则它实际上也定义了默认构造函数。
7.5.2 委托构造函数
C++11 新标准扩展了构造函数初始值的功能 ,使得我们可以定义所谓的委托构造函数(delegating constructor)。一个委托构造函数使用它所属类的其它构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部)职责委托给了其它构造函数。
和其它构造函数一样,一个委托构造函数也有一个成员初始值的列表和一个函数体。在委托构造函数之内,成员初始值列表只有一个唯一的入口,就是类名本身。和其它成员初始值一样,类名后面紧跟着圆括号括起来的参数列表,参数列表必须与类中另外一个构造函数匹配。
class Sales_data{
public:
// 非委托构造函数使用对应的实参初始化成员
Sales_data(std::string s, unsigned cnt, double rev):
bookNo(s), units_sold(cnt), revenue(rev * cnt){}
// 其余构造函数全部都委托给另一个构造函数
Sales_data(): Sales_data("", 0, 0){}
Sales_data(std::string s): Sales_data(s, 0, 0){}
Sales_data(std::istream &is): Sales_data(){read(is, *this);}
};
7.5.3 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时。
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时。
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时。
值初始化在以下情况下发生:
- 在数组初始化的过程中如果我们提供的初始值数量少于数组的大小时。
- 当我们不使用初始值定义一个局部静态变量时。
- 当我们通过书写形如T()的表达式显式地请求值初始化时,其中T是类型名。
类必须包含一个默认构造函数以便在上述情况下使用,其中的大多数情况非常容易判断。不那么明显的一种情况是类的某些数据成员缺少默认构造函数:
class NoDefault{
public:
NoDefault(const std::string&);
// 还有其它成员,但是没有其它构造函数了
};
struct A{
NoDefault my_mem;
};
A a; // 错误:不能为A合成构造函数
struct B{
B(); // 错误:b_mem没有初始值
NoDefault b_mem;
};
使用默认构造函数
Sales_Data obj(); // 编译正确,obj是一个返回Sales_data的函数
Sales_data obj1; // obj1是一个对象,调用默认构造函数
7.5.4 隐式的类类型转换
如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制,有时我们把这种构造函数称作转换构造函数(converting constructor)。
在Sales_data类中,接受string的构造函数和接受istream的构造函数分别定义了这两种类型向Sales_data隐式转换的规则。也就是说,在需要使用Sales_data的地方,我们可以使用string或者istream作为替代:
string null_book = "9-999-99999-9";
item.combine(null_book); // item.combine(Sales_data(null_book));
在这里我们用一个string实参调用了Sales_data的combine成员。这调用是合法的,编译器用给定
的string自动创建了一个Sales_data对象。新生成的这个(临时)Sales_data对象被传递给combine。因为combine的参数是一个常量引用,所以我们可以给该参数传递一个临时量。
只允许一步类类型转换
// 错误,两次隐式转换
item.combine("9-999-99999-9");
// 正确,一次显式转换一次隐式转换
item.combine(string("9-999-99999-9"));
// 正确,一次隐式转换一次显式转换
item.combine(Sales_data("9-999-99999-9"));
类类型转换不总是有效
是否需要从string到Sales_data的转换依赖于我们对用户使用该转换的看法。在此例中,这种转换可能是对的。
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit加以阻止:
class Sales_data{
public:
Sales_data() = default;
Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p * n){}
explicit Sales_data(const std::string &s): bookNo(s){}
explicit Sales_data(std::istream);
};
item.combine(null_book); // 错误
item.combine(cin); // 错误
关键字explicit支队一个实参的构造函数有效。需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为explicit的。只能在类内声明函数时使用explicit关键字,在类外部定义时不应重复:
// 错误
explicit Sales_data::Sales_data(istream &is){
read(is, *this);
}
explicit构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用explicit构造函数:
Sales_data item1(null_book); // 正确
Sales_data item2 = null_book; // 错误
为转换显式地使用构造函数
尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:
item.combine(Sales_data(null_book)); // 正确
item.combine(static_cast<Sales_data>(cin)); // staic_cast可以使用explicit的构造函数
标准库中含有显式构造函数的类
- 接受一个单单参数的const char*的string构造函数不是explicit的
- 接受一个容量参数的vector构造函数是explicit的。
7.5.4 聚合类
聚合类(aggregate class)使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public 的
- 没有定义任何构造函数
- 没有类内初始值
- 没有基类,也没有virtual函数
struct Data{
int ival;
string s;
};
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员:
Data val1 = {0, "Anna"};
与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,则靠后的成员被值初始化。初始值列表的元素个数绝对不能超过类的成员数量。
值得注意的是,显式地初始化类对象的成员存在三个明显的缺点:
- 要求类的所有成员都是public的
- 将正确初始化每个对象的成员的重任交给了类的用户(而非类的作者)。因为用户很容易忘掉某个初始值,或者提供一个不恰当的初始值,所以这样的初始化过程冗长乏味且容易出错。
- 添加或删除一个类成员之后,所有的初始化语句都要更新。
7.5.6 字面值常量类
除了算术类型、引用和指针外,某些类也是字面值类型。和其它类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。
数据成员都是 字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量:
- 数据成员都必须是字面值类型。
- 类必须至少含有一个constexpr构造函数
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象。
constexpr构造函数
尽管构造函数不能是const的,但是字面值常量类的构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。
constexpr构造函数可以声明成=default的形式或者删除函数的形式。否则constexpr构造函数就必须既符合构造函数的要求(意味着不能包含返回语句),又符合constexpr函数的要求(意味着它能拥有的唯一可执行语句就是返回语句)。综合这两点克制,constexpr构造函数体一般来说以该是空的。我们通过前置关键字constexpr就可以声明一个constexpr构造函数了:
class Debug{
public:
constexpr Debug(boo b = true): hw(b), io(b), other(b){}
constexpr Debug(bool h, bool i, bool o): 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){other = b;}
private:
bool hw;
bool io;
bool other;
};
constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数或者是一条常量表达式。
constexpr构造函数用于生成constexpr对象以及constexpr函数的参数或返回类型:
constexpr Debug io_sub(false, true, false);
if(io_sub.any())
cerr << "print approprivate error messagees" << endl;
constexpr Debug prod(false);
if(prod.any())
cerr << "print an error message" << endl;
7.6 类的静态成员
有的时候类需要它的一些成员与类本身直接相关,而不是与类的各个对象保持关联。
声明静态成员
我们通过在成员的声明之前加上关键字static使得其与类关联在一起。和其它成员一样,类静态成员可以是public的或private的。静态数据成员的类型可以是常量、引用、指针、类类型等。
class Account{
public:
void calculate(){amount += amount * interestRate;}
static double rate(){return interestRate;}
static void rate(double);
private:
std::string owner;
double amount;
static double interestRate;
static double initRate();
};
静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果。静态成员函数不能声明成const的,而且我们也不能在static函数体内使用this指针。这一限制既适用于this的显式调用,也对调用非静态成员的隐式使用有效。
使用类的静态成员
使用作用域运算符直接访问静态成员
double r;
r = Account::rate();
虽然静态成员不属于类的某个对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:
Account ac1;
Account *ac2 = &ac1;
r = ac1.rate();
r = ac2->rate();
成员函数不通过作用域运算符就能直接使用静态成员:
class Account{
public:
void calculate(){amount += amount * interestRate;}
private:
static double interestRate;
};
定义静态成员
和其他的成员函数一样,我们既可以在类的内部也可以在类的外部定义静态成员函数。当在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句:
void Account::rate(double newRate){
interestRate = newRate;
}
因为静态数据成员不属于类的任何一个对象,所以它们不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其它对象一样,一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类内初始化。然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员 必须是字面值常量类型constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合常量表达式的地方。
class Account{
public:
static double rate(){return interestRate;}
static void rate(double);
private:
staic constexpr int period = 30;
double daily_tbl(period);
};
如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的const或constexpr static不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。
静态成员能用于某些场景,而普通成员不能
静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类型关系。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
class Bar{
public:
// ...
private:
static Bar mem1; // 正确:静态成员可以是不完全类型
Bar *mem2; // 正确:指针成员可以是不完全类型
Bar mem3; // 错误,数据成员必须是完全类型
};
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参:
class Screen{
public:
Screen& clear(char = bkground);
private:
static const char bkground;
};
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法提供一个对象以便从中获取成员的值,最终引发错误。
一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类内初始化。然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员 必须是字面值常量类型constexpr。初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合常量表达式的地方。
class Account{
public:
static double rate(){return interestRate;}
static void rate(double);
private:
staic constexpr int period = 30;
double daily_tbl(period);
};
如果某个静态成员的应用场景仅限于编译器可以替换它的值的情况,则一个初始化的const或constexpr static不需要分别定义。相反,如果我们将它用于值不能替换的场景中,则该成员必须有一条定义语句。
静态成员能用于某些场景,而普通成员不能
静态数据成员可以是不完全类型。特别的,静态数据成员的类型可以就是它所属的类型关系。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
class Bar{
public:
// ...
private:
static Bar mem1; // 正确:静态成员可以是不完全类型
Bar *mem2; // 正确:指针成员可以是不完全类型
Bar mem3; // 错误,数据成员必须是完全类型
};
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参:
class Screen{
public:
Screen& clear(char = bkground);
private:
static const char bkground;
};
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分,这么做的结果是无法提供一个对象以便从中获取成员的值,最终引发错误。