《编程机制探析》第六章 面向对象

《编程机制探析》第六章 面向对象

面向对象(Object Oriented)是命令式编程的主流编程模型,其概念极其重要。可以说,命令式编程几乎就是面向对象的天下。
面向对象(Object Oriented)这个名词,可能是那帮计算机科学家炮制出来的最成功的名词了。尽管我绞尽脑汁,也不能为这个名词想出一个贴切的含义解释,但并不妨碍这个名词成为计算机编程中流行最广、上镜率最高的词汇。
关于面向对象的书籍资料可谓是汗牛充栋,罄竹难书。各种关于面向对象的神话、传说、流言、谣言、妖言,那也是满天的飞。
有过这方面体会的读者,一看到本章的标题,一定会鄙夷地撇撇嘴,道:“Yet another OOP bullshit.”(又是一个关于面向对象编程的屁话连篇。OOP是Object Oriented Programming的简写)
说实在的,我也有同样的感觉。市面充斥着大量的关于面向对象编程的入门资料和书籍。里面把一些极为基础的复合结构的例子反反复复的讲,然后在加上一堆各种似是而非、人云亦云、天花乱坠的所谓面向对象的优越性,核心问题却一点也不涉及。
在这里,我保证,本书绝不会重复那些无聊的废话。什么关于封装、继承之类的玩意儿,本书一概不涉及。本书只从实际出发,讲解面向对象最本质、最核心的概念以及实现原理。
如果你需要一本编程方面的励志书,比如,一本把面向对象吹得神乎其神、令你心痒难熬的一本面向对象概念书,那么,很遗憾,本书满足不了这种需求。
好了,现在我们正式开始面向对象之旅,揭开各种“buzz words”之下的本来面目。
“面向对象”(Object Oriented)这个概念是针对“面向过程”(Procedure Oriented)这个概念生造出来的。
这要从编程语言的发展历史说起。那时候(请做忆苦思甜状),还没有面向对象这东西,大家都是面向过程的,比如,C语言,Pascal语言等。在这些语言中,程序员可以定义过程(Procedure),从而实现程序代码的重用。因而,这些语言也叫做“面向过程”的。当然,这种提法是在面向对象这个概念出现之后,才特意生造出来作为对比的。
一开始的时候,事情显得很完美。我们可以定义一大堆的过程库,比如:
f1(x) {

}

f2(x) {

}


f9(x) {

}

然后,我们就在程序中直接调用这些过程就好了。比如:
n = f1(13) – f2(51) * f9(33)
m = f1(13) * f2(51) - f9(33)

这种重用方式,是最简单的重用方式,叫做库重用(Library Reuse)。面向过程语言可以很完美地满足这种库重用的需求。但是,随着编程应用的深入,更高级的重用模式出现了——框架重用(Framework Reuse)出现了。
框架重用的概念是极为重要的编程概念,可以说,这是编程模型中最为关键、最为重要的重用方式。这个概念的理解难度为中等,不太容易,也不太难,说起来,可长可短。限于篇幅,我长话短说,直接从一个例子开始。请看下面的代码:
add_log( f, x ) {
print “before f is called”.
n = f(x)
print “after f is called”
return n
}
上面的add_log过程定义接受两个参数,f和x。其中的f是一个类型为函数(过程)的参数,x是一个数值参数。所以,我们在add_log过程体内看到了这样的使用方式 n = f(x)。
我们怎么使用add_log这个过程呢?我们可以这么用写:
add_log(f1, 10)
add_log(f2, 14)
add_log(f9, 24)

