【C++基础】第四十一课:[类]定义抽象数据类型

成员函数,this,常量成员函数,类作用域,构造函数,拷贝、赋值和析构

Posted by x-jeff on May 22, 2022

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

1.前言

类的基本思想是数据抽象(data abstraction)封装(encapsulation)。数据抽象是一种依赖于接口(interface)实现(implementation)分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。

封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。

类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。

2.设计Sales_data类

假设我们要设计一个Sales_data类,其接口要包含以下操作:

  • 一个isbn成员函数,用于返回对象的ISBN编号
  • 一个combine成员函数,用于将一个Sales_data对象加到另一个对象上
  • 一个名为add的函数,执行两个Sales_data对象的加法
  • 一个read函数,将数据从istream读入到Sales_data对象中
  • 一个print函数,将Sales_data对象的值输出到ostream

2.1.使用改进的Sales_data类

在考虑如何实现我们的类之前,首先来看看应该如何使用上面这些接口函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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;//通知用户
}

3.定义改进的Sales_data类

改进的Sales_data类的数据成员包括:

  • bookNo,string类型,表示ISBN编号
  • units_sold,unsigned类型,表示某本书的销量
  • revenue,double类型,表示这本书的总销售收入

除此之外,我们的类还将包含三个成员函数:

  • combine
  • isbn
  • avg_price:用于返回售出书籍的平均价格,目的并非通用,所以它应该属于类的实现的一部分,而非接口的一部分

成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,例如add、read和print等,它们的定义和声明都在类的外部。

1
2
3
4
5
6
7
8
9
10
11
12
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::istream &read(std::istream&, Sales_data&);

定义在类内部的函数是隐式的inline函数

3.1.定义成员函数

尽管所有成员都必须在类的内部声明,但是成员函数体可以定义在类内也可以定义在类外。对于Sales_data类来说,isbn函数定义在了类内,而combine和avg_price定义在了类外。

我们首先介绍isbn函数,它的参数列表为空,返回值是一个string对象:

1
std::string isbn() const { return bookNo; }

关于isbn函数一件有意思的事情是:它是如何获得bookNo成员所依赖的对象的呢?

3.2.引入this

让我们再一次观察对isbn成员函数的调用:

1
total.isbn()

在这里,我们使用了点运算符来访问total对象的isbn成员,然后调用它。当isbn返回bookNo时,实际上它隐式地返回total.bookNo。

成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this。例如,如果调用:

1
total.isbn()

则编译器负责把total的地址传递给isbn的隐式形参this,可以等价地认为编译器将该调用重写成了如下的形式:

1
2
//伪代码,用于说明调用成员函数的实际执行过程
Sales_data::isbn(&total)

其中,调用Sales_data的isbn成员时传入了total的地址。

在成员函数内部,我们可以直接使用调用该函数的对象的成员,而无须通过成员访问运算符来做到这一点,因为this所指的正是这个对象。任何对类成员的直接访问都被看作this的隐式引用,也就是说,当isbn使用bookNo时,它隐式地使用this指向的成员,就像我们书写了this->bookNo一样。

->运算符见:【C++基础】第二十五课:成员访问运算符和条件运算符

对于我们来说,this形参是隐式定义的。实际上,任何自定义名为this的形参或变量的行为都是非法的。我们可以在成员函数体内部使用this,因此尽管没有必要,但我们还是能把isbn定义成如下的形式:

1
std::string isbn() const { return this->bookNo; }

因为this的目的总是指向“这个”对象,所以this是一个常量指针,我们不允许改变this中保存的地址。

3.3.引入const成员函数

isbn函数的另一个关键之处是紧随参数列表之后的const关键字,这里,const的作用是修改隐式this指针的类型。

默认情况下,this的类型是指向类类型非常量版本的常量指针(即只是顶层const,不是底层const)。例如在Sales_data成员函数中,this的类型是Sales_data *const。尽管this是隐式的,但它仍然需要遵循初始化规则,意味着(在默认情况下)我们不能把this绑定到一个常量对象上。这一情况也就使得我们不能在一个常量对象上调用普通的成员函数。

如果isbn是一个普通函数而且this是一个普通的指针参数,则我们应该把this声明成const Sales_data *const。毕竟,在isbn的函数体内不会改变this所指的对象,所以把this设置为指向常量的指针有助于提高函数的灵活性。

然而,this是隐式的并且不会出现在参数列表中,所以在哪儿将this声明成指向常量的指针就成为我们必须面对的问题。C++语言的做法是允许把const关键字放在成员函数的参数列表之后,此时,紧跟在参数列表后面的const表示this是一个指向常量的指针。像这样使用const的成员函数被称作常量成员函数(const member function)

可以把isbn的函数体想象成如下的形式:

1
2
3
4
5
//伪代码,说明隐式的this指针是如何使用的
//下面的代码是非法的:因为我们不能显式地定义自己的this指针
//谨记此处的this是一个指向常量的指针,因为isbn是一个常量成员
std::string Sales_data::isbn( const Sales_data *const this)
{ return this->isbn; }

