4. 类与对象
4.1 面向对象程序设计的基本特点
4.1.1 抽象
抽象,是对具体的对象进行概括,抽出一类对象的公共性质并加以描述的过程。
抽象应包括两个方面:
- 数据抽象:描述某类对象的属性或状态,也就是此类对象区别于彼类对象的特征。
- 行为抽象:描述某类对象共同行为或功能特征。
例如:在计算机上实现一个简单的时钟程序
数据抽象:需要3个整型数来分别存放时,分,秒。
//数据抽象
int hour,int minuute,int second
行为抽象:时钟要具有显示时间、设置时间的的功能。
showTime(),setTime()
对人进行抽象
- 数据抽象:共同的属性,如性别、年龄、姓名等。
string name,string sex,int age
- 行为抽象:吃饭、行走这些生物性行为,工作、学习这些社会性行为
eat(),walk(),work(),study()
4.1.2 封装
封装就是将抽象出来的数据和行为相结合,形成类,数据和函数都是类的成员
例如:将时钟的数据和功能封装起来,构成一个时钟类。
class Clock //class关键字 类名
{ //边界
public: //外部接口
void setTime(int newH,int newM,int newS); //行为,代码成员
void showTime(); //行为,代码成员
private: //特定的访问权限
int hour,minute,second; //属性,数据成员
} //边界
通过封装使一部分成员充当类与外部的接口,而将其他成员隐蔽起来,增强数据的安全性和简化程序编写工作。
4.1.3 继承
继承用于在一般概念基础上、派生出特殊概念,使得一般概念中的属性和行为可以被特殊概念共享,摆脱重复分重复开发的困境。
4.1.4 多态
多态性是指一段程序能够处理多种类型对象的能力。在C++语言中,这种多态性可以通过强制多态、重载多类型参数化多态、包含多态4种形式来实现。
4.2 类和对象
从另一个角度来理解类:
首先定义两个数据int i;bool b
显然,i是用于存储int型数据的,b用于存放bool型数据的,变量声明另一个重要的意义是限定对变量的操作,i可以用于进行算术操作,b可以用于逻辑运算。这说明每一种数据类型都包含了数据本身的属性以及对数据的操作。
C++的语法提供了对自定义类型的支持,这就是类。
4.2.1 类的定义
class 类名称
{
public:
外部接口
protected:
保护型成员
private:
私有成员
}
4.2.2 类成员的访问控制
还是借用钟表的例子,任何一种钟表都记录着时间,正常使用者只能通过面板查看时间,通过旋钮来调整时间;但是,修理师可以拆开钟表。面板、旋钮是我们接触和使用钟表的途径,因此将他们设为类的外部接口。而钟表记录着的时间值,是类的私有成员,使用者只能通过外部接口去访问私有成员。
访问控制属性可以有以下3种:
- 公有类型(public)定义了外部接口。
- 用public关键字声明,在类外只能访问类的公有成员。
- 私有类型(private)
- 如果私有成员紧接着类名称,则关键字可以省略。
- 私有成员只被本类的成员函数访问,来自类外部的任何访问都是非法的。
- 保护类型(protected)
- 性质和私有成员性质相似,差别在于继承过程中对产生的心类影响不同。
4.2.3 对象
类的对象就是该类的某一种特定实体(也称实例)。如果将类看作是自定义的类型,那么类的对象就可以看成是该类型的变量。
声明一个对象和声明一个一般变量相同,格式为
类名 对象名;
例如:Clock my_Clock;
就声明了一个对象myClock
访问对象的成员采用的是“.”操作符,访问数据成员的一般形式是:
对象名.数据成员名
调用函数成员的一般形式是:对象名.函数成员名(参数表)
例如,访问类Clock的对象myClock的成员函数showTime()的方式如下:
myClock.showTime();
在类的外部只能访问到类的公有成员;在类的成员函数中,可以访问到类的全部成员。
4.2.4 类的成员函数
类的成员函数描述的是类的行为,例如Clock类中的成员函数setTime()和showTime()。成员函数是对封装的数据进行操作的方法。
1. 成员函数的实现
函数的原型声明要写在类体中,原型说明了函数的参数表和返回值类型。而函数的具体实现是写在类定义之外的。与普通函数不同,实现成员函数时要指明类的名称,具体形式为:
返回值类型 类名::函数成员名(参数表)
{
函数体
}
例如:
void Clock::setTime(int newH,int newM,int newS) //使用类名限定
{
hour=newH;
minute=newM;
second=newS;
}
void Clock::showTime()
{
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
2. 成员函数调用中的目的对象
调用成员函数需要使用".“操作符指出调用所针对的对象,称为目的对象。
在成员函数中可以不使用”.“操作符而直接引用目的对象,但在引用其他对象的属性和调用其他对象的方法时,都需要使用”."操作符。
3. 带默认形参值的成员函数
类的成员函数也可以有默认形参值,其调用规则同普通函数相同
4. 内联成员函数
如果有的函数成员需要被频繁调用,而且代码比较简单,这个函数也可以定义为内联函数。
内联函数的隐式声明:将函数体直接放在类内,这种方式称之为隐式声明。
例如将时钟类的showTime()声明为内联函数:
class Clock
{
public:
void setTime(int newH,int newM,int newS);
void showTime() //隐式声明
{
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
private:
int hour,minute,second;
}
内联函数的显式声明:
inline void Clock::showTime()
{
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
4.2.5 程序实例
时钟类的完整程序
#include <iostream>
using namespace std;
//定义时钟类
class Clock
{
public:
void setTime(int newH=0, int newM=0, int newS=0);
void showTime();
private:
int hour, minute, second;
};
//时钟类成员函数的具体实现
void Clock::setTime(int newH, int newM, int newS)
{
hour=newH;
minute=newM;
second=newS;
}
inline void Clock::showTime()
{
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
int main() {
Clock myClock;
cout<<"First time set and output:"<<endl;
myClock.setTime();
myClock.showTime();
cout<<"Second time set and output"<<endl;
myClock.setTime(8, 30, 30);
myClock.showTime();
system("pause");
return 0;
}
4.3 构造函数和析构函数
在定义对象的时候进行的数据成员设置,称为对象的初始化。在特定对象使用结束时,还经常需要进行一些清理工作。
C++程序中的初始化和清理工作,分别由构造函数和析构函数来完成。
4.3.1 构造函数
一个基本类型变量的初始化过程:在为对象分配内存单元的同时,在其中写入了变量的初值
对象的建立过程:对象定义时,程序会向操作系统申请一定的内存空间用于存放新建的对象,但是编译器无法同时将数据成员的初始值写入。
如果需要进行对象初始化,程序员要编写初始化程序
构造函数的作用就是在对象被创建时利用特定的值构造对象,将对象初始化为一个特定的状态。
构造函数也是类的一个成员函数,相比于普通的类的特征之外还有特殊的性质:
- 构造函数的函数名与类名相同,而且没有返回值
- 构造函数通常被声明为公有函数
构造函数的形式: - 函数名与类名相同
- 不能定义返回值类型,也不能有return语句
- 可以有形参,也可以无形参
- 可以是内联函数
- 可以重载
- 可以带默认形参值
构造函数在对象被创建的时候将被自动调用,编译器会在建立新对象的地方自动插入对构造函数的调用代码。
调用时无需提供参数的构造函数称为默认构造函数。如果类中没有写构造函数,编译器会自动生成一个隐含的默认构造函数,该构造函数的参数列表和函数体皆为空。在类中声明了构造函数,编译器便不会再为之生成隐含的构造函数。
如果程序员定义了恰当的构造函数,Clock类的对象在建立时就能够获得一个初始的时间值。
将Clock类修改如下:
#include <iostream>
using namespace std;
//定义时钟类
class Clock
{
public:
Clock(int newH, int newM, int newS);
void setTime(int newH, int newM, int newS);
void showTime();
private:
int hour, minute, second;
};
//构造函数的实现
Clock::Clock(int newH, int newM, int newS):hour(newH), minute(newM), second(newS) //冒号后面的时初始化列表
{
}
//时钟类成员函数的具体实现
void Clock::setTime(int newH, int newM, int newS)
{
hour=newH;
minute=newM;
second=newS;
}
inline void Clock::showTime()
{
cout<<hour<<":"<<minute<<":"<<second<<endl;
}
int main() {
Clock c(0,0,0);
c.setTime(8, 30, 30);
c.showTime();
system("pause");
return 0;
}
在建立对象c时,会调用构造函数,将实参值用作初始值。
由于Clock类中定义了构造函数,所以编译器不会再生成隐含的默认构造函数。而自定义的构造函数带有参数,所以建立对象时就必须给出初始值
初始化列表负责为新创建的对象中的一个或几个数据对象指定初始值,需要初始化列表的数据成员使用逗号隔开,们个成员名字后面紧跟括号括起来的初始值。
4.3.2默认构造函数
默认构造函数在调用时可以不给任何实参的构造函数。
如果类中没有显示地定义构造函数,那么编译器会隐式地定义一个默认的构造函数。
如果存在类内的初始值,则使用类内初始值初始化成员,否则,以默认方式初始化成员(基本类型数据成员默认是未定义的值)。
对于一个普通的类,通常需要定义自己的默认构造函数,原因有三:
- 编译器只在类不包含任何构造函数的情况下才会替我们生成默认构造函数,一旦我们定义了其他的构造函数,那么除非我们再定义一个默认的构造函数,否则构造类将没有默认构造函数。
- 合成的默认构造函数可能会执行错误的操作。
- 有时候编译器不能为某些类合成默认的构造函数。
举例,重载的构造函数及其被调用的情况
class Clock
{
public:
Clock(int newH, int newM, int newS); //构造函数
Clock(); //默认构造函数
void setTime(int newH, int newM, int newS);
void showTime();
private:
int hour,minute,second;
};
Clock::Clock():hour(0),minute(0),second(0) //默认构造函数
{
}
//其他函数的实现同上
int main()
{
Clock c1(0,0,0); //调用有参数的构造函数
Clock c2; //调用无参数的构造函数
...
}
4.3.3委托构造函数
一个委托构造函数使用它所属类中的其他构造函数执行它自己的初始化过程。
Clock(int newH, int newM, int newS) //构造函数
{
hour=newH;
minute=newM;
second=newS;
}
Clock():Clock(0,0,0){} //构造函数
第二个构造函数委托给了第一个构造函数来完成数据的初始化,类中往往有多个构造函数,只是参数表和初始化列表不同,其初始化算法相同,这时为了避免代码重复,可以使用委托构造函数。
4.3.4复制构造函数
复制构造函数具有一般构造函数的所有特性,其形参是本类的对象的引用,使用一个已经存在的对象去初始化同类的一个新对象。
如果程序员没有定义类的复制构造函数,系统就会在必要时自动生成一个隐含的复制构造函数,把初始值对象的每个数据成员的值都复制到新建立的对象中。
class 类名
{
public:
类名(形参表); //构造函数
类名(类名&对象); //复制构造函数
//为了保证元件的安全不被破坏,可以加上const: const 类名&对象名
...
};
类名::类名(类名&对象名) //复制构造函数的实现
{
函数体;
}
例子:通过水平方向和垂直方向的坐标值X和Y来确定屏幕上的一个点
//点类(Point)
class Point
{
public:
point(int xx=0, int yy=0) //构造函数
{
x=xx;
y=yy;
}
Point(Point &p); //复制构造函数
int getX() {return x;}
int getY() {return y;}
private:
int x,y;
};
//复制构造函数的实现
Point::Point(Point &p)
{
x=p.x;
y=p.y;
cout<<"Calling the copy constructor"<<endl;
}
复制构造函数被调用的情况
-
当用类的一个对象去初始化该类的另一个对象时。
int main() { Point a(1,2); Point b(a); //用a初始化b,复制构造函数被调用 Point c=a; //用a初始化b,复制构造函数被调用 cout<<b.getX()<<endl; return 0; }
-
如果函数的形参是类的对象,调用函数时,进行实参和形参结合时。
void f(Point p) { cout<<p.getX()<<endl; } int main() { Point a(1,2); f(a); //函数的形参为类的对象 return 0; }
-
如果函数的返回值时类的对象,函数执行完成返回调用者时。
Point g() { Point a(1,2); return a; //函数的返回值时类的对象 } int main() { Point b; b=g(); return 0; }
4.3.5析构函数
在一个函数中定义了一个局部变量,那么当这个函数运行结束返回调用者时,函数中的对象也就消失了,析构函数就是用来完成对象被删除前的一些清理工作,在兑对象的生存期即将结束的时候被自动调用的。
调用析构函数完成之后,对象就消失了,相应的内存空间也被释放。
与构造函数一样,析构函数通常也是类的一个公有成员,它的名称是由类名前面加“~”构成,没有返回值。与构造函数不同的是析构函数不接受任何参数,但可以是虚函数,如果不进行显示说明,系统也会生成一个函数体为空的隐含析构函数。
class Clock
{
public:
Clock();
void setTime(int newH, int newM, int newS);
void showTime();
~Clock(){}
private:
int hour, minute, second;
};
如果希望程序在对象被删除前的时刻自动完成某些事情,就可以把它们写到析构函数中。
4.3.6移动构造函数
C++11标准引入了左值和右值,定义了右值引用的概念,以表明被引用对象在使用后会被销毁,不会再继续使用。直观来看,左值是位于赋值语句左侧的对象变量,右值是赋值语句右侧的值,不依附于对象。
参数引用传递中对持久存在变量的引用,称为左值引用,相对的对短暂存在可被移动的右值的引用称之为右值引用。因此,可通过移动右值引用对象来安全地构造新对象,并且避免冗余复杂对象的代价。
float n=6;
float &lr_n=n; //对变量n的左值引用
float &&rr_n=n; //错误,不能将右值引用绑定到左值n上
float &&rr_n=n * n; //将乘法结果右值绑定到右值引用
float &lr_n=n*n; //错误,不能将左值引用绑定到惩罚结果右值
一个左值对象不能绑定到一个右值引用上。但实际应用中,可能某个对象的作用仅限在初始化其他新对象使用后销毁,标准库utility中声明提供了move函数,将左值对象移动成右值。
float n=10;
float &&rr_n=std::move(n); //将左值对象移动为右值并绑定右值引用
move函数告诉编译器变量n转换为当右值来使用,承诺除对n重新赋值或者销毁它以外,将不再通过rr_n右值引用以外的方式使用它。
基于右值引用的新设定,可以通过移动而不复制实参的高性能方式构造新对象,即移动构造函数。类似于复制构造函数,移动构造函数的参数为该类对象的右值引用,在构造中移动源对象资源,构造后源对象不再指向被移动的资源,源对象可重新赋值或被销毁:
class MyStr
{
public:
string s;
MyStr():s("") {}; //无参构造函数
MyStr(string_s) : s(std::move(_s)) {}; //有参构造函数
MyStr(MyStr &&str) noexcept //告知编译器不会抛出异常
: s(std::move(str.s)) {} //移动构造函数
}
局部string类型变量因用来初始化类内string成员后无其他使用,也可通过move将其转化为右值移动来避免复制。
需要注意的是,移动构造函数不会分配新资源新内存,因此理论上不会报错,为配合编译器异常捕获机制,需声明noexcept表明函数不抛出异常。
4.3.7 default、delete函数
C++11标准提供了default和delete两个关键字来简化构造函数的定义与使用。
使用 =default
可显示要求编译器自动生成默认或复制构造函数。
class MyStr
{
public:
string s;
MyStr()=default; //默认合成的无参构造函数
MyStr(string_s) : s(std::move(_s)) {}; //有参构造函数
MyStr(MyStr &&str)=default; //默认合成的复制构造函数
~MyStr()=default; //默认合成的析构函数
}
通过使用default,可以让编译器合成简单的无参默认构造函数和复制构造函数,但其他使用参数的构造函数,由于编译器不知构造逻辑,需要用户自行定义。当用户不希望定义的类存在复制时,可以通过delete关键字将复制构造函数删除:
class MyStr
{
public:
string s;
MyStr()=default; //默认合成的无参构造函数
MyStr(string_s) : s(std::move(_s)) {}; //有参构造函数
MyStr(MyStr &&str)=delete; //删除复制构造函数
~MyStr()=default; //默认合成的析构函数
}
与default不同的是,delete不限于在无参和复制构造函数上使用,除析构函数外,用户都可以指定为delete删除掉,以便禁止类使用过程中的相关操作,比如上例中的复制操作。