【C++基础】第四十三课:[类]类的其他特性

类型成员,令成员作为内联函数,重载成员函数,可变数据成员,mutable,返回*this的成员函数,类类型,前向声明,不完全类型,类之间的友元关系,令成员函数作为友元,函数重载和友元,友元声明和作用域

Posted by x-jeff on June 21, 2022

【C++基础】系列博客为参考《C++ Primer中文版(第5版)》C++11标准)一书,自己所做的读书笔记。
本文为原创文章,未经本人允许,禁止转载。转载请注明出处。

1.类成员再探

我们定义一对相互关联的类,它们分别是Screen和Window_mgr。

1.1.定义一个类型成员

Screen表示显示器中的一个窗口。每个Screen包含一个用于保存Screen内容的string成员和三个string::size_type类型的成员,它们分别表示光标的位置以及屏幕的高和宽。

除了定义数据和函数成员之外,类还可以自定义某种类型在类中的别名。由类定义的类型名字和其他成员一样存在访问限制,可以是public或者private中的一种:

1
2
3
4
5
6
7
8
9
10
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 contents;
};

用来定义类型的成员必须先定义后使用,这一点与普通成员有所区别。因此,类型成员通常出现在类开始的地方。

1.2.Screen类的成员函数

要使我们的类更加实用,还需要添加一个构造函数令用户能够定义屏幕的尺寸和内容,以及其他两个成员,分别负责移动光标和读取给定位置的字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Screen{
public:
	typedef std::string::size_type pos;
	Screen() = default;//因为Screen有另一个构造函数,所以本函数是必需的
	//cursor被其类内初始值初始化为0
	//如果类中不存在cursor的类内初始值,我们就需要显式地初始化cursor了
	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;
};

1.3.令成员作为内联函数

在类中,常有一些规模较小的函数适合于被声明成内联函数。

get()函数作为定义在类内部的成员函数,自动默认是inline函数。get(pos ht, pos wd)函数虽然没定义在类内,但是其显式地声明为inline函数。move()既没在类内定义,也没在类内显式地声明为inline函数,但其依然可以在类外定义时声明为inline函数:

1
2
3
4
5
6
7
8
9
10
11
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 //在类的内部声明成inline
{
	pos row = r * width; //计算行的位置
	return contents[row + c]; // 返回给定列的字符
}

虽然我们无须在声明和定义的地方同时说明inline,但这么做其实是合法的。不过,最好只在类外部定义的地方说明inline,这样可以使类更容易理解。

1.4.重载成员函数

和非成员函数一样,成员函数也可以被重载,只要函数之间在参数的数量和/或类型上有所区别就行。成员函数的函数匹配过程同样与非成员函数非常类似。

相关链接:【C++基础】第三十七课:函数重载

1.5.可变数据成员

有时(但并不频繁)会发生这样一种情况,我们希望能修改类的某个数据成员,即使是在一个const成员函数内。可以通过在变量的声明中加入mutable关键字做到这一点。

‼️一个可变数据成员(mutable data member)永远不会是const,即使它是const对象的成员。因此,一个const成员函数可以改变一个可变成员的值。举个例子,我们将给Screen添加一个名为access_ctr的可变成员,通过它我们可以追踪每个Screen的成员函数被调用了多少次:

1
2
3
4
5
6
7
8
9
10
11
12
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函数在内都能改变它的值。

1.6.类数据成员的初始值

在定义好Screen类之后,我们将继续定义一个窗口管理类并用它表示显示器上的一组Screen。这个类将包含一个Screen类型的vector,每个元素表示一个特定的Screen。默认情况下,我们希望Window_mgr类开始时总是拥有一个默认初始化的Screen。在C++11新标准中,最好的方式就是把这个默认值声明成一个类内初始值:

1
2
3
4
5
6
class Window_mgr {
private :
	//这个Window_mgr追踪的Screen
	//默认情况下,一个Window_mgr包含一个标准尺寸的空白Screen
	std::vector<Screen> screens{Screen(24, 80, ' ')};
};

当我们提供一个类内初始值时,必须以符号=或者花括号表示。

2.返回*this的成员函数

接下来我们继续添加一些函数,它们负责设置光标所在位置的字符或者其他任一给定位置的字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 r, pos col, char ch)
{
	contents[r*width + col] = ch; //设置给定位置的新值
	return *this; //将this对象作为左值返回
}

