前言
面向过程编程的缺陷
C++的探索路的前三篇对面向过程编程的基本概念进行了复述与实例化操作,内容涉及了数组,指针和函数等方面。这些模块中,函数是面向过程编程中最重要的编程模块,函数别名为过程也正是出于能够实现编程任务中一个过程的原因。
对于函数而言,一个函数能够实现一个或者少数几种功能的操作,比如可以定义print()函数如下:
void print(){
cout<<"我只是想打个印"<<endl;
}
在主函数中,可以调用print()对所需要打印的内容进行打印显示。
但是,面向过程编程也存在着与生俱来的缺陷,比如封装性较差:不能实现对某些功能的隐藏,而将所有内容暴露于光天化日之下。函数定义过多导致程序逻辑结构关系错综复杂,剪不断理还乱!
因此需要一种编程思想将编程解救于杂乱中,面向对象编程就可以很好的解决这一点(当然,这里说的解救杂乱只是广义上的,如果没有良好的编程习惯与数据结构的基础知识,无论拥有什么编程思想都会编写出非常乱的程序)。
面向对象编程的特点
面向对象是一种具备深邃意义的编程思想,起初只是简单的定义为具备“抽象”、“封装”、"继承"、"多态"特点的程序设计。在不断的演化过程中,发现这玩意忒好使,于是便将这种思想泛化到包括现在大热的AI等各种领域中,面向对象思想的最主要思想精髓就是抽象;而抽象这一概念在C++中最典型的应用就是类。
类的基本概念
什么是类呢,类在我们生活中随意可见,小时候经常能听到人类、鸟类、哺乳类,即使在日常生活中我们也经常会使用归类这种词语。所以类可以认为是具备相同特性的抽象体。
类还有个小伙伴:对象,对象就不是男女朋友的意思了,在OO当中,对象可以认为是类中具体化的个体,比如小明之于人类,鸡鸭鹅之于鸟类。类是抽象的,对象就是形象化的,可以直接在编程中使用的,如何定义呢?首先定义一个类C,然后使用C obj定义就可以获得C类的对象obj。
下面进入定义部分,如何定义一个类呢?当然是使用class关键字定义。定义方法:class+类名{...};
...不是忽略不计的意思。其中包含访问类型、成员以及声明。注意定义时一定不要忘了分号;来结束。
什么是访问类型? 类中的访问类型有三种,public、private、protected,正是由于访问类型的存在使得类具备良好的封装性。public的作用是定义的完对象后,谁都可以通过类来对public关键字范围的函数或者数据进行访问操作。而private就词如其名:比较自私,只能自己使用,不是公交汽车。protected关键字则介于两者之间,在后续会对这个关键字的使用进行解释。
既然是一个类,它就如同写文章一样,一定要有血有肉才能丰富里边的东西,C++通过成员对类进行丰富。成员又分为成员变量与成员函数,成员变量就是变量,成员函数就是函数;与正常的函数与变量不同的是,他们是基于类的,可能有不同的访问权限(public、private与protected)。
成员函数可以重载也可以设定默认形参,而成员函数也可以定义于类外。
类的作用是抽象,抽象完以后需要运用时应当采取一定的方法,对类内的成员进行访问和调用,C++的调用中有三种方法,第一种是“.成员名”法,第二种是"指针->成员名法",第三种是“引用.成员名”法,这三种访问方法各有特色。
例子
上述三个部分为类的一些基本概念,可能有点抽象,下面通过三个实例分别对类的基本定义、访问方式(".","->","&")以及访问类型(private,protected和public):
第一个例子--类的基本定义
#include<iostream>
using namespace std;
class CRectangle {
public:
int length;
int width;
void printArea();
};
void CRectangle::printArea() {
cout << length*width << endl;
}
int main()
{
CRectangle rect;
rect.length = 3;
rect.width = 2;
rect.printArea();
return 0;
}
这个CRectangle类:包括成员变量width和length,以及成员函数printArea();成员变量限定了矩形类的大小,而printArea为这个类可以实现的功能:打印。
在CRectangle的类下面还有一段很眼花缭乱的语句:void CRectangle::printArea(),这种写法即为printArea()定义于类外的体现,这么写的好处是可以避免在类内出现一大段代码,降低可读性。
函数定义于类外的实现过程为:
函数类型 类名::函数名+函数体
其中 ::为类作用域,表示这一部分为类内部定义的成员,这种写法不仅适用于成员函数,也适用于成员变量。
第二个例子--访问方式的示例:
上面为简单的类与对象的例子,对CRectangle类的对象成员访问时采用了.访问的方式,除了这种.访问成员方式以外,还有其他两种访问方式
第一种:指针->成员名 和第二种:引用名.成员名
#include<iostream>
using namespace std;
class CRectangle {
public:
int width;
int height;
void Init(int w_,int h_);
int area();
void printarea();
};
void CRectangle::Init(int w_, int h_) {
width = w_;
height = h_;
}
int CRectangle::area() {
return width*height;
}
void CRectangle::printarea() {
cout << "矩形面积为:"<<area() << endl;
}
int main() {
CRectangle r;
int width, length;
cout << "please count in number for CRectangle1:" << endl;
cout << "the format:__(for width) __(for length)" << endl;
cin >> width >> length;
r.Init(width , length);
r.printarea();
cout << endl;
cout << "-----print once more for pointer~~---" << endl;
cout << "please count in number for CRectangle2(pointer):" << endl;
cout << "the format:__(for width) __(for length)" << endl;
cin >> width >> length;
CRectangle *p;
p = &r;
p->Init(width, length);
p->printarea();
cout << endl;
cout << "-----print once more---" << endl;
cout << "please count in number for CRectangle3:" << endl;
cout << "the format:__(for width) __(for length)" << endl;
cin >> width >> length;
CRectangle &c=r;
c.Init(width, length);
c.printarea();
return 0;
}
在定义了CRectangle类的基础上,这段代码集成了三种类访问成员的形式。
第一种即普通形式的访问,该部分位于主函数的第一段,定义了r后,直接通过".成员"的形式进行操作。
第二种为指针->成员名的形式,这种形式位于主函数的第二段,首先将r对象赋值给p指针,然后p指针利用->运算符访问成员。
另外在书写这种指针的时候应当联系C++的探索路3部分内容:对指针使用时一定需要先初始化再引用,否则将出现野指针,进而导致内存错误的问题。
第三种形式:引用名.成员名。这种形式和普通访问形式没有两样。
第三个例子--类成员的访问范围
#include<cstring>
#include<iostream>
using namespace std;
class CEmployee {
private:
char szName[30];
public:
int salary;
void setName(char*name);
void getName(char*name);
};
void CEmployee::setName(char*name) {
strcpy_s(szName, strlen(name)+1,name);
}
void CEmployee::getName(char*name) {
strcpy_s(name, strlen(szName) + 1, szName);
}
void averageSalary(CEmployee e1, CEmployee e2) {
cout<< "the averageSalary of the two guys is: "<<(e1.salary + e2.salary) / 2<<endl;
}
int main() {
CEmployee e,e1;
//strcpy_s(e.szName, strlen("Tom123456789")+1, "Tom123456789");
e.setName("Tom");
e.salary = 5000;
e1.setName("Jack");
e1.salary = 4000;
averageSalary(e, e1);
return 0;
}
这个例子定义了雇员类CEmployee,内部包含私有成员szName对雇员的名字进行存储,也包含了public成员变量salary存储薪水和setName,getName成员函数对名字进行操作。
主函数部分首先定义了雇员类的两个对象e和e1,接着在//注释的部分主函数试图对e的成员变量进行赋值操作,然而在运行过程中这一句将会报错,这表明private的成员变量szName无法为外界直接访问操作。
在//后紧接着的是分别对e,e1两个对象进行赋值操作,最终通过averageSalary函数可以实现平均薪水的运算。由e.setName和e1.setName可以知道,被私有化的成员变量仅仅可以通过对象的内部接口函数进行操作运算。
类的基本概念差不多就是这么多,部分总结如下图所示:
类的初始化与收尾工作
类也和普通变量一样,需要进行初始化工作,在C++当中,使用构造函数(constructor)对类进行初始化;对应的通过析构函数进行最终的收尾工作。除了构造函数与析构函数外,友元与this指针为面向对象编程中(比如运算符重载章节)非常重要的运用点。
构造函数
构造函数的作用就是对类进行初始化操作,构造函数可以分为几种:普通构造函数,复制构造函数与类型转换构造函数,这三类构造函数各有特色,但使命相同---初始化。
普通构造函数
首先我们先从变量的角度来谈谈为什么需要初始化:
As we all know,变量依据作用范围大小不同可分为全局变量与局部变量。如前两篇文章所述,全局变量内存地址分配在全局数据区,内存地址在运行过程中固定不变;而局部变量则分配在栈区,每一次调用函数都会导致分配新的地址。变量是需要数值的,对于全局变量来说,他们处于全局数据区,也就是相当于他们有了当地户口,可以享有该城市的福利待遇;编译器默认给他们赋初始值为0。对于局部变量而言,他们只有在调用的过程中才会重新被分配地址,因此他们是没有户口的;如果来一次就要给他们赋值的话,将带来很大的开销,这个时候就需要他们自带干粮:由用户给他们赋初值,也就是给他们颁发个暂住证。如果没有对他们进行赋值,则很多福利待遇他们享受不了啊,如果他们要强行享受所谓的各种福利政策,则会引发内存问题(对应于指针中野指针的概念)。
类其实也是变量,所以需要赋初值,不然会变成黑户;构造函数便能实现类的初始化。
构造函数的形式有:1,名字与类名相同。2,可重载(参数表不同)。3,不写返回值类型。4,一定存在,没有定义则编译器定义了默认构造函数进行初始化。
通过一个程序来说明下问题:
#include<iostream>
using namespace std;
class Complex {
private:
double real, imag;
public:
void Set(double r, double i);
Complex(double r);
Complex(double r, double i);
Complex(Complex c1, Complex c2);
};
Complex::Complex(double r) {
imag = 0;
real = r;
cout << "constructor 1" << endl;
}
Complex::Complex(double r, double i) {
real = r; imag = i;
cout << "constructor 2" << endl;
}
Complex::Complex(Complex c1, Complex c2) {
real = c1.real + c2.real;
imag = c1.imag + c2.imag;
cout << "constructor 3" << endl;
}
void Complex::Set(double r, double i) {
real = r;
imag = i;
}
int main() {
Complex c1(3), c2(1, 2), c3(c1, c2), c4 = 7;
return 0;
}
该程序定义了一个复数类Complex,内部定义了三个构造函数,参数表分别为单参、双参、双对象;依据不同类参数表的不同实现赋初值。
复制构造函数
上面的构造函数全是基于参数的普通构造函数(为了方便与后续区分,暂且这么自定了一个这种名字,学术上是没有这种名字的~~),要很繁杂的写对应的参数,如果我们需要直接将一个对象赋值给另外一个对象时,则将导致参数不匹配的现象。But, fortunately,C++提供了复制构造函数这个操作方法,解决同类对象相互赋值的问题。
复制构造函数又称拷贝构造函数,既然是拷贝,因此其参数类型就是本类对象的引用。
复制构造函数只能为单参,而且不论程序员定不定义,复制构造函数始终存在;这一点不像默认构造函数:一旦用户定义了构造函数,则默认构造函数不复存在。
为了大致展现一下复制构造函数,现将上述的Complex类稍微改编,加入print成员函数,可得
#include<iostream>
using namespace std;
class Complex {
private:
double real, imag;
public:
void Set(double r, double i);
Complex(double r);
Complex(double r, double i);
Complex(Complex c1, Complex c2);
void print();
};
Complex::Complex(double r) {
imag = 0;
real = r;
cout << "constructor 1" << endl;
}
Complex::Complex(double r, double i) {
real = r; imag = i;
cout << "constructor 2" << endl;
}
Complex::Complex(Complex c1, Complex c2) {
real = c1.real + c2.real;
imag = c1.imag + c2.imag;
cout << "constructor 3" << endl;
}
void Complex::Set(double r, double i) {
real = r;
imag = i;
}
void Complex::print() {
cout << real << "+" << imag << "i" << endl;
}
int main() {
Complex c1(3), c2(1, 2), c3(c1, c2), c4(c3);
cout << endl;
cout << "c3:";
c3.print();
cout << "c4:";
c4.print();
return 0;
}
复制构造函数也不仅仅简单的存在于直接类之间的赋值操作过程中,其存在三种情况:
第一种情况就是上述简单的利用一个对象初始化同类的另外一个对象,则引发了复制构造函数的调用操作。
#include<iostream>
using namespace std;
class A {
public:
A() { };
A(A&) {cout<<" copy constructor called"<<endl; }
};
int main() {
A a;
A b(a);
return 0;
}
本程序为了更好的显示复制构造函数的作用,编写了面向用户的拷贝构造函数A(A&),如果调用该函数则将打印“copy constructor called”语句。
经过运行,打印了该行语句,说明程序中调用了复制构造函数,其调用的原因就是b对象是通过a对象初始化的,因此进行了复制构造函数的调用。
第二种情况:如果函数的参数存在类的对象,那么函数被调用时,将引发类的复制构造函数的调用。
#include<iostream>
using namespace std;
class A {
public:
A() { };
A(A&) {cout<<"copy constructor called"<<endl; }
};
void print(A) {
cout << "haha" << endl;
}
int main() {
A a;
print(a);
return 0;
}
比如这一段程序,print的函数中调用了类A的对象,甭管用没用,依然触发了复制构造函数的使用。基于这个程序我们可以进行两个方面的思考与拓展应用:
第①点:函数的形参值不一定等于实参
该程序的print函数不仅仅使用了A对象,更是利用复制构造函数做了额外的事,因此形参值不一定等于实参。
第②点:注意复制构造函数的花销问题。
由上述代码可知,函数中如果使用类的对象,则将导致复制构造函数的调用,然而复制构造函数的调用是有额外的花销的,因此可以如下述程序,将函数的参数表由A改成A&
void print(A&)
这样就可以避免调用失败。但是引用作为形参有被改变的风险,因此在形参前应当加一个const进行限制。
第三种情况:函数的返回值为类A的对象
#include<iostream>
using namespace std;
class A {
public:
int v;
A(int n) { v = n; };
A(const A&a) {
v = a.v;
cout << "copy constructor called" << endl;
}
};
A Func() {
A a(4);
return a;
}
int main() {
cout << Func().v << endl;
return 0;
}
这种情况暂时接触的不多,先将书上的代码粘贴过来,后续有新知识则继续补充。
类型转换构造函数
类型转换构造函数的作用就是类型转换,其实和前面的普通构造函数差不多。
下面看一个Complex类的例子:
#include<iostream>
using namespace std;
class Complex {
public:
double real, imag;
Complex(int i) {
cout << "IntConstructor called" << endl;
real = i; imag = 0;
}
Complex(double r, double i) {
real = r; imag = i;
}
};
int main() {
Complex c1(7, 8);
Complex c2 = 12;
c1 = 9;
cout << c1.real << "," << c1.imag << endl;
}
该程序调用了两次类型转换构造函数,其一是初始化Complex c2=12;其二是赋值c1=9;唯一不同之处在于c1=9不是初始化,其会产生临时对象。
析构函数
析构函数的基本概念
析构函数的作用就是打扫战场(析构函数在消亡时自动调用),因此其功能相当于扫地僧。为了表明派别,扫地僧的名字与应遵从类名,与类名相同。与此同时,扫地僧是无欲无求的,所以析构函数不存在参数以及返回值。
析构函数的写法为~本类名称(){函数体}。
举个栗子:
#include<iostream>
using namespace std;
class CDemo {
public:
~CDemo() {
cout << "deconstructor called" << endl;
}
};
int main() {
CDemo array[2];
CDemo *pTest = new CDemo;
delete pTest;
cout << "-----这是一条分割线----" << endl;
pTest = new CDemo[2];
delete[]pTest;
cout << "----another one-----" << endl;
return 0;
}
这段程序创建了三次对象。第一次为定义了内容大小为2的CDemo数组。第二次new了一个指针。第三次new了两个数组。
第一次调用析构函数在----这是一条分割线-----之前。因为new出的内存被delete掉了,所以调用析构函数,打印deconstructor called语句。
第二次调用析构函数在delete[]pTest时,由于new了两个对象,因此消亡两次,调用两次析构函数。最后程序结尾时,全部对象GG,对主函数首先定义的CDemo array[2]进行扫尾工作,再度打印两次deconstructor called。
析构函数与构造函数的变量生存期
不同的对象有不同的作用范围,因此也就决定了析构函数将会在不同的位置被调用。一个例子说明一下析构函数与构造函数的变量生存期范围
#include<iostream>
using namespace std;
class CDemo {
int id;
public:
CDemo(int i) {
id = i;
cout << "id=" << id << "constructed" << endl;
}
~CDemo() {
cout << "id=" << id << "deconstructor called" << endl;
}
};
CDemo d1(1);
void Func() {
static CDemo d2(2);
CDemo d3(3);
cout << "func" << endl;
}
int main() {
CDemo d4(4);
d4 = 6;
cout << "main" << endl;
{
CDemo d5(5);
}
Func();
cout << "main ends" << endl;
return 0;
}
整个程序流程图如下图所示
对整个程序进行概览,发现程序对CDemo类与Func函数进行了调用。其中CDemo类内包含类型转换构造函数,与析构函数;类型转换构造函数将整数转化为CDemo类的对象,同时输出id=xx constructed的内容。而析构函数的作用是使得对象消亡,并对相应的id值进行显示。
Func函数则包含值为2的CDemo类的静态成员d2,值为3的CDemo的局部成员d3,在程序结束时输出func。在分析了结构后,对程序的执行流程顺序进行输出解剖。(后文中将id=1 constructed缩写为 1 c,将id=1 deconstructed缩写为 1 d)程序首先运行值为1的全局对象d1,此时输出1 c。
接着进入到main函数当中,在main函数中第一个遇到的是值为4的d4,则输出 4 c。程序继续往下走,程序将6赋值给d4,注意这个时候是赋值而不是初始化了,将会生成一个临时的对象,于是先输出6 c,紧接着临时对象消亡,输出6 d。
这一步结束后,输出main并且进入包含CDemo d5的函数体。首先输出 5 c,接着d5出函数体消亡,输出5 d。
函数体结束后进入Func()函数当中值为2的d2首先被激活,输出 2 c;接着激活d3,输出 3 c,然后输出func后又要出函数体,则输出 3 d将d3消亡。
但注意此时d2由于是静态变量,将不会执行清零操作。出了Func()函数体后,输出main ends,准备结束整个函数。
首先输出6 d,将首先出现在main函数的对象进行消亡。注意,这个时候d4值为6,之前是消亡了临时对象,而不是彻底消亡了这一部分!
第二步要消亡的则是静态对象d2,输出2 d。
最后一步消亡掉全局对象d1,输出 1 d。
因此,整个的输出顺序为:
1 c
4 c
6 c
6 d
main
5 c
5 d
2 c
3 c
func
3 d
main ends
6 d
2 d
1 d
这一段有点长,其实要比看到的还长,写了一大段,结果CSDN博客给卡死了,上也上不了,下也下不了;一刷新,之前写的内容全没了,too terrible,而且关键的是,这个博客bug在有时竟然无法用鼠标选择写的范围,导致写了一大段根本无法复制粘贴。
不吐槽了,说正事,这一篇文章基本将类与对象的内容叙述完毕,还剩下一小部分将放在C++的探索路5中进行叙述。
下面这个图为面向对象与类这部分的一些小小的总结,方便大家进行相应的梳理以及查漏补缺。
下一部分见