面向对象和类
面向对象的基本概念
面向对象的意义
- 将日常生活中习惯的思维方式引入程序设计中
- 将需求中的概念直观的映射到解决方案中
- 以模块为中心构建可复用的软件系统
- 提高软件产品的可维护性和可扩展性
类和对象是面向对象中的两个基本概念
- 类:指的是一类事物,是一个抽象的概念
- 对象:指的是属于某个类的具体实体
- 类是一种模型,这种模型可以创建出不同的对象实体
- 对象实体是类模型的一个具体实例
一个类可以有多个对象,而一个对象必然属于某个类
类和对象的意义
- 类用于抽象的描述一类事务所特有的属性和行为
如:电脑拥有CPU,内存和硬盘,并且可以开机和运行程序 - 对象是具体的事物,拥有所属类中描述的一切属性和行为
如:每一只老虎都有不同体重,不同食量等
一些有趣问题
类一定存在实际的对象吗?
不一定,比如:恐龙,理论上来看,考古学家通过化石构建出一个恐龙,但是现在并不存在一个真真切切的恐龙
类的对象数目是确定的吗?
不一定确定,比如有多少只老虎?
类一定来源于现实生活?
不一定,虽然将现实中类的思想移到编程思想中,但一些辅助类需要我们自己抽象构建
类都是独立的吗?类之间存在关系吗?
类不是独立的,类之间显然存在关系
对象实例一定只属于一个类?
不一定。
对象实例可能完全相同吗?
现实中没有两片相同的叶子,程序设计中有吗?
类之间的基本关系
继承
- 从已存在类细分出来的类和原类之间具有继承关系(is-a)
- 继承的类(子类)拥有原类(父类)的所有属性和行为
比如华南虎和老虎是继承关系(单向)。华南虎 is a 老虎
而不能反过来说老虎 is a 华南虎
组合
- 一些类的存在必须依赖于其它的类,这种关系叫组合
- 组合的类在某一局部上有其他类组成
电脑由CPU,主板,内存,硬盘等,而电脑这个类依赖于CPU类,主板类,内存类,硬盘类等组成
组合关系的特点
- - 挎其它类的对象作为当前类的成员使用
- - 当前类的对象与成员对象的生命期相同
- - 成员对象在用法上与普通对象完全一致
类的表示法
但是编译器懂这个图吗?我们可以简化成编译器比较能理解的表示法
上面图属性重复定义,我们继续改进
继续改进,使用了大括号
继续改进,我们用struct
来代替中文的类
。用:
来代替中文的继承
。
我们继续改进,用变量描述属性,函数描述行为
经过多次改进,这样表示不遗漏信息且成功让C++编译器能够读懂。
类与封装
类通常分为以下两个部分
- 类的使用细节
- 类的使用方式
- 当使用类的时候,不需要关心其内部实现细节
- 当创建类时,才需要考虑其内部实现细节
例如
普通用户使用手机,只需要学习如何发短信,打电话,拍照,等等等
手机开发工程师,需要考虑手机内部的实现细节
封装的基本概念
根据经验:并不是类的每个属性都是对外公开的
比如:女孩子不希望外人知道自己的体重和年龄
比如:男孩子不希望别人知道自己的身高和收入而一些类的属性时对外公开的
比如:人的姓名,学历,国籍等必须在类的表示法中定义属性和行为的公开级别
类似文件系统中文件的权限
C++中类的封装
- 成员变量:C++中用于表示类属性的变量
- 成员函数:C++中用于表示类行为的函数
- C++中可以给成员变量和成员函数定义访问级别
public 成员变量和成员函数可以在类的内部和外界访问和调用
private 成员变量和成员函数只能在类的内部被访问和调用
类成员的作用域
- 类成员的作用域都只在类的内部,外部无法直接访问
- 成员函数可以直接访问成员变量和调用成员函数
- 类的外部可以通过类变量访问public成员
- 类成员的作用域与访问级别没有关系
C++中用struct定义的类中所有成员默认为public
1 |
|
类的真正形态
一个问题:
经过不停的改进,结构体struct变得越来越不像它原来在C语言的样子了
类的关键字
- struct在C语言中已经有了自己的含义,必须继续兼容
- 在C++中提供了新的关键字class用于类定义
- class和struct的用法是完全相同的
class和struct区别
在用struct定义类是,所有的成员的默认访问级别为public
在用class定义类时,所有成员的默认访问级别为private
1 |
|
- C++中的类支持声明和实现的分离
- 将类的实现和定义分开
.h头文件中只有类的声明(成员变量和成员函数的声明)
.cpp源文件中完成类的其他实现(成员函数的具体实现)
对象的构造
一个问题:对象中成员变量的初始值是多少?
1 |
|
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
类其实是一种数据类型,这种数据类型定义的变量即可在静态变量区,也可以在栈空间,也可以在堆空间。
因此gt.i,gt.j在静态变量区,初始值为0。t1.i,t1.j在栈上初始化为随机值,而pt->i,pt->j在堆上随机初始化,这里结果是0只是一个巧合。
对象的初始化
生活中的对象都是在初始化后上市的
初始状态是对象普遍存在的一个状态
解决方案:
- 在类中提供一个public的initialize函数
- 对象创建后立即调用initialize函数进行初始化
1 |
|
观察我们的代码,initialize()函数是一个普通函数,要求我们自己手工调用,而如果忘记调用或者调用顺序不对,运行结果可能是不对的
因此我有一个大胆的想法:能不能让initialize在对象定义时自动被调用?
构造函数
C++可以定义与类名相同的特殊成员函数,这种特殊的成员函数叫做构造函数
- 构造没有任何返回类型的声明
- 构造函数在对象定义时自动被调用
1 | class Test |
带有参数的构造函数
- 构造函数可以根据需要定义参数
- 一个类中可以存在多个重载的构造函数
- 构造函数的重载遵循C++重载的规则
注意: 对象的定义和对象声明不同
- 对象定义 : 申请对象的空间并调用构造函数
- 对象声明 : 告诉编译器存在这样一个对象
构造函数的自动调用
与重载规则联系起来
1 |
|
注意初始化的写法,回想C语言int i = 100;
这是初始化,而int i(100);
这也是初始化。
同样地,Test t2 = 2;
也是初始化,Test t2(2);
也是初始化。
但是注意
1 | Test t; // 调用 Test() |
这里t = t2
是赋值而不是初始化,初始化是初始化,赋值是赋值,二者不可混为一谈!
初始化与赋值不同
初始化:对正在创建的对象进行初值设置
赋值:对已经存在的对象进行值设置
构造函数的手动调用【特殊情况】
1 |
|
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
特殊的构造函数
- 无参构造函数 :没有参数的构造函数
当类中没有定义任意的构造函数时,编译器默认提供一个无参构造函数,并且其函数体为空
反过来说,假设我们的类中已经定义了构造函数,编译器将不再提供无参构造函数 - 拷贝构造函数 :参数为
const class_name&
的构造函数
当类中没有定义拷贝构造函数时,编译器默认提供一个拷贝构造函数,简单的进行成员变量的值复制。
一道经典面试题:以下这个类有什么东西?
1 | class T |
C++空类的大小不为0,存在一个无参构造函数等
1 |
|
运行上述程序,观察结果如下,可以知道调用了默认的构造函数Test t1
和默认的拷贝构造函数Test t2 = t1
。
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
注意,如果我给上述代码仅仅只提供一个拷贝构造函数,
编译报错,这是因为编译器觉得我们已经给类提供了一个构造函数(拷贝构造函数也是构造函数),编译器就不给我们提供无参构造函数了,因此在代码32行的Test t;
这行编译器报错error: no matching function for call to ‘Test::Test()’
拷贝构造函数
拷贝构造函数的意义
- 浅拷贝 :拷贝后的对象的物理状态相同
- 深拷贝 :拷贝后的对象的逻辑状态相同
编译器提供的默认拷贝函数仅仅是浅拷贝。
1 |
|
我们继续修改代码,给类对象增加一个int型指针p并且删除拷贝构造函数,创建一个我们写的构造函数
然后打印,可以看到两个对象t1和t2的p指针都指向堆的同一块内存
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
但是如果我们在main函数分别用t1,t2两个对象调用成员函数free()释放对应的内存则会造成重复释放同一块内存的严重错误!
既然t1初始化的时候指向了堆空间的一块内存,我们t2拷贝构造的时候应该指向堆空间的一块新的内存。
1 | Test(const Test& t) |
现在虽然p指针变量存储的地址变量不一样的,但是指向的内存空间存储的值是一样的。
这也就是深拷贝。
什么时候需要进行深拷贝?
对象中有成员指代了系统中的资源
- 成员指向了动态内存空间
- 成员打开来外存中的文件
- 成员使用了系统中的网络端口
一般性原则 :自定义拷贝构造函数,必然要实现深拷贝!!!
初始化列表的使用
类中是否可以定义const成员?
答案:可以,是一个只读变量。
下面的类定义是否合法?如果合法,ci的值是什么,存储在哪里?
1 | class Test |
注意,类定义合法,但是未对const int ci进行初始化,我们要定义一个构造函数来初始化ci变量,但是ci是一个只读变量不能作为左值,因此我们引出了初始化列表,用初始化列表对ci进行初始化。
C++中提供了初始化列表对成员变量进行初始化
语法规则
1 | ClassName::ClassName():m1(v1), m2(v2), m3(v3) |
成员初始化顺序
- 成员的初始化顺序与成员的声明顺序相同
- 成员的初始化顺序与初始化列表中的位置无关
- 初始化列表先于构造函数的函数体执行
测试程序:
1 |
|
Test类的三个成员Value类m1,m2,m3应该如何初始化呢?
1 | private: |
首先这种方式是不允许的,语法错误。要初始化成员变量只能使用初始化列表。(c++11新特性允许为数据成员提供类内初始值)
改为Test() : m1(1), m2(2), m3(3)
接着运行,成功运行。
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
如图所示,可以看到一个特别奇怪的现象:
- 成员的初始化顺序与初始化列表中的位置无关
- 成员的初始化顺序与成员的声明顺序相同
- 初始化列表先于构造函数的函数体执行
这个十分的重要!!!
类中const成员
- 类中的const成员会被分配空间的
- 类中的const成员的本质是只读变量
- 类中的const成员只能在初始化列表中指定初始值
对象的构造顺序
局部对象的构造顺序
C++的类可以定义多个对象,那么对象的构造顺序怎样的
对于局部对象
当程序执行流到达对象的定义语句时进行构造
1 | int i = 0; |
可以看到执行顺序非常正常
但是如果增加goto语句可能忽略某些类的构造而未进行初始化,引起灾难性的错误。
幸运的是g++编译器发现了这个错误并且提醒了我们。
但是并不是所有编译器都能发现这个错误,比如vs2010。
堆对象的构造顺序
- 当程序执行流到达new语句时创建对象
- 使用new创建对象将自动触发构造函数的调用
1 | int i = 0; |
全局对象的构造顺序
- 全局对象的构造顺序是不确定的!!!
- 不同编译器使用不同的规则确定构造顺序
在进入main函数之前不存在程序执行流的概念。
这里我创建了四个全局对象分别位于t1.cpp,t2.cpp,t3.cpp,test.cpp内然后运行观察到构造函数执行顺序是t4->t1->t2->t3。
再到windows下的vs2010下执行相同的代码。发现顺序又不一样。
小结
- 局部对象的构造顺序依赖于程序的执行流
- 堆对象的构造顺序依赖于new的使用顺序
- 全局对象的构造顺序是不确定的
如果构造函数抛出异常?
构造函数中拋出异常
- 构造过程立即停止
- 当前对象无法生成
- 析构函数不会被调用
- 对象所占用的空间立即收回(不会内存泄漏)
工程项目中的建议
- 不要在构造函数中拋出异常
- 当构造函数可能产生异常时 , 使用二阶构造模式
避免在析构函数中拋出异常 ! !
析构函数的异常将导致 :对象所使用的资源无法完全释放。
对象的销毁
一般而言,需要的销毁的对象都应该做清理
解决方案:
- 为每一个类都提供一个public的free函数
- 对象不再需要时立即调用free函数进行清理
回想如果我们的电脑手机等软件出现问题
为什么重启可以解决90%的问题?
这是因为软件不稳定造成的,长时间运行出现的不稳定多半是内存泄漏引起的。
存在的问题:
- free只是一个普通的函数,必须显示的调用
- 对象销毁前没有做清理,很可能造成资源泄漏
那么我们的C++编译器是否能够自动调用某个特殊的函数进行对象的清理?
答案是可以的,析构函数。
析构函数
C++类中可以定义一个特殊的清理函数
这个特殊的清理函数叫做析构函数
析构函数的功能与构造函数相反定义:~ClassName()
析构函数没有参数也没有返回值类型声明(没有参数自然就无法实现重载)
析构函数在对象销毁时自动被调用
注意上述代码中如果忘记释放堆上的内存delete pt;
那么将不会执行析构函数
析构函数的定义准则:
当类中自定义了构造函数,并且构造函数中使用了系统资源(如:内存申请,文件打开等),需要自定义析构函数。
析构函数的顺序
单个对象
单个对象创建时构造函数的调用顺序
- 调用父类的构造过程
- 调用成员变量的构造函数(如果成员也是类)(成员变量的调用顺序与声明顺序相同)
- 调用类自身的构造函数
析构函数与对应构造函数的调用顺序相反
- 调用类自身的析构函数
- 调用成员变量的析构函数
- 调用父类的析构过程
多个对象
多个对象析构顺序与构造顺序相反
1 |
|
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
关于栈对象和全局对象,类似于入栈和出栈的顺序,最后构造的对象最先析构!!
堆对象的析构发生在使用delete的时候,与delete的使用顺序相关!!
神秘的临时对象
观察这一段程序,作者的程序意图是在main函数中执行Test t;
希望t中mi的值被设置为0。
- 在Test()中以0作为参数调用Test(int i)
- 将成员变量mi的初始值设置为0
但是执行观察运行结果:成员变量mi的值为随机值。
构造函数是一个特殊的函数
- 是否可以直接调用?
- 是否可以在构造函数中调用构造函数?
- 直接调用构造函数的行为是什么?
答案:
- 直接调用构造函数将产生一个临时对象
- 临时对象的生命周期只有一条语句的时间
- 临时对象的作用域只在一条语句中
- 临时对象是C++中值的警惕的灰色地带
因此上面的程序问题在于第10行创建了一个临时对象,因此上述程序的构造函数等价于一个空的构造函数。
那我们如果对代码进行复用操作?
我们可以自己定义一个init函数。在不同的构造函数中调用init函数。
1 |
|
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
我们应该在编程里尽力地减少代码中临时对象的产生。
C++编译器在不影响最终执行结果地前提下,也会尽力减少临时对象的产生。
1 |
|
39行Test t = Test(10);
理论分析:1.生成临时对象。(调用构造函数)2.用临时对象初始化t对象(调用拷贝构造函数)
事实上上述代码等价于直接调用一次构造函数Test t = 10
,编译器会进行优化仅调用一次构造函数使得程序的性能得到提升
40行Test tt = func()
理论分析:1.生成临时对象并且返回 2.函数结束后销毁临时对象 3.用函数返回的临时对象初始化tt对象(调用拷贝构造函数)。
事实上,C++编译器会进行优化,尽量减少临时对象的产生。Test tt = func(); ==> Test tt = Test(20); ==> Test tt = 20;
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
关于类成员的疑问
问题:
1 | Test::Test(const Test& t) |
为什么可以访问另一个对象的private成员。
成员函数和成员变量都是隶属于具体对象的吗?
- 从面向对象的角度,对象由属性(成员变量)和方法(成员函数)构成
- 从程序运行的角度,对象由数据和函数构成
数据可以位于栈,堆,和全局数据区
函数只能位于代码段(所有对象共享一套成员函数)
测试程序:
1 |
|
1 | fengyun@ubuntu:~/share$ g++ test.cpp -o test |
结论
- 每一个对象拥有自己独立的属性(成员变量)
- 所有的对象共享类的方法(成员函数)
- 方法(成员函数)能够直接访问任何所属类对象的属性(成员变量)
- 方法中隐藏参数this用于指代当前对象
成员函数只有一套,可以访问任何所属类对象的属性。
类的静态成员变量
回顾类的成员变量属性
- 通过对象名能够访问public 成员变量
- 每个对象的成员变量都是专属的
- 成员变量不能在对象之间共享
那么如果有以下的需求:
- 统计在程序运行期间某个类的对象数目
- 保证程序的安全性( 不能使用全局变量 )
- 随时可以获取当前对象的数目
在 C+ + 中可以定义静态成员变量
- 静态成员变量属于整个类所有
- 静态成员变量的生命期不依赖于任何对象
- 可以通过类名直接访问公有静态成员变量
- 所有对象共享类的静态成员变量
- 可以通过对象名访问公有静态成员变量
静态成员变量的特性
- 在定义时直接通过 static 关键字修饰
- 静态成员变量需要在类外单独分配空间
- 静态成员变量在程序内部位于全局数据区
语 法 规 则 :Type ClassName::VarName = value;
1 | class Test |
类的静态成员函数
继续回顾之前的需求
- 统计在程序运行期间某个类的对象数目【满足】
- 保证程序的安全性( 不能使用全局变量 )【满足】
- 随时可以获取当前对象的数目( Failure ) 【未满足】
我们可以将static int cCount;
设为public,但是这样就不安全了
我们需要什么?
- 不依赖对象就可以访问静态成员变量
- 必须保证静态成员变量的安全性
- 方便快捷的获取静态成员变量的值
在 C++中可以定义静态成员函数
- 静态成员函数是类中特殊的成员函数
- 静态成员函数属于整个类所有
- 可以通过类名直接访问公有静态成员函数
- 可以通过对象名访问公有静态成员函数
静态成员函数与普通成员函数的根本区别在于:普通成员函数有 this 指针,可以访问类中的任意成员;而静态成员函数没有 this 指针,只能访问静态成员(包括静态成员变量和静态成员函数)。
最终解决方法:
1 | class Test |
小结:
- 静态成员函数是类中特殊的成员函数
- 静态成员函数没有隐藏的 this 参数
- 静态成员函数可以通过类名直接访问
- 静态成员函数只能直接访问静态成员变量( 函数 )
二阶构造模式
关于构造函数
- 类的构造函数用于对象的初始化
- 构造函数与类同名并且没有返回值
- 构造函数在对象定义时自动被调用
那么引出了如下问题:
- 如何判断构造函数的执行结果?
- 在构造函数中执行 return 语句会发生什么?
- 构造函数执行结束是否意味着对象构造成功 ?
答案:
- 无法判断构造函数执行结果
- return会直接返回
- 并不意味对象构造成功
构造函数能决定的只是对象的初始状态 , 而不是对象的诞生 ! !
1 |
|
以上代码,构造函数中有return语句并且提前返回,我们运行结果如下:对象的初始化并不成功
1 | fengyun@ubuntu:~/share$ ./test |
我们可以自己手动保存一个status变量,若构造函数执行成功,status置为true
1 |
|
但是这种解决方法仍然不够优美。
半成品对象的概念
- 初始化操作不能按照预期完成而得到的对象
- 半成品对象是合法的 C+ + 对象 , 也是 Bug 的主要来源
二阶构造
工程开发中的构造过程可分为
- 资源无关的初始化操作
不可能出现异常情况的操作 - 需要使用系统资源的操作
可能出现异常情况 , 如 : 内存申请 , 访问文件
二阶构造实例一
1 | class TwoPhaseCons { |
注意13行TwoPhaseCons* ret = new TwoPhaseCon();
,注意有个错误的说法:静态成员函数只可以访问静态成员变量/静态成员函数不能访问非静态成员。
静态函数没有默认的this对象指针。但是可以通过其他方式传入对象的地址,便可以在静态成员函数中访问到非静态成员函数。这种说法不够严密。仅仅是不能在静态成员函数中,使用this隐式或者显式调用非静态成员。因为静态函数不与对象绑定在一起,因此也不能声明成const的。
小结
- 构造函数只能决定对象的初始化状态
- 构造函数中初始化操作的失败不影响对象的诞生
- 初始化不完全的半成品对象是 Bug 的重要来源
- 二阶构造人为的将初始化过程分为两部分
- 二阶构造能够确保创建的对象都是完整初始化的
友元的尴尬能力
友元的概念
- 友元是 C++ 中的一种关系
- 友元关系发生在函数与类之间或者类与类之间
- 友元关系是单向的 ,不能传递
友元的用法
- 在类中以 friend 关键字声明友元
- 类的友元可以是其它类或者具体函数
- 友元不是类的一部分
- 友元不受类中访问级别的限制
- 友元可以直接访问具体类的所有成员
1 | class Point |
注意29行ret = (p2.y - p1.y) * (p2.y - p1.y) + (p2.x - p1.x) * (p2.x - p1.x);
我们C语言原先就是这样写的,
但是C++对对象的封装导致若要访问x,y必须调用getX(),getY()。
这么一个简单的语句要调用8次函数为人们所吐槽,因此C++设计者门为了兼容C语言而设计出了友元。
友元的尴尬
- 友元是为了兼顾 C 语言的高效而诞生的
- 友元直接破坏了面向对象的封装性(C++开发变为了C语言开发)
- 友元在实际产品中的高效是得不偿失的
- 友元在现代软件工程中已经逐渐被遗弃
Java,csharp诞生于C++,但是都没有友元这个能力,因此C++开发尽量减少友元的使用。
注意事项
- 友元关系不具备传递性
- 类的友元可以是其它类的成员函数
- 类的友元可以是某个完整的类 —所有的成员函数都是友元
小结
- 友元是为了兼顾 c 语言的高效而诞生的
- 友元直接破坏了面向对象的封装性
- 友元关系不具备传递性
- 类的友元可以是其它类的成员函数
- 类的友元可以是某个完整的类
类中的函数重载
函数重载
- 函数重载的本质为相互独立的不同函数
- C+ + 中通过函数名和函数参数确定函数调用
- 无法直接通过函数名得到重载函数的入口地址
- 函数重载必然发生在同一作用域中
类中的成员函数可以进行重载
- 构造函数的重载
- 普通成员函数的重载
- 静态成员函数的重载
万变不离其宗
- 重载函数的本质为多个不同的函数
- 函数名和参数列表是唯一的标识(和有没有static关键字无关)
- 函数重载必须发生在同一作用域中
重载的意义
- 通过函数名对函数功能进行提示
- 通过参数列表对函数用法进行提示
- 扩展系统中已经存在的函数功能
扩展系统函数strcpy
1 |
|
重载能够扩展系统中已经存在的函数功能 !
那么重载是否也能够扩展其它更多的功能 ?
1 |
|
操作符重载
- C++ 中的重载能够扩展操作符的功能
- 操作符的重载以函数的方式进行
- 本 质 : 用特殊形式的函数扩展操作符的功能
- 通过 operator 关键字可以定义特殊的函数
- operator 的本质是通过函数重载操作符
1 | Type operator Sign(const Type pi, const Type p2) |
因此只需简单的修改上述的代码,就可以实现重载操作符
1 | Complex Add(const Complex& p1, const Complex& p2) |
1 | Complex c3 = Add(c1, c2); |
我们上述修改依然是基于友元的方法,那么如何不依赖友元重载操作符?
可以将操作符重载函数定义为类的成员函数,差别如下:
- 比全局操作符重载函数少一个参数(左操作数)
- 不需要依赖友元就可以完成操作符重载
- 编译器优先在成员函数中寻找操作符重载函数
1 | class Type |
1 |
|
小结
- 操作符重载是 C+ + 的强大特性之一
- 操作符重载的本质是通过函数扩展操作符的功能
- operator 关键字是实现操作符重载的关键
- 操作符重载遵循相同的函数重载规则
- 全局函数和成员函数都可以实现对操作符的重载
注意事项:
- C+ + 规定赋值操作符( = )只能重载为成员函数
- 操作符重载不能改变原操作符的优先级
- 操作符重载不能改变操作数的个数
- 操作符重载不应改变操作符的原有语义