常量对象,以及常量对象的引用或指针都只能调用常量成员函数。

3.4.类作用域和成员函数

类本身就是一个作用域。类的成员函数的定义嵌套在类的作用域之内。值得注意的是,即使bookNo定义在isbn之后,isbn也还是能够使用bookNo。因为编译器分两步处理类:首先编译成员的声明,然后才轮到成员函数体(如果有的话)。因此,成员函数体可以随意使用类中的其他成员而无须在意这些成员出现的次序。

3.5.在类的外部定义成员函数

像其他函数一样,当我们在类的外部定义成员函数时,成员函数的定义必须与它的声明匹配。也就是说,返回类型、参数列表和函数名都得与类内部的声明保持一致。如果成员被声明成常量成员函数,那么它的定义也必须在参数列表后明确指定const属性。同时,类外部定义的成员的名字必须包含它所属的类名:

1
2
3
4
5
6
double Sales_data::avg_price() const {
	if (units_sold)
		return revenue/units_sold;
	else
		return 0;
}

函数名Sales_data::avg_price使用作用域运算符来说明如下的事实:我们定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域内。一旦编译器看到这个函数名,就能理解剩余的代码是位于类的作用域内的。

3.6.定义一个返回this对象的函数

将combine函数定义如下:

1
2
3
4
5
6
Sales_data& Sales_data::combine(const Sales_data &rhs)
{
	units_sold += rhs.units_sold;//把rhs的成员加到this对象的成员上
	revenue += rhs.revenue;
	return *this;//返回调用该函数的对象
}

该函数一个值得关注的部分是它的返回类型和返回语句。函数的左侧运算对象被当成左值返回,因此combine函数必须返回引用类型(详见:引用返回左值)。

试了下返回类型不用引用,也可以正常运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

using namespace std;


struct Sales_data {
    float units_sold;
    float revenue;

    Sales_data combine(const Sales_data &rhs);
};

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

int main() {
    Sales_data A, B;
    A.units_sold = 1;
    A.revenue = 2;
    B.units_sold = 3;
    B.revenue = 4;
    A.combine(B);
    cout << A.units_sold << endl;//4
    cout << A.revenue << endl;//6
    cout << B.units_sold << endl;//3
    cout << B.revenue << endl;//4
}

其实不用return *this;也可以得到一样的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>

using namespace std;


struct Sales_data {
    float units_sold;
    float revenue;

    void combine(const Sales_data &rhs);
};

void Sales_data::combine(const Sales_data &rhs) {
    units_sold += rhs.units_sold;//把rhs的成员加到this对象的成员上
    revenue += rhs.revenue;
}

int main() {
    Sales_data A, B;
    A.units_sold = 1;
    A.revenue = 2;
    B.units_sold = 3;
    B.revenue = 4;
    A.combine(B);
    cout << A.units_sold << endl;//4
    cout << A.revenue << endl;//6
    cout << B.units_sold << endl;//3
    cout << B.revenue << endl;//4
}

4.定义类相关的非成员函数

一般来说,如果非成员函数是类接口的组成部分,则这些函数的声明应该与类在同一个头文件内。

4.1.定义read和print函数

1
2
3
4
5
6
7
8
9
10
11
12
13
//输入的交易信息包括ISBN、售出总数和售出价格
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函数则负责将给定对象的内容打印到给定的流中。

read和print分别接受一个各自IO类型的引用作为其参数,这是因为IO类属于不能被拷贝的类型,因此我们只能通过引用来传递它们。

4.2.定义add函数

1
2
3
4
5
6
Sales_data add(const Sales_data &lhs, const Sales_data &rhs)
{
	Sales_data sum = lhs;//把lhs的数据成员拷贝给sum
	sum.combine(rhs);//把rhs的数据成员加到sum当中
	return sum;
}

我们用lhs的副本来初始化sum,默认情况下,拷贝类的对象其实拷贝的是对象的数据成员。

5.构造函数

每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。

构造函数是一个非常复杂的问题,本文将介绍构造函数的基础知识,后续会继续深入介绍。

构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型;除此之外类似于其他的函数,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数之间必须在参数数量或参数类型上有所区别。

不同于其他成员函数,构造函数不能被声明成const的。当我们创建类的一个const对象时,直到构造函数完成初始化过程,对象才能真正取得其“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。

5.1.合成的默认构造函数

我们的Sales_data类并没有定义任何构造函数,可是之前使用了Sales_data对象的程序仍然可以正确地编译和运行。例如定义两个对象:

1
2
Sales_data total;
Sales_data trans;

此时,total和trans是如何初始化的呢?

我们没有为这些对象提供初始值,因此我们知道它们执行了默认初始化。类通过一个特殊的构造函数来控制默认初始化过程,这个函数叫做默认构造函数(default constructor)。默认构造函数无须任何实参。

如我们所见,默认构造函数在很多方面都有其特殊性。其中之一是,如果我们的类没有显式地定义构造函数,那么编译器就会为我们隐式地定义一个默认构造函数。

