31. 面向对象的思想主要包括什么?

31. 面向对象的思想主要包括什么?
2024-12-27 23:13:55
推荐回答(2个)
回答1:

前面已说明设计程序就是编写程序欲解决的问题的描述,也就是编写论调。而论调可以只用“名词性概念”和“动词性概念”表现出来,对象又正好是“名词性概念”的实现,而利用前面说的没有成员变量的类来映射“动词性概念”就可以将其转换为对象。因此,一个世界,可以完全由对象组成,而将算法所基于的世界只用对象表现出来,再进行后续代码的编写,这种编程方法就被称作面向对象的编程思想。
注意,先设计算法应基于的世界,再全部用对象将其表述出来,然后再设计算法,最后映射为代码。但前面在编写商人过河问题时是直接给出算法的,并没有设计世界啊?其实由于那个问题的过于简单,我直接下意识地设计了世界,并且用前面所说的河岸论来描述它。应注意世界的设计完全依赖于问题,而准确地说,前面我并没有设计世界,而是设计了河岸论来描述问题。
接着,由于对象就是实例,因此以对象来描述世界在C++中就是设计类,通过类的实例来组合表现世界。但应注意,面向对象是以对象来描述世界,但也描述算法,因为算法也会提出一些需要被映射的概念,如前面商人过河问题的算法中的过河方案。但切记,当描述算法时操作了描述世界时定义的类,则一定要保持那个类的设计,不要因为算法中对那个类的实例的操作过于复杂而将那部分算法映射为这个类的一个成员函数,因为这严重遮蔽了算法的实现,破坏了程序的架构。如一个算法是让汽车原地不停打转,需要复杂的操作,那么难道给汽车加一个功能,让它能原地不停地打转?!这是在设计类的时候经常犯的错误,也由于这个原因,一个面向对象编写的代码并不是想象的只由类组成,其也可能由于将算法中的某些操作映射成函数而有大量的全局函数。请记住:设计类时,如果是映射世界里的概念,不要考虑算法,只以这个世界为边界来设计它,不要因为算法里的某个需要而给它加上错误的成员。
因此,将“名词性概念”映射成类,“名词性概念”的属性和状态映射为成员变量,“名词性概念”的功能映射为成员函数。那么“动词性概念”怎么办?映射成没有成员变量的类?前面也看见,由于过于别扭,实际中这种做法并不常见(STL中也只是将其作为一种技巧),故经常是将它映射为函数,虽然这有背于面向对象的思想,但要易于理解得多,进而程序的架构要简明得多。
随着面向对象编程思想的问世,一种全新的设计方式诞生了。由于它是如此的好以至于广为流传,但理解的错误导致错误的思想遍地而生,更糟糕的就是本末倒置,将这个设计方式称作面向对象的编程思想,它的名字就是封装。

封装

先来看现在在各类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. 由需要决定使用何种世界驱动方式,并实现以完善算法和论调(世界的驱动方式也可能带入一些原来并不存在的概念)。

回答2:

继承 多态 封装

● 封装:用抽象的数据类型将数据和基于数据的操作封装在一起,数据被保护在抽象数据类型内部。

● 继承:子类拥有父类的所有数据和操作。

● 多态:一个程序中同名的不同方法共存的情况。

有两种形式的多态– 重载与重写。