我们看到,在上述的代码中,我们重用的是add_log这个过程。而add_log这个过程又接受另外一个过程作为参数,并在add_log过程体内对这个过程参数进行了调用。
这种重用模式就叫做框架重用(Framework Reuse)。因为我们重用的是add_log这个框架(Framework),变化的部分是参数f,叫做回调函数(callback)。
关于库重用和框架重用之间的区别,我们可以借用这样一个比喻来帮助理解——房间和家具。库重用的情况就是,我们可以把同一种家具放到不同的房子当中,这时候,我们重用的就是家具(即库过程,库函数)。框架重用的情况就是,我们可以在同一种房子当中放置不同的家具,这时候,我们重用的就是房子(即框架)。
需要特别提出的是,上述的简化代码实际上是一种函数式编程语言(Functional Programming Language)的表达方式,函数名(过程名)可以直接作为参数传入到另一个过程中。命令式语言一般不允许这么做,你必须传入一个特殊的指定类型。
我们首先以面向过程的C语言为例。在C语言中,你不能直接把一个过程名作为参数(即回调函数)传给另一个过程,而是必须使用一种特殊的数据类型——过程指针类型。
注:在C语言中,过程指针类型的学名叫做函数指针类型。为了避免与函数式编程的概念混淆,我这里用了过程指针这个词,在意义上和概念上没有分别。
那么,什么叫做过程指针呢?为了弄清这个问题,我们必须首先理解指针的概念。
指针(Pointer),是C语言中最为臭名昭著的概念,极其蹩脚,极其令人生厌。我到现在都不明白,C语言设计者为什么要生造出来这么个晦涩难懂、徒增烦扰的概念。为了说明一下指针类型有多么蹩脚,我们来看一段C语言语法书籍中常见的一类例子。
int a = 1;
这条语句很简单,声明了一个整数类型的变量。关于C语言的基本语法,本书不会详细介绍。请读者自行参阅C语言相关入门资料。
int* b = &a;
这条语句就十分晦涩了。int* 表示一个指向整数类型的指针类型。& 这个符号后面跟着一个变量名,表示取得这个变量名所对应的内存地址。&a 就表示取得a这个变量名的内存地址。我们想象一下,在一个布满了小格子的大柜子上,每个小格子上都有一个地址编号。其中某一个小格子上贴着标签“a”,该格子的内存地址是1001。那么,&a返回的结果就是1001这个地址编号。
int** c = &b;
这条语句就更加晦涩了。int**表示一个指向“指向一个整数类型的指针类型”的指针类型。亲爱的读者,请问一下,您能看懂这段话吗?
这还不算完,这个游戏还能够继续玩下去。
int*** d = &c;
int**** e = &d;

