面向对象与面向过程,谁才是“银弹”?

面向对象与面向过程,谁才是"银弹"?

设计模式系列第①节

相信各位有过开发经验的朋友们,或多或少都了解过面向对象 和面向过程两种开发范式。我们通常会使用这样的比喻进行介绍:

面向过程就像是做菜:我们在做菜时往往遵循固定的步骤------洗菜、切菜、热锅、炒菜、出锅,按照流程操作之后,饭菜也就做好了。它强调的是从输入到输出之间的处理过程,即**"怎么做"**。

而面向对象 就像是经营餐馆:我们需要了解一家餐馆怎么为顾客提供服务,需要哪些岗位的员工,这些不同的岗位工作的职责是什么,怎么管理以及协调他们之间的工作。它强调的是对整个业务场景的建模,即"谁来做"。

这些比喻非常形象地描述了二者不同的关注点:面向过程关注逻辑链路的实现,而面向对象关注整个业务的建模。

听到这儿,可能有朋友会感到疑惑:我们每天的工作,不就是把产品经理的需求翻译成逻辑代码吗?只要逻辑跑得通,功能实现了,任务就算完成了。至于什么"业务建模"和我们开发有什么关系。当我们面对一个需求时,直接上手写过程代码不应该效率会更高吗?面向对象会不会只是为了显得"专业"而搞出来的**"过度设计"**呢?

在此,我想用一个实际的案例来回答这个问题,同时让大家对这两种开发范式有更加深入的了解。

开发案例-计算器

第一个版本

假如老板让你开发一个简单的计算器,第一版只需要具备基本的四则运算功能,也就是两个数之间的加减乘除,你会如何实现呢?

如果采用面向过程 的设计的话,我们可以定义一个基本的计算链路:获取用户输入,解析数字以及获取运算符,根据运算符的类型分别进行对应的计算(加减乘除), 输出结果,按照这个链路依次实现对应节点的代码,流程图如下。

如果采用面向对象 的设计的话,我们首先需要先构造出一个能够充分描述当前业务场景,也就是计算的总体模型,那么该怎么构造呢?计算器解析用户输入并执行计算并返回结果,实际上可以抽象出两个组件,一个是解析器(Parser),用于解析表达式的内容,区分出数字以及运算符,另一个就是运算符对象(Operation),用于执行对应的运算逻辑。同时考虑到不同运算符有不同的运算逻辑,也可以定义一个操作接口及其子类,用于表示不同的运算逻辑实现,类图如下。

通过对比代码很容易看出,面向对象的设计要比面向过程的设计复杂得多,一个简单的计算函数用了六个类才实现,而面向过程的代码只用了90行左右的代码就完成了。如果需求仅止于此,那么面向对象的实现确实是一个典型的"过度设计",大家肯定也不会乐于看到这种"花里胡哨"的代码。

第二个版本

但是之后,老板又派发了新需求,让计算器支持带有括号以及涉及多个数字的复杂表达式。

接下来又该如何实现呢?

如果采用面向过程的设计的话,原来的逻辑需要推倒重写:由于原来的逻辑仅支持二元计算,逻辑非常简单,只需要先用空格拆分字符串,然后根据中间的符号选择对应的计算函数计算即可,但是现在需要支持多元且带括号优先级的表达式,这套逻辑肯定就失效了,需要重新实现一套算法。这里采取先将表达式转为逆波兰表达式,再执行逆波兰表达式的算法,这个算法相比使用栈边解析边执行的算法,一定程度上存在性能损失,其优势在于更好区分解析与计算的过程,原函数就能够拆分为解析和计算两个函数了,代码会更加清晰,流程图如下。阅读第二版的代码,很明显可以看出代码开始膨胀了,可读性也降低了不少。

如果采用面向对象的设计的话,我们需要对计算模型进行进一步抽象。此时,我们将注意力放到表达式本身:这是一个比较复杂的表达式,我们可以这么进行拆解,首先整个表达式可以看作是3与2*(5-4*6)这两个表达式计算值的加法运算,前者是一个单独的数字,值就是它本身,后者也是一个比较复杂的表达式,它可以被继续拆解为一个纯数字与一个被括号包裹的表达式的乘积,按照这样拆分下去,我们可以发现整个表达式被转换成了一个树形结构,这棵树的叶子节点都是纯数字节点,可以直接获得值,而其非叶子节点是一个个完整的表达式,拥有自己计算值的逻辑,而表达式的计算逻辑通过对应的计算符对象实现。

