C++ 学习笔记(九)(类篇二)

主要是自己学习过程的积累笔记,所以跳跃性比较强,建议先自学后拿来作为复习用。上半篇传送门:C++ 学习笔记(八)(类篇一)

4 类的其它特性

4.1 类成员更多特性

4.1.1 隐藏实现细节

读者可以把每一处的代码复制到一个文本中,就不需要重复的往前看了。

首先定义一个新的 Screen 类:

class Screen
{
public:
	// 为 string::siez_type 取一个别名 pos
	typedef string::size_type pos;
	// 因为 Screen 有自定义的构造函数,所以必须加上这句话
	Screen() = default;
	// 能够自定义屏幕大小和内容的构造函数
	Screen(pos ht, pos wd, char c) : height(ht), width(wd), contents(ht * wd, c) {}		
	// 一个常量成员函数,用于获取光标处的字符,隐式的内联函数
	char get() const { return contents[cursor]; }
	// get() 的重载函数,用于获取给定位置的字符,个显式的内联函数
	inline char get(pos ht, pos wd) const;
	// 该函数负责移动光标,可在之后被设定为内联函数,
	Screen &move(pos r, pos c);
	
private:
	pos cursor = 0;				// 光标的位置
	pos height = 0, width = 0;	// 屏幕的高和宽
	string contents;			// 保存屏幕的内容
};

几点需要注意:

  • 使用类型别名等价的声明一个类型的名字,还可以用 using。这么做的目的是为了隐藏 Screen 实现的细节。
using pos = string::size_type;
  • string::size_type 是一种由 string 类定义的类型。它是一个无符号整型数,可以保证足够大,能够存储任意字符串的大小。它在不同的机器上,长度是可以不同的,并非固定的长度。
  • 自定义的构造函数(接受三个参数那个)中不包含 cursor,由 2.3.4 的内容可知,此时 cursor 会使用类提供的初始值初始化,如果类没有提供则进行默认初始化。

4.1.2 类中的内联函数

2.1.1 中提到,我们通常在类的内部声明函数,在类的外部定义函数。如果一个函数在类的内部实现了,那么它会被指定为内联函数。所以 Screen 类中,获取光标处的字符的 get 函数(即第一个 get 函数)默认是内联的。当然,在类的内部和类的外部都能用 inline 关键字修饰函数。

// 在类的外部定义函数,用 inline 修饰
inline Screen &Screen::move(pos r, pos c)
{
	pos row = r * width;		// 计算行的位置
	cursor = row + c;			// 在行内将光标移动到指定的列
	return *this;				// 以左值的形式返回对象
}

// 在类的外部定义函数,inline 在类内已经修饰过了
char Screen::get(pos r, pos c) const
{
	pos row = r * width;		// 计算行的位置
	return contnets[row + c];	// 返回给定列的字符
}

4.1.3 mutable 关键字

通常来讲,我们不会改变常量对象的数据成员,但有些情况下我们确实有这样的需求。比如需要在 Screen 类中加入一个专门用于统计函数调用次数的变量,那么即便是常量对象调用了函数,也需要改变变量值。通过 mutable 关键字就可将一个变量声明成可变数据成员。这样即使是常量成员函数,也可以改变可变数据成员的值:

class Scrren
{
public:
	void call_count() const;
private:
	mutable size_t count;	// 声明可变数据成员,所有成员函数都能修改它
	// 其他成员与之前的保持一致
};

// 即使是常量成员函数也能修改可变数据成员的值
void Screen::call_count() const
{
	++count;	// 累积计数值,用于记录成员函数被调用的次数
	// 完成其他的工作
}

4.1.4 类数据成员的初始值

继续定义一个窗口管理类 Window_mgr,用于管理显示器上的一组 Screen,它包含一个 Screen 类型的 vector。我们希望 Window_mgr 类拥有一个默认初始化的 Screen,就可以这么写:

class Window_mgr
{
private:
	// 一个 Window_mgr 对象都包含一个标准尺寸的空白 Screen
	// 调用了 Screen 的第二个构造函数进行初始化
	vector<Screen> screens{Screen(24, 80, ' ';)};
};

