面向对象的代码风格

本文探讨面向对象编程的核心概念,包括封装、继承和多态,分析其与结构化编程的区别及优势。并通过名词化建模、充血模型等实践方式,展示面向对象编程在现代软件开发中的应用。

曾几何时,“面向对象”这个词一度风靡软件软件开发界。现如今长期霸占最热门编程语
言榜前三的,里面就有一门叫 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 三个层次的实践经验,对于新人来说,这无疑是一条明确
的升阶之路。这个路径为软件业界提供了大量的优秀人才和作品,因此非常值得推广。

转载于:https://www.cnblogs.com/Dahsouh/p/9587996.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值