第10章 对象和类
本章内容包括:
- 过程性编程和面向对象编程
- 类概念
- 如何定义和实现类
- 共有类访问和私有类访问
- 类的数据成员
- 类方法(类函数成员)
- 创建和实用类对象
- 类的构造函数和析构函数
- const成员函数
- this指针
- 创建对象数组
- 类作用域
- 抽象数据类型
OOP(面向对象编程)最重要的特性:
- 抽象
- 封装和数据隐藏
- 多态
- 继承
- 代码的可重用性
10.2 抽象和类
指定基本类型完成了三项工作:
- 决定数据对象需要的内存量
- 决定如何解释内存中的位(long float位数相同,但是转换方法不同)
- 决定可使用数据对象执行的操作或方法
类的定义,一般来说,类规范由两个部分组成:
- 类声明:以数据成员的方式描述数据部分,以成员函数的方法描述公有接口
- 类方法定义:描述如何实现类成员函数
简单说就是类声明提供了类的蓝图,方法定义提供了细节。
什么是接口?
接口是一个共享框架,供两个系统之间(比如任何计算机系统之间或者人和计算机之间)交互时使用。
C++关键字中class指出了这些代码定义了一个类设计。使用类定义接口时,将会自动指定使用对象的规则。
1.访问控制
关键字private和public也是新的,描述了对类成员的访问控制。使用类对象的程序都可以直接访问公有部分,但是只能通过公有成员函数或者友元函数来访问对象的私有成员。防止程序直接访问数据被称为数据隐藏。
类设计尽可能将公有接口和实现细节分开。公有接口表示小合集的抽象组件,将实现细节放在一起并将他们和抽象分开被称为封装。
2.控制对成员的访问:公有还是私有
由于OOP,数据项通常会放在私有部分,组成类接口的成员函数被放在公有部分。
使用私有成员函数来处理不属于公有接口的实现细节。
C++中结构和类都能够使用private和public,但是结构中默认访问类型为public,但是类中默认访问类型为private。C++中通常实用类实现类描述,而把结构限制为指标是纯粹的数据对象。
10.2.3 实现类成员函数
类成员函数相比于普通函数的两个特殊特征:
- 定义函数时,使用作用域解析符(::)来标识函数所属的类
- 类方法可以访问类的pivate组件
比如,update()成员函数:
void Stock::update(double price)
类成员函数的内联方法
函数定义为与类声明中的函数都将自动成为内联函数。类声明常将短小的成员函数作为内联函数。
如果想在声明之外定义内联成员函数,只需要加iniline限定符号。
根据改写规则,在类声明中定义的方法等同于使用原型替换方法定义,然后在类声明的后面将定义改写为内联函数。所以两者其实等价。
调用成员函数时,将会使用对象本身的数据成员。
每个对象都有字节的存储空间,用来存储内部变量和类成员。但是同一个类的所有对象共享一组类方法,即每种方法只有一个副本。
在OOP中,调用成员函数被称为发送消息,因此将同样的消息发送给两个不同的对象将会调用同一个方法。
10.3 类的构造函数和析构函数
由于类的成员变量存在私有,所以不能够使用之前常规的方法进行初始化。
所以类构造函数为了解决创建时自动初始化的问题,提供了特殊的类成员构造函数,构造函数的函数名和类名相同,且没有声明类型,也没有返回值。
构造函数中的参数名不能和类成员相同,不然会引起混乱。常见的解决方法是在成员变量之后或者之前加上特定的前缀。
10.3.2 使用构造函数
C++可以显示或者隐式调用构造函数。
// 显示方法
Stock food = Stock("Woril", 220, 1.25);
// 隐式方法
Stock food("Woril", 220, 1.25);
// 将构造函数和new一起使用的方法
Stock *food = new Stock("Woril", 220, 1.25);
两种构造方法等价。
构造函数只能够用来创建对象,不能够被对象调用。
如果没有提供任何构造函数,C++将会自动提供默认构造函数。
Stock::Stock(){}
但只有当没有提供构造函数时,C++才会提供默认构造函数;
如果提供了构造函数,那么必须提供一个默认构造函数。
定义默认构造函数的方法有两种,一种是为构造函数的所有参数提供默认值;一种是提供一个没有参数的构造函数。
10.3.4 析构函数
在创建的对象过期后,程序将会自动调用析构函数。析构函数用来完成清理工作。
析构函数的名称是~类名。比如:
~Stock(){}
与构造函数不同的是,析构函数没有参数。何时调用析构函数由编译器来决定,不应该显示的调用析构函数。
如果没有提供析构函数,那么编译器将会自动添加一个隐式析构函数。
比如,如果定义一个静态类型对象,那么将会在整个程序结束后调用析构函数;如果定义一个自动变量,那么在执行完代码块时调用析构函数;如果通过new来创建对象,那么在delete时将会调用析构函数。
在默认情况下,将一个对象付给同类型的另一个对象时,将会将每个数据成员的内容复制到目标对象的数据成员中。
Stock s1 = Stock{"asd" , 100 . 45.0};
s2 = Stock{"asd" , 100 . 45.0};
考虑上面两个语句。
第一条语句是初始化语句,创建有指定值的对象,可能会创建临时对象;第二条语句是赋值语句,在赋值语句中使用构造函数总会创建一个临时对象。
如果既可以通过初始化也可以通过赋值来设置对象的值,则应该采用初始化的方式,通常这种方法的效率更高。
const 成员函数
const Stock land = Stock("dsadsa");
land.show();
编译器将会拒绝第二行的操作,因为无法保证调用对象不被修改,
之前会通过函数参数声明为const引用或者指向const的指针来解决这个问题,但是这里这样做会出现语法问题,因此需要一种新的语法来保证函数不会修改调用对象。
C++将const关键字放在函数括号后面来解决这个问题。
void show() const;
void Stock::show() const;
使用这种方法声明定义的类函数被称为const成员函数。
只要类方法不修改调用对象,就应该将其声明为const。
10.4 this指针
如果类成员函数中涉及到了多个对象,那么为了区分,就需要用到this指针。
定义一个比较函数,使用const引用作为参数,函数不改变调用对象的值,返回一个满足函数条件的引用:
const Stock & topval(const Stock & s) const
{
if (s.val > val)
{
return s;
}
else
{
return ???
}
}
- 括号中的const表明,该函数不会修改被显式访问的对象。
- 括号后的const表明,该函数不会修改被隐式访问的对象。
- 由于该函数返回两个const对象之一的引用,因此返回类型也是const引用。
- 返回类型为引用意味着返回的是调用对象本身,而不是副本
此时有个问题,如何表示隐式访问的对象本身?
C++中使用this的特殊指针,指向用来调用成员函数的对象。
const Stock & topval(const Stock & s) const
{
if (s.val > val)
{
return s;
}
else
{
return *this
}
}
10.5 对象数组
用户通常会创建一个类的多个对象,此时就需要用到对象数组。
声明对象数组的方法和声明标准类型数组的方法是相同的。
Stock mystaff[4];
此时如果没有构造函数,那么就会调用隐式构造函数。
也可以显示调用构造函数:
const int STKS = 4;
Stock stocks[STKS] = {
Stock{"s1", 1, 1.0},
Stock{"s2", 2, 1.0},
Stock{"s3", 3, 1.0}
};
当然,也可以将其中的构造函数换成不同的构造函数。
显示构造函数之外的对象都将调用隐式默认构造函数。
10.6 类作用域
C++类引入了一种新的作用域叫做类作用域。
在类中定义的名称,比如数据成员名和类成员函数名,作用域都为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可知的。
类作用域意味着不能从外部直接访问类的成员。使用公有也是一样,需要通过对象来访问。
10.6.1 作用域为类的常量
不能够在声明类的时候赋值常量(直接使用const在类内不行),但是有两种方式能够达到相同的效果。
第一种方式是在类中声明一个枚举:
class Bakery
{
private:
enum {Months = 12};
double costs{Months};
...
}
使用这种方式声明枚举并不会创建类数据成员,也就是说,所有对象中都不会包含枚举。
第二种方式是使用关键字static:
class Bakery
{
private:
static const int Months= 12;
double costs{Months};
...
}
此时将会创建一个名为Months的常量,该常量和其他静态变量储存在一起,而不是存储在对象中。
此时只有一个Months常量,被所有Bakery对象共享。
10.6.2 作用域内枚举(C++11)
enum egg{Small,Medium,Large};
enum t_shirt{Small,Medium,Large};
此时将无法通过编译,因为发生了冲突。
C++11提供了一种新的枚举方法,枚举量的作用域为类:
enum class egg{Small,Medium,Large};
enum class t_shirt{Small,Medium,Large};
也可以通过struct代替class。此时使用枚举名来限制枚举量:
egg choice = egg::Large;
t_shirt Floyed = t_shirt::Small;
使用这种方法提高了类型安全性,作用域内枚举不能隐式与int进行转换。
第11章 使用类
本章内容包括:
- 运算符重载
- 友元函数
- 重载<<运算符,用于输出
- 状态成员
- 使用rand()生成随机数
- 类的自动转换和强制类型转换
- 类转换函数
11.1 运算符重载
运算符重载是一种形式的C++多态。
之前的章节中介绍了函数多态的方式。而运算符重载将重载的概念扩展到运算符上,允许赋予C++运算符多重含义。
C++允许将运算符扩展到用户定义的类型,要重载运算符,需要使用被称为运算符函数的特殊函数形式,运算符函数的格式如下:
operator op (argument-list)
比如说,operator + ()重载+运算符,operator *()重载*运算符。
当然,op必须是有效的C++运算符,不能够虚构一个新的符号。
举例来说,如果重载了Saleperson类的+操作,那么就可以在代码中使用如下的形式:
ans = sid + sara;
编译器此时发现数据类型为Saleperson类,那么将会替换成如下形式:
ans = sid.operator+ (sara);
总之,使用operator+()或者使用运算符表示法都可以进行调用。
11.2.2 重载限制
C++对用户定义的运算符重载有限制:
- 重载后的运算符必须至少有一个操作数是用户定义类型。防止用户将标准类型进行重载。
- 使用运算符不能违反运算符原来的句法规则。比如不能将%(使用两个操作数)重载成使用一个操作数。同样,也不能修改运算符的优先级。
- 不能创建新的运算符。
- 以下运算符不能够被重载:
sizeof 内存量 . 成员运算符 .* 成员指针运算符 :: 作用域解析运算符 ?: 条件运算符 typeid 一个RTTI运算符 const_cast 强制类型转换运算符 dynamic_cast 强制类型转换运算符 reinterpret_cast 强制类型转换运算符 static_cast 强制类型转换运算符 - 下面的运算符只能通过成员函数进行重载:
- =:赋值运算符
- () :函数调用运算符
- []:下标运算符
- ->:通过指针访问类成员的运算符
11.3 友元
只能通过公有方法对数据进行访问,有时候这种限制过于严格。C++提供了另外一种形式的访问权限:友元。
友元有三种:
- 友元函数
- 友元类
- 友元成员函数
考虑使用非成员函数,进行运算符重载:
A= 2.7 * B;
编译器将会与下面的非成员函数调用进行匹配:
A= operator * (2.7, B);
该函数的原型如下:
Time operator * (double m,const Time & t);
使用非成员函数可以按照所需要的顺序获取操作数(先是double ,然后是Time)但是问题是,非成员函数不能够直接访问类的私有数据。然而, 有一类特殊的非成员函数,可以访问类的私有成员,它们被称为友元函数。
11.3.1 创建友元
创建友元函数的第一步是将函数的原型放在类声明中,并在原型声明前加上关键字friend。
friend Time operator* (double m , const Time & t);
该原型会说明:
- operator *函数不是成员函数,不能够使用成员运算符来调用
- operator *函数和成员函数的访问权限相同
第二步是编写函数定义,因为不是Time的成员函数,所以不用加Time ::的限定符。另外不要在定义中使用friend的关键字:
Time operator* (double m , const Time & t)
{
Time result;
long totalminutes = t.hours * 60 + t.minutes;
result.hours = totalminutes / 60;
result.minutes = totalminutes % 60;
return result;
}
友元函数是否违背了C++的OOP呢?其实没有,应该将友元函数视作类的扩展接口的组成部分。
如果要为类重载运算符,并将分类的项作为其第一个操作数,则可以用友元函数来反转操作数的顺序。
11.3.2 常用的友元,重载<< 运算符
1. << 的第一种重载版本
如果要将类作为第一个操作数,意味着需要这样写:A << cout;
这样写会让人迷惑,所以可以通过友元函数来进行重载:
void operator << (ostream & os, const Time & t)
{
os << t.hours << " hours, "<< t.minutes << " minutes";
}
// 之后可以直接使用cout进行输出
cout << A;
由于只需要对于os整体进行使用,所以不需要成为os的友元函数。
2. << 的第二种重载版本
方法1实际上存在一个问题:
只能使用cout << A的形式,如果cout中有其他类型,那么就不允许了,比如cout << "Trip Time:" << A;
cout << A << B等同于(cout << A)<< B
<<运算符要求左边是一个ostream对象,所以可以对友元函数采用相同的方法,只要修改operator <()函数,让他返回ostream对象的引用即可:
ostream & operator << (ostream & os, const Time & t)
{
os << t.hours << " hours, "<< t.minutes << " minutes";
return os;
}
11.4 重载运算符:成员函数还是非成员函数
在定义运算符时,必须选择其中的一种格式,而不能同时选择两种方式,否则会出现二义性错误。
11.5 再谈重载:矢量类
(这里是使用的一个例子举例进行说明,不展开)
对已重载的运算符进行重载
因为运算符重载是通过函数来实现的,所以只要运算符函数的特征值不同,使用的运算符数量与相应的内置C++运算符相同,就可以多次重载同一个运算符。
比如对 - 进行重载,有两种版本:
// 两个操作数的版本
Vector operator - (const Vector & b) const;
// 一个操作数的版本
Vector operator - () const;
谈谈随机数
标准ANSI库有一个rand()函数,会返回一个从0到某个值之间的随机整数,但直接使用一般由于种子数默认,返回的都是伪随机数。
可以使用srand(time(0))来覆盖默认的种子值,其中time(0)代表返回当前时间,这样每次运行都会设置不同的种子。
11.6 类的自动转换和强制类型转换
如果类的构造函数只有一个参数,那么可以直接进行赋值,比如:
Stonewt myCat;
myCat =15.6;
此时,程序会调用构造函数创建一个临时对象,并将临时对象赋值。这一过程为隐式转换。
但这种情况只有接收一个参数的构造函数才能作为转换函数。对于两个或多个参数的构造函数,如果之后的参数都有默认值,也可以进行转换。
但是这种转换其实并不安全。
C++新增了explicit关键字,用来关闭这种自动特性。
explicit Stonewt(double lbs);
这样将会关闭隐式转换,但是还是会允许显式进行转换。
11.6.1 转换函数
可以将数字转换为类,那么能不能将类转为数字?
可以,但是不是使用构造函数,而是使用特殊的C++运算符函数——转换函数。
转换函数是用户定义的强制类型转换,可以像使用强制类型转换一样使用。
转换函数形式:
operator typeName();
其中:
- 转换函数必须是类方法
- 转换函数不能指定返回类型
- 转换函数不能有参数
例如,如果要将类转换为int和double 。那么类声明中应该包含如下的原型:
operator int();
operator double();
// 定义如下:
Stonewt::operator int() const
{
return int (pandas + 0.5);
}
由于二义性的原因,所以如果不能显示的指出需要转换成什么类型,那么就不能进行转换。
如果指出了,那么可以进行隐式转换:
int w = stonewt;
如果不想进行隐式转换的话,可加上explicit关键字。
11.6.2 转换函数和友元函数
要将double类和自定义类相加,有两种选择。
第一种方法是,将函数定义为友元函数,调用构造函数,将double转换为自定义类。
operator +(const Stonewt &,const Stonewt &);
第二种方法是,将加法运算符重载为一个显示使用double参数的函数:
Stonewt operator+(double x);
friend Stonewt operator+(double x, Stonewt &s);
优缺点分析:
第一种方法(依赖于因式转换)能够让程序更加简短,工作量少,不易出错。但是缺点是每次需要转换时,都需要调用转换构造函数,增加内存和时间开销。
第二种方法(增加显示匹配类型函数)则相反,程序较长,工作量大,容易出错。但是运行速度较快。