4.2 返回 *this 的成员函数

4.2.1 从普通成员函数返回 *this

继续添加名为 set 的成员函数,该函数负责设置光标所在位置的字符,或者任一给定位置上的字符:

class Screen
{
public:
	// 声明函数时可以先不指定具体的参数名称
	Screen &set(char);
	Screen &set(pos, pos, char);
};

inline Screen &Screen::set(char c)
{
	contents[cursor] = c;	// 将当前光标所在位置的字符设置为 c
	return *this;			// 将 this 对象作为左值返回
}

inline Screen &Screen::set(pos r, pos c, char c)
{
	contents[r * width + c] = c;	// 将给定位置的字符设置为 c
	return *this;					// 将 this 对象作为左值返回
}

对比一下 4.1.2 中的 move 函数:

inline Screen &Screen::move(pos r, pos c)
{
	pos row = r * width;		// 计算行的位置
	cursor = row + c;			// 在行内将光标移动到指定的列
	return *this;				// 以左值的形式返回对象
}

如果它们都返回调用函数的对象的引用,就可以把这些操作连在一条表达式中(要注意的是,连在一条表达式中时,函数的调用是从左到右进行的):

Screen myScreen;
// 把光标移动到一个指定的位置,然后将该位置的字符设置为 #
myScreen.move(4, 0).set('#');
// 相当于连续执行下面两条语句
myScreen.move(4, 0);
myScreen.set('#');

假如它们返回的是 Screen 而不是 Screen&,上面的两条语句就会变成:

Screen temp = myScreen.move(4, 0);
temp.set('#');

此时 move 函数返回的是 myScreen 的副本而不是 myScreen,也就无法继续设置 myScreen 的字符了。

4.2.2 从常量成员函数返回 *this

继续添加名为 display 的函数,该函数负责打印 Screen 的内容。我们希望它也能与 move 和 set 函数连起来,返回调用函数的对象的引用。

理一下定义它的逻辑:显示一个 Screen 并不需要改变它的内容,所以我们可以将它声明成常量成员函数,即:

Screen &display(ostream os) const;

此时的 this 就变成了一个指向常量的常量指针,从而解引用 this 指针得到的是一个常量,所以函数的返回类型也要改成常量类型,从而 display 函数可以声明成:

const Screen &display(ostream os) const;

那么问题就来了,在这种情况下函数返回的是一个对常量的引用,这就意味着后面针对 myScreen 的各种修改都是不合法的。一个常量成员函数如果以引用的形式返回 *this,那么它的返回类型也一定得是对常量的引用。因此如果希望打印内容后对对象进行一些修改,就不能将函数声明成常量成员函数。

4.2.3 基于 const 的重载

对于一个成员函数来说,可以定义常量版本和非常量版本,相当于重载函数。一个对象如果调用该成员函数,具体调用前者还是后者取决于对象是否是常量。比如我们定义 display 的重载函数:

class Screen
{
public:
	// display 函数的非常量版本
	Screen &display(ostream &os)
		{ os << contents; return *this; }
	// display 函数的常量版本
	const Screen &display(ostream &os) const
		{ os << contents; return *this; }
};

Screen myScreen_1(5, 3);			// 非常量对象
const Screen myScreen_2(5, 3);		// 常量对象
myScreen_1.set('#').display(cout);	// 非常量对象调用非常量版本
myScreen_2.display(cout);			// 常量对象调用常量版本

4.2.4 前向声明

类似于将函数的定义和声明分开,类也可以只声明而不定义:

class Screen;	// Screen 类的声明

这种声明就叫前向声明。在 Screen 类的定义出现之前,它都是一个不完全类型:仅仅知道 Screen 是一个类,但不知道有哪些成员。我们可以定义指向这种类型的指针或引用,也可以在其他函数声明中以不完全类型作为参数或者返回的类型。

但是在创建类的对象之前必须先把类的定义补充完整,因为创建对象就需要确定存储空间了,如果仅仅是声明过,编译器便无法获知类的准确大小。