编译器创建的构造函数又被称为合成的默认构造函数(synthesized default constructor)。对于大多数类来说,这个合成的默认构造函数将按照如下规则初始化类的数据成员:

  • 如果存在类内的初始值,用它来初始化成员。
  • 否则,默认初始化该成员。

5.2.某些类不能依赖于合成的默认构造函数

合成的默认构造函数只适合非常简单的类。对于一个普通的类来说,必须定义它自己的默认构造函数,原因有三:

  1. 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认的构造函数。一旦我们定义了一些其他的构造函数,那么除非我们再定义一个默认的构造函数,否则类将没有默认构造函数。
  2. 对于某些类来说,合成的默认构造函数可能执行错误的操作。如果定义在块中的内置类型或复合类型(比如数组和指针)的对象被默认初始化,则它们的值将是未定义的。该准则同样适用于默认初始化的内置类型成员。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者定义一个自己的默认构造函数。否则,用户在创建类的对象时就可能得到未定义的值。
  3. 有的时候编译器不能为某些类合成默认的构造函数。例如,如果类中包含一个其他类类型的成员且这个成员的类型没有默认构造函数,那么编译器将无法初始化该成员。对于这样的类来说,我们必须自定义默认构造函数,否则该类将没有可用的默认构造函数。

5.3.定义Sales_data的构造函数

对于我们的Sales_data类来说,我们将使用下面的参数定义4个不同的构造函数:

  • 一个istream&,从中读取一条交易信息。
  • 一个const string&,表示ISBN编号;一个unsigned,表示售出的图书数量;以及一个double,表示图书的售出价格。
  • 一个const string&,表示ISBN编号;编译器将赋予其他成员默认值。
  • 一个空参数列表(即默认构造函数),正如刚刚介绍的,既然我们已经定义了其他构造函数,那么也必须定义一个默认构造函数。

给类添加了这些成员之后,将得到:

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

5.4.=default的含义

1
Sales_data() = default;

因为该构造函数不接受任何实参,所以它是一个默认构造函数。我们定义这个构造函数的目的仅仅是因为我们既需要其他形式的构造函数,也需要默认的构造函数。

在C++11新标准中,如果我们需要默认的行为,那么可以通过在参数列表后面写上=default来要求编译器生成构造函数。其中,=default既可以和声明一起出现在类的内部,也可以作为定义出现在类的外部。和其他函数一样,如果=default在类的内部,则默认构造函数是内联的;如果它在类的外部,则该成员默认情况下不是内联的。

上面的默认构造函数之所以对Sales_data有效,是因为我们为内置类型的数据成员提供了初始值。如果你的编译器不支持类内初始值,那么你的默认构造函数就应该使用构造函数初始值列表来初始化类的每个成员。

5.5.构造函数初始值列表

接下来介绍类中定义的另外两个构造函数:

1
2
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),它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。

只有一个string类型参数的构造函数使用这个string对象初始化bookNo,对于units_sold和revenue则没有显式地初始化。当某个数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。在此例中,这样的成员使用类内初始值初始化,因此只接受一个string参数的构造函数等价于:

1
Sales_data(const std::string &s) : bookNo(s), units_sold(0), revenue(0) { }

不过,如果你的编译器不支持类内初始值,则所有构造函数都应该显式地初始化每个内置类型的成员。

有一点需要注意,在上面的两个构造函数中函数体都是空的。这是因为这些构造函数的唯一目的就是为数据成员赋初值,一旦没有其他任务需要执行,函数体也就为空了。

5.6.在类的外部定义构造函数

与其他几个构造函数不同,以istream为参数的构造函数需要执行一些实际的操作。在它的函数体内,调用了read函数以给数据成员赋以初值:

1
2
3
4
Sales_data::Sales_data(std::istream &is)
{
	read(is, *this);//read函数的作用是从is中读取一条交易信息然后存入this对象中
}

构造函数没有返回类型,所以上述定义从我们指定的函数名字开始。和其他成员函数一样,当我们在类的外部定义构造函数时,必须指明该构造函数是哪个类的成员。因此,Sales_data::Sales_data的含义是我们定义Sales_data类的成员,它的名字是Sales_data。又因为该成员的名字和类名相同,所以它是一个构造函数。

这个构造函数没有构造函数初始值列表,或者讲得更准确一点,它的构造函数初始值列表是空的。尽管构造函数初始值列表是空的,但是由于执行了构造函数体,所以对象的成员仍然能被初始化。

6.拷贝、赋值和析构

除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。如果我们不主动定义这些操作,则编译器将替我们合成它们。例如:

1
total = trans;

相当于:

1
2
3
4
//Sales_data的默认赋值操作等价于:
total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;

后续博客将介绍如何自定义上述操作。

6.1.某些类不能依赖于合成的版本

尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是必须要清楚的一点是,对于某些类来说合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。例如,管理动态内存的类通常不能依赖于上述操作的合成版本。

不过值得注意的是,很多需要动态内存的类能(而且应该)使用vector对象或者string对象管理必要的存储空间。使用vector或者string的类能避免分配和释放内存带来的复杂性。

进一步讲,如果类包含vector或者string成员,则其拷贝、赋值和销毁的合成版本能够正常工作。