类的基本思想是数据抽象和封装。
- 数据抽象是一种依赖于接口和实现分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数
- 封装实现了累的接口和实现的分离。封装后的类隐藏了他的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无需考虑类型的工作细节。
7.1 定义抽象数据类型
7.1.1 设计Sales_data类
我们的最终目的是令Sales_data
支持与Sales_item
类完全一样的操作集合。Sales_item
类有一个名为isbn
的成员函数,并且支持+、=、+=、<<和>>运算符。
我们将在以后学习如何自定义运算符,现在,我们先为这些运算定义普通(命名的)函数形式。
Sales_data
的接口应该包含以下操作:
- 一个
isbn
成员函数,用于返回ISBN编号 - 一个
combine
成员函数,用于讲一个Sales_data
对象加到另一个对象上 - 一个名为
add
的函数,执行两个Sales_data
对象的加法 - 一个
read
函数,将数据从istream
读入到Sales_data
对象中 - 一个
print
函数,将Sales_data
对象的值输出到ostream
使用改进的Sales_data类
我们使用这些函数编写书店程序的另外一个版本,其中不再使用Sales_item
对象,而是使用Sales_data
对象:
Sales_data total; // 保存当前求和结果的变量
if (read(cin, total)) { // 读入一笔交易
Sales_data trans; // 保存下一条交易数据的变量
while (read(cin, trans)) { // 读入剩余的交易
if (total.isbn() == trans.isbn()) // 检查isbn
total.combine(trans); // 更新变量total当前的值
else {
print(cout, total) << endl; // 输出结果
total = trans; // 处理下一本书
}
}
print(cout, total) << endl; // 输出最后一条交易
} else { // 没有输入任何信息
cerr << "No data?!" << endl; // 通知用户
}
7.1.2 定义改进的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的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istrean &read(std::istream&, Sales_data&);
【Note】定义在类内部的函数时隐式的inline函数
定义成员函数
尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。
引入this
成员函数通过-一个名为this
的额外的隐式参数来访问调用它的那个对象。
在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点, 因为this所指的正是这个对象。任何对类成员的直接访问都被看作this的隐式引用。
对于我们来说,this形参是隐式定义的。实际上,任何自定义名为this的参数或变量的行为都是非法的。我们可以在成员函数体内部使用this,因此尽管没有必要。
this是常量指针,我们不允许改变this中保存的地址。
引入const成员函数
isbn函数中的const关键字的作用是修改隐式this指针的类型。
如果isbn是一个普通函数而且this是一个普通的指针参数,则我们应该把this声明成const Sales_data *const
毕竟,在isbn的函数体内不会改变this所指的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。
然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题。C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数。
【Note】常量对象,以及常量对象的引用或指针都只能调用常量成员函数
类作用域和成员函数
回忆之前我们所学的知识,类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内,因此,isbn 中用到的名字bookNo其实就是定义在Sales_data内的数据成员。
值得注意的是,即使bookNo定义在isbn之后,isbn也还是能够使用bookNo。因为编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。
在类的外部定义成员函数
像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。
double Sales_data::avg_price() const{
if (units_sold)
return revenue / units_sold;
else
return 0;
}
当avg_price
使用revenue
和units_sold
时,实际上它隐式地使用了Sales_data
的成员。
定义一个返回this对象的函数
函数combine
的设计初衷类似于复合赋值运算+=,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则通过显式的实参被传入函数:
Sales_data& Sales_data::combine(const Sales_data& rhs){
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
该函数一个值得关注的部分是它的返回类型和返回语句。一般来说,当我们定义的函数类似于某个内置运算符时,应该令该函数的行为尽量模仿这个运算符。内置的赋值运算符把它的左侧运算对象当成左值返回,因此为了与它保持一致,combine函数必须返回引用类型。因为此时的左侧运算对象是一个sales_data的对象,所以返回类型应该是sales_data&。
7.1.3 定义类相关的非成员函数
类的作者常常需要定义一些辅助函数,比如add、read 和print等。尽管这些函数定义的操作从概念上来说属于类的接口的组成部分,但它们实际上并不属于类本身。
我们定义非成员函数的方式与定义其他函数一样,通常把函数的声明和定义分离开来。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明(而非定义)在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。
【Note】一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。
定义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;
}
第一点, read和print分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。而且,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。
第二点,print函数不负责换行。一般来说,执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。
定义add函数
Sales_data add(const Sales_data &lhs, const Sales_data &rhs){
Sales_data sum = lhs'
sum.combine(rhs);
return sum;
}
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。 构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。
不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。
合成的默认构造函数
默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。
这个默认构造函数将通过以下规则初始化该类的数据成员:
- 如果存在类内的初始值,用它来初始化成员(如类为revenue、units_sold提供了初始值)
- 否则,默认初始化该成员(如bookNo默认初始化为空字符串)
某些类不能依赖于合成的默认构造函数
- 只有当类没有声明任何构造函数时,编译器才会主动地生成默认构造函数,否则类将没有默认的构造函数
- 某些类默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型(数组、指针)的对象被默认初始化,则它们的值是未定义的。因此,我们应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。
- 有时候编译器不能为某些类合成默认的构造函数。例如如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数。
定义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的含义
在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){}
在参数列表之后函数体之前出现了新的部分,这个部分称为构造函数初始值列表,它负责为新创建的对象的一个或几个数据成员赋初值。
构造函数初始值时成员名字的一个列表,每个名字后面紧跟括号括起来的成员初始值。
当某个数据成员被构造函数初始值列表忽略时,它将以默认构造函数相同的方式隐式初始化。如只接受一个string参数的构造函数等价于:
Sales_data(const std::string &s):
bookNo(s), units_sold(0), revenue(0){}
通常情况下,构造函数使用类内初始值不失为一种好的选择, 因为只要这样的初始值存在我们就能确保为成员赋予了一个正确的值。不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。
在类的外部定义构造函数
与其他几个构造函数不同,以istream为参数的构造函数需要执行一些实际的操作。在它的函数体内,调用了read函数以给数据成员赋以初值:
Sales_data::Sales_data(std::istream &){
read(is, *this); // read函数的作用是从is中读取一条交易信息,然后存入this对象中
}
我们在类的外部定义构造函数时,和其他成员函数一样,需要指明该构造函数时哪个类的成员。
这个构造函数没有构造函数初始值列表,或者讲得更准确一点,它的构造函数初始值列表是空的。
没有出现在构造函数初始值列表中的成员将通过相应的类内初始值(如果存在的话)初始化,或者执行默认初始化。
7.1.5 拷贝、赋值和析构
拷贝:初始化变量、值传递、返回一个对象
赋值:使用赋值运算符
析构:对象不再存在
如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。
某些类不能依赖于合成的版本
当类需要分配类对象之外的资源时,合成的版本常常会失效。如管理动态内存的类通常不能依赖于上述操作的合成版本。
但如果类包含vector或者string成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector成员的对象执行拷贝或者赋值操作时,vector类会设法拷贝或者赋值成员中的元素。当这样的对象被销毁时,将销毁vector对象,也就是依次销毁vector中的每一个元素。 这一点与string是非常类似的。
7.2 访问控制与封装
到目前为止,我们已经为类定义了接口,但并没有任何机制强制用户使用这些接口。我们的类还没有封装,也就是说,用户可以直达Sales_ data对象的内部并且控制它的具体实现细节。在C++语言中,我们使用访问说明符加强类的封装性:
- 定义在
public
说明符之后的成员在整个程序内可被访问,public成员定义类的接口。 - 定义在
private
说明符之后的成员可以被类的成员函数访问,但是不能被使用该类的代码访问,private部分封装了(即隐藏了)类的实现细节。
再定义一次Sales_data类:
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 ? revennue / units_sold : 0; }
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istrean &read(std::istream&, Sales_data&);
作为借口的一部分,构造函数和部分成员函数(即isbn和combine)紧跟在public说明符之后;而数据成员和作为实现部分的函数紧跟在private说明符之后。
一个类可以包含0个或多个访问说明符,而且对于某个访问说明符能出现多少次也没有严格限定。每个访问说明符指定了接下来的成员的访问级别,其有效范围直到出现下一个访问说明符或者到达类的结尾处为止。
使用class或struct关键字
struct和class的默认访问权限不一样。
struct默认是public,class是private
7.2.1 友元
既然Salse_data的数据成员是private的,我们的read、print和add函数就无法正常编译了。
方法是令其他类或者函数成为它的友元。
class Sales_data{
// 为Sales_data的非成员函数所做的友元声明
friend Sales_data add(const Sales_data&, const Sales_data&);
friend std::ostream &print(std::ostream&, const Sales_data&);
friend std::istrean &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 ? revennue / units_sold : 0; }
std::string bookNo;
unsigned units_sold = 0;
double revenue = 0.0;
};
// Sales_data的非成员接口函数
Sales_data add(const Sales_data&, const Sales_data&);
std::ostream &print(std::ostream&, const Sales_data&);
std::istrean &read(std::istream&, Sales_data&);
友元声明只能出现在类定义的内部,但是在类内出现的具体位置不限。一般来说,最好在类定义开始或结束前的位置集中声明友元。
友元不是类的成员也不受它所在区域访问控制级别的约束。
封装的益处
封装有两个重要的优点:
- 确保用户代码不会无意间破坏封装对象的状态。
- 被封装的类的具体实现细节可以随时改变,而无须调整用户级别的代码。
一旦把数据成员定义成private的,类的作者就可以比较自由地修改数据了。当实现部分改变时,我们只需要检查类的代码本身以确认这次改变有什么影响;换句话说,只要类的接口不变,用户代码就无须改变。如果数据是public的,则所有使用了原来数据成员的代码都可能失效,这时我们必须定位并重写所有依赖于老版本实现的代码,之后才能重新使用该程序。
把数据成员的访问权限设成private还有另外一个好处,这么做能防止由于用户的原因造成数据被破坏。如果我们发现有程序缺陷破坏了对象的状态,则可以在有限的范围内定位缺陷:因为只有实现部分的代码可能产生这样的错误。因此,将查错限制在有限范围内将能极大地降低维护代码及修正程序错误的难度。
友元的声明
友元的声明仅仅指定了访问的权限,而非一个通常意义上的函数声明。
为了使友元对类的用户可见,我们通常把友元的声明与类本身放置在同一个头文件(类的外部)。因此,我们的Sales_data头文件应该为read、print和add提供的声明(除了类内部的友元声明之外)。
7.3 类的其他特性
类型成员、类的成员的类内初始值、可变数据成员、内联成员函数、从成员函数返回*this、如何使用类类型及友元
7.3.1 类的其他特性
定义一对相互关联的类:Screen
、Window_mgr
定义一个类型成员
Screen
表示显示器中的一个窗口。 每个Screen包含一个用于保存Screen内容的string成员和三个string::size_type
类型的成员,它们分别表示光标的位置以及屏幕的高和宽。
除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种:
class Screen{
public:
typedef std::string::size_type pos;
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
把pos定义成public成员可以隐藏Screen实现的细节,用户就不知道Screen使用了一个string对象来存放它的数据。
我们使用了typedef
,也可以等价的使用类型别名:
class Screen{
public:
using pos = std::string::size_type;
// ...
};
用来定义类型的成员必须先定义后使用,因此,类型成员通常出现在类开始的地方。
Screen类的成员函数
class Screen{
public:
typedef std::string::size_type pos;
Screen() = default; // 因为Screen有另一个构造函数,所以本函数是必须的
// cursor被类内初始化为0
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 contents;
};
令成员函数作为内联函数
定义在类内部的成员函数是自动inline
的,因此Screen的构造函数和返回光标所指字符的get函数默认是内联函数。
我们可以在类的内部把inline
作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用关键字inline关键字修饰函数的定义:
inline // 可以在函数的定义处指定inline
Screen &Screen::move(pos r, pos c){
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];
}
重载成员函数
和非成员函数一样,成员函数也可以被重载。
可变数据成员
有时(不频繁),我们希望能修类的某个数据成员,即使在一个const成员函数内。可以通过在变量的声明中加入mutable
关键字做到。
一个可变数据成员永远不会是const
,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。举个例子,我们为Screen添加一个名为access_ctr
的可变成员,通过它我们可以追踪每个Screen成员函数被调用了多少次:
class Screen{
public:
mutable size_t access_ctr; // 即使在一个const对象中也能被修改
// 其他成员与之前一样
};
void Screen::some_member() const{
++access_ctr;
// 该成员需要完成的其他工作
}
尽管some_member是一个const成员函数,它仍然能够改变access_ctr的值。该成员是个可变成员,因此任何成员函数,包括const函数在内都能改变它的值。
类数据成员的初始值
我们再定义一个窗口管理类并用它表示显示器上的一组Screen。这个类包含一个Screen类型的vector。每个元素表示一个特定的Screen。默认情况下,我们希望Window_mgr类开始时总是拥有一个默认初始化的Screen,最好的方式是把这个默认值声明称一个类内初始值:
class Window_mgr{
private:
// 这个Window_mgr追踪的Screen默认情况下,一个Window_mgr包含一个标准尺寸的空白Screen
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
当我们初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。
如我们之前所知的,类内初始值必须使用**=的初始化形式(初始化Screen的数据成员时所用的)或者花括号**括起来的直接初始化形式(初始化Screens所用的)。
7.3.2 返回*this的成员函数
接下来我们继续添加一些函数, 它们负责设置光标所在位置的字符或者其他任一给定位置的字符:
class Screen{
public:
Screen &set(char);
Screen &set(pos, pos, char);
// ...
};
inline Screen &Screen::set(char){
contents[cursor] = c; // 设置当前光标所在位置的新值
return *this; // 将this对象作为左值返回
}
inline Screen &Screen::set(pos r, pos col, char ch){
contents[r * width + col] = ch; // 设置给定位置的新值
return *this; // 将this对象作为左值返回
}
返回引用的函数时左值的,返回的是对象本身,而不是对象的副本。
如果我们将这一系列操作连接在一条表达式中的话:
// 把光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4, 0).set('#'); // 等价于下面两条语句
myScreen.move(4, 0);
myScreem.set('#');
如果返回的是Screen而不是&Screen,则:
Screen temp = myScreen.move(4, 0); // 对返回值进行拷贝
temp.set('#'); // 不会改变myScreen的contents
假如当初我们定义的返回类型不是引用,则move的返回值将是*this的副本,因此调用set只能改变临时副本,而不能改变myScreen的值。
从const成员函数返回*this
接下来,我们继续添加一个名为diplay
的操作,它负责打印Screen的内容。我们希望这个函数能和move以及set出现在同一序列中,因此类似于move和set、diplay函数也应该返回执行它的对象的引用。
从逻辑上来说,显示一个Screen并不需要改变它的内容,因此我们令diplay为-个const成员,此时,this 将是一个指向const的指针而*this是const对象。由此推断,display的返回类型应该是const Sales_data&
。然而,如果真的令diplay返回一个const的引用,则我们将不能把display嵌入到一组动作的序列中去:
// 如果display返回常量引用,则调用set将引发错误
myScreen.display(cout).set('*');
【Note】一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。
基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const而重载函数的原因差不多。
因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const函数。
在下面的这个例子中,我们将定义一个名为do_display
的私有成员,由它负责打印Screen的实际工作。所有的display操作都将调用这个函数,然后返回执行操作的对象:
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:
// 该函数负责显示Screen的内容
void do_display(std::ostream &os) const { os << contents; }
// ...
};
当我们在某个对象.上调用display 时,该对象是否是const决定了应该调用display的哪个版本:
Screen myscreen(5, 3);
myScreen.set('#').display(cout); // 调用非常量版本
blank.display(cout); // 调用常量版本
【Note】:对于公共代码使用私有功能函数
为什么要费力定义一个do_display
函数?
- 一个基本的愿望是避免在多处使用同样的代码
- 我们预期随着类的规模发展,display函数有可能变得更加复杂,此时,把相应的操作写在一处而非两处的作用就比较明显了。
- 我们很可能在开发过程中给do_display函数添加某些调试信息,而这些信息将在代码的最终产品版本中去掉。显然,只在do_display一处添加或删除这些信息要更容易一些。
- 这个额外的函数调用不会增加任何开销。因为我们在类内部定义了do_display,所以它隐式地被声明成内联函数。这样的话,调用do_display就不会带来任何额外的运行时开销。
在实践中,设计良好的C++代码常常包含大量类似于do_display的小函数,通过调用这些函数,可以完成一组其他函数的“实际”工作。
7.3.3 类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类。例如:
struct First{
int memi;
int getMem();
};
struct Second{
int memi;
int getMem();
};
First obj1;
Second obj2 = obj1; // 错误:类型不同
我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我们也可以把类名跟在关键字class或struct后面,这是从C语言继承而来:
Sales_data item1;
class Sales_data item1; // 等价声明
类的声明
就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:
class Screen; // 声明
这种声明有时被称作前向声明,它向程序中引入了名字,并指明Screen是一种类型。对于类型Screen,在它声明之后定义之前是一个不完全类型,也就是说,此时我们已知Screen是一个类型,但是不清楚它到底包含哪些成员,所以一个类的成员不能是他自己。然而,一旦一个类的名字出现后,他就被认为是声明过了(但尚未定义),因此类允许包含指向他自身类型的引用或指针:
class Link_screen{
Screen window;
Link_screen *next;
Link_screen *prev;
};
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
7.3.4 友元再探
我们的Sales_data类把三个普通的非成员函数定义成了友元。类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的)的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。举个友元类的例子,我们的Window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据,将Window_mgr被指定为Screen的友元,因此我们可以将Window_mgr
的clear成员写成如下的形式:
例如,假设我们需要为Window_mgr添加一个名为clear的成员,它负责把一个指定的Screen的内容都设为空白。为了完成这一任务,clear需要访问Screen的私有成员;而要想令这种访问合法,Screen需要把Window_mgr指定成它的友元:
class Screen{
// Window_mgr的成员可以访问Screen类的私有部分
friend class Window_mgr;
// ...
};
class Window_mgr{
public:
// 窗口中每个屏幕的编号
using ScreenIndex = std::vector<Screen>::size_type;
// 按照编号将指定的Screen重置为空白
void clear(ScreenIndex);
private:
std::vector<Screen> screens{Screen(24, 80, ' ')};
};
void Window_mgr::clear(ScreenIndex i){
// s是一个Screen的引用,指向我们想清空的那个屏幕
Screen &s = screens[i];
// 将那个选定的Screen重置为空白
s.contents = string(s.height * s.width, ' ');
}
每个类负责控制自己的友元类或友元函数
友元不存在传递性。如果Window_mgr有它自己的友元,则这些友元并不具有访问Screen的特权
令成员函数作为友元
除了令整个Window_mgr作为友元之外,Screen还可以只为clear提供访问权限。当我们把成员函数声明为友元时,我们必须明确指出该成员函数属于哪个类:
class Screen{
// Window_mgr::clear必须在Screen类之前被声明
friend void Window_mgr::clear(ScreenIndex);
// ...
}
要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序(同一文件下):
- 首先定义Window_mgr 类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen。
- 接下来定义Screen,包括对于clear的友元声明。
- 最后定义clear,此时它才可以使用Screen的成员。
如果分文件编写,Window_mgr.h不能include Screen.h,如果需要Screen,则声明Screen——class Screen。
Screen.h:
#pragma once
#include <string>
#include "Window_mgr.h"
class Screen {
friend void Window_mgr::clear(ScreenIndex);
public:
typedef std::string::size_type pos;
Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht* wd, c) {}
private:
pos cursor = 0;
pos height = 0, width = 0;
std::string contents;
};
Window_mgr.h:
#pragma once
#include <vector>
class Screen;
class Window_mgr {
public:
Window_mgr();
// 窗口中每个屏幕的编号
using ScreenIndex = std::vector<Screen>::size_type;
// 按照编号将指定的Screen重置为空白
void clear(ScreenIndex);
private:
std::vector<Screen> screens;
};
Window_mgr.cpp:
#include "Window_mgr.h"
#include "Screen.h"
#include <string>
void Window_mgr::clear(ScreenIndex i) {
// s是一个Screen的引用,指向我们想清空的那个屏幕
Screen& s = screens[i];
// 将那个选定的Screen重置为空白
s.contents = std::string(s.height * s.width, ' ');
}
Window_mgr::Window_mgr() {
screens = { Screen(24, 80, ' ') };
}
函数重载和友元
尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明。
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。
甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的。
7.4 类的作用域
在类的作用域之外,普通的数据和函数成员只能由对象、引用或者指针使用成员访问运算符来访问。对于类类型成员则使用作用域运算符访问。
Screen::pos ht = 24, wd = 80; // 使用Screen定义的pos类型
Screen scr(ht, wd, ' ');
Screen *p = &scr;
char c = scr.get(); // 访问scr对象的get成员
c = p->get(); // 访问p所指对象的get成员
作用域和定义在类外部的成员
在类外定义成员函数时,定义的剩余部分(除函数名)就在类的作用域之内了,这里的剩余部分包括参数列表和函数体。结果就是,我们可以直接使用类的其他成员而无须再次授权了。
7.4.1 名字查找与类的作用域
名字查找过程:
- 首先,在名字所在的块中寻找其声明语句,只考虑在名字的使用之前出现的声明。
- 如果没找到,继续查找外层作用域。
- 如果最终没有找到匹配的声明,则程序报错。
编译器处理完类的全部声明之后才会处理成员函数的定义,类的定义分两步:
- 首先,编译成员的声明。
- 直到类全部可见后才编译函数体。
类型名要特殊处理
一般来说,内层作用域可以重新定义外层作用域中的名字,即使该名字已经在内层作用域中使用过。然而在类中,如果成员使用了外层作用域中的某个名字,而该名字代表一种类型,则类不能在之后重新定义该名字:
typedef double Money;
class Account{
public:
Money balance() {return bal;}
private:
typedef double Money; // 错误
Money bal;
// ...
}
成员定义中的普通块作用域的名字查找
- 首先,在成员函数内查找该名字的声明。和前面一样,只有在函数使用之前出现的声明才被考虑。
- 如果在成员函数内没有找到,则在类内继续查找,这时类的所有成员都可以被考虑。
- 如果类内也没找到该名字的声明,在成员函数定义之前的作用域内继续查找。
7.5 构造函数再探
7.5.1 构造函数初始值列表
构造函数的初始值有时必不可少
有时我们可以忽略数据成员初始化和赋值之间的差异,但并非总能这样。如果成员是const或者是引用的话,必须将其初始化。类似的,当成员属于某种类类型且该类没有定义默认构造函数时,也必须将这个成员初始化。
class ConstRef{
private:
int i;
const int ci;
int &ri;
};
和其他常量对象或者引用一样,成员ci和ri都必须被初始化。因此,如果我们没有为它们提供构造函数初始值的话将引发错误:
ConstRef::ConstRef(int ii){
i = ii;
ci == ii; // 错误
ri = i; // 错误
}
随着构造函数体-开始执行, 初始化就完成了。我们初始化const或者引用类型的数据成员的唯一机会就是通过构造函数初始值,因此该构造函数的正确形式应该是:
// 正确
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) {}
**建议使用构造函数初始值:**如果成员是const、引用,或者属于某种未提供默认构造函数的类类型,我们必须通过构造函数初始值列表为这些成员提供初值。
成员初始化的顺序
在构造函数初始值中每个成员只能出现一次。
成员的初始化顺序与它们在类定义中的出现顺序一致:第一个成员先被初始化,然后第二个,以此类推。构造函数初始值列表中初始值的前后位置关系不会影响实际的初始化顺序。
7.5.2 委托构造函数
一个委托构造函数使用它所属类的其他构造函数执行它自己的初始化过程,或者说它把它自己的一些(或者全部职责委托给了其他构造函数。
class Sales_data{
public:
// 非委托构造函数使用对应的实参初始化成员
Sales_data(std::string s, unsigned cnt, double price):
bookNo(s), units_sold(cnt), revenue(cnt * price){}
//其余构造函数全部委托给另一个构造函数
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 默认构造函数的作用
当对象被默认初始化或值初始化时自动执行默认构造函数。默认初始化在以下情况下发生:
- 当我们在块作用域内不使用任何初始值定义一个非静态变量或者数组时
- 当一个类本身含有类类型的成员且使用合成的默认构造函数时
- 当类类型的成员没有在构造函数初始值列表中显式地初始化时
在实际中,如果定义了其他构造函数,那么最好也提供一个默认构造函数。
7.5.4 隐式的类类型转换
如果构造函数只接受一个实参,则他实际上定义了转换为此类函数的隐式转换机制,我们把这种构造函数称为转换构造函数
在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
item.combine(null_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");
类类型转换不总是有效
// 使用istream构造函数创建一个函数传递给combine
item.combine(cin);
Sales_ data 对象是个临时量,一旦combine完成我们就不能再访问它了。实际上,我们构建了一个对象,先将它的值加到item中,随后将其丢弃。
抑制构造函数定义的隐式转换
在要求隐式转换的程序上下文中,我们可以通过将构造函数声明为explicit
加以阻止:
class Sales_data{
public:
Sales_data() = default;
Sales_data(const std::string &s): bookNo(s){}
explicit Sales_data(const std::string &s, unsigned n, double p):
bookNo(s), units_sold(n), revenue(p * n){}
explicit Sales_data(std::istream &);
}
item.combine(null_book); // 错误
item.combine(cin); // 错误
explicit构造函数只能用于直接初始化
发生隐式转换的一种情况是当我们执行拷贝形式的初始化时(使用=)。此时,我们只能使用直接初始化而不能使用explicit构造函数:
Sales_data item1(null_book); // 正确
Sales_data item2 = null_book; // 错误
为转换显式地使用构造函数
尽管编译器不会将explicit的构造函数用于隐式转换过程,但是我们可以使用这样的构造函数显式地强制进行转换:
// 正确static_cast可以使用explicit构造函数
item.combine(static_cast<Sales_data>(cin));
// 正确:实参是一个显式构造的Sales_data对象
item.combine(Sales_data(null_book));
标准库中含有显式构造函数的类
- 接受一个单参数的
const char*
的string构造函数不是explicit的。 - 接受一个容量参数的
vector
构造函数是explicit的。
7.5.5 聚合类
聚合类使得用户可以直接访问其成员,并且具有特殊的初始化语法形式。当一个类满足如下条件时,我们说它是聚合的:
- 所有成员都是public
- 没有定义任何构造函数没
- 有类内初始值
- 没有基类,也没有
virtual
函数
例如这个类是聚合类:
struct Data{
int ival;
string s;
};
我们可以提供一个花括号括起来的成员初始值列表,并用它初始化聚合类的数据成员,但是初始值的顺序必须与声明的顺序一致:
Data vall = {0, "Anna"};
值得注意的是,显式的初始化类的对象成员存在以下缺点:
- 要求类的所有成员都是public
- 将正确初始化每个对象的每个成员的重任交给了类的用户(而非类的作者),这样的初始化过程冗长乏味且容易出错。
- 添加或删除一个成员之后, 所有的初始化语句都需要更新。
7.5.6 字面值常量类
constexpr
函数的参数和返回值必须是字面值类型。除了算术类型、引用和指针外,某些类也是字面值类型。和其他类不同,字面值类型的类可能含有constexpr函数成员。这样的成员必须符合constexpr函数的所有要求,它们是隐式const的。
数据成员都是字面值类型的聚合类是字面值常量类。如果一个类不是聚合类,但它符合下述要求,则它也是一个字面值常量类:
- 数据成员都必须是字面值类型。
- 类必须至少含有一个constexpr构造函数。
- 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式;或者如果成员属于某种类类型,则初始值必须使用成员自己的constexpr构造函数。
- 类必须使用析构函数的默认定义,该成员负责销毁类的对象
constexpr构造函数
尽管构造函数不能是const的,但是字面值常量类构造函数可以是constexpr函数。事实上,一个字面值常量类必须至少提供一个constexpr构造函数。
通常情况下constexpr构造函数体都是空的:
class Debug{
public:
constexpr Debug(bool b = true): hw(b), io(b), other(b){}
constexpr Debug(bool h, bool i, bool o):
hw(b), io(b), other(b){}
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;
bool io;
bool other;
};
constexpr构造函数必须初始化所有数据成员,初始值或者使用constexpr构造函数,或者是一条常量表达式。
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();
};
类的静态成员存在于任何对象之外,对象中不包含任何与静态数据成员有关的数据。因此,每个Account对象将包含两个数据成员:owner和amount。只存在一个interestRate对象而且它被所有Account对象共享。
类似的,静态成员函数也不与任何对象绑定在一起,它们不包含this指针。作为结果,静态成员函数不能声明成const的,而且我们也不能在static函数体内使用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关键字,该关键字只出现在类内部的声明语句:
因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们不能在类的内部初始化静态成员。相反的,必须在类的外部定义和初始化每个静态成员。和其他对象一样,一个静态数据成员只能定义一次。
类似于全局变量,静态数据成员定义在任何函数之外。因此一旦它被定义,就将一直存在于程序的整个生命周期中。
double Account ::interestRate = initRate();
要想确保对象只定义一次,最好的办法是把静态数据成员的定义与其他非内联函数的定义放在同一个文件中。
静态成员的类内初始化
通常情况下,类的静态成员不应该在类的内部初始化。然而,我们可以为静态成员提供const整数类型的类内初始值,不过要求静态成员必须是字面值常量类型的constexpr。
例如我们可以使用一个初始化了的静态数据成员指定数组成员的维度:
class Account{
public:
static double rate() {return interestRate;}
static void rate(double);
private:
static constexpr int period = 30; // period是常量表达式
double daily_tbl[period];
};
静态成员能用于某些场合,而普通成员不能
例如:静态数据成员可以是不完全类型,特别的,静态数据成员的类型可以就是他所属的类类型。而非静态数据成员则受到限制,只能声明成它所属类的指针或引用:
class Bar{
public:
// …
private:
static Bar mem1; // 正确:静态成员变量可以是不完全类型
Bar *mem2; // 正确:指针成员可以是不完全类型
Bar mem3; // 错误:数据成员必须是完全类型
};
静态成员和普通成员的另外一个区别是我们可以使用静态成员作为默认实参。
非静态数据成员不能作为默认实参,因为它的值本身属于对象的一部分, 这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。
class Account{
public:
Screen& clear(char = background);
private:
static const char bkground;
};
小结
类是C++语言中最基本的特性。类允许我们为自己的应用定义新类型,从而使得程序更加简洁且易于修改。
类有两项基本能力:一是数据抽象,即定义数据成员和函数成员的能力;二是封装,即保护类的成员不被随意访问的能力。通过将类的实现细节设为private, 我们就能完成类的封装。类可以将其他类或者函数设为友元,这样它们就能访间类的非公有成员了。
类可以定义一种特殊的成员函数:构造函数,其作用是控制初始化对象的方式。构造函数可以重载,构造函数应该使用构造函数初始值列表来初始化所有数据成员。
类还能定义可变或者静态成员。一个可变成员永远都不会是const,即使在const成员函数内也能修改它的值;一个静态成员可以是函数也可以是数据,静态成员存在于所有对象之外。