4.3 友元再探

4.3.1 类之间的友元关系

假如我们要为 Window_mgr 类添加一个名为 clear 的成员,它负责把一个指定的 Screen 的内容清空。因此,clear 需要能够访问 Screen 的私有成员,要使得这种访问合法,就需要把 Window_mgr 类指定为 Screen 类的友元:

class Screen
{
	 // window_mgr 的成员可以访问 Screen 类的私有部分
	 friend class Window_mgr;
	 // Screen 类的剩余部分
};

Screen 类把 Window_mgr 指定为了友元,那么 Window_mgr 的成员函数就能访问 Screen 的所有成员。所以 clear 函数就能这么写:

void Window_mgr::clear(ScreenIndex i)
{
	// s 是一个 Screen 的引用,指向我们想清空的屏幕
	Scrren &s = screens[i];
	// 将指定的 Screen 清空
	s.contents = string(s.height * s.width, ' ');
}

在 clear 函数中,引用 s 首先被绑定到 screens 中第 i 个位置上的 Screen,然后利用该 Screen 的 height 和 width 来计算出一个新的 string 对象,并赋给它的 contents 成员。如果 Window_mgr 不是 Screen 的友元,clear 就不可能访问得到这些数据成员。

还有一点要注意的是,友元关系不能传递。A 是 B 的友元,B 是 C 的友元,不能认为 A 是 C 的友元。每个类只负责控制自己的友元类和友元函数

4.3.2 令其他类的成员函数为友元

如果说 Screen 只想为 clear 提供访问权限,就可以单独指定它为友元函数,此时必须明确指出该成员函数属于哪个类:

class Screen
{
	friend void Window_mgr::clear(ScreenIndex);
	// Screen 类的剩余部分
};

在这里,Window_mgr::clear 必须在 Screen 类之前被声明。要想令其他类里的成员函数为友元,就必须好好组织程序的结构,以满足声明和定义的彼此依赖关系。在此种情况下,可以按照如下方式设计程序:

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

如果一个类想把一组重载函数声明成它的友元,就必须对每个重载函数都进行一次友元声明。

最后还是要重点强调一点,友元的声明不是普通意义上的声明,我们仍然需要对友元函数进行额外的声明!

5 构造函数再探

5.1 构造函数初始值列表

5.1.1 初始化和赋值的区别

在 2.3.4 中我们提到过初始值列表。如果没有在构造函数的初始值列表中显式地初始化成员,则该成员将在编译器进入函数体之前执行默认初始化。例如:

// 未提供构造函数初始值列表的构造函数
Screen(const string &s, pos ht, pos wd)
{
	contents = s;
	height = ht;
	width = wd;
}
// 提供了构造函数初始值列表的构造函数
Screen(const string &s, pos ht, pos wd) : 
	contents(s), height(ht), width(wd) {}

这两个构造函数从结果来看是没有区别的,都给变量提供了相应的初始值。但它们的区别在于,前者是在函数体中对数据成员进行赋值操作,后者是在进入函数体前就执行了初始化。有时候我们可以忽略这两个方法之间的差异,但并非总能这样。比如当成员是常量或者引用时,就必须将先将它初始化;或者说当成员是另一个类的对象,而该类没有定义默认构造函数时,也必须先将这个成员初始化。例如:

class ConstRef
{
public:
	ConstRef(int a);	// 该类的构造函数
private:
	int b;
	const int c;
	int &d;
};

// 类外定义构造函数
ConstRef::ConstRef(int a)
{
	// 赋值
	b = a;	// 正确
	c = a;	// 错误:c 是常量,不能给常量赋值
	d = b;	// 错误:d 是引用,没有初始化过,不能赋值
}

在这个类中,成员 c 和成员 d 都必须被初始化。如果用该构造函数创建对象,编译器进入函数体时,c 和 d 的初始化就已经完成了,此时再去给常量 c 或者引用 d 赋值就会引发错误。因此,初始化常量或者引用类型的数据成员的唯一机会就是通过构造函数初始值,该构造函数的正确形式应该是:

