类
这部分内容对应于 C++ Primer 第五版的第七章全部内容。原书当中的内容包括:
- 7.1 定义抽象数据类型;
- 7.2 访问控制与封装;
- 7.3 类的其它特性;
- 7.4 类的作用域;
- 7.5 构造函数再探;
- 7.6 类的静态成员;
类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于**接口(interface)和实现(implementation)**分离的编程技术。
类的接口包括用户可以执行的操作。而类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需要的各种私有函数。
封装实现了类的接口和实现的分厘。封装后的类隐藏了类的实现细节,即:类的用户只能够通过接口来执行类的操作,或是访问类的数据,而无法访问实现的部分。
要想实现类的数据抽象和封装,首先要做的就是定义一个抽象数据类型(abstract data type)。
7.1 定义抽象数据类型
7.1.1 设计 Sale_data 类
顺承 C++ Primer 原书当中的上下文,在第一章开始时,原书展示了一个Sales_data
类的设计,但最初的实现并不完整,在本书的此部分予以补全。
Sales_data
的接口应该包含以下操作:
- 一个 isbn 成员函数,返回对象的 ISBN 编号;
- 一个 combine 成员函数,用于将一个 Sales_data 对象加到另一个之上;
- 一个 add 函数,执行两个 Sales_data 对象的加法(实际上可以通过运算符的重载来完成,对应于原书内容的第十四章);
- 一个 read 函数,将数据从 istream 读入到 Sales_data 对象当中(这里的 istream 指的是输入流,对于输入流我目前尚不了解,但我推测 C++ 的输入/输出流应该是可以执行各种的重定向,比如重定向到文件的输入,或是直接从键盘输入,输入流 istream 应该是上述具体输入形式的抽象);
- 一个 print 函数,将 Sales_data 对象的值输出到 ostream;
使用改进的 Sales_data 类
以下是一个使用 Sales_data 类接口的例子:
Sales_data total;
if(read(cin, total)) {
Sales_data trans;
while(read(cin, trans)) {
if(total.isbn() == trans.isbn()) total.combine(trans);
else {
print(cout, total) << endl; // 输出结果, cout 为函数的一部分
// 说明输出流执行在了命令行窗口当中
total = trans;
}
print(cout, total) << endl;
} else {
cerr << "No data ?!" << endl;
}
}
最初定义的 Sales_data 对象 total 用于保存实时的汇总信息。在 if 内部使用 read 函数将第一条交易读入到 total 当中。read 函数返回它的流参数,条件部分负责检查返回值是否为真。
如果检测到输入数据,定义 trans 用于存放每一条交易。如果 total 和 trans 指的是一本书,调用 combine 将 trans 的内容加到 total 表示的实时汇总结果。如果 trans 指的是新书,那么使用 print 函数将前一本书的汇总信息输出出来。由于 print 返回的是流参数的引用,可以把 print 的返回值作为 <<
运算符的左侧运算对象。
7.1.2 定义改进的 Sales_data 类
改进之后的 Sale_data 类如下:
struct Sales_data { // 原书当中此处仍然使用 struct 来对 Sales_data 复合数据类型进行声明
// 新成员: 关于 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 函数(内联函数)
定义成员函数
类的所有成员函数必须在类的内部声明,但成员函数体可以在类内也可以在类外进行定义。对于上述的 Sales_data 类来说,isbn 函数定义在了类内,而 combine 和 avg_price 只是声明在了类内,它们的定义会在类外。
首先介绍 isbn 函数,它的参数列表为空,返回值是一个 string 对象:
std::string isbn() const { return bookNo; }
关于 isbn 成员函数有一个有趣的事,即:它是如何获得 bookNo 成员所依赖的对象的呢?
引入 this 指针
一次 isbn 成员函数的调用如下:
total.isbn();
此处我们使用了点运算符来对 total 对象的 isbn 成员进行调用。实际上,当我们在调用成员函数时,实际上是在替某个对象调用它。如果 isbn 指向 Sales_data 的成员,则隐式地指向调用该函数的对象的成员。在上面的调用中,当 isbn 返回 bookNo 时,实际上它隐式地返回 total.bookNo。
成员函数通过名为 this 的隐式参数来访问调用它的对象。例如,对于上述的调用,编译器负责把 total 的地址传递给 isbn 的隐式形参 this。
在成员函数内部,可以直接访问调用该函数的对象的成员,而无需通过成员访问运算符来完成,因为 this 指的就是这个对象。
this 形参是隐式定义的。任何自定义名为 this 的参数或变量的行为都是非法的。
isbn 函数的另一种定义形式如下:
std::string isbn() const { return this -> bookNo; }
以上定义显式地使用了 this,但是 isbn 本身是类成员函数,this 实际上是可以省略的,以上函数的定义是等价的定义方式,只是为了展示 this 隐式形参的作用。
在运算符重载当中,this 的使用将较为频繁。
引入 const 成员函数
isbn 函数的另一个关键之处在于紧随形参列表的 const 关键字。这里 const 的作用是修改隐式 this 指针的类型。
C++ 允许把 const 放在 成员函数的形参列表 之后,此时,紧跟在参数列表后面的 const 表示 this 是一个指向常量的指针。像这样使用 const 的成员函数被称作 常量成员函数。
因为 this 是指向常量的指针,所以常量成员函数不能改变调用它的对象的内容。对应到上例,isbn 可以读取调用它的对象的数据成员,但是不可以写入新值。
原书部分使用了大量的术语来对类成员函数声明当中紧随在形参列表之后的 const 关键字进行解释,大概可以概括为,定义在形参列表之后的 const 限定了 隐式定义的 this 指针,这意味着 this 指针是一个指向常量的指针。在默认情况下,this 指针是一个指向非常量类对象的常量指针,翻译过来就是:this 指针本身指向的对象是不可以改变的(因为它是一个常量指针,指针的值是常量不可以修改,而指针的值是地址,即指针所指的对象),但 this 指针指向的对象并非一个常量。如果在形参列表后加入 const 关键字,则将 this 声明为一个指向常量类对象的常量指针,这时不仅 this 本身是常量,this 指向的对象也是常量。既然 this 指向的对象是常量,那么我们当然不可以对常量的值进行修改。这意味着,在类成员函数的声明当中,如果在形参列表之后加入 const 关键字,则不能够通过这个类成员函数来对当前类对象的成员值进行修改。
类作用域和成员函数
类本身就是一个作用域。类成员函数的定义嵌套在类的作用域当中。因此,isbn 中用到的 bookNo 实际上就是 Sales_data 内的数据成员。
在类的外部定义成员函数
与其它函数类似,当我们在类的外部定义成员函数时(上文已经提到,类的成员函数可以在类内声明和定义,也可以在类外定义,但是声明应该在类内完成),成员函数的定义必须与它的声明匹配,即:返回类型、参数列表、函数命必须与类内部的声明保持一致。
此外,如果成员被声明为常量成员函数(即,在定义成员函数时,其形参列表之后使用 const 关键字进行修饰),那么在定义它时也必须在参数列表后明确指定 const 属性。同时,类外部定义的成员的名字必须包含它所属的类名(通过::
完成):
double Sales_data::avg_price() const {
if(units_sold) {
return revenue / units_sold;
} else {
return 0;
}
}
定义一个返回 this 对象的函数
函数 combine 的初衷类似于复合赋值运算符+=
,调用该函数的对象代表的是赋值运算符左侧的运算对象,右侧运算对象则显式地通过实参传入参数:
Sales_data& Sales_data::combine(const Sales_data &rhs) {
units_sold += rhs.units_sold; // 将 rhs 与 this 对象的 units_sold 相加
revenue += rhs.revenue; // 理解方式同上
return *this;
}
该函数指的关注的部分是它的返回类型和返回语句。一般来说,当我们定义一个类似于某个内置运算符的函数时,应当令这个函数的行为尽可能地模仿运算符。内置的运算符把它的左侧运算对象当作左值返回,因此为了与内置运算符保持一致,combine 函数必须返回引用类型。此时,左侧的运算对象是一个 Sales_data 对象,返回类型是 Sales_data &。
使用 return 返回 解引用的 this 指针,以获得执行该函数的对象,返回值就是 this 对象的引用,即:针对total.combine(trans);
这条语句,combine 函数的返回值就是 total 的引用。
7.1.3 定义相关的非成员函数
类的作者通常需要定义一些类相关的辅助函数,比如 add、read、print 等。这些函数在概念上来说属于类的接口的组成部分,但它们并非属于类本身。
如果函数在概念上属于类但是不定义在类中,通常将它与类的声明(而非定义)放在同一个文件当中。如此,用户使用接口的任何部分都只需要引入一个文件。
定义 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;
}
定义 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 类型。
合成的默认构造函数
当我们在程序中对类对象进行定义时:
Sales_data total;
Sales_data trans;
由于没有为对象提供初值,我们需要知道,类对象 total 和 trans 是被默认初始化的。类通过一个特殊的构造函数来控制默认初始化的进程,这个函数就被叫做默认构造函数。默认构造函数无需任何实参。
如果我们没有显式地定义默认构造函数,那么编译器就会为我们隐式地定义默认构造函数。编译器创建的构造函数又被称为合成的默认构造函数,它按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,则用它来初始化成员;
- 否则,默认初始化该成员;
某些类不能依赖于合成的默认构造函数
合成的默认构造函数只适用于非常简单的类。
一个例子就是如果定义在块当中的内置类型或复合类型(比如指针或数组)的对象被默认初始化,它们的值将会是未定义的。因此,含有内置类型或复合类型成员的类应该在类的内部初始化这些成员,或者自己定义默认的构造函数,否则使用合成的默认构造函数将会是危险的。
此外,有时候编译器不能为某些类合成默认的构造函数。比如当某个类包含一个其它类类型的成员,且这个成员的类型没有默认构造函数,则编译器无法初始化该成员。
定义 Sales_data 的构造函数
对于 Sales_data 类,使用下面的参数定义 4 个不同类型的构造函数:
- 一个
istream&
,从中读取一条交易信息; - 一个
const string&
,表示 ISBN 编号;一个unsigned
,表示售出图书的数量; - 一个
const string&
,表示 ISBN 编号;编译器将赋予其他成员默认值; - 一个空参数列表(即定义默认构造函数);
定义构造函数的类定义如下:
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 的含义
对于默认构造函数的定义:
Sales_data() = default;
由于它不接受任何实参,所以它是一个默认构造函数。在 C++ 11 标准当中,如果我们需要默认的行为,可以通过在参数列表后面加上 = 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) { }
这两个定义出现了新的部分,即冒号以及冒号和花括号之间的代码,其中花括号定义了(空的)函数体,新出现的部分被称作构造函数初始值列表,它负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的是成员的初始值。不同成员的初始化使用逗号隔开。
在第二个构造函数的定义当中,含有三个参数,使用初始值列表来通过相应的行为(比如,revenue通过 p 和 n 的相乘来获得)对类成员进行初始化;而对于第一个构造函数,只包含一个参数,在初始值列表当中也只是用参数 s 对 bookNo 进行初始化,而其它没有进行初始化的类成员将默认采用隐式初始化的方式来进行初始化。
在类的外部定义构造函数
以 istream 为参数的构造函数需要执行一些实际的操作。在它的函数体内,调用了 read 函数以给数据成员赋予初值:
Sales_data::Sales_data(std::istream &is) {
read(is, *this);
}
std::istream& read(std::istream &is, Sales_data &item) {
double price = 0;
is >> item.bookNo >> item.units_sold >> price;
item.revenue = price * item.units_sold;
return is;
}
当我们在类的外部定义构造函数时,必须指明该构造函数属于哪个类成员。这个构造函数没有构造函数初始值列表,但是由于执行了构造函数体,所以对象的成员仍然能够被初始化。
7.1.5 拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时发生的行为。如果我们不主动定义这些操作,编译器将会替我们合成它们。一般来说,编译器生成的版本将对对象的每一个成员都执行拷贝、赋值和销毁操作。
某些类不能依赖于合成的版本
当类需要分配对象之外的资源时,合成的版本常常会失效。
值得注意的是,很多需要动态内存的类能够(而且应该)使用 vector 对象或 string 对象管理必要的存储空间。使用 vector 或 string 的类能避免分配和释放内存带来的复杂性。
进一步来说,如果类包含 vector 或 string 成员,则其拷贝、赋值和销毁的合成版本能够正常工作。
标签:const,定义,函数,Sales,C++,抽象数据类型,data,构造函数 From: https://blog.csdn.net/Coffeemaker88/article/details/143670462