和move操作一样,我们的set成员的返回值是调用set的对象的引用。返回引用的函数是左值的,意味着这些函数返回的是对象本身而非对象的副本。如果我们把一系列这样的操作连接在一条表达式中的话:

1
2
//把光标移动到一个指定的位置,然后设置该位置的字符值
myScreen.move(4,0).set('#');

这些操作将在同一个对象上执行。上述语句等价于:

1
2
myScreen.move(4,0);
myScreen.set('#');

如果我们令move和set返回Screen而非Screen&,则上述语句的行为将大不相同。在此例中等价于:

1
2
3
//如果move返回Screen而非Screen&
Screen temp = myScreen.move(4,0); //对返回值进行拷贝
temp.set('#'); //不会改变myScreen的contents

2.1.从const成员函数返回*this

接下来,我们继续添加一个名为display的操作,它负责打印Screen的内容。我们希望这个函数能和move以及set出现在同一序列中,因此类似于move和set,display函数也应该返回执行它的对象的引用。

从逻辑上来说,显示一个Screen并不需要改变它的内容,因此我们令display为一个const成员,所以display的返回类型应该是const Sales_data&。然而,如果真的令display返回一个const的引用,则我们将不能把display嵌入到一组动作的序列中去:

1
2
3
Screen myScreen;
//如果display返回常量引用,则调用set将引发错误
myScreen.display(cout).set('*');

set不是一个const成员函数。

即使myScreen是个非常量对象,对set的调用也无法通过编译。问题在于display的const版本返回的是常量引用,而我们显然无权set一个常量对象。

一个const成员函数如果以引用的形式返回*this,那么它的返回类型将是常量引用。

2.2.基于const的重载

通过区分成员函数是否是const的,我们可以对其进行重载。在下面的这个例子中,我们将定义一个名为do_display的私有成员,由它负责打印Screen的实际工作。所有的display操作都将调用这个函数,然后返回执行操作的对象:

1
2
3
4
5
6
7
8
9
10
class Screen {
public:
	//根据对象是否是const重载了display函数
	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; }
	//其他成员与之前的版本一致
};

和我们之前所学的一样,当一个成员调用另外一个成员时,this指针在其中隐式地传递。因此,当display调用do_display时,它的this指针隐式地传递给do_display。而当display的非常量版本调用do_display时,它的this指针将隐式地从指向非常量的指针转换成指向常量的指针。

当do_display完成后,display函数各自返回解引用this所得的对象。在非常量版本中,this指向一个非常量对象,因此display返回一个普通的(非常量)引用;而const成员则返回一个常量引用。

当我们在某个对象上调用display时,该对象是否是const决定了应该调用display的哪个版本:

1
2
3
4
Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); //调用非常量版本
blank.display(cout); //调用常量版本

3.类类型

每个类定义了唯一的类型。对于两个类来说,即使它们的成员完全一样,这两个类也是两个不同的类型。例如:

1
2
3
4
5
6
7
8
9
10
struct First {
	int memi;
	int getMem();
};
struct Second {
	int memi;
	int getMem();
};
First obj1;
Second obj2 = obj1; //错误:obj1和obj2的类型不同

我们可以把类名作为类型的名字使用,从而直接指向类类型。或者,我们也可以把类名跟在关键字class或struct后面:

1
2
Sales_data item1; //默认初始化Sales_data类型的对象
class Sales_data item1; //一条等价的声明

上面这两种使用类类型的方式是等价的,其中第二种方式从C语言继承而来,并且在C++语言中也是合法的。

3.1.类的声明

就像可以把函数的声明和定义分离开来一样,我们也能仅仅声明类而暂时不定义它:

1
class Screen; //Screen类的声明

这种声明有时被称作前向声明(forward declaration),它向程序中引入了名字Screen并且指明Screen是一种类类型。对于类型Screen来说,在它声明之后定义之前是一个不完全类型(incomplete type),也就是说,此时我们已知Screen是一个类类型,但是不清楚它到底包含哪些成员。

不完全类型只能在非常有限的情景下使用:可以定义指向这种类型的指针或引用,也可以声明(但是不能定义)以不完全类型作为参数或者返回类型的函数。例如:

1
2
3
4
5
class Screen; //只声明未定义
Screen *s1; //正确
Screen &s2=*s1; //正确
Screen s3=*s1; //错误
Screen s4; //错误