ConstRef::ConstRef(int a) : b(a), c(a), d(b) {}

谨记:如果某个类的数据成员是常量、引用或者另一个未提供默认构造函数的类类型,就只能通过构造函数初始值列表为这些成员提供初值,或者在类中定义时就指定初值。其次,构造函数初始值列表是直接初始化数据成员,而另外一个操作是初始化过后再去函数体中赋值,后者效率更低。

5.1.2 成员初始化的顺序

数据成员的初始化顺序只与它们在类中的定义的顺序有关,与构造函数初始值列表中的顺序无关。当我们需要用一个成员来初始化另一个成员时,就需要考虑它们在类中定义的先后顺序了:

class X
{
	int i;
	int j;
public:
	X(int value) : j(value), i(j) {}
};

在这个构造函数中,看似是先用 value 初始化 j,再用 j 初始化 i。实际上编译器是先初始化 i 的,所以这是在尝试用一个未定义的 j 的值来初始化 i。如果可能的话,最好用构造函数的参数作为成员的初始化,而不要用一个成员去初始化另一个成员。

补充一点:如果一个构造函数为所有参数都提供了默认实参,那么它实际上就是一个默认构造函数,比如:

X(int value_1 = 1, int value_2 = 2) : i (value_1), j(value_2) {}

默认实参的知识可参考:C++ 学习笔记(四)(函数篇) 5.1节的内容。

5.2 委托构造函数

委托构造函数本身是一个构造函数,但它可以使用其它的构造函数来执行初始化,也就是说,把它自己的部分或全部职责委托给了其它的构造函数。比如我们重写 Sales 类:

class Sales
{
public:
	// 非委托构造函数使用对应的实参初始化成员
	Sales(string s, int cnt, double price) : 
		bookNo(s), sold(cnt), revenue(cnt * price) {}	// 三参数构造函数
	// 其余构造函数全都委托给了其它的构造函数
	Sales() : Sales("", 0, 0) {}						// 第一个委托构造函数
	Sales(string s) : Sales(s, 0, 0) {}					// 第二个委托构造函数
	Sales(istream &is) : Sales() { read(is, *this); }	// 第三个委托构造函数
	// 其他成员与之前的版本一致
};

Sales 类的初始版本在 2.2 节中。第一个委托构造函数是默认构造函数(因为它不接受任何实参),它将初始化的任务交给了三参数构造函数;第二个委托构造函数接受一个参数,然后再委托给三参数构造函数。第三个委托构造函数将初始化过程委托给了默认构造函数,再由默认构造函数委托给三参数构造函数。

5.3 默认构造函数的作用

当对象被默认初始化或值初始化时自动执行默认构造函数。

发生以下情况会执行默认初始化

  • 在块作用域内不使用任何初始值定义一个非静态变量或者数组。
  • 类本身含有类类型的成员且使用合成的默认构造函数。
  • 类类型的成员没有在构造函数初始值列表中显式地初始化。

发生以下情况会执行值初始化

  • 数组初始化的过程中,提供的初始值数量少于数组的大小。
  • 不使用初始值定义一个局部静态变量。
  • 通过书写形式如 T() 的表达式显式地请求值初始化,其中 T 是类型名。

5.4 隐式的类类型转换

5.4.1 隐式的类类型转换

类似于内置类型之间的自动转换规则,我们也能为类定义隐式转换规则。如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换规则。这种构造函数被称为转换构造函数

比如在 Sales 类中:

class Sales
{
public:
	// 非委托构造函数使用对应的实参初始化成员
	Sales(string s, int cnt, double price) : 
		bookNo(s), sold(cnt), revenue(cnt * price) {}	// 三参数构造函数
	// 其余构造函数全都委托给了其它的构造函数
	Sales() : Sales("", 0, 0) {}						// 第一个委托构造函数
	Sales(string s) : Sales(s, 0, 0) {}					// 第二个委托构造函数
	Sales(istream &is) : Sales() { read(is, *this); }	// 第三个委托构造函数
	// 其他成员与之前的版本一致
};

