面向对象的代码风格
发表于2016-03-01
曾几何时,“面向对象”这个词一度风靡软件软件开发界。现如今长期霸占最热门编程语言榜前三的,里面就有一门叫Java的语言。这门语言就号称是贯彻面向对象思想设计的——“一切皆对象”是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两个行为,任何的程序都可以按这个模式处理数据,处理程序可以和数据分开。又比如通讯系统中,数据结构常常已由通信协议确定,而对协议包的是处理流程比较多样。再比如一些银行、电商业务,长期的业务流程早已定义了大量的单据、表格,所以数据模型比较稳定。我认为,面向对象的“封装性”是为了解决程序“状态”复杂而提出的思想,如果我们的业务本身“状态”是较易稳定的,强行“封装”反而令程序的灵活性受限。关键是我们要明确“封装”的用途和缺点。另一方面,失血模型是面向对象的一种有益补充,让面向对象编程方法,吸收结构化编程的优点。
最后,说说API易用性问题。在传统的操作系统API中(如linux系统调用,WindowsAPI,gclib库),学习如何使用它们往往不那么容易,因为有两个困难:第一个是API的调用顺序需要学习,一批不同的函数如何组合使用,如何先后初始化,这些都要看例子程序才能学会。举个例子,文件操作API会要求用户先fopen()打开文件,获得一个FILE*文件指针,然后再对它执行read()或write()操作,才能读写文件。最后关闭文件也需要传入最开始返回的文件指针变量。而Java的文件类如FileInputStream/FileOutputStream就简单太多了,这种面向对象的API,首先需要用户构造一个FileOutputStream对象(这是使用任何对象都必须要先做的,无需额外学习),然后就可以直接调用这个对象上的任何方法,来操作文件了。这个对象本身也代表了在操作系统中打开的这个文件句柄。这些操作完全没有任何组合、顺序上的要求。即便你的调用顺序不对,比如在Close()后还调用了Read(),这样也最多会得到一个异常,而不会有什么奇怪的后果。面向对象的API的学习,基本上只要看手册就行了,而那些不是类库的API,既要看例程学习使用顺序,又要查手册看参数列表含义。第二个传统API学习的困难,在于参数的数量。过程式API的参数数量要明显多于类库型API,原因在于,有大量的“过程变量”和“配置变量”,由于需要组合API使用,所以要在相关的每个函数接口上重复。类的对象本身就能承载状态,所以方法函数的参数仅仅需要开放那些最必要的逻辑输入即可。对于配置变量,对象可以提供大量的setter方法,在运行时随时修改这些配置,而且还不会影响到其他的对象实例。所以,在API易用性上,面向对象基本完胜过程式函数,除非这是一个非常明确的无状态逻辑,如很多数学运算。
三、面向对象代码的结构
在结构化编程中,代码的结构以分解流程,实现处理方案为核心,代码的分解原色是以实现步骤为主。理解这种结构的代码,我们需要先理解问题的解决方案,如果需求变化,一般都需要修改代码。面向对象思想,针对结构化编程的这些缺点,提出了著名的“开-闭”原则。意思是代码应该对添加开放,对修改关闭。能做到这个原则,是需要代码结构上利用面向对象的特性才能做到的。
面向对象代码结构的重点是定义“类”,与结构化编程倾向分解问题解决步骤不同,面向对象编程更重视描述问题本身。由于代码按“类”划分,所以一般不会完全解决本身,而是全面的划分问题本质相关的角色。能做到“对添加开放”的根本原因,是以基类或接口描述了问题的“外观”,而需求的变化一般不涉及问题接口,而是实现的细节,因此利用多态,就能仅仅添加代码以完成增加新的实现代码。“对修改关闭”主要是通过面向对象的封装特性实现的,我们可以把接口基类和部分实现类编译成库,用户没有源代码就无法修改实现是类,但是他们依然可以继承、实现接口类。只要系统可以提供“注册”具体实现类的接口,就能轻易添加新功能了,而这种“注册”功能,正是所谓Ioc控制反转体系的基本功能。
在设计接口和实现类,以及设计基类和子类时,我们往往会不自觉的把日常生活中的分类方法用于程序设计:把通用的设计基类,把特殊的设计成子类。但实际上这种想法可能会是错误的,正确的设计应该是规则约束少的为基类,规则约束多的为子类。最著名的例子是矩形和正方形。日常观念中,矩形是比较通用的,而正方形是比较特殊的图形。所以我们很容易把矩形设计成基类,而正方形设计成继承矩形的子类。但是这就是一个错误的设计,因为如果用户以矩形的接口,去使用正方形的实例对象,调用了设置长度、宽度的方法时,其中的一个设置可能就是无效的,因为正方形不能接受不同的长度和宽度。这很容易产生逻辑错误。正确的做法是把正方形作为基类,而矩形继承正方形类,这样“设置边长”的方法也可用于矩形。我们在设计类的继承关系时,必须注意所谓“一般”和“特殊”的真实含义。由于在面向对象设置中,代码如按此“依赖倒置”原则设计,业务逻辑必将会被继承结构拆分成“一般”和“特殊”的层次结构。此种结构类对比结构化编程,就是把大流程拆分成多层级的子流程。但是,在面向对象的语义下,这种拆分的约束更多,更细致。比结构化编程的指导性更强。
在面向对象程序的结构中,还有一条原则叫“最小知识原则”,此原则要求代码间的耦合尽量简单:函数参数尽量少,引用的类型数量尽量少……。在结构化编程中,我们由于要组合多个函数,就会使用大量的过程变量,这样的代码无论如何简化,都不可能太简单。由于每个函数的调用都不带上下文,因此很多API设计者都喜欢设计常常的参数列表,以便使用者能更“灵活”的使用。但是这样的代码阅读区来宛如天数,即便你熟悉这些API,你也难以从一串参数中一样看出其含义。面向对象的代码结构,就要破解这种难以阅读的代码:由于每个调用层次的类、方法,都要求“缩小”耦合范围,简化使用形式,所以其类名、方法名就能带上更多语言,从而提高可读性。而这些类可以通过“开闭原则”,被拆分为多个层次的其他组合类,用户可以通过使用这些较低层的类来扩展功能,或直接通过继承来添加新的功能。
四、面向对象代码建模
面向对象思想是与结构化编程不同的一种思路,但并不是说就一定比结构化更先进。他们的关系应该是平等的。结构化编程思想诞生于计算机早期应用领域,以计算密集型任务为主,应用范围比较集中于需求稳定的领域,比如军事、金融、通信、操作系统;而面向对象这是在计算机应用范围快速扩大之后,大量商业、娱乐业务,需要更多的需求变化能力,因此代码的可读性,修改能力,变得更加重要。面向对象编程,就是为了这种需求变化而设计出来的。在面向对象方法中,最自然的就是针对业务领域的对象去建模,就是看业务领域中有什么东西,直接用这些东西来建立类。在游戏领域,这种方法最常见,因为游戏世界中本来就有许多虚拟角色、物品、场景。在电子商务这些与现实结合的领域,使用直接映射建“类”也很方便,现实业务领域提供了大量的概念定义。相比之下,结构化编程更依赖于程序的理性思考,对问题做细致分解;面向对象领域程序员有大量业务领域参照物,看起来简单得多。
虽然用直接业务领域映射的方法,很容易满足代码理解的需求,但是并不一定是最优方案。因为需求变更导致的代码修改,并不一定能很简单的对应到业务领域模型上。这就引入了面向爱你个对象思想的另外一个原则:需求变化的原因,就是对象建模的边界。——如果你发现有个需求变化,一定要修改代码,那么这个修改的地方,就是代码应该“切分”耦合的位置。这里的切分,就意味需要有两个不同的类。在需求的不断变化中,好的面向对象程序会逐步“进化”,变得越来越适应真实需求。这和传统的思维:需求变化会让代码“腐化”,是很不一样的。因此说面向对象思想是一种拥抱变化的思想。
在大量的编程实践中,人们总结了23种经典的“设计模式”。归根到底,这些模式利用面向对象的语言机制,更好的应对现实需求变化而产生的手段。设计模式把多种对象间常见的关系模型,抽象成模式。从直接的业务领域建模,转化成使用设计模式建模,往往需要一些思考分析,幸运的是,设计模式的资料汗牛充栋,而模式本身也就那么几种,全部记住也不是难事。因此,在理解了设计模式的使用条件后,这些知识就比较容易协助开发者建模。从这点上看,结构化编程中对于编程思想的指导就显得抽象的多,因此也更难以被掌握与良好的运用。在设计模式之上,人们还总结出针对更大型系统的设计经验:架构模式。虽然架构模式不限于使用面向对象特性来实现,但是设计模式却能很有效的用于构建各种架构模式。
在面向对象的实践中,许多思想往往只是一句话,但实现手段则可能很多种,因此业界总结出了:OOP->OOD->OOA三个层次的实践经验,对于新人来说,这无疑是一条明确的升阶之路。这个路径为软件业界提供了大量的优秀人才和作品,因此非常值得推广。