既然对表达式的抽象结构有了基本的了解,那就不难推出它的计算方法了:我们只需要在整棵树的顶部,自顶向下地将计算任务通过父表达式调用子表达式的方式传导到所有节点,即可完成最后的计算,其实也就是一个递归的算法,改动点如下:相对于前一版设计,我们需要新增一个Expression接口,用于定义表达式的行为,然后定义几个表达式的实现类来实现这个接口;原来的Operation接口也要新增一个getPriority方法,用于获取操作符的优先级,并且原来的apply方法的两个参数也要同时改成Expression类型。接着我们还要创建一个Parser类,用于完成将表达式转成对应的表达式对象的逻辑。最后就是重构原来的一部分代码,将这一套新的模型接入到原来的模型中,也就完成了本次的需求。类图如下,虽然新增了很多类和代码,但是改动的内容非常简单,而且结构依旧清晰。

到了这一阶段,虽然在开发效率上,面向对象设计并不占优势,但是代码在可读性上以及可维护性上的便利开始显现。

第三个版本

在加班加点完成第二波需求后,老板又找上门了:想要继续添加用户自定义函数的支持,同时还要实现一些系统自带的函数。

这时候该怎么做呢?让我们看看在两种设计思路下这个新的需求该如何实现吧:

如果采用面向过程的设计的话,会发现本次新的需求几乎每一个逻辑节点都要重构代码:新的需求包括三部分,一是要求添加一个新的功能,就是让用户能够创建自定义函数,函数名和对应计算函数的映射需要通过一个全局变量管理;二是能够解析并识别用户输入的表达式中的函数,将其依旧保留在逆波兰表达式中,并且此处还需要考虑到一个问题:假如出现嵌套函数,或者以表达式作为入参的函数该怎么处理;三是在计算的时候也同样要能够识别用户输入的表达式中的函数并进行计算,这里也同样需要考虑第二部分中相同的问题。这一版实现如图,代码基本已经复杂到看不太懂的程度了。

如果采用面向对象的设计的话:很显然,函数也可以被看作表达式,我们此时需要新增一个函数接口,统一定义函数的入参与出参,接着定义需要的那几个系统函数以及单独的自定义函数类,它们都需要实现函数接口,为了将函数作为一个表达式去使用,还需要定义一个函数表达式的实现类,将函数挂载到表达式树上,并且为了实现函数内的参数与入参的绑定,还可以定义一个表达式实现类用于表示一个变量表达式。最后只需要修改Parser以及Calculator中的部分代码即可将新的函数模型进行接入。如果对设计模式有过研究的朋友可能会发现,这些类参考了工厂模式(Factory Pattern)以及解释器模式(Interpreter Pattern)的设计思路,只不过在类的命名以及部分细节上存在不同,类图如下:

从这里开始,面向对象的优势愈加明显。随着业务变得越来越复杂,面向过程的代码的复杂度爆炸式增长,小节点数量增多,主流程节点函数越来越长,逻辑也越来越乱,逐渐变成难以维护的屎山;而面向对象的代码随着版本不断的迭代,代码量以及类的数量一直在快速膨胀,但是可以察觉到的是,在迭代过程中,面向对象的代码对过去旧代码的改动相当有限,每一次的新增的代码就像是一个个的积木一样,很自然的拼在原来的代码上,也没有破坏原来的逻辑。并且,随着业务深入,面向对象的代码对业务的表达能力也越来越强,一个个类将自己的实现逻辑隔离开来,只对外提供访问接口,屏蔽了内部逻辑的复杂性。这样的代码可读性强,梳理方便,扩展简单,这正是面向对象的核心优势。

第四个版本

假如又过了一阵子,老板又给了一个新的需求,说是要让现在的计算器能够支持定义变量和自定义运算符,又该如何实现呢?这个问题就作为一个思考留给大家吧,如果大家对于以上任何一种范式的实现有好的想法,可以在评论区中分享给大家哦~

总结

通过这一连串的迭代案例,大家对两种范式各自的优缺点应该有了全面的认识:

面向过程的优势在于在小型系统场景下极致的开发效率以及算法性能,但面对复杂系统容易出现代码逻辑混乱,高耦合,迭代阻力大以及状态管理难的问题;