在 Sales 类中,接受 string 和 istream 的构造函数分别定义了从这两种类型向 Sales 隐式转换的规则。为什么这么说呢,可以看下面一段代码:

string null_book = "nihaoya,world!";
// 构造一个临时的 Sales 对象
// 该对象的 sold 和 revenue 等于0,bookNo 等于 null_book
item.combine(null_book);

// combine 函数的定义
Sales& Sales::combine(const Sales &rhs)
{
    sold += rhs.sold;	// 把 rhs 的成员加到 this 对象的成员上
    revenue += rhs.revenue;
    return *this;		// 返回调用该函数的对象
}

combine 函数只接受 const Sales& 类型的参数,但是在这里我们向 combine 函数传入了 string 类型的 null_book,而且这么做是合法的。因为编译器会利用 null_book 创建一个临时的 Sales 对象,这个临时对象才是真正传递给 combine 函数的参数。

5.4.2 只允许一步类类型转换

编译器只会执行一步类型转换,比如:

item.combine("nihaoya,world!");

上述代码是错误的,因为"nihaoya,world!"首先需要转换成 string 类型,才能再转换成 Sales 类型。如果想完成上述调用,可以先显式的把字符串转换成 string 或 Sales 对象:

// 正确:显式地转换成 string,隐式地转换成 Sales
item.combine(string("nihaoya,world!"));
// 正确:隐式地转换成 string,显式地转换成 Sales
item.combine(Sales("nihaoya,world!"));

5.4.3 抑制构造函数定义的隐式转换

可以通过将构造函数声明为 explicit 来阻止隐式转换:

class Sales
{
public:
	Sales() = default;
	Sales(const string &s, int n, double p) : 
		bookNo(s), sold(n), revenue(p * n) {}
	explicit Sales(const string &s) : bookNo(s) {}
	explicit Sales(istream&);
	// 其他成员与之前的版本一致
};

item.combine(null_book);
item.combine(cin);

没有任何构造函数能用于隐式地创建 Sales 对象,所以上述两个调用会变成错误的。但是关键字 explicit 只对一个实参的构造函数有效需要多个实参的构造函数不能用于执行隐式转换,所以无须将这些构造函数指定为 explicit 的。而且 explicit 关键字只能用于类内声明构造函数,在类的外部不能重复出现。

5.5 聚合类

当一个类满足如下条件时,我们说它是聚合的:

  • 所有成员都是 public 的。
  • 没有定义任何构造函数。
  • 没有类内初始值。
  • 没有基类,也没有 virtual 函数。

聚合类使得用户可以直接访问其成员,并且有特殊的初始化语法:

// struct 下的成员默认是 public 的
struct Data
{
	int value;
	string s;
};

// 其初始化的方法如下
Data data = {0, "Sakura"};

聚合类初始化时,初始值的顺序必须与声明的顺序一致。 与初始化数组元素的规则一样,如果初始值列表中的元素个数少于类的成员数量,靠后的成员会执行值初始化。

5.6 字面值常量类

5.6.1 含义

数据成员都是字面值类型聚合类字面值常量类。如果它不是聚合类,符合下述要求也属于字面值常量类:

  • 数据成员都是字面值类型。
  • 类中至少有一个 constexpr 构造函数。
  • 如果一个数据成员含有类内初始值,则内置类型成员的初始值必须是一条常量表达式。
  • 如果成员属于某种类类型,则初始值必须使用成员自己的 constexpr 构造函数。
  • 类必须使用析构函数的默认定义,该成员负责销毁类的对象。

5.6.2 constexpr 构造函数

constexpr 函数的知识可参考:C++ 学习笔记(四)(函数篇) 5.2.2节。

只有字面值常量类的构造函数可以是 constexpr 函数,而且字面值常量类至少得有一个 constexpr 构造函数。constexpr 构造函数可以声明成 = default 的形式,或者是删除函数的形式。它既得符合构造函数的要求(不能包含返回语句),又得符合 constexpr 函数的要求(能拥有的唯一一条可执行语句就是返回语句),综合起来就可知道,constexpr 构造函数的函数体必须是空的。