对于一个类来说,在我们创建它的对象之前该类必须被定义过,而不能仅仅被声明。否则,编译器就无法了解这样的对象需要多少存储空间。类似的,类也必须首先被定义,然后才能用引用或者指针访问其成员。毕竟,如果类尚未定义,编译器也就不清楚该类到底有哪些成员。

直到类被定义之后数据成员才能被声明成这种类类型(即一个类的成员类型不能是该类自己),但是类允许包含指向它自身类型的引用或指针。例如:

1
2
3
4
5
6
class Link_screen {
	Screen window; //之前Screen类必须已经被定义
	Link_screen *next; //正确
	Link_screen *prev; //正确
	Link_screen Link_test; //错误
};

4.友元再探

【C++基础】第四十二课:[类]访问控制与封装一文中,我们的Sales_data类把三个普通的非成员函数定义成了友元。类还可以把其他的类定义成友元,也可以把其他类的成员函数定义成友元。此外,友元函数能定义在类的内部,这样的函数是隐式内联的。

4.1.类之间的友元关系

举个友元类的例子,我们的Window_mgr类的某些成员可能需要访问它管理的Screen类的内部数据。例如,假设我们需要为Window_mgr添加一个名为clear的成员,它负责把一个指定的Screen的内容都设为空白。为了完成这一任务,clear需要访问Screen的私有成员;而要想令这种访问合法,Screen需要把Window_mgr指定成它的友元:

1
2
3
4
5
class Screen {
	//Window_mgr的成员可以访问Screen类的私有部分
	friend class Window_mgr;
	//Screen类的剩余部分
};

如果一个类指定了友元类,则友元类的成员函数可以访问此类包括非公有成员在内的所有成员。通过上面的声明,Window_mgr被指定为Screen的友元,因此我们可以将Window_mgr的clear成员写成如下的形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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, ' ');
}

如果clear不是Screen的友元,上面的代码将无法通过编译,因为此时clear将不能访问Screen的height、width和contents成员。而当Screen将Window_mgr指定为其友元之后,Screen的所有成员对于Window_mgr就都变成可见的了。

⚠️必须要注意的一点是,友元关系不存在传递性。也就是说,如果Window_mgr有它自己的友元,则这些友元并不具有访问Screen的特权。

每个类负责控制自己的友元类或友元函数。

4.2.令成员函数作为友元

除了令整个Window_mgr作为友元之外,Screen还可以只为clear提供访问权限。当把一个成员函数声明成友元时,我们必须明确指出该成员函数属于哪个类:

1
2
3
4
5
class Screen {
	//Window_mgr::clear必须在Screen类之前被声明
	friend void Window_mgr::clear(ScreenIndex);
	//Screen类的剩余部分
};

要想令某个成员函数作为友元,我们必须仔细组织程序的结构以满足声明和定义的彼此依赖关系。在这个例子中,我们必须按照如下方式设计程序:

  • 首先定义Window_mgr类,其中声明clear函数,但是不能定义它。在clear使用Screen的成员之前必须先声明Screen。
  • 接下来定义Screen,包括对于clear的友元声明。
  • 最后定义clear,此时它才可以使用Screen的成员。

4.3.函数重载和友元

尽管重载函数的名字相同,但它们仍然是不同的函数。因此,如果一个类想把一组重载函数声明成它的友元,它需要对这组函数中的每一个分别声明:

1
2
3
4
5
6
7
8
//重载的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类把接受ostream&的storeOn函数声明成它的友元,但是接受BitMap&作为参数的版本仍然不能访问Screen。

4.4.友元声明和作用域

类和非成员函数的声明不是必须在它们的友元声明之前。当一个名字第一次出现在一个友元声明中时,我们隐式地假定该名字在当前作用域中是可见的。然而,友元本身不一定真的声明在当前作用域中。

甚至就算在类的内部定义该函数,我们也必须在类的外部提供相应的声明从而使得函数可见。换句话说,即使我们仅仅是用声明友元的类的成员调用该友元函数,它也必须是被声明过的:

1
2
3
4
5
6
7
8
9
struct X {
	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的声明在作用域中了

‼️关于这段代码最重要的是理解友元声明的作用是影响访问权限,它本身并非普通意义上的声明。

请注意,有的编译器并不强制执行上述关于友元的限定规则。