曾几何时,“面向对象”这个词风靡软件软件开发界。现如今,长期霸占最热门编程语言榜前三的,就有 Java 语言。这门语言号称贯彻面向对象思想——“一切皆对象”。但现在,越来越多的新语言、新思想在软件开发界兴起,而 C 语言这类传统的结构化语言依然顽强的存在着,反而“面向对象”思想变得看起来有点“老土”。不过,那些言必称 lamda 的程序员们,也未必真正的理解“面向对象”这个编程体系。因此,我希望能重新思考与描述一下“面向对象”的概念和一些常见的编程实践,以便在继承伟大思想遗产后,更好的学习新的技术成果。
关于面向对象的论述非常多,我觉得应该从最最基本的编程语言语法层面,去认识它。也许这不是“面向对象”的全貌,但起码是最基本的“功能”。要理解面向对象代码编码的思想,就应该与另外一个著名的编程思想——结构化编程思想来对比。
面向对象编程思想的基本特征有三个:封装、继承、多态。
这个是三个特征中最本质和最重要的特征。封装标准的说法是:把逻辑相关的数据和操作他们的代码封闭起来,让别的代码不可直接访问。
在结构化编程中常见一种写法:我们常常使用全局变量,或者用堆变量(new/malloc构造)的指针,来记录计算的过程结果。由于计算的过程常常需要修改,所以这些指针在使用上显得非常灵活有效。但是缺点也很明显,就是内存中的数据有太多的可能状态。如果对修改内存的代码没有限制,会让逻辑错误难以跟踪。程序一旦复杂之后,程序员往往就无法确定内存状态,这会导致代码在复用的时候,也能难以保证内存状态的稳定性。
针对上面的问题,“面向对象”思想提出了代码和状态结合,这样的好处是可以确定每行代码的状态,和每个状态的变更。为了实现这个目的,面向对象思想还提出了用“对象”这个概念了包装代码,以及代码相关状态变量的方法。这样一来,“对象”除了封装状态,还形成了对某个固定功能的语义集合。也就是说,我们不再像结构化编程那样,只能忽视处理的数据含义,而是把处理过程作为代码的语言来理解。使用“对象”及其“类”的语言,我们可以按业务领域中的名词来建模,这种封装后的代码,可重用性会更强。
这个特征现在的名声不太好。业界充满了“尽量不要用继承”的告诫。因此还诞生了所谓“失血模型”的设计:天然不易产生继承的用法。因此现在更多人倾向忽视“继承”。然而,“继承”找到如此多的攻击,正是因为它太好用了,很容易被滥用。我们反而应该深入的了解这个特性,才能更好的避免它的缺点。
在我们有“继承”之前,为了掌握强大的函数库,程序员们需要学习大量近似但不同的API,这可比背单词困难多了。如果想在写好的系统换一套其他类似的API,更是可能需要大动干戈,修改大量的代码,这意味着搞出很多bug来。但是,如果用了“类库”,我们可以只学习一个标准的类库接口,掐所有类似功能的类都会继承这个标准。
我们以后还可以不修改使用代码,直接替换其中的一些实现类,实现升级功能或者优化功能。这些都是极好的特性。然后“继承”最受诟病的问题,是对于同一个基类的属性继承后,子类对象就打破了封装,可以在不受既有代码控制下修改状态。——这个特性能如果让子类程序的开发变得非常简单,因为可以少管理很多状态,直接摆弄父类写好的内容即可。但这样也带来了风险,就是可能改变父类的接口承诺而不自知。
我们在编写复杂状态逻辑时,带继承能力的对象确实是更灵活简便的组合出多种目标对象的。如游戏领域中,角色类型的数量非常大,而且修改非常频繁。如果我们把怪物、玩家、NPC都继承“角色”类,那么脚本系统就能使用“角色”接口函数,通用的控制游戏中的所有“活物”,从而让游戏中越来越多不同种类的游戏角色能很简单添加。
继承特性在C++语言中,有初始化顺序、析构顺序等多个“看不见”的内部机制需要学习,如果使用“多重继承”,那情况就会更加复杂。但是我认为不应该因噎废食,在扩展功能对象,碰到明显的“Is A”关系时,还是应该用继承。因为大多数商业系统中,软件系统是需要长期维护,并且不断升级的。这些系统大多数在完成新功能的同时,还需要保持旧能力的稳定。
最简单的做法就是利用继承来扩展旧的类,添加新的功能。这样的做法不能说是很好,但在实际环境下,往往是唯一可行的方案。但是我们也应该清晰的看到继承的缺点:它很容易“扭曲”被继承类的形式。这其实是要求使用继承的人具有足够清晰的模型识别能力,不能让子类“误解”父类。所以我觉得所有继承,最后能让父类的代码维护者来设计。
在封装和继承中,其技术细节很多,但设计的外延却很少,面向对象真正对于程序设计的利器,其实是多态这个特性。
多态在代码形式上的一个重要作用,就是取代 switch…case 。结构化编程的经验中,也有使用“查表”的方法来代替大段的 switch…case 的做法,而多态从实现上来说,其实也不过是用了“虚表”来做了隐式的查表。但是,我还是认为多态的方案较好。
首先是因为有编译器的维护,虚表更不容易出错。其次是使用者定义接口和子类,这种代码比跟有利于需求领域的建模,从而方便未来的维护人员。设计模式中的策略模式,本质上就是利用多态配置不同情况下运行不同的代码。我们代码中最常见的糟糕情况,就是大量的 if…else 或 switch…case 中结合了大量的代码,就是多态最拿手解决的问题。
C++ 语言既有面向对象的多态,又有模板,因此被视为一门异常复杂的语言。虽然很多功能既可以用多态来实现,又可以用模板实现。但是多态能获得更多的类型检查,而模板只能在编译时提示出错。
有人说编译模板后的代码名字很长,难以阅读,但是多态运行时错误同样不好调试。因此,真正决定用模板而不是多态,往往还是由于 C++ 没有反射功能:当我们在编写一些期望很“通用”的代码时,往往希望“类”能与其他一些概念对应起来:
在ORM中,我们希望类结构映射成表;
在RPC中,我们希望类结构映射成通信协议;
在算法容器中,我们希望类结构仅仅看成一个对象——在这些地方,我们把类对象,看成是一个模板参数传进来,从而可以统一的按某种“模板逻辑”做处理。
在JAVA中,模板的类型参数是可以限制范围的,所以编写模板函数是可以约定使用协议的,否则如C++就只能靠编译时,看有没有“同样”的名字成员检查,因此不太好体现设计中的设计用途。
Spring 框架在 Java 开源框架中久负盛名,其最受欢迎的功能能够就是 IoC 控制反转功能。这个功能让大家觉得好用的原因,主要是因为在服务器端软件开发中,有一个通用性需求:管理复杂的初始化过程。服务器端系统的输入基本上只有一种,就是协议包。
因此系统由针对多种协议包处理的模块组合而成。初始化系统的工作,就是搭建这些模块。在没有多态的情况下,各个模块的处理接口就是一堆回调函数的函数指针,代码非常不好阅读;如果用了多态,函数指针编程了接口,实现模块还可以自由替换,大大增加了系统的灵活程度。特别是使用 IoC 功能框架后,这些根据确定接口来开发的跟踪模块,可以只使用配置文件就可以组装成不同的服务器进程,而无需重新编译长长的初始化脚本。这对于灵活部署分布式系统非常有帮助。(配置文件是不是真的比代码好,这个问题还是要看具体情况,如果初始化的过程有很多逻辑判断,可能还是代码好)
多态对代码结构的改变是非常革命性的,以至于《设计模式》中的一个重要模式:策略模式,几乎就是“多态”的一种使用说明。
从面向对象代码的特性,在实际中我们可以得到几个典型的代码形式:一是名词化建模;二是充血模型和失血模型;三是高度易用性API。
在结构化编程中,我们对于业务逻辑往往是用动词化建模的,也就是把问题分拆成一个个流程,然后再把每个流程拆分成几个更细节的子流程。并且以这些流程为功能范围建立函数。因此这些函数,都是代表着分解的处理过程,往往是以名词来命名的。面向对象编程这与上述方法大相径庭,面向对象的编程方法不会直接开始解决“业务功能”的问题,而是先考察业务需求涉及哪些对象,如使用角色,业务模块,然后对这些对象分析建模,建立起很多“类”,随后用“类”的属性与方法来描述业务功能。这样建立的“类”属性与方法就可以用来描述业务功能。因为对应的是对象而不是行为,这样建立的类往往是名词命名的。
作为中国人,我们往往更容易理解结构化编程中的思想,因为汉语的动词非常丰富,我们的思维中,分解问题往往是“怎么干”,而不是“是什么”。但是英语词汇中,名词比动词更丰富,所以英语使用者在面对对象建模时更有优势。我们常常在中国程序员的代码中见到诸如:XXManager/XXControllor/XXHelper 这样的类名,这就是对于名词词汇缺乏的例子。不过,角色对比与流程来说,是更稳定的,因为基于角色、对象的建模,应对需求变化的能力更好。
在网络上,这两种模型的争论非常激烈,依我来看,失血模型是不符合“封装”这个面向对象特征的。但是,失血模型也是有事实的好处的:针对那种数据类型很稳定,但处理逻辑很多变的业务来说,失血模型和结构化编程一样灵活方便。
比如操作系统中,Linux 把所有的数据处理都抽象成 send 和 receive 两个行为,任何的程序都可以按这个模式处理数据,处理程序可以和数据分开。又比如通讯系统中,数据结构常常已由通信协议确定,而对协议包的是处理流程比较多样。再比如一些银行、电商业务,长期的业务流程早已定义了大量的单据、表格,所以数据模型比较稳定。
我认为,面向对象的“封装性”是为了解决程序“状态”复杂而提出的思想,如果我们的业务本身“状态”是较易稳定的,强行“封装”反而令程序的灵活性受限。关键是我们要明确“封装”的用途和缺点。另一方面,失血模型是面向对象的一种有益补充,让面向对象编程方法,吸收结构化编程的优点。
在《领域驱动建模》中,是旗帜鲜明的反对“失血模型”的。作者认为领域模型的对象,就是应该代表了数据和操作功能的。对于那些存粹只有数据的对象,称为 Value Object,是不能单独完成整个系统的,而是应该和其他领域模型对象合作一起工作。
在传统的操作系统 API 中(如 linux 系统调用,WindowsAPI,gclib 库),学习如何使用它们往往不那么容易,因为有两个困难:
第一个是 API 的调用顺序需要学习,一批不同的函数如何组合使用,如何先后初始化,这些都要看例子程序才能学会。举个例子,文件操作 API 会要求用户先 fopen() 打开文件,获得一个 FILE* 文件指针,然后再对它执行 read() 或 write() 操作,才能读写文件。最后关闭文件也需要传入最开始返回的文件指针变量。而 Java 的文件类如 FileInputStream/FileOutputStream 就简单太多了,这种面向对象的 API,首先需要用户构造一个 FileOutputStream 对象(这是使用任何对象都必须要先做的,无需额外学习),然后就可以直接调用这个对象上的任何方法,来操作文件了。
这个对象本身也代表了在操作系统中打开的这个文件句柄。这些操作完全没有任何组合、顺序上的要求。即便你的调用顺序不对,比如在 Close() 后还调用了 Read() ,这样也最多会得到一个异常,而不会有什么奇怪的后果。面向对象的 API 的学习,基本上只要看手册就行了,而那些函数库的 使用,既要看例程学习使用顺序,又要查手册看参数列表含义。
// Windows API
HANDLE CreateFileA(
[in] LPCSTR lpFileName,
[in] DWORD dwDesiredAccess,
[in] DWORD dwShareMode,
[in, optional] LPSECURITY_ATTRIBUTES lpSecurityAttributes,
[in] DWORD dwCreationDisposition,
[in] DWORD dwFlagsAndAttributes,
[in, optional] HANDLE hTemplateFile
);
第二个传统 API 学习的困难,在于参数的数量。过程式 API 的参数数量要明显多于类库型 API,原因在于,有大量的“过程变量”和“配置变量”,由于需要组合 API 使用,所以要在相关的每个函数接口上重复。类的对象本身就能承载状态,所以方法函数的参数仅仅需要开放那些最必要的逻辑输入即可。
对于配置变量,对象可以提供大量的 setter 方法,在运行时随时修改这些配置,而且还不会影响到其他的对象实例。所以,在 API 易用性上,面向对象基本完胜过程式函数,除非这是一个非常明确的无状态逻辑,如很多数学运算。
在结构化编程中,代码的结构以分解流程,实现处理方案为核心,代码的分解原色是以实现步骤为主。理解这种结构的代码,我们需要先理解问题的解决方案,如果需求变化,一般都需要修改代码。面向对象思想,针对结构化编程的这些缺点,提出了著名的“开-闭”原则。意思是代码应该对添加开放,对修改关闭。能做到这个原则,是需要代码结构上利用面向对象的特性才能做到的。
面向对象代码结构的重点是定义“类”,与结构化编程倾向分解问题解决步骤不同,面向对象编程更重视描述问题本身。由于代码按“类”划分,所以一般不会完全解决本身,而是全面的划分问题本质相关的角色。能做到“对添加开放”的根本原因,是以基类或接口描述了问题的“外观”,而需求的变化一般不涉及问题接口,而是实现的细节,因此利用多态,就能仅仅添加代码以完成增加新的实现代码。
“对修改关闭”主要是通过面向对象的封装特性实现的,我们可以把接口基类和部分实现类编译成库,用户没有源代码就无法修改实现是类,但是他们依然可以继承、实现接口类。只要系统可以提供“注册”具体实现类的接口,就能轻易添加新功能了,而这种“注册”功能,正是所谓Ioc控制反转体系的基本功能。
在设计接口和实现类,以及设计基类和子类时,我们往往会不自觉的把日常生活中的分类方法用于程序设计:把通用的设计基类,把特殊的设计成子类。但实际上这种想法可能会是错误的,正确的设计应该是规则约束少的为基类,规则约束多的为子类。
最著名的例子是矩形和正方形。日常观念中,矩形是比较通用的,而正方形是比较特殊的图形。所以我们很容易把矩形设计成基类,而正方形设计成继承矩形的子类。但是这就是一个错误的设计,因为如果用户以矩形的接口,去使用正方形的实例对象,调用了设置长度、宽度的方法时,其中的一个设置可能就是无效的,因为正方形不能接受不同的长度和宽度。这很容易产生逻辑错误。正确的做法是把正方形作为基类,而矩形继承正方形类,这样“设置边长”的方法也可用于矩形。
我们在设计类的继承关系时,必须注意所谓“一般”和“特殊”的真实含义。由于在面向对象设置中,代码如按此“依赖倒置”原则设计,业务逻辑必将会被继承结构拆分成“一般”和“特殊”的层次结构。此种结构类对比结构化编程,就是把大流程拆分成多层级的子流程。但是,在面向对象的语义下,这种拆分的约束更多,更细致。比结构化编程的指导性更强。
在面向对象程序的结构中,还有一条原则叫“最小知识原则”,此原则要求代码间的耦合尽量简单:函数参数尽量少,引用的类型数量尽量少……。在结构化编程中,我们由于要组合多个函数,就会使用大量的过程变量,这样的代码无论如何简化,都不可能太简单。由于每个函数的调用都不带上下文,因此很多API设计者都喜欢设计常常的参数列表,以便使用者能更“灵活”的使用。但是这样的代码阅读区来宛如天数,即便你熟悉这些API,你也难以从一串参数中一样看出其含义。
面向对象的代码结构,就要破解这种难以阅读的代码:由于每个调用层次的类、方法,都要求“缩小”耦合范围,简化使用形式,所以其类名、方法名就能带上更多语言,从而提高可读性。而这些类可以通过“开闭原则”,被拆分为多个层次的其他组合类,用户可以通过使用这些较低层的类来扩展功能,或直接通过继承来添加新的功能。
面向对象思想是与结构化编程不同的一种思路,但并不是说就一定比结构化更先进。他们的关系应该是平等的。结构化编程思想诞生于计算机早期应用领域,以计算密集型任务为主,应用范围比较集中于需求稳定的领域,比如军事、金融、通信、操作系统;而面向对象这是在计算机应用范围快速扩大之后,大量商业、娱乐业务,需要更多的需求变化能力,因此代码的可读性,修改能力,变得更加重要。
面向对象编程,就是为了这种需求变化而设计出来的。在面向对象方法中,最自然的就是针对业务领域的对象去建模,就是看业务领域中有什么东西,直接用这些东西来建立类。在游戏领域,这种方法最常见,因为游戏世界中本来就有许多虚拟角色、物品、场景。在电子商务这些与现实结合的领域,使用直接映射建“类”也很方便,现实业务领域提供了大量的概念定义。相比之下,结构化编程更依赖于程序的理性思考,对问题做细致分解;面向对象领域程序员有大量业务领域参照物,看起来简单得多。
虽然用直接业务领域映射的方法,很容易满足代码理解的需求,但是并不一定是最优方案。因为需求变更导致的代码修改,并不一定能很简单的对应到业务领域模型上。这就引入了面向爱你个对象思想的另外一个原则:需求变化的原因,就是对象建模的边界。——如果你发现有个需求变化,一定要修改代码,那么这个修改的地方,就是代码应该“切分”耦合的位置。这里的切分,就意味需要有两个不同的类。在需求的不断变化中,好的面向对象程序会逐步“进化”,变得越来越适应真实需求。这和传统的思维:需求变化会让代码“腐化”,是很不一样的。因此说面向对象思想是一种拥抱变化的思想。
在大量的编程实践中,人们总结了23种经典的“设计模式”。归根到底,这些模式利用面向对象的语言机制,更好的应对现实需求变化而产生的手段。设计模式把多种对象间常见的关系模型,抽象成模式。
从直接的业务领域建模,转化成使用设计模式建模,往往需要一些思考分析,幸运的是,设计模式的资料汗牛充栋,而模式本身也就那么几种,全部记住也不是难事。因此,在理解了设计模式的使用条件后,这些知识就比较容易协助开发者建模。从这点上看,结构化编程中对于编程思想的指导就显得抽象的多,因此也更难以被掌握与良好的运用。
在设计模式之上,人们还总结出针对更大型系统的设计经验:架构模式。虽然架构模式不限于使用面向对象特性来实现,但是设计模式却能很有效的用于构建各种架构模式。
在面向对象的实践中,许多思想往往只是一句话,但实现手段则可能很多种,因此业界总结出了:OOP->OOD->OOA三个层次的实践经验,对于新人来说,这无疑是一条明确的升阶之路。这个路径为软件业界提供了大量的优秀人才和作品,因此非常值得推广。
“基于对象”是面向对象编程新趋势,是走向动态化的一次变革。业界在多个领域已经感受到它的优点,并且开始广泛接受这个概念。Lua/JS/Go 等语言,基本都是按照“基于对象”的思想去设计面向对象思想的语言功能。
什么是“基于”对象呢?就是关注“对象之间”的关系,而不是关注对象和类的关系。“面向对象编程”(OOP)的概念已经诞生了很多年,在业界可谓深入人心。像著名的编程语言 C++/JAVA/C# 都是按照这个概念去设计的。但是面向对象编程概念,在实践中,也受到了大量的挑战,很多人认为面向对象编程有很多缺点,其中就不乏重量级的人物如 Linux 的作者。
在所有的挑战和质疑之中,大部分都是指向面向对象编程的复杂性的。面向对象编程的三个特点封装、继承、多态,都要比单纯的结构化编程,让使用者学习更多的关键字,理解更多的隐喻,遵守更多“不成文”的规范,这确实是提高了编程的复杂度。由于面向对象编程的设计目标,就是要应对复杂的“业务需求”,所以大部分的概念和设置,都无可避免的带上了试图解决灵活度的问题,而在灵活性和本身复杂度之间,要取得平衡确实不容易。
但是,在现代的编程语言发展至今,有一个明显的趋势,就是动态化和脚本化。我们可以发现最新兴起的语言,绝大多数都脚本语言,比如Ruby/Lua/Python,而C++语言的新规范,也更多的倾向动态类型推断和lamda表达式(动态函数)。所以面向对象编程概念的发展,也进入了一个更动态化,更脚本化的新时代——基于对象。
现在应用最广的基于对象的语言,应该是JavaScript和Lua。其中JS语言的发展尤其快,已经从浏览器脚本,发展成一门通用的脚本语言,通过node.js框架在服务器端也占据了一席之地,并且随之HTML5在手机端的流行,JS更是成为了前端编程的必备武器。
要了解什么是基于对象,我们可以和传统的面向对象编程三特征:封装、继承、多态,来做对比,获得最直观的印象。
在面向对象编程经典概念中,封装的含义是,把函数和属性,都封装在一个叫“类”的盒子里面,然后我们通过实例化“类”得到“对象”,通过“对象”来实现我们的业务逻辑。
在我们的观念中,函数和变量,是构成代码的两个基本概念,而“类”则是一个用来封装这两个基本概念的“新概念”。反而“对象”很好理解,就是一个自定义数据类型的变量而已。“类”是比较静态的概念,是运行时的“对象”的模板。
一般来说,“类”的定义是编译时就固定了的,所以“对象”在运行期的行为和属性,其结构也是固定在“类”这个框框里面的。“类”就好像图纸,而“对象”就好像照图纸建造的房子,为了获得复杂的房子,我们的图纸必定会变得很复杂,因为图纸必须包含全部的建筑信息,而不能在房子建成后再修改其结构。
第一个区别是:只有“对象”而没有“类”的概念,所以也没有各种“类型”的变量。所有的对象,都统一是“Object”类型。所以如果你要新建一个“对象”,是无需指定类型的,只要好像新建一个int变量一样就好了,也就是没有所谓“实例化”的过程。
当然这样新建的对象是一个空白的对象,没有任何功能。由于“基于对象”里的“对象”,基本上都是可以“动态”变化内容的(就是可以运行时添加成员),所以我们创建出空白对象之后,就会“动态的”(运行时)用代码给它添加各种成员属性,从而让它变得有用起来。
第二个区别是:函数(方法)也是变量。function关键字就是函数变量的类型。这意味着,函数和变量的概念统一起来了。函数也可以像变量一样,被赋值、被用作参数、被作为对象的成员所携带。由于函数也是变量,所以“对象”就没有必要把自己的成员分为“方法”和“属性”两种,统统都可以看成是“成员属性”即可。想要类似以前的“类”上携带“方法”的效果,只需要简单的为“对象”添加一个函数类型的成员变量即可。
使用基于对象的模型编程,我们会用动态的组装对象(初始化或赋值),为对象安插上数据变量和函数变量作为成员,代替“方法”和“属性”的作用。这个过程取代了先定义“类”,然后“实例化”对象的做法。当然这种简化也会带来其它问题,我们后面会说。
这两个区别,全面的简化了传统面向对象编程中概念的数量。因为我们不再需要构建复杂的自定义“类型”系统,没有了“类”(class)关键字,也没有和类相关的“方法”和普通函数差异的概念。这无疑大大减轻了学习的负担,同时也大大增加了运行时的灵活性。
传统面向对象编程中,“继承”这个特性是一个大杀器。威力巨大,副作用也巨大,据说现在都流行不要用这个特性了——说继承会破坏“封装”,让父类的信息被泄露到子类去。而且有的语言(C++程序员请冷静)支持多重继承,这更是让程序变得超级复杂。不过,从本源来说,继承是为了把一种对象的模板(父类),复制到另外一种对象的模板(子类)里的技术。
由于两种模板的定义可能是随心所欲的,所以要真正的能让它们结合的既好用,又不会出错,其实是挺困难的。所以我们为继承的特性创造出一大堆的关键字,比如virtual, overwrite, super…然并卵的是,这些关键字往往除了让语言级别考试作为题目以外,实际运用中往往没有特别大的作用。程序员们一般都希望能以最简单(这样不容易出错)的某个惯例来编写代码,有些精心设置的特性几乎从来不会使用(比如操作符重载,C++程序员请继续冷静)。
基于对象的语言,在继承上的实现,就和以前的概念有很大不同。基于对象的继承,是根据一种叫原型链的方法来实现的。意思就是,父类和子类的关系,并非是“类”定义的关系,而是对象之间的关系。因为根本没有“类”这个东西,所以父类的特性,是由一个父类地位的对象所携带,然后作为一个特殊成员“原型”(也就是一个叫prototype的属性),被子类对象所引用的。当然父类对象还可以自己再链接着另外一个父类对象,这样就形成了一条对象的链。
在运行的时候,如果调用一个对象任何方法或者成员,是找不到的时候,就会顺着这条原型链挨个查找,看看链上的对象有没有对应的方法和成员属性,如果找到了就访问它。这样就实现了子类对象拥有父类对象的能力。虽然这样做多少有点简单粗暴,但是足够清晰。
原型链这种继承方式,在使用上和传统的继承差别似乎不大,但是它使用的是一种“默认委托”来实现。从本质上来说,子对象和其原型对象实际上通过组合,而不是严格意义的“继承”来结合的,这正是很多面向对象编程规范苦口婆心教育大家的“尽量用组合,少用继承”。
需要注意的是,原型链下的继承方法,如果里面有用到this关键字,指的是那个方法所对应的对象,也就是有可能是原型链上的原型对象,而不是最终的子对象。也就是说,在“父类”方法中的this,不是多态的,不能代表最终的“子类”对象。
用原型链来实现继承,从性能上来说是比不上传统的继承的,因为有可能要遍历原型链上的所有对象。从理解上来说,其实也显得没必要的复杂。因为,如果对象都是动态的,如果我需要某个对象的能力,可以直接把那个对象的成员方法拆下来,装在自己身上。甚至可以动态的拆装多个所需对象的结构,组合成一个独特的新对象。——这些工作完全不需要修改类定义文件,不需要编译,而仅仅是写几行脚本即可。
基于对象的编程方案,其实最有价值的,最能提升开发效率的,就在于对“多态”的增强。
我们如果Java编程,由于函数不可以脱离类存在,如果使用某些框架来开发,你会发现你要实现大量的接口。每个接口或多或少的几个方法,可能会让你自己的类变得面目全非。这些需要实现的接口,本身的命名和语义,都是按照框架本身的含义来设计的,而你自己的类,一般和框架的整个语境不同,强迫你的类加入一些奇怪的函数,确实是严重的“污染”了整个代码的可读性。
如果你想每个接口都单独实现一个类,那么你马上就会陷入“类爆炸”——你的项目中有大量的类型,而绝大多数其实都是为了接口而制造的。对于代码维护者来说,看着这么大堆的代码就头晕。所以有人说Java编程就是在表演一套复杂的仪式,来完成一个很简单的操作。其中就有接口-实现类的“功劳”。
对于基于对象的语言来说,接口其实什么都不是,因为函数本身是一种类型,所以函数这种类型,本身就是一个“超级接口”。所有的回调、事件都可以绑定到任何函数上。函数作为一个变量,也可以作为其他函数的参数传来传去。这样你就再也不需要定义任何形式的函数接口了,你只需要用fun()的写法,把一个变量当作函数调用即可。因为没有需要绑定的接口,所以也避免了因为大量的回调接口造成的“类爆炸”。而且这种做法写起来也很简单,如果你想让你的代码使用一个函数参数,直接声明一个就好,不需要去表演一整套的“定义接口——完成实现——多态调用”的过程,这极大的提升了开发的效率。
我们在jquery、node.js以及很多AJAX异步框架中,能明显的体验到基于对象对‘多态’优化带来的好处。这一类框架,由于是针对异步操作程序的,所以回调函数是最常见的东西。整个业务流程,都是由大量的回调函数“串接”起来的。如果没有函数变量类型,将要有无数个接口类型需要声明。
虽然基于对象有节省大量“声明、定义”的优点,但这个反过来说也可能是个缺点。以为所有的函数变量都只有一个类型,因此你无法在编译器作任何函数形式的检查,也无法预防运行期发生不合适的函数变量被调用的问题。另外一个缺点,就是表现力(可读性)上面的,类型系统的好处是:类型本身就是一个自定义的语义,可以赋予良好的可读意义,但是一个简单的function类型,就没有任何可读语义。这对于比较复杂的系统,其实是挺大的问题,因为各种函数对象传来传去,如果没有类型信息用来协助理解,代码阅读起来会非常困难。
面向对象和基于对象,都有各自的优点和缺点,但是有没有可以兼顾两方面的方案呢?很多项目都在这个方面做了一些尝试,一般来说这类尝试都走向一种叫组件化的方向。
所谓组件化,通常是指,把我们业务中常见的逻辑单元,都先抽象成一种叫“组件”的对象,不同的逻辑特性构成不同类型的“组件”。而这些组件对象,都是可以在运行时,遵循基于对象的原则,可以灵活的组合成真正需要的逻辑对象。
由于“组件”本身是一个接口(类),所以各种各样的“组件”还是有各自的面向对象的“类型信息”的,这就能利用上面向对象的静态检查、可读性等优点。
而由于“组件”是可以灵活的组合的,所以避免了复杂的继承结构,而能提供千变万化的对象。这些对象变化恰恰是能很好的应对业务逻辑上的复杂变化的。为了让组合后的对象能简便的调用“组件”提供的能力,往往需要使用一些“反射”类的特性,用来让业务逻辑对象能直接“拥有”那些反射的方法和属性。这种做法虽然性能可能稍微低一点,但是却能得到非常大的灵活性。
游戏引擎Unity的C#语言方案里面,就是一个组件化的编程的优秀实践。在Unity里面,所有在游戏中存在的东西,不管是可见还是不可见,都叫做GameObject,而所有这些GameObject通通都可以被放在一个容器Scene(场景)中。游戏就是由一个个的场景组成的,非常容易理解。GameObject在游戏中可以表示任何东西,这个就是“基于对象”中的对象。比如我们常见的有“摄像机”(没有它游戏就不会显示哦,而且一个场景里面可以有多个摄像机,这样可以做类似监控录像的功能),“地面”,“玩家”、“建筑物”、“子弹”等等,都是GameObject。在Unity的编程环境中,也对应的存在一个这样GameObject的类型(class)。然而,这个GameObject类型本身却没有多少功能(方法和属性),基本上都是一些创建、删除、查找子对象、查找组件的方法。而真正为这个GameObject提供能力的,是它所包含的各种各样的组件(Component)。
一般一个在游戏里面的角色,都会包含如下一些组件:一个代表此对象在3D场景中位置和方向的Transform类型组件(Position位置、Rotation方向、Scale比例三个属性都由x\y\z三个float数表示);一个表示自己3D外观的MeshFilter组件;一个用于记录如何渲染自己的MeshRenderer组件;如果需要碰撞检测功能,还会带有Rigidbody和BoxCollider组件;更重要的是,如果你想为这个角色带上一些自定义的功能,可以带上一个或者多个Script组件。如果你有多个游戏对象,都具有同样的行为,比如一大群各种各样的怪物,你完全可以只编写一个Script对象,然后加到所有这个怪物身上,他们就会具有一样的行为了。
而一个Camera(摄像机)对象除了会带有Transform组件(你可以通过它来控制摄像机的位置和方向),还会有Camera组件控制景深、背景颜色等等,另外还会有GUI Layer组件用于显示UI面板工具栏,甚至还有Audio Listener组件用于播放声音(当然你可以同时附加一个Audio Source组件,这样组合起来就可以播放背景音乐了)。可以说几乎所有的游戏的功能,都是通过各种各样不同类型(class)的组件对象,通过组合的形式放在GameObject类型(class)的对象里实现的。
所有这些组件,都会成为GameObject的一个属性存在,比如gameObject.transform 就能获得Transfrom组件,从而读取或修改GameObject对象的位置。而且这个组合的过程,你可以通过Unity的图形编辑器来实现,不需要编写一个真正的类定义的文件。这里就用到了一些动态反射的机制:访问一个属性的请求,自动转换成访问一个组件对象。
GameObject和Component都是可编程的类型(class),但是那些一个个由不同组件组合成的GameObject,如果想变成一个可复制的模板,则需要建立一个叫Prefab(预制件)的对象。这里的Prefab负责了“类型-继承”的能力。由于Unity不为每个GameObject建立独立的类型(class),所以我们无法通过一个类型(class)去实例化多个具备同样功能的对象(object);但是使用“基于对象”的概念,我们可以从一个对象模版(Prefab)去克隆(复制)出多个同样功能的对象: GameObject.Instantiate(Object original) ——我们只要传入一个Prefab对象,就能得到其复制品对象。而Prefab对象是无需存在于游戏中的场景里的,从某种意义上说,可以看成是“类”(class)的替代品。
在游戏开发中,我们每天都要设计、修改大量不同特征的游戏对象,如果为了每个对象都去编写其类定义代码文件,将会是很枯燥和繁琐的事情,甚至于最后为了起类的名字都会想破头。但是如果我们用了基于对象的方法,我们可以专心于处理一个个游戏中独特的对象。对于那些大量重复的对象,同样可以使用复制的方法来控制。这对于快速开发游戏,应对大量不同的需求变更(游戏策划是最爱变更需求的人了),是非常有好处的。
虽然每个GameObject都可以带有不同的Script(脚本)组件,但只要每个脚本有Update()方法,Unity就能在每次渲染时调用这个Update()方法。值得注意的是这个Update()方法并不是定义在Script组件类型MonoBehaviour里的,而完全只是一个单独的公开方法。Unity引擎会使用反射的方法来找到这个方法来调用。Unity使用反射而不是多态的方式来设计这个Update()接口的原因,实际上也是基于对象的思想:方法应该是动态的,是一个属性。当然啦,Unity本身除了C#,也支持JS脚本,在JS里面,根本就没有“虚方法”(继承得来的方法)这个概念。
在Unity里面的C#编程方案里面,既存在传统的面向对象概念,如C#的类型系统,也使用了基于对象的架构:GameObject-Componet模型。因为有C#的类型概念,所以各种组件实际上是有自己的类型(class)的,这些类定义可以帮助使用者快速浏览和掌握引擎提供的各种方法和属性,并且提供足够的编译期检查。开发者自己的代码,也可以采用类型系统来编写严格定义的类,用来构建严谨和良好可读的复用代码。而那些变化非常频繁的代码和模型,则可以使用基于对象的GameObject-Componet的组件模型,灵活的组合出各种业务对象,降低开发成本。所以这种设计既能兼顾传统面向对象的优点,又能用上基于对象的好处,是一个非常值得学习的思路。
“基于对象”是“面向对象”一次动态化变迁,它依赖于现代语言的动态特性,让方法和属性统一起来;用组合取代继承;以函数对象查找取代多态的方法调用。这些变化让面向对象复杂的仪式化定义代码变得灵活轻巧,更加适合那些需求多变的业务领域。而组件化编程思想则把“面向对象”的严谨和代码可读性结合到“基于对象”上,从而兼顾两者的优点,从而渐渐成为现代编程理念的一个潮流。
一口气把面向对象的想法说完了,下面应该进入软件工程更深入一层的知识了。
评论区
共 12 条评论热门最新