简单说,软件开发模型有瀑布模型(自顶向下模型)、自底向上模型、快速原型法以及后来的敏捷开发模型等不同的项目分解、实施思路——至于面向对象和面向过程……这是编程语言支持的接口/模块设计的风格差异:相对于项目设计方案,它过于初级了,是风马牛不相干的两件事。
打个比方的话,要打仗了,我们要制定个作战计划;然后告诉第一军团,你们负责这个;第二军团,你们负责那个……
然后,每个军团接到任务后,再把任务分解了——第一军团司令部令,第一军第一师,你们负责这个;第一军第二师,你们负责那个……
依此类推。
这里面没有面向对象/面向过程插嘴的余地。它们实在太初级,不配在作战会议上发言。
那么,面向过程/面向对象的差异,究竟体现在哪里呢?
喏:
这是面向过程风格的接口。你得自己知道其中的每个表头/每个指针/每个按钮都是干什么的——不知道就自己查手册或者问师傅——这才知道该怎么看它。
而这是面向对象风格的接口:
带个tooltips,看一眼就知道每个接口是干什么的、能不能随便碰——当然,过于技术性的东西,比如遇到什么数值应该采取什么措施,还是只能看课本/问师傅。
但能够少查一些手册,这已经非常值得了。
如果你是程序员,那当然是面向对象风格的接口简单、清晰,对吧:
1、从一个类继承了哪些基类,我们马上就知道它可以支持哪些功能(比如editable控件肯定支持编辑操作);
2、从一个类方法是不是public,我们马上就知道我们可不可以调用它;
3、最帅的,不光你知道,我知道,编译器也知道!所以它可以自动提示我们,你做错了!
接口自带tooltips,这就是项目设计师眼中面向对象风格的所有好处。别的没了。
其他好处,都是从设计里来的。
比如Linux kernel是C写的,但却有一个“泛文件抽象”——清清爽爽的单根结构,彻彻底底的面向对象,对吧。
而你过去见到的那些低水平项目呢,它们压根没有设计。
没有功能分区,没有操作保护,没有威胁隔离……乱糟糟一团:
这是那些项目看起来累的根本原因。
反之,如果我们在项目设计时花了心思,把功能、逻辑、接口都理的清清楚楚,再拿不同颜色区分的明明白白:
爽了吧。
当然了,事实上,软件开发者面对的情况并不仅仅是面板接线。
那是更复杂、更严峻的、动态而微妙的、啮合的逻辑——以至于我都不敢在知乎上提及。
不然哪怕最最初级的技术,得到的回应必然是“太长不看”。
这种东西要理清楚、整明白,显然比面板接线是难太多了:
这就是为什么优秀软件工程师无论是开发效率还是错误定位速度全都十倍百倍碾压普通码农的根本原因。
很明显,无论最终你搞成面向过程风格还是面向对象风格,两者都是换汤不换药:你理清楚了,那么你是按面向过程风格要求使用者查手册、对编码,还是按面向对象风格在上面贴便签,都一样方便;但如果你没理清楚、乱糟糟一团,那什么风格都救不了你。
只不过,把软件功能、逻辑都理清楚、模块划分搞好、在动手之前就把一切理的清清爽爽实在太难,这才让一大群只懂面向对象的家伙抢了风头……反正都是看不懂,而面向对象起码还有点比喻,让人感到似乎透了那么一点点亮……
那么,这玩意儿被外行追捧,也就不奇怪了。
——尤其是,业界很多混子大师、以及捧混子大师臭脚的外行、骗钱的营销号,更是把面向对象都捧上了天;却不知道面向对象恰恰是极其反常识的东西,甚至“类”这个命名本身就是误导(正确理解用术语表示叫is-a),硬生生把软件开发搞成了跳大神。
“面向对象编程是一个极其糟糕的主意,只有硅谷里的人能干出这种事情。” — Edsger W. Dijkstra(图灵奖获得者)
“面向对象设计是用罗马数字做计算。” — Rob Pike(Go语言之父)
“‘面向对象’这个词包含很多意思。有一半是显而易见的,而另一半是错误的。”— Paul Graham(美国互联网界如日中天的教父级人物)
“实现上的继承就跟过度使用goto语句一样,使程序拧巴和脆弱。结果就是,面向对象系统通常遭受复杂和缺乏复用的痛苦。” — John Ousterhout( Tcl and Tk 的创始人) Scripting, IEEE Computer, March 1998
“90%的这些胡说八道都称现在它很流行,非要往我的代码里搓揉进面向对象的石粒。” — kfx
“有时,优雅的实现只需要一个函数。不是一个方法。不是一个类,不是一个框架。只是一个函数。” — John Carmack(id Software的创始人、第一人称射击游戏之父)
“面向对象编程语言的问题在于,它总是附带着所有它需要的隐含环境。你想要一个香蕉,但得到的却是一个大猩猩拿着香蕉,而其还有整个丛林。” — Joe Armstrong(Erlang语言发明人)
“我一度曾经迷恋上了面向对象编程。现在我发现自己更倾向于认为面向对象是一个阴谋,企图毁掉我们的编程乐趣。” — Eric Allman(sendmail的创造者)
谁告诉你面向对象的“类”这个比喻可以方便你理解,他就一定是骗子。
这个事实,我们在199x年代末已经理的清清楚楚、剖析的明明白白。到现在还不知道的,那是既无知又……
你不怕死,尽管跟着学。
简单来说你需要找个人带一下,了解游戏的领域知识,正如很多公司招了实习生要给配个mentor一样。这和用什么语言关系,用什么编码范式关系并不大。
在看代码之前,首先要先了解系统的“设计”。一般会有很多设计文档从高层次去描述我这个系统里有哪些概念,这些概念的关系是什么,如何交互等。没有这个理解,很难去直接通过看代码来了解整体设计思路(如果你能做到,一定有数十年功力了,也就不会来问这个问题)。
其次,一个设计的好坏有很多维度。比如代码可读性,性能等。代码的性能好,并不一定说明可读性就好。实际上存在很多性能优化的奇技淫巧,其他人看来都是WTF。比如可以写一段汇编怎对特定类型CPU的寄存器进行优化编码,可能能得到比通用编译器更好的效果。
而可读性不好,并不一定是真的不好读,而是存在一个认知门槛。在达到门槛之前,是很难理解。这就像是,如果你没学过开飞机,看到飞机驾驶舱里的各种按键仪表就会懵逼。而当你玩了一年微软模拟飞行后,你会对各种功能如数家珍,倒背如流,闭着眼睛也能找到。而这一年里就是需要去阅读大量的手册和资料,去理解这些功能背后的动机,设计,操控方式,反馈。
而一个设计看起来很简单的,很直观的程序。其“简单”可能主要是因为他by design要解决的问题就少。而那些他不解决的问题,大幅降低了门槛。让其“容易看懂”。但这也只能说明这个项目更适合初学而已。正如你自己写程序加个锁可能就lock一下,而mysql的锁的种类,行为,配置多得令人抓狂。而从mysql角度,只有实现成那样的锁才能有效支持ACID语义,同时保证性能上可接受。
但既然问到了就顺便扯一扯。
什么是面向过程?
面向过程是一种以过程为中心的编程思想,即编程直接对应于要执行的动作。对于CPU而言,他只需要知道要下一步执行的代码在内存的什么位置即可。把尺度放大,比如做一笔交易要“扣库存”-“产生订单”-“完成支付”三步。
为了支持这个机制,通常编程语言会提供顺序执行,分支(if),循环(for),函数,goto等来方便开发者来控制”执行流“。
什么是面向对象?
最早的面向对象来自于20世纪60年代的模拟系统的研究。可追溯的最早的面向对象语言是Simula。其主要涉及思想是对事务的模拟。所以衍生了class,实例,继承(is-a)等概念。
20世纪70年代后,诞生了另外一门面向对象语言smalltalk。其设计思想是把一个系统想象为像很多细胞组成的复杂组织。但细胞之间只通过消息传播。细胞各自维护自己的独立性和内部状态,而不得直接干涉其他细胞的”内政“。smalltalk和基于smalltalk开发的一系列系统证明基于这个思路可以构建庞大和复杂的软件系统。
这两个派系思想相互融合,逐步诞生了后续一系列面向对象语言,如C++,Java,Ruby,Erlang等。一般面向对象语言的使用者会将面向对象简单抽象为“封装”+“继承”+“多态“。但回归本源,这三个特征完全是3个相对独立的解决问题的思路。如”封装“可以立即为上面提到的”细胞“内部细节不对外暴露。”继承“是由于模拟系统里两类事务可以产生is-a的关联,而在实现中,这种机制可以在一定程度上帮助复用代码(但有副作用)。而”多态“其实和面向对象没多大关系。比如java里可以定一个interface,所有实现interface的对象都可以在执行相同方法时,发起不同的动作。而在如javascript的函数里,不管对象有没有关系,只要实现了同名的函数都可以被执行。
面向对象比面向过程更高级吗?
明显不是。你会发现这俩完全实在解决不同的问题。对于明显是”流程“的问题,自然要用面向过程解决。设计时,自然会画个流程图,然后代码照着写。
而对于代码的组织问题,则可以用面向对象的思路去解决(但不一定非得这么做)。去思考哪些逻辑应该归属到一起形成一个“细胞”。规定每个“细胞”可以接收什么消息,接收到后会对内部状态产生什么效果,以及会进一步触发别的什么消息。
除了简单的脚本代码之外,一般常规软件系统中,流程问题和组织协作问题会同时存在。所以编码时,实际上是在同时用两种思路进行编码。而一个“面向对象”的语言,实际上是同时支持面向对象和面向过程的编码方式的。(难不成你用java写不了if和for?)只不过一般出于宣传的需要,面向对象语言会刻意的着重强调其面向对象的特征。
此时,在存在多个对象通过协作完成一个事务时,通常用“泳道图”表达。”泳道图“可以同时描述不同对象之间的交互关系的同时描述整个过程(包括跨对象的过程和某个对象内部的过程)。而对应的代码就是我们常见的不同对象调用来调用去的代码。但编码时那个“对象”,可以是一个表示模块的函数,可以是java的对象,可以是单例,可以是spring的service,可以是一个微服务或者互联网商开放的接口……。在设计层面上对象的形式与语言无关。
因此把两个编程范式看成非此即彼,或者把对象看成真理和追求,是完全错误的。正如没人会比较改锥和扳手谁更高级一样。
一定要用面向对象语言写代码才是面向对象吗?
很明显不是。面向对象的思想首先要求把逻辑抽象为一个个对象。至于这个抽象怎么实现的并不重要。例如Linux内核实现为由进程、内存、文件系统、磁盘、网络等管理组件协作,实现操作系统对外的功能。只不过实现的机制是靠C编写的一个个函数实现。一个微服务系统是由大量的微服务通过某种rpc通讯,实现庞大的系统。
面向对象语言为了一些面向对象的特种提供了语法支持,比如继承,方法覆写,基于继承的多态,访问权限管控等。但用过的人都会发现这些要素对于真实业务往往太过于简单,以至于并没有什么卵用。必须增加更多的特性来支持日益复杂的逻辑编写。如常见的IoC,动态Proxy,动态字节码生成,trait…… 但如果还觉得不够用,开发者还自己构建组件来实现。
举个例子,一个游戏里可能有成千上万个可交互实体,且随着运营要求会不断的更新和变化。但倘若用class实现这些实体的建模,就意味着这些实体都要进行硬编码。每次变更都要改代码,测试,发布才能上线。代价极其高昂。一个变通的手段可以定义一个class Entity,区分不同type,不同的type的entity可以有相同的属性,也可以有自定义的扩展属性。系统启动时从数据库中去加载所有定义的资源数据。而这些数据可以通过运营系统由游戏策划、美工、运营等录入,全程无需编码。在工程中,这比纯class的方法好得多。但这依然是“面向对象”。从游戏玩家角度,那些实体依然是实体,在互相砍杀,掉金币,开箱子;从技术角度,被渲染的Entity和渲染器,数值管理器等组件在不断通讯,实现系统功能。
总之,面向对象关键点是【设计】,而不是语法支持。要把用面向对象的思路去设计,和用特定的语法去解决特定的编码问题看做是两件完全不同的事情。对于那些语法用着合适就用,不合适就去实现适用的方案。
同时,也要避免总是用“模拟”,“比喻”的方式来设计程序。现实中,几乎不存在“幼儿园名词卡片”的程序。
使用面向对象就能得到更容易维护的代码吗?
面向对象对比面向过程的优势是在代码量多了之后,可以更好组织代码,实现更低的变更成本和变更风险。但这里的关键问题是要对代码进行【正确】的设计后才能对代码产生正面效果。错误的设计会导致更加难以理解和维护的代码。
面向对象本身并不能自动做【正确】的设计,还是要靠开发者自己。主要要靠两个方面。首先是,是开发者对问题本身的理解和未来演进方向的把握。俗称懂业务。这就是上面提到的题主要恶补的地方。比如一个做游戏的,表面主要规则就是游戏中的各种元素(如人物,boss,NPC),在一定的规则下完成交互,变更相关的数值(如血量,经验值,道具数量)的系统。但表面之下还有很多要素,如数据收集,可靠性(限流,容灾),运营,安全……。游戏不光是一个看起来的模拟器,而是一个需要精心考虑各种要素的系统。
第二个是面向对象设计的方法论。比如如何对一个业务进行抽象,哪些适合作为“实体”,哪些适合作为“事件”,一个业务规则适合实现在哪一层次等等。例如DDD就是这么一套方法论,指导开发复杂业务做出设计。但坦率来讲,我不觉得这个世界上存在通用的方法论。好的方法论总是在实践中不断打磨出来的,与要解决的问题的领域紧密相关。比如做游戏的,做电商的,做在线文档编辑的,做推荐系统的,会采用完全不同的方法。
Human.Attack(Monster)是面向对象吗?是好的写法吗?
首先,这的确是面向对象的一种写法。它表达了向Human发一个Attack消息,消息的参数是Monster这个对象。对这个消息的处理函数会对Human的内部状态做一些修改。
但现实中,这明显不是一种好的设计。
一般来讲A攻击B时,A和B的状态都会发生变化。而A.Attack(B)的语法造成这个函数会被定义到A里,修改A的状态。如果要改变B的状态,就得要求再来一次B.AttackedBy(A)。那么就有两种可能性,一种是在A.Attack(B)里的实现里写B.AttackedBy(A)。这明显是很不好的设计,因为攻击和被攻击从概念上是对等的。而A.Attack(B)里的实现里写B.AttackedBy(A)会把它变成包含关系。如果这样可以,似乎也没有什么可以阻止有人在D.AttackedBy(C)时调用C.Attack(D)。这样就会引发混乱。当然,可以规定“所有的AttackedBy”都必须包含在Attack里。但如果交互规则更复杂,这种规则就会更多,程序员就要背诵,且无法用编译器或者linter等来检查确保执行。
这里还有一个问题是如果A.Attack()用来调用B.AttackedBy(),就意味着A.Attack()可能是“主控逻辑”。但主控逻辑还有很多别的,除了A和B之外的逻辑要做,比如做鉴权,做统计……,与A本身毫无关系。此外这些逻辑放在A.Attack()里,那么是不是B.Attack()要放,C.Attack()要放……类似的逻辑会散的到处都是。修改这段逻辑会触发巨大的修改量。
因此,这里把A和B作为两个独立的对象来设计是非常不妥的。除了能满足初学者对于“比喻式编程”的盲目追求和满足感之外,没有任何实际的好处。
另外一种设计是,弄一个【攻击Handler】。这个攻击Handler接收主客体A和B(可以扩展到任意多个参数),根据攻击的处理逻辑,把A和B要变更的数值计算出来,然后告诉A和B该怎么变。
更抽象一点,这个Handler可以不只处理攻击,而是可以从存储中动态加载任意类型动作的处理逻辑(加载的逻辑可以用js或者lua)。这样上游就可以调用handle(action, params)
这样形式处理任意逻辑。且因为规则是动态加载的,可以实现在线热变更。而对于A和B的变更,如果A和B没有什么其他逻辑的话,就可以简化为直接对A和B存储进行变更的指令,而不需要浪费CPU和内存去创建A对象,然后再GC掉。比如set(makeKey(A), property_HP, -100)
表示扣掉A的HP 100点。
此时你说A不就没有封装性了吗?是的,没有了,因为业务需求上压根就不需要他有封装性。一个看得到的游戏人物,在显示器看起来就是一堆像素,在信号线里是一堆电信号,在数据库里是一行数据,在代码中是一个具有特定type的entity,在一个handler里是一个主体/客体参数+lua动态逻辑。整套系统里每个部分都用自己最舒服的形式抽象了这个“A”。但偏偏就是没有,也不需要对一个具体的A进行抽象——他不需要存在。而为啥要为一个不需要的东西增加封装特性呢?就因为语法支持?
为一个不需要的封装硬加一个封装是极其愚蠢的。
但如果说,这里对A的数值变化就一定需要额外逻辑,这些逻辑就是不能放到主handler里,一定要“封装”呢?没问题,我们可以给A的数值变化再做一个不同的handler,这个handler再去加载动态逻辑,和主handler的逻辑一起得到最终要变更的数值,再写入A的存储即可。
你可能说,那如果这些handler太多了,已经搞不清楚一次动作到底会触发多少个handler了怎么办?也很简单,每次请求产生一个唯一id,作为上下文,传递到每一个handler里。每个handler在执行时通过埋点/log上报自己的名称,参数和计算结果。这些上报信息被数据收集后就能画一个“链路图”。于是就可以非常清晰的看到实时的handler是怎么工作的。
所以你以为的面向对象是Human和Monster。而实际的对象是各种handler,存储IO组件和数据收集Agent。而后者的设计具有好的得多的灵活性和可维护性。
总之,编码问题不可能因为一句“面向对象”,或者“抽象”,“封装”,“多态”等特征或者语法就自动变成优秀的设计。用是不是面向对象来描述一段代码是否优秀是完全没有意义。