📄 c++从零开始(十二)何谓面向对象编程思想.txt
字号:
封装
先来看现在在各类VC教程中关于对象的讲解中经常能看见的如下的一个类的设计。
class Person
{ private: char m_Name[20]; unsigned long m_Age; bool m_Sex;
public: const char* GetName() const; void SetName( const char* );
unsigned long GetAge() const; void SetAge( unsigned long );
bool GetSex() const; void SetSex( bool );
};
上面将成员变量全部定义为private,然后又提供三对Get/Set函数来存取上面的三个成员变量(因为它们是private,外界不能直接存取),这三对函数都是public的,为什么要这样?那些教材将此称作封装,是对类Person的内部内存布局的封装,这样外界就不知道其在内存上是如何布局的并进而可以保证内存的有效性(只由类自身操作其实例)。
首先要确认上面设计的荒谬性,它是正宗的“有门没锁”毫无意义。接着再看所谓的对内存布局的封装。回想在《C++从零开始(十)》中说的为什么每个要使用类的源文件的开头要包含相应的头文件。假设上面是在Person.h中的声明,然后在b.cpp中要使用类Person,本来要#include "Person.h",现在替换成下面:
class Person
{ public: char m_Name[20]; unsigned long m_Age; bool m_Sex;
public: const char* GetName() const; void SetName( const char* );
unsigned long GetAge() const; void SetAge( unsigned long );
bool GetSex() const; void SetSex( bool );
};
然后在b.cpp中照常使用类Person,如下:
Person a, b; a.m_Age = 20; b.GetSex();
这里就直接使用了Person::m_Age了,就算不做这样蹩脚的动作,依旧#include "Person.h",如下:
struct PERSON { char m_Name[20]; unsigned long m_Age; bool m_Sex; };
Person a, b; PERSON *pP = ( PERSON* )&a; pP->m_Age = 40;
上面依旧直接修改了Person的实例a的成员Person::m_Age,如何能隐藏内存布局?!请回想声明的作用,类的内存布局是编译器生成对象时必须的,根本不能对任何使用对象的代码隐藏有关对象实现的任何东西,否则编译器无法编译相应的代码。
那么从语义上来看。Person映射的不是真实世界中的人的概念,应该是存放某个数据库中的某个记录人员信息的表中的记录的缓冲区,那么缓冲区应该具备那三对Get/Set所代表的功能吗?缓冲区是缓冲数据用的,缓冲后被其它操作使用,就好像箱子,只是放东西用。故上面的三对Get/Set没有存在的必要,而三个成员变量则不能是private。当然,如果Person映射的并不是缓冲区,而在其它的世界中具备像上面那样表现的语义,则像上面那样定义就没有问题,但如果是因为对内存布局的封装而那样定义类则是大错特错的。
上面错误的根本在于没有理解何谓封装。为了说明封装,先看下MFC(Microsoft Foundation Class Library——微软功能类库,一个定义了许多类的库文件,其中的绝大部分类是封装设计。关于库文件在说明SDK时阐述)中的类CFile的定义。从名字就可看出它映射的是操作系统中文件的概念,但它却有这样的成员函数——CFile::Open、CFile::Close、CFile::Read、CFile::Write,有什么问题?这四个成员函数映射的都是对文件的操作而不是文件所具备的功能,分别为打开文件、关闭文件、从文件读数据、向文件写数据。这不是和前面说的成员函数的语义相背吗?上面四个操作有个共性,都是施加于文件这个资源上的操作,可以将它们叫做“被功能”,如文件具有“被打开”的功能,具有“被读取”的功能,但应注意它们实际并不是文件的功能。
按照原来的说法,应该将文件映射为一个结构,如FILE,然后上面的四个操作应映射成四个函数,再利用名字空间的功能,如下:
namespace OFILE
{
bool Open( FILE&, … ); bool Close( FILE&, … );
bool Read( FILE&, … ); bool Write( FILE&, … );
}
上面的名字空间OFILE表示里面的四个函数都是对文件的操作,但四个函数都带有一个FILE&的参数。回想非静态成员函数都有个隐藏的参数this,因此,一个了不起的想法诞生了。
将所有对某种资源的操作的集合看成是一种资源,把它映射成一个类,则这个类的对象就是对某个对象的操作,此法被称作封装,而那个类被称作包装类或封装类。很明显,包装类映射的是“对某种资源的操作”,是一抽象概念,即包装类的对象都是无状态对象(指逻辑上应该是无状态对象,但如果多个操作间有联系,则还是可能有状态的,但此时它的语义也相应地有些变化。如多一个CFile::Flush成员函数,用于刷新缓冲区内容,则此时就至少有一个状态——缓冲区,还可有一个状态记录是否已经调用过CFile::Write,没有则不用刷新)。
现在应能了解封装的含义了。将对某种资源的操作封装成一个类,此包装类映射的不是世界中定义的某一“名词性概念”,而是世界的“动词性概念”或算法中“对某一概念的操作”这个人为定出来的抽象概念。由于包装类是对某种资源的操作的封装,则包装类对象一定有个属性指明被操作的对象,对于MFC中的CFile,就是CFile::m_hFile成员变量(类型为HANDLE),其在包装类对象的主要运作过程(前面的CFile::Read和CFile::Write)中被读。
有什么好处?封装提供了一种手段以将世界中的部分“动词性概念”转换成对象,使得程序的架构更加简单(多条“动词性概念”变成一个“名词性概念”,减少了“动词性概念”的数量),更趋于面向对象的编程思想。
但应区别开包装类对象和被包装的对象。包装类对象只是个外壳,而被包装的对象一定是个具有状态的对象,因为操作就是改变资源的状态。对于CFile,CFile的实例是包装类对象,其保持着一个对被包装对象——文件内核对象(Windows操作系统中定义的一种资源,用HANDLE的实例表征)——的引用,放在CFile::m_hFile中。因此,包装类对象是独立于被包装对象的。即CFile a;,此时a.m_hFile的值为0或-1,表示其引用的对象是无效的,因此如果a.Read( … );将失败,因为操作施加的资源是无效的。对此,就应先调用a.Open( … );以将a和一特定的文件内核对象绑定起来,而调用a.Close( … );将解除绑定。注意CFile::Close调用后只是解除了绑定,并不代表a已经被销毁了,因为a映射的并不是文件内核对象,而是对文件内核对象操作的包装类对象。
如果仔细想想,就会发现,老虎能够吃兔子,兔子能够被吃,那这里应该是老虎有个功能是“吃兔子”还是多个兔子的包装类来封装“吃兔子”的操作?这其实不存在任何问题,“老虎吃兔子”和“兔子被吃”完全是两个不同的操作,前者涉及两种资源,后者只涉及一种资源,因此可以同时实现两者,具体应视各自在相应世界中的语义。如果对于真实世界,则可以简略地说老虎有个“吃”的功能,可以吃“肉”,而动物从“肉”和“自主能动性”多重继承,兔子再从动物继承。这里有个类叫“自主能动性”,指动物具有意识,能够自己动作,这在C++中的表现就是有成员函数的类,表示有功能可以被操作,但收音机也具有调台等功能,难道说收音机也能自己动?!这就是世界的意义——运转。
方法——世界的驱动方式
算法就是方法,前面已说过其由操作和被操作的资源组成,即资源的类型和操作的类型。方法指出如何使用世界中定义出的各种操作,但并不执行。由前面的阐述,世界可以只由对象组成,当对象产生后,世界中所有对象的状态和属性,即成员变量,的一份拷贝,称作世界的状态的一份快照,而世界的状态的变化称作世界的运转。世界的状态就是世界中所有对象的状态和属性,要改变它,就是要执行世界定义的操作,但只能通过方法指出如何执行它以改变世界的状态,进而驱动世界,即使世界定义的操作被执行才能驱动世界。
上面越说越远了,感觉虚得很,有什么意义?考虑为什么要提出世界这个概念。世界是我们欲编程解决的问题所基于的规则集合体,而设计程序就是设计描述世界的论调,然后在这个论调上设计算法,编写出代码,执行代码,得到结果。“得到结果”?!什么是结果?即世界最终状态中的某一部分,如求圆周率的值。这其实是目的,但值得注意的是目的不止这种。代码执行的过程往往是另一种目的,如将数据保存到某个文件中;将文件打开编辑再保存等,这种目的并不关心世界的状态最后如何。而世界的状态的变化过程,也就是世界的运转则是另一种非常流行的目的——电子游戏。
不管什么样的目的,都需要改变世界的状态,即要驱动世界运转,也就必须使世界定义的操作被执行,而这只能通过方法来实现。因此在设计算法时,也就决定了驱动世界的方式。
对于上面的第一种目的,由于是要看世界的最终状态,因此一直连续执行操作到最后。在《C++从零开始(八)》中给出的商人过河的例子就在通过算法得到结果后直接调用printf打印出结果并结束。对于第二种目的,由于要的是执行的过程,因此也可直接连续执行操作到完。但这种目的往往要求由用户决定何时执行且执行不止一段代码,如文件打开后,直到用户给出命令(通过键盘或鼠标或其它输入方式)后才进行编辑操作,且用户可能随机地执行不同的编辑操作,最后也由用户决定是否保存文件。这种世界的运转完全由用户控制的世界驱动方式称为用户驱动方式。这里的算法仅仅是如何打开、保存文件,如何编辑数据,但由于决定是用户驱动方式,则算法就必须修改以实现这种驱动方式。再看第三种目的,要的是世界的状态的变化,则前面两种都可以。但很明显,第一种变化过程不能持续,连续执行完就完;第二种由用户驱动,则太麻烦。因此往往都会有个循环,在游戏编程中一般称其为主循环,每次循环都按照一定的规则改变世界中部分对象的状态,此称作循环驱动方式。每次循环,都会被改变状态的对象就被称作具有自主能动性,如前面提到的动物的实例。除此以外的就不称作具有自主能动性,如前面提到的收音机的实例。同样,游戏中的算法依旧不会涉及到上面提到的循环驱动方式,因此必须修改算法以实现循环驱动方式。
上面将那么多只为了说明一点,已经不能再如《C++从零开始(八)》中说的那几步来编写程序了,下面给出一个方法。
1. 当得到一个问题,应同时得到这个问题的算法(程序员并不是科学家),或由客户给出或由于过于简单而直接得出。
2. 由问题抽象设计出它的描述,即前面说的论调,也就是所谓的程序设计。
3. 将之前给出的算法用刚设计出的论调进行描述,并完善这个论调(因为算法可能带入一些原来世界中并不存在的概念)。
4. 由需要决定使用何种世界驱动方式,并实现以完善算法和论调(世界的驱动方式也可能带入一些原来并不存在的概念)。
5. 继续《C++从零开始(八)》中提出的三步。
这里给出的步骤只是一般性的,当程序对应的世界过于复杂时,上面的第2步将还需细分。先设计程序的大框架;再设计接口;最后决定接口的具体运用。关于接口会在《C++从零开始(十八)》中说明,而这里提到的关于接口的设计方式并不用管它,它只是使设计简单化,并不是必须的。由于其与本系列无关,在此不表。
本文提出了多个抽象的概念,例子较少,且只说了以面向对象思想设计类时应注意的一些问题,提到世界定义的概念越少越好,但并没说如何决定有哪些概念。对此,其实从前面关于老虎吃兔子的问题中就可看出,因为问题是现实世界中的问题,因此只需简单地映射现实世界中的概念,并去掉或简化一些次要概念(其实这就是语义在另一方面的体现)。在下篇,将针对本文提出的那五个程序编写步骤给出一个基于对象设计的简单样例以大致说明如何编写面向对象的程序
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -