文章目录
类的基本思想是 数据抽象和 封装。
数据抽象是一种依赖于接口和实现分离的编程(以及设计)技术。
类的接口包括用户所能执行的操作;类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
封装实现了类的接口和实现的分离。封装后的类隐藏了它的实现细节,也就是说,类的用户只能使用接口而无法访问实际部分。
我的理解是,数据抽象就是设计类该提供哪些功能以及编写这些功能的实现方式的技术。
封装则是将调用和实现分离的技术。
抽象数据类型:只知道接口(怎么用)不知道实现。
定义抽象数据类型
设计Sales_data类
书店程序
从一个文件中读取销售记录,生成每本书的销售报告,显示售出册数、总销售额和平均售价。我们假定每个ISBN书号的所有销售记录在一个文件中是聚在一起保存的。
程序会将每个ISBN的所有数据合并起来,存入名为total的变量中。
使用另一个名为trans的变量保存读取的每条销售记录。
如果trans和total指向相同的ISBN,我们会更新total的值。否则,我们会打印total的值,并将其重置为刚刚读取的数据(trans)。
接口
Sales_data的接口应该包含一下操作:
- 一个isbn成员函数,用于返回对象的ISBN编号;
- 一个combine成员函数,用于将一个Sales_data对象加到另一个对象上;
- 一个名为add的函数,执行两个Sales_data对象的加法,两个对象必须表示同一本书(相同的ISBN)。加法的结果是一个新的Sales_item对象,其ISBN与两个运算对象相同,而其总销售额和售出册数则是两个运算对象的对应值之和;
- 一个read函数,将数据从istream读入到Sales_data对象中;
- 一个print函数,将Sales_data对象的值输出到ostream。
来自标准库的头文件时,应该使用尖括号来包围头文件;对于不属于标准库的头文件,则使用双引号包围。
调用成员函数item.isbn()
。成员函数是定义为类的一部分函数,有时候也被称为方法。
使用点运算符(.)来表达我们需要“名为item的对象的isbn成员”。点运算符只能用于类类型的对象。
当用点运算符访问一个成员函数时,通常是想要调用该函数。使用调用运算符(())赖调用一个函数。
定义改进的Sales_data类
数据结构是把一组相关的数据元素组织起来然后使用它们的策略和方法。
Sales_data是一个数据结构,相关数据为ISBN编号、售出量和销售收入等等组织在一起,并且提供各式各样的操作。
定义Sales_data类型
ISBN编号:bookNo string类型;
销量:units_sold unsigned类型;
总销售收入:revenue double类型。
类将包含两个成员函数:combine和isbn。另外设计一个成员函数用于返回售出书籍的平均价格(avg_price),该成员函数属于类的实现的一部分,并非接口的一部分。
定义成员函数的方式与普通函数差不多。成员函数的声明必须在类的内部,它的定义则既可以在类的内部也可以在类的外部。
作为接口组成部分的非成员函数add、read、print,它们的定义和声明都在类的外部。这也说明了类的接口可以是非成员函数。
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&,const Sales_data&);
定义成员函数
关于this
定义在类内部的函数是隐式的inline函数。
在类内部访问数据成员是直接的,可以看做this的隐式引用。以isbn成员函数为例。其相当于:
// 定义
Sales_data::isbn(Sales_data* const this) const {return this -> bookNo;}
//调用
total.isbn(&total)
this是一个常量指针,不允许改变this中的地址。
关于const
isbn的另一个关键之处是紧随参数列表之后的const关键字。这里的const的作用是修改隐式this指针的类型。
指向常量的指针:指针所指对象不能被指针改变。const Sales_data*
。所指对象可以不是常量。
this的类型是Sales_data* const
这意味着我们不能将this绑定到一个常量对象上。对于一般情况而言,我们是不希望this指向的对象改变的,所以加个const(底层const)。
像这样使用const的成员函数被称为常量成员函数。
相当于每个成员函数默认带了一个self。
关于在外部定义的成员函数
在编译的过程中,编译器首先编译成员的声明,再编译成员函数体,所以成员函数体可以随意使用类中的其他成员。
类本身就是一个作用域。
以avg_price为例:
double Sales_data::avg_price() const {
if (units_sold)
return revenue/units_sold;
else
return 0;
}
外部定义的成员函数需要给出函数的范围即Sales_data::avg_price
,目的是为了说明:我们定义了一个名为avg_price的函数,并且该函数被声明在类Sales_data的作用域内。
返回this对象的函数
Sales_data& Sales_data::combine(const Sales_data &rhs){
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
返回引用是返回左值,返回其他则是返回右值。无论是否有返回值,都可以单独存在,不一定要赋值对象。
combine实现了+=这样一个的效果。
定义类相关的非成员函数
定义非成员函数的方式与定义其他函数一样,通常需要把函数的声明和定义分离开。如果函数在概念上属于类但是不定义在类中,则它一般应与类声明在同一个头文件内。
read和print函数
//输入交易信息包括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;
}
add函数
Sales_data &add(const Sales_data &lhs, const Sales_data &rhs){
Sales_data sum = lhs;
sum.combine(rhs);
return sum;
}
构造函数
类通过一个或几个特殊的成员函数来控制其对象的初始化过程,这些函数叫做构造函数。构造函数的任务是初始化类对象的数据成员。
构造函数的名字和类名相同。和其他函数不一样的是,构造函数没有返回类型。
类可以包含多个构造函数,和其他重载函数差不多,不同的构造函数必须在参数数量或者参数类型上有所区别。
不同于其他成员函数,构造函数不能被声明成const的(给this加个底层const)。当我们创建一个类的一个const对象时,直到构造函数完成初始化,对象才能真正获得“常量”属性。因此,构造函数在const对象的构造过程中可以向其写值。
合成的默认构造函数
我们之前并没有为Sales_data写上任何构造函数,但是它也能正常执行。这是因为,其中的数据成员被默认初始化了。类通过一个特殊的构造函数在控制默认初始化过程,这个函数叫做默认构造函数。默认构造函数不需要任何实参。编译器创建的构造函数又被称为合成的默认构造函数。
默认初始化:定义变量时没有指定初值,则变量被默认初始化。默认值到底是什么由变量类型决定,同时定义变量的位置也会对此有影响。
如果是内置类型的变量未被显式初始化,它的值由定义的位置决定。定义于任何函数体之外的变量被初始化为0。定义在函数体内部的内置类型变量将不被初始化。
各个类各自决定其初始化对象的方式。而且,是否允许不经初始化就定义对象也由类自己决定。如果类允许这种行为,他将决定对象的初始值到底是什么。
对于大多数类来说,这个合成的默认构造函数将按照如下规则来初始化类的数据成员:
- 如果存在类内的初始值,用它来初始化成员;
- 否则,默认初始化该成员。
某些类不能依赖于合成的默认构造函数
对于一个普通的类,必须有它自己的默认构造函数,原因有三:
- 编译器只有在发现类不包含任何构造函数的情况下才会替我们生成一个默认构造函数;一旦我们定义了其他的构造函数,除非我们自己定义一个默认构造函数,那么我们的类讲没有默认构造函数。
- 对于某些类来说,合成的默认构造函数可能会执行错误的操作(如果类包含有内置类型或者复合类型的成员,则只有当这些成员全都被赋予了类内的初始值时,这个类才适合使用合成的默认构造函数)。
- 有时候编译器不能为某些类合成默认的构造函数。比如说,如果类中包含一个其他类类型的成员且这个成员没有默认构造函数,那么编译器将无法初始化该成员。
定义Sales_data的构造函数
我们用下面的参数定义4个不同的构造函数:
- 一个istream&,从中读取一条交易信息;
- 一个const string&,表示ISBN编号;一个unsigned,表示售出的图书数量;一个double,表示图书的售出价格;
- 一个const string&,表示ISBN编号;编译器将赋予其他成员默认值;
- 一个空参数列表(即默认构造函数),我们已经定义了其他构造函数,那么也必须定义一个默认构造函数;
// Condition4:
Sales_data() = default;
// Condition3:
Sales_data(const std::string &s):bookNo(s) {};
// Condition2:
Sales_data(const std::string &s, unsigned n, double p):bookNo(s), units_sold(n), revenue(p*n) {};
// Condition1:
Sales_data(std::istream &);
我们希望有一个函数的作用完全等同于之前使用的合成默认构造函数。在C++11新标准中,对于默认行为可以采用在参数列表后面写上=default来要求编译器生成构造函数。
构造函数初始值列表
对于condition3和condition2在结构上有着很大的相似。两个定义出现了新的部分,即冒号和冒号和花括号之间的代码。其中花括号定义了空的函数体。我们把新出现的部分称为构造函数初始值列表。
构造函数初始值列表的作用是为新创建的对象的一个或几个数据成员赋初值。由数据成员名字(成员初始值)组成。
当数据成员被构造函数初始值列表忽略时,它将以与合成默认构造函数相同的方式隐式初始化。
由于上述两个构造函数的目的只是为了初始化,所以函数体是空的。
在类外部定义构造函数
与其他几个构造函数不同的是,以istream为参数的构造函数需要执行一些实际的操作。在它的函数体之外,调用了read函数以给数据成员赋初值。
Sales_data::Sales_data(std::istream &is){
read(is,*this);
}
构造函数没有返回类型,所以上述定义从我们指定的函数名字开始。
尽管构造函数初始值列表为空,但是由于执行了构造函数体,所以对象的成员函数仍能被初始化。
拷贝、赋值和析构
我们除了定义如何初始化之外,还需要考虑我们的类是怎么拷贝、赋值以及销毁的。如果我们不主动定义的话,编译器会自动地替我们合成他们。
对于合成的默认赋值操作:
// total = item 等价于
total.bookNo = item.bookNo;
...
...