jnt******…. z = &y
最终,我们得到的是这样一个丑陋不堪的东西,一个指向整数类型指针的指针的指针的指针的指针的指针….的指针的指针。
我就不明白了,那帮人是不是吃饱了撑的,整出来这么个玩意儿来难为我们这些大好青年。
我之所以对指针满怀怨怼,是有原因的。我学习C语言是在学习汇编语言之前。那时候,指针的概念搞得我极为头痛。同学们也有类似的感觉。当然,有时候,我们也把指针当做智力上的挑战,仿佛能够理解指针是多么了不起的本事,就好像证明了哥德巴赫猜想似的。
但是,在我学了汇编语言之后,这种感觉立刻就烟消云散了。在汇编语言中,从来没有什么指针类型,只有内存地址的概念。我一下子豁然开朗。什么指针,什么指针的指针,全都是浮云,一切都是空。回归到实质之后,所谓指针,不过是一个内存单元中存储了另外一个内存单元的地址编号而已。
大家是否还记得前面章节中讲到的那个顺藤摸瓜的“寻宝游戏”的例子?在那个例子中,我们实际上就是根据内存单元存储的下一个内存地址,一步步寻找到最终的目标数据。那就是一个典型的“指针的指针的…指针的指针”的例子。
明白了这一切之后,我突然感觉到,我们一大帮子人在那里乐此不疲地研究指针的用法和概念,简直是一个天大的笑话。一切本来都是那么简单,却被人为地极端复杂化了。
指针带来的麻烦不止如此。也许是嫌指针带来的概念混淆还不够,那帮人又变本加厉地引入了形参、实参之类的吃饱了称的的概念,还有相应的一堆传地址、传指针、传引用之类的说法。本来很简单的问题,参数值有可能是一个普通数值,也有是一个内存单元的地址编号。如此而已。为什么要人为地制造这些不必要的麻烦呢?
我内心中产生了强烈的疑惑,为什么?这是为什么?为什么要把简单的概念复杂化?为什么不直接引入内存地址的简单概念?为什么要引入指针这个蹩脚的概念?居心何在?
后来,随着工作经验的积累,我大致想明白了这个问题。在软件编程业,我们经常会遇到“自产自销”的现象。怎么说呢?软件编程业本来应该是解决实际问题。但是,实际上,并没有那么多可以解决的实际问题。这时候,软件编程业,就会自己制造问题,自己解决。我们程序员经常会遇到这样的情况,某个业界大佬开始炒作一个“看起来很美”的假大空概念,然后,提供一套大而无当的编程模型。这套模型通常十分庞大繁杂,不管是学习,还是应用,都十分困难。这就创造了一大批咨询培训业务,从而养活了一大批人。软件业从而就越发兴盛,从业人员也就越来越多。
当然,正如所有的泡沫最终都是会破碎的。任何大而无当的东西最终都会走向灭亡。但没有关系。在软件编程业,一种编程模型或者一种编程技术的生命周期通常都是很短的。很多小而美的编程语言或者模型,因为乏人问津,也很快就消亡了。相比起来,那些大而无当的东西的生命周期还是算长的。这已经足够一大批人因此而发财致富了。
一波泡沫破灭之后,下一波泡沫又飘来了。我们永远不用担心没有赚钱的机会。当然,机会是机会,能不能赚到那又是另一回事。
在软件编程业,很多情况下,不管是真金,还是泡沫,最终的命运大体都是一致的。如果说有区别的话,那可能是真金沉得更快一些,而泡沫反而能飘得更长。在其他很多行业中,也有大致的规律。
这些都是题外话。我们继续本章之前引出的话题——过程指针。
顾名思义,过程指针就是指向过程的指针。C语言的过程指针(真正的学名叫做函数指针)类型的定义比较麻烦,而且,我个人很讨厌指针类型,也不觉得这是什么重要的概念,这里就不赘述了。感兴趣的读者可以自行参阅C语言学习资料。
过程指针足以应付简单的框架重用。但是,随着应用的深入,我们会遇到更加复杂的情况。有时候,我们希望把一组相关联的数据和过程作为参数,一起传入到某个框架过程当中去。这时候,单纯的过程指针显然不够用了,我们必须用到复合结构的类型。
对于C语言来说,复合结构类型就是structure(结构)类型。structure里面既可以放数据,也可以放过程指针。但是,structure类型是单纯为数据属性设计的,对于过程指针的支持并不好,在过程指针的调用上很不方便。
为什么不方便?这个问题,读者可以自己去尝试并找到答案。我们下面就会讲到面向对象语言在框架重用方面的优势。我们完全可以用C语言的structure类型模拟面向对象语言的class类型,实现同样的功能。但是,在实现上却要复杂很多。有兴趣的读者,可以自己尝试一下,还可以加深对structure和class这两种类型的理解。
C语言的structure类型里面并不能直接进行过程的定义,只能在外面定义过程,并定义好相应的过程指针类型,然后,再用几条赋值语句,把过程指针设置到structure的对应属性中。这样的用法相当繁琐和复杂。
为了解决这个问题,计算机科学家对C语言进行了扩展,引入了一个class类型,允许程序员直接在class内部定义过程。这种扩展之后的语言叫做C++,英文读法是C Plus Plus,因此,C++也经常被写成CPP。
C++这个名字很有趣味。“++”是C语言的自增操作符,表示在本变量上增加1。比如,x++这条语句在结果上就等同于 x = x+1这条赋值语句。C++就有点在C语言上自增一步的含义。所以,这个名字起得还是相当有意思的。
引入了class这个类型之后,C++摇身一变,就一个成为了一门面向对象的语言。这也是大多数面向对象语言的惯例。在那些语言中,都不可避免地引入class这个核心类型。
大部分的面向对象语言都是二元型语言,类型定义和数据实例是分开的。比如,int是整数类型,而数值1就是一个整数类型的数据实例。class类型的数据实例叫做对象(Object),这也是这类语言为什么叫做面向对象语言的原因。
class类型相对于structure类型的优越性主要体现在以下两点上。
第一点,前面已经说过了,class内部可以直接定义过程。根据面向对象语言的惯例,在class内部定义的过程叫做方法(method)。
需要注意的是,class类型内部可能定义两种类型的方法。一种叫做静态方法(Static Method),其含义和用法与定义在class类型外部的公用过程是一样的。一种叫做对象方法(Object Method)。这种方法是该class类型的对象实例专有的。只有创建了对象实例之后,才可能调用对象方法。
为了明晰起见,本书提到方法(Method)的时候,通常是指对象方法(Object)。本书会尽量避免静态方法(Static Method)的提法,即使提到了,也尽量使用“静态公用过程”这个名词。
第二点,就是本书要着重强调的一点,class内部定义的所有方法(即过程),第一个参数必然指向本对象(class类型的一个数据实例),第一个参数之后,后面才跟着其他参数。
在大多数面向对象语言中,对象方法的第一个参数是隐藏起来的,是一个隐式参数,并不需要在方法定义的参数列表中显示声明。这第一个参数的名词通常叫做this。比如,在C++、Java等语言中,我们可以在对象方法中直接使用this这个参数,而不需要特意在方法定义的参数列表中声明。
在有些语言,比如,Python语言中,则必须在对象方法的参数列表中显式的声明第一个参数。这第一个参数叫做self,和C++、Java语言中的this参数的含义是一样的。但是,在调用对象方法的时候,第一个参数却可以被省略掉。
我的建议是,学习面向对象语言时,最好从Python开始,或者,至少要了解一下Python的对象方法的相关语法。这样,你就能够更加直观地理解对象方法的第一个参数。
无论是this还是self,都是指向本对象的参数,从而方便对象方法对该对象的其他属性(数据或者方法)进行访问。
那么,现在有一个问题。this参数或者self参数的值是由谁来负责设置的?程序员本人吗?显然不是的,因为,无论是this参数,还是self参数,我们都是拿来直接用的,从来不考虑去设置它。
事实上,所有的面向对象语言中,对象方法的第一个参数(this或者self),都是第一个参数都是编译器或者解释器自动设置的。
既然讲到了这里,就顺便解释一下编译语言、解释语言、虚拟机的概念。
C、C++语言是编译语言。我们编好C和C++的源代码之后,需要一个编译器先对源代码进行编译,最终得到可以执行的目标代码。C和C++语言编译之后的目标代码就是汇编语言代码(相当于可以执行的机器代码)。对于这一点,我们可以通过反编译来证明。
反编译这个词,很容易引起误解。这里说明一下。反编译并非是字面表示的那样,能够根据目标代码还原回高级语言源代码,那是不可能的任务。反编译的意思是,用一些专用的分析工具,对编译后的目标语言进行分析,将目标语言代码翻译成一条条文本形式的人眼可读的代码。
反编译是一项很简单的基本功。我们只要把语言名字和“反编译”作为关键字,就能够在网上搜出很多相关资料。
对于程序员来说,反编译是一项很有用的技术。程序员可以通过反编译,学习到某些高级语言语句最终会被编译成怎样的目标代码。
比如,C语言代码中x = x + 1语句通常就会被编译成几条对应的汇编语句:把x对应的内存单元的内容读取到寄存器;加上1;把结果存入到x对应的内存单元。
解释语言,是在解释器中执行的语言。这种语言并不能直接在计算机的操作系统中运行,必须要一个专门的语言解释器来分析这么语言,再进行执行。解释语言通常更加灵活,更加易用,但是,在运行效率上低于编译语言。
Java也是一门编译语言,但Java与C、C++不同,Java是一种基于虚拟机技术的编译语言。Java源代码并不会直接编译成汇编语言代码,而是编译成一种类似于汇编语言的自定义指令代码,叫做Bytecode(字节码)。这些Bytecode并不能由操作系统直接执行,而是必须由Java虚拟机来执行。对于Bytecode来说,Java虚拟机其实承担了解释器的角色。
虚拟机(Virtual Machine)是一项很流行的技术,其主要作用正如其名称所示:虚拟一个机器。虚拟什么机器呢?这里虚拟的自然是计算机,或者说,更确切一点,虚拟的是操作系统。
操作系统能够直接执行汇编代码,而Java虚拟机能够直接执行Bytecode。两者的功能恰好是对应起来的。而且,同操作系统一样,Java虚拟机内部实现了内存管理、线程管理(线程的概念同进程比较类似,后面章节会讲到)等操作系统特有的功能。对于Java程序来说,Java虚拟机就是一个操作系统。
Python语言的情况比C、C++、Java等语言都要复杂得多。首先,Python是一种解释语言,它可以由Python解释器解释执行。其次,它又是一种编译语言,它可以被编译成某种格式的中间代码。然后,这些中间代码可以由Python虚拟机执行。因此,Python同时又是一种虚拟机语言。
我们可以看到,无论是编译器、解释器、还是虚拟机,都是可以并存的,而不是非此即彼。那些只不过是一些实现细节问题。
现在,我们继续回到C++的class类型上来。上面讲到,class类型相对于structure类型,主要有两点优势。一是可以在class内部定义方法过程,二是class内部的对象方法中能够自动得到一个指向本对象的参数(this或者self)。
第一点很重要,但也很基本,不需要多说。第二点,很重要,而且值得特别强调。this或者self参数的自动定义和获取,极大地节省了程序员的工作量。不信的话,你可以自己试试,用C语言的structure类型和过程指针类型,来模拟实现C++的class类型的对应功能。无论是定义,还是应用,structure的方式都是相当麻烦的。
class相对于structure的优势就仅此而已了吗?远非如此。
在C++语言中,class中的对象方法分为两种——实方法和虚方法。这是两个很重要的概念。通过分析这两个概念的异同,我们可以加深对class内存结构的理解。
要讲清这两个问题,就不得不从C++的继承语法讲起,还需要一大堆的例子。
第一,我本人对继承这种语法现象很不感兴趣。
第二,我本人觉得,实方法是一种在语言设计过程中遗留下来的历史沉渣,不需要掌握;只有虚方法才是真正地体现了面向对象编程思想的设计精髓。而在Java、Python、Ruby等更加高级的面向对象语言中,所有的对象方法都是虚方法。只要理解了那些高级语言中的方法,虚方法的概念自然也就掌握了。
我对实方法的这种看法,可能会引起C++、C#等程序员的不悦,因为在这些语言中,仍然保留了实方法的语法特性。可是,我确实就是这么认为的。
基于以上这两个理由,我不打算对实方法和虚方法的区别进行详细说明。我这里直接给出结论。
实方法的调用是在编译期,通过具体类型的内存结构映射,就已经决定了的,没有任何悬念,也没有面向对象编程的多态性。
实方法在对象的内存结构中是直接铺开的。计算机只需要通过一次地址映射寻址,就可以直接定位到对应的实方法。
虚方法的调用是在运行期决定的,具有面向对象编程的多态性,真正实现了定义与实现相分离的特性。
虚方法在对象的内存结构是存在于一个叫做虚表(Virtual Table,简写为VTable)的结构中的。对象的内存结构中的最后一格内容就是虚表的地址。计算机每次调用虚方法的时候,需要进行两次地址映射,首先,找到对象内存结构中的虚表地址,然后,再从虚表中找到对应的虚方法。
虚表(VTable)是面向对象语言的非常重要的概念。基本上,所有的面向对象语言都是基于虚表结构来实现的。正是由于二级映射的虚表的存在,才实现了面向对象编程的多态特性,从而实现了定义与实现相分离的特性。
虚标的结构并不复杂,读者最好在脑海里多做一下这样的想象:两份表格,一份是对象内存结构表,一份是虚表。对象内存结构表的最后一行内容,就是虚表所在的地址。
什么叫做“多态”,什么叫做“定义与实现相分离”,这些都是面向对象编程的基本知识点。这两个知识点很容易从各种资料中获取,而且很容易理解,请读者自行学习。
我个人的建议是, Java语言的interface类型和class类型很好地展示了定义与实现相分离的概念。有兴趣的读者可以参阅相关内容。
C语言和C++语言是偏于底层应用的语言,允许程序员自己分配回收内存,还提供了指针类型允许程序员直接访问内存地址。这样的特性对于底层硬件开发很有用,但是,对于高端的应用开发来说,这样的特性就不再是优势,而是一种劣势了。要知道,内存分配回收,还有指针类型的时候,是C语言和C++语言中著名的难点。因此,在一般的不涉及底层硬件开发的应用程序中,人们一般都选用基于虚拟机或者解释器的面向对象语言,比如,Java、C#、Python、Ruby等。因为虚拟机和解释器都实现了内存自动回收的功能,免除了程序员自己释放内存的负担,也大大减少了内存泄露的危险。
好了,在本章结束之前,让我们总结一下本章的要点。重用方式主要有两种方式,一种叫做库重用,一种叫做框架重用。对于库重用来说,面向对象语言相对于面向过程语言的优势并不明显。但是,对于框架重用来说,面向对象语言相对于面向对象语言的优势就极为明显了。
在面向对象语言中,我们可以很容易地把一个包装了一组数据和方法的对象作为参数,传入到框架过程当中。面向过程语言要做到这一点,则相当麻烦和不便。
因此,面向对象最能发挥效能的地方,就是框架重用。而在框架重用中,面向对象发挥的最重要的特性就是多态性。而多态性是由一个叫做虚表(Virtual Table)的结构来实现的。虚表(Virtual Table)在内存结构中表现为一个二级映射表。
以上就是本章的知识要点,都是面向对象编程模型和概念的重中之重,理解起来有一定的难度,请读者一定多花费点心思,全面而深入地掌握这些知识要点。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值