面向对象虽然前期投入大,但它通过将变化隔离在了一个个独立的对象中,让系统具备了真正的可扩展性。这也恰恰回答了开头的那个疑问------**面向对象不是"过度设计",而是一种"前瞻性设计"。**对于面向对象的开发范式而言,虽然前期需要投入更多的建模成本,但随着业务的深入,它展现出的扩展性和可读性优势却会越来越明显。

但面向对象也有它的劣势,一个是过度设计风险 :在小项目中,面向对象可能是杀鸡用牛刀,设计出的对象可能根本发挥不了作用,却浪费了时间。二是性能开销 :相比面向过程,面向对象通过委托和多态实现逻辑,由于引入了大量的对象和动态方法调用,也会带来一定的性能损失。三是设计难度,一个业务模型随着其复杂度的上升,设计难度也会越来越高,对开发者以及设计者的业务理解能力也会有着更高的要求。

那么,既然两者各有优劣,能不能把它们结合起来用呢?其实,我们每天都在这么做。

举个例子,大家如果平时写 Java Web 项目的话,是不是大部分时间都在写增删改查的逻辑?从接收请求、校验参数、到查数据库、最后返回结果......一步步来,非常直观。你可能会觉得,这不就是面向过程吗?面向对象在哪呢?

其实,面向对象就藏在你看不见的地方。你能把业务逻辑写得这么简单,全靠 Spring 这类框架在底层用面向对象的思想(比如依赖注入、对象封装)把脏活累活都包揽了。如果没有这些复杂的面向对象组件,我们的系统早就寸步难行了。

换句话说,不是你的应用里只有面向过程,而是框架替你承担了面向对象的复杂性。 这就是现代开发中最理想的组合模式:底层组件和全局架构用面向对象来"打地基",而上层的业务逻辑用面向过程来"砌砖头"。 两者结合,才能既保证系统的健壮性,又保证开发的效率。

最后让我们再回到标题:面向对象与面向过程,谁才是"银弹"? 事实上,对于软件工程而言,本就不存在任何能够完美解决一切业务问题的通用方案。尽管这个视频中其实花了大篇幅来介绍面向对象范式在复杂系统上的优势,似乎倾向于支持面向对象作为这个问题的答案,但是具有优势并不意味着它是一切软件系统的万金油:一方面,在简单的业务场景下,不需要额外的面向对象设计就能快速地让一个功能投入使用,另一方面,因为往往业务时时刻刻都在发生变化,之前构建的业务模型很可能会仅仅要满足一个功能而推倒重做。在这里,我只想说的是,没有任何软件系统能够逃离重构,但是良好的设计,其优势就在于能够尽可能推迟下一次重构到来的时间,并且降低重构需要付出的代价,这也是我们要学习面向对象设计思维乃至软件工程的重要原因。

尾声

感谢大家看到这里,目前这个系列是有关设计模式的技术讲解系列,下一期将会聚焦于面向对象开发范式,为大家讲解面向对象相关的技术概念。同时,这个账号后续也会更新更多有关软件工程,大数据,大模型等IT行业相关的技术内容,如果这篇文章对您有所帮助,感谢点赞并关注,如果文章中存在错误的地方,也欢迎大家批评指正 ,非常感谢大家!

项目地址:Github

相关推荐
geovindu7 小时前
rust: Iterator Pattern
开发语言·后端·设计模式·rust·行为模式·迭代器模
高晶1 天前
ARM 嵌入式 Linux 系统启动流程实战指南
设计模式·程序员
高晶1 天前
BMS 通信开发实战:从 PMBus 协议到数据采集的完整指南
设计模式·程序员
geovindu1 天前
rust:Memento Pattern(续)
开发语言·后端·设计模式·rust·行为模式·备忘录模
电子IT笔记1 天前
老MES登录改造:弱密码和暴破一起治
设计模式
牧瀬クリスだ1 天前
单例模式终极指南:从入门到防破坏
单例模式·设计模式
博风1 天前
9、设计模式应用举例:责任链模式:计算阶乘
设计模式·责任链模式
电子IT笔记1 天前
人走了,记录不能“空白”: MES×HR 离职人员下拉回显实战
设计模式
博风2 天前
8、设计模式应用举例:适配器模式:Iterator接口与Enumeration接口
设计模式·适配器模式