7.1 定义抽象数据类型
在C++语言中,我们使用类定义自己的数据类型。通过定义新的类型来反映待解决问题中的各种概念,可以使我们更容易编写、调试和修改程序。本章是第2章关于类的话题的延续,主要关注数据抽象的重要性。数据抽象能帮助我们将对象的具体实现与对象所能执行的操作分离开来。第13章将讨论如何控制对象拷贝、移动、赋值和销毁等行为,在第14章中我们将学习如何自定义运算符。
类的基本思想
类的基本思想是数据抽象(data abstraction)和封装(encapsulation)。数据抽象是一种依赖于接口(interface)和实现(implementation)分离的编程(以及设计)技术。类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实现部分。
类要想实现数据抽象和封装,需要首先定义一个抽象数据类型(abstract data type)。在抽象数据类型中,由类的设计者负责考虑类的实现过程;使用该类的程序员则只需要抽象地思考类型做了什么,而无须了解类型的工作细节。
7.1 定义抽象数据类型
在第1章中使用的Sales_item类是一个抽象数据类型,我们通过它的接口(例如1.5.1节描述的操作)来使用一个Sales_item对象。我们不能访问Sales_item对象的数据成员,事实上,我们甚至根本不知道这个类有哪些数据成员。
与之相反,Sales_data类(参见2.6.1节)不是一个抽象数据类型。它允许类的用户直接访问它的数据成员,并且要求由用户来编写操作。要想把Sales_data变成抽象数据类型,我们需要定义一些操作以供类的用户使用。一旦Sales_data定义了它自己的操作,我们就可以封装(隐藏)它的数据成员了。
7.1.1 设计Sales_data类
我们的最终目的是令Sales_data支持与Sales_item类完全一样的操作集合。Sales_item类有一个名为isbn的成员函数(member function),并且支持+、-、+=、<<和>>运算符。
我们将在第14章学习如何自定义运算符。现在,我们先为这些运算定义普通(命名的)函数形式。由于14.1节将要解释的原因,执行加法和减法的函数不作为Sales_data的成员,相反的,我们将其定义成普通函数;执行复合赋值运算的函数是成员函数。Sales_data类无须专门定义赋值运算,其原因将在7.1.5节介绍。
综上所述,Sales_data的接口应该包含以下操作:
- 一个isbn成员函数,用于返回对象的ISBN编号
- 一个combine成员函数,用于将一个Sales_data对象加到另一个对象上
- 一个名为add的函数,执行两个Sales_data对象的加法
- 一个read函数,将数据从istream读入到Sales_data对象中
- 一个print函数,将Sales_data对象的值输出到ostream
关键概念:不同的编程角色
程序员们常把运行其程序的人称作用户(user)。类似的,类的设计者也是为其用户设计并实现一个类的人;显然,类的用户是程序员,而非应用程序的最终使用者。
当我们提及“用户”一词时,不同的语境决定了不同的含义。如果我们说用户代码或者Sales_data类的用户,指的是使用类的程序员;如果我们说书店应用程序的用户,则意指运行该应用程序的书店经理。
使用改进的Sales_data类
在考虑如何实现我们的类之前,首先来看看应该如何使用上面这些接口函数。举个例子,我们使用这些函数编写1.6节书店程序的另外一个版本,其中不再使用Sales_item对象,而是使用Sales_data对象:
// 保存当前求和结果的变量
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; // 没有输入任何信息
}
一开始我们定义了一个Sales_data对象用于保存实时的汇总信息。在if条件内部,调用read函数将第一条交易读入到total中,这里的条件部分与之前我们使用>>运算符的效果是一样的。read函数返回它的流参数,而条件部分负责检查这个返回值,如果read函数失败,程序将直接跳转到else语句并输出一条错误信息。
如果检测到读入了数据,我们定义变量trans用于存放每一条交易。while语句的条件部分同样是检查read函数的返回值,只要输入操作成功,条件就被满足,意味着我们可以处理一条新的交易。
在while循环内部,我们分别调用total和trans的isbn成员以比较它们的ISBN编号。如果total和trans指示的是同一本书,我们调用combine函数将trans的内容添加到total表示的实时汇总结果中去。如果trans指示的是一本新书,我们调用print函数将之前一本书的汇总信息输出出来。因为print返回的是它的流参数的引用,所以我们可以把print的返回值作为<<运算符的左侧运算对象。通过这种方式,我们输出print函数的处理结果,然后转到下一行。接下来,把trans赋给total,从而为接着处理文件中下一本书的记录做好了准备。
处理完所有输入数据后,使用while循环之后的print语句将最后一条交易的信息输出出来。
希望这篇博客能帮助您更好地理解如何定义抽象数据类型以及如何设计一个封装良好的类。如果有任何问题或需要进一步的解释,请随时留言!
7.1.2 定义改进的Sales_data类
在前一节中,我们讨论了如何定义抽象数据类型。这一节,我们将探讨如何改进Sales_data
类,使其更加符合数据抽象和封装的原则。
改进后的Sales_data类
改进之后的Sales_data
类的数据成员将与2.6.1节中定义的版本保持一致,它们包括:
bookNo
:string
类型,表示ISBN编号units_sold
:unsigned
类型,表示某本书的销量revenue
:double
类型,表示这本书的总销售收入
此外,我们的类将包含两个成员函数:combine
和isbn
。我们还将添加一个名为avg_price
的成员函数,用于返回售出书籍的平均价格。因为avg_price
的用途并非通用,所以它应该属于类的实现部分,而非接口部分。
类的声明和定义
定义和声明成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,而定义则既可以在类的内部也可以在类的外部。作为接口组成部分的非成员函数,例如add
、read
和print
等,它们的定义和声明都在类的外部。
改进后的Sales_data类示例
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 add(const Sales_data&, const Sales_data&);
std::ostream& print(std::ostream&, const Sales_data&);
std::istream& read(std::istream&, Sales_data&);
详细解释
成员函数isbn
isbn
函数的参数列表为空,返回值是一个string
对象。它直接返回对象的bookNo
数据成员。
std::string isbn() const { return bookNo; }
成员函数combine
combine
函数用于将一个Sales_data
对象的内容合并到调用该函数的对象中。它的参数是一个常量引用,因为不需要修改传入的对象。
Sales_data& combine(const Sales_data& rhs) {
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
在这个函数中,我们将rhs
对象的units_sold
和revenue
累加到调用该函数的对象中,并返回当前对象本身。
成员函数avg_price
avg_price
函数用于计算并返回售出书籍的平均价格。如果units_sold
为0,则返回0。
double avg_price() const {
if (units_sold)
return revenue / units_sold;
else
return 0;
}
非成员接口函数
非成员接口函数通常包括add
、read
和print
等,它们用于执行基本的I/O操作。
Sales_data add(const Sales_data& lhs, const Sales_data& rhs) {
Sales_data sum = lhs;
sum.combine(rhs);
return sum;
}
std::ostream& print(std::ostream& os, const Sales_data& item) {
os << item.isbn() << " " << item.units_sold << " " << item.revenue << " " << item.avg_price();
return os;
}
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;
}
这些函数分别实现了两个Sales_data
对象的加法操作,输出一个Sales_data
对象的内容,以及从输入流中读取Sales_data
对象的数据。
引入this
指针
成员函数通过一个名为this
的隐式参数来访问调用它的对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this
。例如:
total.isbn()
在这里,我们使用点运算符来访问total
对象的isbn
成员然后调用它。当isbn
返回bookNo
时,实际上它隐式地返回total.bookNo
。
引入const
成员函数
为了保证成员函数不会修改对象的内容,我们在成员函数后面加上const
关键字。例如:
double avg_price() const { ... }
这样,编译器会确保在这个函数内部不会修改对象的任何成员。
总结
通过这一节,我们学习了如何定义一个改进的Sales_data
类,如何通过成员函数和非成员函数来实现数据抽象和封装。希望这些内容能帮助您更好地理解C++中类的设计和使用。如果有任何问题或需要进一步的解释,请随时留言!
7.1.3 定义类相关的非成员函数
在C++编程中,类的作者常常需要定义一些辅助函数,例如add
、read
和print
。虽然这些函数在概念上属于类的接口部分,但它们实际上并不属于类本身。定义非成员函数的方式与定义其他函数一样,通常将函数的声明和定义分开。本文将详细介绍如何定义这些辅助函数,以及它们在类设计中的重要性。
定义非成员函数
如果一个非成员函数在概念上属于类,但不定义在类中,则它通常应与类的声明在同一个头文件内。在这种方式下,用户使用接口的任何部分都只需要引入一个文件。
定义read
和print
函数
下面的read
和print
函数与2.6.2节中的代码作用一样,而且代码本身也非常相似:
// 输入的交易信息包括ISBN、售出总数和售出价格
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;
}
std::ostream& print(std::ostream& os, const Sales_data& item) {
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
read
函数从给定的输入流中读取数据到Sales_data
对象中。print
函数将Sales_data
对象的内容打印到输出流中。
关键点
- 引用传递I/O流:
read
和print
函数分别接受一个输入流和输出流的引用作为其参数。这是因为I/O类属于不能被拷贝的类型,因此只能通过引用来传递它们。此外,因为读取和写入的操作会改变流的内容,所以两个函数接受的都是普通引用,而非对常量的引用。 - 格式控制:
print
函数不负责换行。执行输出任务的函数应该尽量减少对格式的控制,这样可以确保由用户代码来决定是否换行。
定义add
函数
add
函数接受两个Sales_data
对象作为其参数,返回值是一个新的Sales_data
对象,用于表示前两个对象的和:
Sales_data add(const Sales_data& lhs, const Sales_data& rhs) {
Sales_data sum = lhs; // 拷贝lhs的数据成员到sum
sum.combine(rhs); // 将rhs的数据成员加到sum中
return sum;
}
关键点
- 拷贝和合并:在函数体中,我们定义了一个新的
Sales_data
对象并将其命名为sum
。sum
用于存放两笔交易的和,我们用lhs
的副本来初始化sum
。接下来我们调用combine
函数,将rhs
的units_sold
和revenue
添加到sum
中。 - 返回值:函数返回
sum
的副本。通过返回Sales_data
对象,我们可以在不修改原始对象的情况下合并数据。
非成员函数的声明与定义
头文件声明
通常,非成员函数的声明应该与类在同一个头文件内:
// Sales_data.h
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 add(const Sales_data&, const Sales_data&);
std::ostream& print(std::ostream&, const Sales_data&);
std::istream& read(std::istream&, Sales_data&);
源文件定义
在源文件中定义非成员函数的实现:
// Sales_data.cpp
#include "Sales_data.h"
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;
}
std::ostream& print(std::ostream& os, const Sales_data& item) {
os << item.isbn() << " " << item.units_sold << " "
<< item.revenue << " " << item.avg_price();
return os;
}
Sales_data add(const Sales_data& lhs, const Sales_data& rhs) {
Sales_data sum = lhs;
sum.combine(rhs);
return sum;
}
结论
通过定义类相关的非成员函数,我们可以扩展类的功能,同时保持类的接口简洁明了。这种设计方式有助于分离接口和实现,使代码更加模块化和易于维护。
希望这些内容能帮助您更好地理解C++中类相关非成员函数的设计和使用。如果有任何问题或需要进一步的解释,请随时留言!
7.1.4 构造函数
每个类都分别定义了它的对象被初始化的方式,类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数(constructor)。构造函数的任务是初始化类对象的数据成员,无论何时只要类的对象被创建,就会执行构造函数。
在这一节中,我们将介绍定义构造函数的基础知识。构造函数是一个非常复杂的问题,我们还会在后续章节中介绍更多关于构造函数的知识。
构造函数的基本概念
构造函数的名字和类名相同。与其他函数不同的是,构造函数没有返回类型。除此之外,构造函数也有一个(可能为空的)参数列表和一个(可能为空的)函数体。类可以包含多个构造函数,和其他重载函数一样,不同的构造函数之间必须在参数数量或参数类型上有所区别。
合成的默认构造函数
当我们没有显式定义任何构造函数时,编译器会为我们合成一个默认构造函数(synthesized default constructor)。这个合成的默认构造函数将按照如下规则初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员。
- 否则,默认初始化该成员。
定义Sales_data的构造函数
对于我们的Sales_data
类,我们将定义四个不同的构造函数:
- 一个
istream&
,从中读取一条交易信息。 - 一个
const string&
,表示ISBN编号;一个unsigned
,表示售出的图书数量;以及一个double
,表示图书的售出价格。 - 一个
const string&
,表示ISBN编号,其他成员使用默认值。 - 一个空参数列表(即默认构造函数)。
以下是改进后的Sales_data
类的定义:
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
来要求编译器生成构造函数。
构造函数初始值列表
构造函数初始值列表(constructor initializer list)负责为新创建的对象的一个或几个数据成员赋初值。构造函数初始值是成员名字的一个列表,每个名字后面紧跟括号括起来的(或者在花括号内的)成员初始值。不同成员的初始化通过逗号分隔开来。
例如:
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) {}
在类的外部定义构造函数
与其他几个构造函数不同,以istream
为参数的构造函数需要执行一些实际的操作:
Sales_data::Sales_data(std::istream &is) {
read(is, *this); // read函数的作用是从is中读取一条交易信息然后存入this对象中
}
构造函数没有返回类型,所以上述定义从我们指定的函数名字开始。和其他成员函数一样,当我们在类的外部定义构造函数时,必须指明该构造函数是哪个类的成员。
总结
构造函数是类的关键组成部分,用于初始化类对象的数据成员。通过定义不同的构造函数,我们可以灵活地控制对象的初始化过程。希望这些内容能帮助您更好地理解C++中构造函数的设计和使用。如果有任何问题或需要进一步的解释,请随时留言!
7.1.5 拷贝、赋值和析构
除了定义类的对象如何初始化之外,类还需要控制拷贝、赋值和销毁对象时的行为。对象在几种情况下会被拷贝,如我们初始化变量以及以值的方式传递或返回一个对象等。当我们使用赋值运算符时会发生对象的赋值操作。当对象不再存在时,例如一个局部对象在创建它的块结束时被销毁,或者vector
对象销毁时,存储在其中的对象也会被销毁。
合成的拷贝、赋值和析构操作
如果我们不主动定义这些操作,则编译器将替我们合成它们。一般来说,编译器生成的版本将对对象的每个成员执行拷贝、赋值和销毁操作。例如在前面的书店程序中,当编译器执行如下赋值语句时:
total = trans; // 处理下一本书的信息
它的行为与下面的代码相同:
// Sales_data的默认赋值操作等价于:
total.bookNo = trans.bookNo;
total.units_sold = trans.units_sold;
total.revenue = trans.revenue;
在第13章中,我们将详细讨论如何自定义上述操作。
某些类不能依赖于合成的版本
尽管编译器能替我们合成拷贝、赋值和销毁的操作,但是对于某些类来说,合成的版本无法正常工作。特别是,当类需要分配类对象之外的资源时,合成的版本常常会失效。例如,管理动态内存的类通常不能依赖于上述操作的合成版本。不过,很多需要动态内存的类能(而且应该)使用vector
对象或者string
对象管理必要的存储空间。使用vector
或者string
的类能避免分配和释放内存带来的复杂性。
进一步讲,如果类包含vector
或者string
成员,则其拷贝、赋值和销毁的合成版本能够正常工作。当我们对含有vector
成员的对象执行拷贝或者赋值操作时,vector
类会拷贝或者赋值成员中的元素。当这样的对象被销毁时,将销毁vector
对象,也就是依次销毁vector
中的每一个元素。这一点与string
是非常类似的。
定义Sales_data的拷贝、赋值和析构函数
对于我们的Sales_data
类,我们可以定义拷贝构造函数、赋值运算符和析构函数,确保对象在这些操作中能正确处理其成员。
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 &);
// 拷贝构造函数
Sales_data(const Sales_data& other) : bookNo(other.bookNo), units_sold(other.units_sold), revenue(other.revenue) {}
// 赋值运算符
Sales_data& operator=(const Sales_data& other) {
if (this != &other) {
bookNo = other.bookNo;
units_sold = other.units_sold;
revenue = other.revenue;
}
return *this;
}
// 析构函数
~Sales_data() = default;
// 其他成员函数
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(const Sales_data& other) : bookNo(other.bookNo), units_sold(other.units_sold), revenue(other.revenue) {}
赋值运算符
赋值运算符用于将一个对象的值赋给另一个对象:
Sales_data& operator=(const Sales_data& other) {
if (this != &other) {
bookNo = other.bookNo;
units_sold = other.units_sold;
revenue = other.revenue;
}
return *this;
}
析构函数
析构函数用于在对象生命周期结束时释放资源:
~Sales_data() = default;
总结
通过定义拷贝构造函数、赋值运算符和析构函数,我们可以控制对象在拷贝、赋值和销毁时的行为。这些操作的正确实现对于管理资源和确保类的正确性至关重要。希望这些内容能帮助您更好地理解C++中对象生命周期管理的基本原理。如果有任何问题或需要进一步的解释,请随时留言!