C++ Primer 类的其他特性
专栏简介:本专栏主要面向C++初学者,解释C++的一些基本概念和基础语言特性,涉及C++标准库的用法,面向对象特性,泛型特性高级用法。通过使用标准库中定义的抽象设施,使你更加适应高级程序设计技术。希望对读者有帮助!
目录
- 7.3类的其他特性
- 类成员再探
- 可变数据成员
- 类数据成员的初始值
- 返回*this的成员函数
- 从const成员函数返回*this
- 类类型
- 类的声阴
- 友元再探
- 类之间的友元关系
- 令成员函数作为友元
- 函数重载和友元
- 友元声明和作用域
7.3类的其他特性
虽然Sales_data类简单,但是通过它我们已经了解C++语言中关于类的许多语法要点。在本节中,我们将继续介绍Sales_data没有体现出来的一些类的特性。这些特性包括:类型成员、类的成员的类内初始值、可变数据成员、内联成员函数、从成员函数返回*this、关于如何定义并使用类类型及友元类的更多知识。
类成员再探
为了展示这些新的特性,我们需要定义一对相互关联的类,它们分别是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;
}
我们在Screen的public部分定义了pos,这样用户就可以使用这个名字。Screen的用户不应该知道Screen使用了一个string对象来存放它的数据,因此通过把pos定义成public成员可以隐藏Screen实现的细节。
关于pos的声明有两点需要注意。首先,我们使用了typedef,也可以等价地使用类型别名:
class Screen{
public:
//使用类型别名等价地声明一个类型名守
using pos=std::string::size_type;
//其他成员与之前的版本一致
}
其次,用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别。因此,类型成员通常出现在类开始的地方。
Screen类的成员函数
要使我们的类更加实用,还需要添加一个构造函数令用户能够定义屏幕的尺寸和内
容,以及其他两个成员,分别负责移动光标和读取给定位置的字符:
```cpp
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;
因为我们已经提供了一个构造函数,所以编译器将不会自动生成默认的构造函数。如果我们的类需要默认构造函数,必须显式地把它声明出来。在此例中,我们使用=default告诉编译器为我们合成默认的构造函数。
需要指出的是,第二个构造函数(接受三个参数)为cursor成员隐式地使用了类内初始值。如果类中不存在cursor的类内初始值,我们就需要像其他成员一样显式地初始化cursor了。
令成员作为内联函数
在类中,常有一些规模较小的函数适合于被声明成内联函数。如我们之前所见的,定义在类内部的成员函数是自动inline的。因此,screen的构造函数和返回光标所指守符的get函数默认是in1ine函数。
我们可以在类的内部把inline作为声明的一部分显式地声明成员函数,同样的,也能在类的外部用inline关键字修饰函数的定义:
inline//可以在函数的定义处指定in1ine
Screen&Screen::move(pos i,pos c)
{
pos row= r*width;//计算行的位置
cursor=row + C;//在行内将光标移动到指定的列
return *this;//以左值的形式返回对象
}
char Screen::get(posx,posc)const//在类的肉部声明成inline
{
pos row= r* width;//计算行的位置
return contents[row+c];//返回给定列的字符
}
虽然我们无须在声明和定义的地方同时说明inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明in1ine,这样可以使类更容易理解。
和我们在头文件中定义inline函数的原因一样,inline成员靥数也应该与相应的类定义在同一个头文件中。
重载成员函数
和非成员函数一样,成员函数也可以被重载,只要函数之间在参数的数量和/或类型上有所区别就行。成员函数的函数匹配过程同样与非成员函数非常类似。
举个例子,我们的Screen类定义了两个版本的get函数。一个版本返回光标当前位置的字符;另一个版本返回由行号和列号确定的位置的字符。编译器根据实参的数量来决定运行哪个版本的函数:
```cpp
Screen myscreen;
char ch=myscreen.get();//调用Screen::qet()
ch=myscreen.get(0,0);//调用Screen::get(pos,pos)
可变数据成员
有时〔但并不频繁〕会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。
一个可变数据成员(mutable data member)永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。举个例子,我们将给Screen添加一个名为access_ctr的可变成员,通过它我们可以追踪每个Screen的成员函数被调用了多少次:
class Screen{
public:
void some_member()const;
private:
mutable size_t access_ctr;//即使在一个const对象内也能被修改
//其他成员与之前的版本一致
]
void Screen::some_member()const
{
++ access_ctr;//保存一个计数值,用于记录成员函数被调用的次数
//该成员需要完成的其他工作
}
尽管some_member是一个const成员函数,它仍然能够改变access_ctr的值。该成员是个可变成员,因此任何成员函数,包括const函数在内都能改变它的值。
类数据成员的初始值
在定义好Screen类之后,我们将继续定义一个窗口管理类并用它表示显示器上的一组screen。默认情况下,我们希望Window_mgz类开始时总是拥有一个默认初始化的Screen。在C++11新标准中,最好的方式就是把这个默认值声明成一个类内初始值:
class Window_mgr {
private:
//这个Window_mgz追踪的Screen
//默认情况下,一个Nindowmgz包含一个标准尺寸的空白Screen
std::vector<Screen>screens{Screen(24,80,' ')};
}
当我们初始化类类型的成员时,需要为构造函数传递一个符合成员类型的实参。在此例中,我们使用一个单独的元素值对vector成员执行了列表初始化,这个Screen的值被传递给vector的构造函数,从而创建了一个单元素的vector对象。具体地说,Screen的构造函数接受两个尺寸参数和一个孙符值,创建了一个给定大小的空自屏幕对象。
如我们之前所知的,类内初始值必须使用=的初始化形式(初始化Screen的数据成员时所用的)或者花括号括起来的直接初始化形式(初始化screens所用的)。当我们提供一个类内初始值时,引须以符号=或者花括号表示。
返回*this的成员函数
接下来我们继续添加一些函数,它们负责设置光标所在位置的字符或者其他任一给定位置的字符:
class Screen{
public:
Screen&set(char)}
Screen&set(pos,pos,char);
//其他成员和之前的版本一致
inline Screen &Screen::set(char c)
{
contents[cursor]=c;//设置当前光标所在位置的新值
return*this;//将this对象作为左值返回
}
inline Screen& Screen::set(pos,pos col,char ch)
{
contents[r*width+col] = ch;//设置给定位置的新值
return*this;//将this对象作为左值返回
}
和move操作一样,我们的set成员的返回值是调用set的对象的引用。返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。如果我们把一系列这样的操作连接在一条表达式中的话:
```cpp
//把光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4,0).set('#');
这些操作将在同一个对象上执行。在上面的表达式中,我们首先移动myScreen内的光标,然后设置myscreen的contents成员。也就是说,上述语句等价于
myScreen.move(4,0);
myScreen.set('#');
如果我们令move和set返回Screen而非Screen&,则上述语句的行为将大不相同。在此例中等价于:
//如果move返回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对象。由此推断,disp1ay的返回类型应该是constSales_datag。然而,如果真的令diplay返回一个const的引用,则我们将不能把display嵌入到一组动作的序列中去:
Screen myScreen;
//如果display返回常量引用,则调用set将引发错误
myScreen.displ1ay(cout).set('*')
即使myScreen是个非常量对象,对set的调用也无法通过编译。问题在于disp1ay的const版本返回的是常量引用,而我们显然无权set一个常量对象。
基于const的重载
通过区分成员函数是否是const的,我们可以对其进行重载,其原因与我们之前根据指针参数是否指向const而重载函数的原因差不多。具体说来,因为非常量版本的函数对于常量对象是不可用的,所以我们只能在一个常量对象上调用const成员函数。另一方面,虽然可以在非常量对象上调用常量版本或非常量版本,但显然此时非常量版本是一个更好的匹配。
在下面的这个例子中,我们将定义一个名为do_display的私有成员,由它负责打印Screen的实际工作。所有的display操作都将调用这个函数,然后返回执行操作的对象:
class Screen{
public:
//根据对象是否是const重载了display函数
Screen& display(std::ostream& os)
{
ao_aisplay(os);return*thits;
}
const Screen& display(std::ostream&os)const
{
do_display(os);return*thts;
}
private::
//该函数负责显示Screen的内部
void do_daisplay(std::ostream&os)const{os<<contents;}
//其他成员与之前的版本一致
和我们之前所学的一样,当一个成员调用另外一个成员时,this指针在其中隐式地传递。因此,当display调用do_display时,它的this指针隐式地传递给do_disp1ay。而当displ1ay的非常量版本调用do_display时,它的this指针将隐式地从指向非常量的指针转换成指向常量的指针。
当do_display完成后,display函数各自返回解引用this所得的对象。在非常量版本中,this指向一个非常量对象,因此display返回一个普通的(非常量)引用;而const成员则返回一个常量引用。
当我们在某个对象上调用display时,该对象是否是const决定了应该调用disp1ay的哪个版本:
Screen myScreen(5,3);
const Screen blank(5,3);
myScreen.set('#').display(cout);//调用非常量版本
blank.display(cout); //调用常量版本
建议:对于公共代码使用私有功能函数
有些读者可能会奇怪为什么我们要费力定义一个单独的do_display函数。毕竟,对do_display的调用并不比do_display函数所做的操作简单多少。为什么还要这么做呢?实际上我们是出于以下原因:
- 一个基本的愿望是避免多出使用同样的代码。
- 我们预期随着类的规模发展,display函数有可能变得更加复杂,此时,把相应的操作写在一处而非两处的作用就比较明显了。
- 我们很可能在开发的过程中给do_display函数添加某些调试信息,而这些信息将在代码的最终产品版本中去掉。显然,只在do_display一处添加或删除这些信息更容易一些。
- 这个额外的函数调用不会增加任何开销。因为我们在类内部定义do_disp1ay,所以它隐式地被声明成内联函数。这样的话,调用do_display就不会带来任何额外的运行时开销。
在实跌中,设计良好的C++代码常常包含大量类似于do_qisplay的小函数,通过调用这些函数可以完成一组其他函敷的“实际“工作。
类类型
每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。例如:
struct First{
int memi;
int getMem();
}
struct Second{
int memi;
int getMem();
}
First obj1;
Second bj2=obj1;//错误:obj1和obj2的类型不同
即使两个类的成员列表完全一致,它们也是不同的类型。对于一个类来说,它的成员和其他任何娄(或者任何其他作用域)的成员都不是一回事儿。
我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我们也可以把类名跟在关键字class或struct后面:
Sales_data item1;//默认初始化Sales_data类型的对象
class Sales_data item;//一条等价的声明
上面这两种使用类类型的方式是等价的,其中第二种方式从C语言继承而来,并且在C++语言中也是合法的。
类的声阴
就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:
class Screen;//Screen类的声明
这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。
不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。
对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仪被声明。否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员。
换句话说,我们必须首先完成类的定义,然后编详器才能知道存储该数据成员需要多少空间。因为只有当类全部完成后类才算被定义,所以一个类的成员类型不能是该类自己。然而,一旦一个类的名字出现后,它就被认为是声明过了(但尚未定义),因此类允许包含指向它自身类型的引用或指针:
class Link_screen《
Screen window;
Link_Screen*next;
Link_Screen*prev;
};
友元再探
我们的Sales_data类把三个普通的非成员函数定义成了友元。类还可以把其他的类定义成友元,也可以把其他类(之前已定义过的的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。
类之间的友元关系
举个友元类的例子,我们的Window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据。例如,假设我们需要为Windowmgr添加一个名为clear的成员,它负责把一个指定的Screen的内容都设为空白。为了完成这一任务,clear需要访问Screen的私有成员;而要想令这种访问合法,Screen需要把Window_mgr指定成它的友元:
class Screen{
//Window_mgr的成员可以访问Screen类的私有部分
friend class Window_mgr;
//Screen类的剩余部分
}
如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。通过上面的声明,Window_mgr被指定为Screen的友元,因此我们可以将Window_mgr的clear成员写成如下的形式:
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,' ');
}
一开始,首先把s定义成screens vector中第i个位置上的Screen的引用,随后利用Screen的height和width成员计算出一个新的string对象,并令其含有若干个空白字符,最后我们把这个含有很多空白的字符串赋给contents成员。
如果clear不是Screen的友元,上面的代码将无法通过编译,因为此时clear将不能访问Screen的height、width和contents成员.而当Screen将Windowmgr指定为其友元之后,Screen的所有成员对于Window_mgr就都变成可见的了。
必须要注意的一点是,友元关系不存在传递性。也就是说,如果Windowmgr有它自己的友元,则这些友元些不能理所当然地具有访问Screen的特权。
瞌每个类负责控制自已的友元类或友元出数。
令成员函数作为友元
除了令整个Window_mgr作为友元之外,screen还可以只为clear提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:
class Screen{
//Window_mgr::clear必须在Screen类之前被声明
friend void Window_mgr::clear(ScreenIndex);
//screen类的剩余部分
}
要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:
- 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen。
- 接下来定义Screen,包括对于clear的友元声明。
- 最后定义clear,此时它才可以使用Screen的成员。
函数重载和友元
尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明:
//重载的storeOn 函数
extern std::ostream&storeOn(std::ostream& ,Screen&);
extern BitMap&storeOn(BitMap&,Screen&);
class Screen {
//storeOn的ostream版本能访问Screen对象的私有部分
friend std::ostream&storeOn(std::ostream&,Screen&);
}
Screen类把接受ostreamg的storeon函数声明成它的友元,但是接受BitMap&作为参数的版本仍然不能访问Screen。
友元声明和作用域
类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。
甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:
struct X {
friend void f(){/*友元函数可以定义在类的内部*/}
X(){f();})//错误:f还没有被声明
void g();
void h();
};
void X::g(){return0;} //错误:f还没有被声明
void f(); //声明那个定义在X中的函数
void X::h(){return f();} //正确:现在王的声明在作用域中了
关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明。