在前两节我们介绍了C++对C的扩展,接下来我们就要进入C++中最重要的内容的学习了。面向对象是现代程序设计语言的最大特征之一。面向对象通过抽象封装继承和多态实现了代码的复用和程序的高效开发。
类和对象
什么是类?
在C中有结构体这种复合数据类型。结构体将一些基本的数据类型进行了封装,通过点运算符来操作结构体中的成员。C++中的类只不过是在结构体的基础上进一步扩展。结构体不能封装函数并且没有访问控制;而类能够封装函数并且具有访问控制。什么是对象
对象是类实例化出来的。我们可以把类看做一个数据类型,对象看成是变量。数据类型定义一个变量,类实例化一个对象。本质上都是开辟内存空间的过程。什么是封装
封装是类的基本特点之一。封装主要有两个功能:一是通过封装将变量和函数封装到类中成为成员变量和成员函数(区分了类内和类外,在类内,成员函数可以直接操作成员变量)。二是通过封装实现访问控制。什么是访问控制?
在C中,我们知道结构体只能封装基本数据类型不能封装函数。但是在C++中,结构体不仅可以封装变量同时也可以封装函数。那C++中的结构体和C++中的类有什么区别呢? 答案就是访问控制。C++中的结构体没有访问控制,所以其成员函数和成员变量的访问权限是public;而C++中的类有访问控制,权限分为: public,private和protected。默认的class的访问权限是private。
面对对象开发和面向过程开发
讲类与对象绕不开的就是面对对象和面向过程这两种开发模式。举个例子:狗吃屎用面对对象开发就是
狗.吃(屎)
用面向过程开发就是吃(狗,屎)
面对对象是对象驱动事件,面向过程是函数驱动事件。
面对对象开发的设计模式为”三明治模式”分为逻辑层(main函数),架构层(类的声明),实现层(类的定义)。先将大楼的骨架也就是架构层搭好,再填充细节。就像是拿着图纸去盖楼一样。
而面向过程开发模式更像一棵树。通过divide and conquer将大问题分解成小问题。自顶向下去设计,自下而上的去实现。就像走一步看一步,没有一个全局的规划,只能在下一层实现后才能搭建上一层。
补充一下:带类的C有两种语言一种是C++,一种是Objective-C。C++的变种语言有Lua,魔兽世界就是Lua开发的。
面向对象开发小技巧–类的声明和定义分离(架构层和实现层的分离)
我们在定义一个类的时候,往往将类中的函数的实现写在类中。这在类的功能较为单一的时候是可行的。但是当类比较复杂时,这样定义一个类会使类十分庞大,可读性和可维护性差。我们可以将一个类写在两个文件中,实现类的声明和定义分离。将类的声明写在.h文件中,将类的定义写在.cpp文件中即可实现该功能。
三文件开发模型:
.h文件中第一行往往是#pragma once这是为了防止头文件中的内容重定义而写的。这是C++的风格。C的风格为:
#ifndef _CIRCLE_H_ #define _CIRCLE_H_ xxx//填充头文件内存 #endif
- .cpp文件中先引入头文件,然后采用命名空间实现在类外定义成员函数。
- 我们最后在主cpp文件(如:main.cpp)文件中实现逻辑层功能。
通过三文件开发模型,我们能很容易的实现面对对象风格的开发。
面对对象入门小程序
这里给出一些练习,可以用来熟悉三文件开发模型。判断日期是否为闰年
面对对象:定义一个日期类,有属性年月日,有方法isleapyear(),如果是闰年返回true。
面向过程: 定义一个函数isleapyear,接收参数年月日,如果是闰年返回为真。求圆的面积和周长
- 求立方体是否相等
- 求点与圆的位置关系
- 求圆是否相交
构造函数和析构函数
我们知道类可以实例化一个对象,但是该对象并没有初始化,所以该对象中的属性的值是混乱的。我们在C中定义变量的一个要求就是定义变量的时候要初始化。类在实例化对象的时候也不例外,同样需要初始化对象,C++中提供构造函数来实现这一需求。在函数结束的时候,要对内存中的数据进行释放,这个时候就需要析构函数来处理对象。需要注意的是:构造函数不是在内存中开辟内存空间存放对象,而是将已经有内存空间的对象赋值,而且必须是在实例化一个对象的时候赋值。析构函数不摧毁一个对象,而是释放该对象所占内存空间中的数据。构造函数(又叫构造器)
- 构造函数能够被重载,因为均没有返回值且函数名相同,参数列表不同。
- 分类
- 默认构造函数&默认拷贝构造函数
当类中没有显示的构造函数时,类会提供一个默认构造函数和一个默认拷贝构造函数。默认构造函数格式为Test(){},没有参数没有语句。当显示定义了一个构造函数后,默认构造函数被隐藏。但是默认拷贝构造函数不会被隐藏,除非显示定义一个拷贝构造函数。当使用默认拷贝构造函数时,此时的拷贝是浅拷贝,如果类中有在堆上开辟内存空间,浅拷贝是无法拷贝堆上的内存空间的。如果显示定义一个拷贝构造函数,则能够自定义的去深拷贝。 - 无参构造函数
格式为Test(){xxx} - 带参数的构造函数
格式为Test(xxx){xxx} - 拷贝构造函数
格式为Test(const Test & t){xxx}
- 拷贝构造函数的使用场景
- 直接使用
- Test t(1,1); Test t2(t1);
- Test t(1,1); Test t2=t1;
- 伪使用(实际上调用的是=运算符)
- Test t(1,1); Test t2; t2=t1;
- 函数参数,传参数时发生值拷贝
- func(Test t);
- 函数返回值
- 没有接收函数返回值
返回一个匿名对象。因为匿名对象没有被接收,所以匿名对象直接析构。 - 接收函数返回值
Test t=func()
返回一个匿名对象,给匿名对象赋名字为t;此时匿名对象转化为t对象。(即这个语句没有拷贝构造函数的调用)Test t; t=func()
返回一个匿名对象,用这个匿名对象调用=运算符。然后改匿名对象被析构。
- 没有接收函数返回值
- 直接使用
- 拷贝构造函数的使用场景
- 默认构造函数&默认拷贝构造函数
- 功能
构造函数的功能就是用类实例化对象时能够初始化对象。避免实例化对象后出现的混乱期。
析构函数
- 概念:在销毁对象之前用来清洗对象的函数。
- 格式:~Test()
- 功能:防止内存泄漏,但是要注意防止多次释放同一内存空间。
- 析构函数不能重载,因为析构函数没有参数列表。
构造函数和析构函数调用的顺序
对象创建和销毁的顺序类似于栈LIFO
构造函数的初始化列表
通过构造函数初始化列表,不仅能够初始化成员对象,也能够初始化成员变量。类中有三种情况必须要使用构造函数初始化列表来初始化:1. const常量的初始化 2. 引用的初始化 3. 对象的初始化(调用带参数的构造函数或者拷贝构造函数)
如果一个类中有多个成员对象,那么这些对象构造和析构的顺序应该是什么样的呢?类中成员对象的定义顺序直接决定了成员对象的构造析构顺序,与初始化列表的排列顺序无关。
强化练习一
分析以下构造函数和析构函数的调用顺序
//分析一下构造函数和析构函数的调用顺序 #include <iostream> using namespace std; class ABCD { public: ABCD(int a, int b, int c) { _a = a; _b = b; _c = c; printf("ABCD() construct, a:%d,b:%d,c:%d \n", _a, _b, _c); } ~ABCD() { printf("~ABCD() destruct,a:%d,b:%d,c:%d \n", _a, _b, _c); } int getA() { return _a; } private: int _a; int _b; int _c; }; class MyE { public: MyE() :abcd1(1, 2, 3), abcd2(4, 5, 6), m(100) { cout << "MyE()" << endl; } MyE(const MyE & obj) :abcd1(7, 8, 9), abcd2(10, 11, 12), m(100) { printf("MyE(const MyE() & obj)\n"); } ~MyE() { cout << "~MyE()" << endl; } public: ABCD abcd1; //c++编译器不知道如何构造abc1 ABCD abcd2; const int m; }; int doThing(MyE mye1) //mye1 = myE //mye1.拷贝构造函数(myE) { printf("doThing() mye1.abc1.a:%d \n", mye1.abcd1.getA()); return 0; } int run() { MyE myE; //调用的无参构造函数 doThing(myE); return 0; } int run2() { printf("run2 start..\n"); ABCD(400, 500, 600); //临时对象的⽣命周期 //会产生一个临时的匿名对象。 //再次析构匿名对象 //匿名的临时对象,编译器会立刻销毁。不等到正常函数调用完毕。, ABCD abcd = ABCD(100, 200, 300); printf("run2 end\n"); //在此析构abcd return 0; } int main(void) { run(); //run2(); return 0; }
思路:有两个程序run()和run2()
对run():
将run()中的程序翻译一下,run中程序代表了以下几个过程:- MyE无参构造
- mye1的拷贝构造
- doThing函数体的执行
- mye1的析构
- MyE的析构
对MyE的无参构造函数又分为以下几个步骤:
- abcd1的有参构造
- abcd2的有参构造
- MyE的无参构造
对mye1的拷贝构造又分为以下几步:
- abcd1的有参构造
- abcd2的有参构造
- MyE的拷贝构造
对doThing函数体的执行直接为语句
printf("doThing() mye1.abc1.a:%d \n", mye1.abcd1.getA());
的执行对mye1的析构函数分为以下几个部分:
- MyE析构
- abcd2析构
- abcd1析构
对MyE的析构分为以下几个部分:
- MyE析构
- abcd2析构
- abcd1析构
综上所述,整个构造函数和析构函数的调用顺序为:
- abcd1的有参构造
- abcd2的有参构造
- MyE的无参构造
- abcd1的有参构造
- abcd2的有参构造
- MyE的拷贝构造
- doThing函数体
- MyE析构
- abcd2析构
- abcd1析构
- MyE析构
- abcd2析构
- abcd1析构
对run2():
直接写结果了:- run2 start ..
- ABCD带参构造函数构造匿名对象,该匿名对象没有被接收
- 析构匿名对象
- ABCD带参构造函数构造匿名对象,该对象被接收,赋予名称abcd
- run2 end
- 析构对象abcd
强化练习二
在构造函数中调用构造函数不能给当前构造函数提供初始化属性的机会。#include <iostream> using namespace std; //构造中调⽤构造是危险的⾏为 class MyTest { public: MyTest(int a, int b, int c) //有参 构造函数 { _a = a; _b = b; _c = c; } MyTest(int a, int b) //有参数的构造函数,两个参数 { _a = a; _b = b; //构造函数中,无法嵌套构造函数 来通过构造函数给自己的成员变量赋值, //此构造函数已经又创建了另一个对象。 MyTest(a, b, 100); //产⽣新的匿名对象 //新的匿名对象 a->1 b->2 c ->100 } ~MyTest() { printf("MyTest~:%d, %d, %d\n", _a, _b, _c); } int getC() { return _c; } void setC(int val) { _c = val; } private: int _a; int _b; int _c; }; int main() { MyTest t1(1, 2); //t1.a -->1 t1.b -->2 t1.c--->? printf("c:%d\n", t1.getC()); //请问c的值是? return 0; }