class Sample
{
public:
	// constexpr 构造函数的函数体不能包含任何语句
	constexpr Sample(int value = 1) : a(value) {}
	constexpr Sample(int value_1, double value_2) : a(value_1), b(value_2) {}
private:
	int a;
	double b;
};

constexpr 构造函数必须初始化所有的数据成员。初始值可以使用 constexpr 构造函数设置,或者由一条常量表达式提供。

6 类的静态成员

6.1 声明静态成员

通过在成员的声明前加上 static 关键字使其与关本身联在一起。静态成员既可以是 public 的,也可以是 private 的。其类型可以是常量、引用、指针、类类型等。静态成员不属于任何对象,对象中也不包含任何与静态数据成员有关的数据。它的值被所有对象共享,只能在类中进行更改。

class Sample
{
public:
	void add() {}
	static getUser() { return user; }
	static getSum() { return sum; }
private:
	static string user;
	static int a;
	static int b;
	int sum;
};

类似的,静态成员函数也不与任何对象绑定在一起,也不包含 this 指针,也就不能在 static 函数体内使用 this 指针。静态成员函数不能声明常量成员函数,可以直接通过类名+类作用域运算符+函数名进行调用。

6.2 使用静态成员

可以使用类作用域运算符直接访问静态成员:

int result;
result = Sample::getSum();	// 使用作用域运算符访问静态成员

虽然静态成员不属于类的任何对象,但是我们仍然可以使用类的对象、引用或者指针来访问静态成员:

Sample sp1;
Sample *sp2 = &sp1;
// 调用静态成员函数 getSum 的等价形式
result = sp1.getSum();		// 通过 sp1 的对象或引用
result = sp2->getSum();		// 通过指向 sp1 对象的指针

此外,成员函数不需要通过作用域运算符就能访问静态成员。

6.3 定义静态成员

我们既可以在类的内部,也可以在类的外部定义静态成员函数。在外部定义时,不能重复 static 关键字,该关键字只能出现在类的内部在类的外部定义时,也需要指明函数属于哪个类

因为静态数据成员不属于类的任何一个对象,所以它们并不是在创建类的对象时被定义的。这意味着它们不是由类的构造函数初始化的。而且一般来说,我们必须在类的外部定义和初始化每个静态成员,每个静态成员只能定义一次。和全局变量一样,一旦静态成员被定义,就一直存在于程序的整个生命周期中。

6.4 静态成员的类内初始化

只能在类的外部初始化静态成员也是有一个例外的:我们可以为静态成员提供 const 整数类型的类内初始值,不过这就要求静态成员必须是字面值常量类型的 constexpr。其初始值必须是常量表达式,因为这些成员本身就是常量表达式,所以它们能用在所有适合于常量表达式的地方。

class Sample
{
private:
	static constexpr int len = 10;	// len 是常量表达式
	static const int len2 = 10;		// 这样也是对的
	int array[len];
};

如上例中用一个初始化了的静态成员来指定数组的长度。

6.5 静态成员与普通成员应用场景的区别

在某些场合,静态成员可以正常使用,而非静态成员可能就不行。比如静态成员可以是不完全类型。尤其是,静态成员的类型可以是它所属的类类型,而非静态成员只能声明成它所属类的指针或引用:

class Sample
{
private:
	static Sample a;	// 正确:静态成员可以是不完全类型
	Sample *b;			// 正确:指针成员可以是不完全类型
	Sample c;			// 错误:数据成员必须是完全类型
};

另外一个区别在于,我们可以将静态成员作为默认实参:

class Sample
{
public:
	Sample &function(int b = a);
private:
	static const int a;
};

非静态成员是不能作为默认实参的,因为它的值属于对象的一部分,这么做的结果是无法真正提供一个对象以便从中获取成员的值,最终将引发错误。


希望本篇博客能对你有所帮助,也希望看官能动动小手点个赞哟~~。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值