欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-05-17 13:33:18
...
《编程机制探析》第六章 面向对象

面向对象(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)在内存结构中表现为一个二级映射表。
以上就是本章的知识要点,都是面向对象编程模型和概念的重中之重,理解起来有一定的难度,请读者一定多花费点心思,全面而深入地掌握这些知识要点。

上一篇: 避免日照

下一篇: 你打娘胎里就懒