返回 登录
0

面向对象编程:一个灾难性的故事

在我的整个编程生涯中,我一直反复思考关于面向对象编程的问题:用还是不用。不过,去年我终于确定下来,决定不再使用面向对象编程,下面我会说明具体原因。

先讲一个小故事:

起初都是面条式代码(译注:spaghetti code指代码控制结构复杂、混乱而难以理解,尤其用了很多GOTO、例外、线程、或其他无组织的分歧架构)。

Dijkstra说:“要有结构式编程!应当考虑到goto的危害性,用恰当的控制流机制来组织代码、构建功能。”

程序员说:“没问题,当然要这样做。”

但之后Dijkstra发现代码还是面条式的,他呼吁:“停止乱七八糟的状态共用!应当避免使用全局变量,借调用图来传达所有的状态。”

程序员又说:“呃,嗯,等一下,真要这么做吗?我们还没真正想出来怎么使用这个函数式编程的玩意儿,也不想为如今机器的不可变数据支付开销,因此你提出的这个做法对于重要的项目来说,太过不切实际,也不方便。”

不过程序员确实承认:共用状态是有问题的,也许可以减少一些全局变量。

因此,面向对象编程诞生了,而全局变量伪装成单例对象字段之后,继续幸福地存在下去。

面向对象编程是什么?

在驳斥面向对象编程(OOP)之前,我先对它下个定义。OOP可归结为三样东西:多态、继承和封装。

无论是优是劣,多态无需真正与继承体系绑在一起,因此不应被认为是OOP独有的。
继承是(大多数)人从中惊醒的一个噩梦。

然后是封装;令人困惑的是,封装至少有两个不同的含义,我会分开来解释:

封装=管理状态

尽管在80年代末和90年代初,在可复用代码的旗帜下OOP十分流行;不过在最初70年代时,人们是希望按照所谓的“对象”将状态分类并导入单独盒来分类并处理状态的。

在最初的版本中,面向对象编程由对象图构成,每个对象都是指向自身的一个状态岛。各个对象不会直接读取或写入其他对象的状态,而是彼此发送消息。虽然消息可能触发接收对象的状态更新,接收对象也可能返回状态信息,但严格来讲,状态自身不会从一个对象发到另一个对象。简而言之,对象不会共用或分发引用给状态,而只会通过有状态数据的副本向彼此发送报告。这样一来,每个对象都保留着自己状态的独有控制权,就像网络上的计算机可能会共享存储的内容,但保留对自身存储的完全控制权一样。

到目前为止,这都是一个相当简单的故事。但如果我们认真思考“状态岛”这个概念,最终面对的不是一张状态对象图,而是层次严格的结构组成。程序状态均以单独的根对象为结束,而根对象本身又由有状态的对象组成,因此反过来都是由有状态的对象组成。这个层级的对象可以将消息传递给最近的子对象,而不是发给ancestor、sibling或further descendant。

在结构化方面,什么都比不上一个干净的层次结构。然而问题在于,尽管将程序状态呈现为结构层次非常容易,但OOP需要我们在同样的层次结构中将程序状态操作结构化,在面对数量可观的状态时情况会完全不同。只要我们有横切关注点,相应操作就会涉及到多个非直接相关的对象,而该操作应当留存在那些对象的共同ancestor中。这不仅意味着相应操作通常结束在不够直观的地方,还代表这些操作需要一系列相关操作,才能达到相关的状态。

当然在实践中,程序员一般会通过自认为更简单的办法——随意共用对象引用来避免这种麻烦的模式,不过这样一来包含状态的严格层级结构就会完全被打破。真实情况下,面向对象代码往往充斥着一堆乱七八糟的泄漏封装对象,其中每个对象都可能与其他对象有串联。当猫狗对象共处一室时,混乱就成为主基调;状态封装就在程序员遇到真实成本的那一刻就不翼而飞。

即便没有并发性问题,在构建可变对象的大型对象图时,大型OOP程序也会遇到日益复杂的问题。需要了解并牢记在心:在调用方法时会发生什么事情,负面效应是什么?——Rich Hickey(Clojure之父)

现在得出正确结论:

在层级结构中的封装状态并非不好,相反在某种程度上只要有状态,所有状态理论上应当是封装的,而有状态的对象理论上应当由严格的层次构成,而不是自由形态的图表。但要注意,这里的关键在于:“在某种程度上”。想要不留后患地成功管理状态,应当保持对象层次尽可能单薄。

要想做到这一点,我们需要尽可能减少状态的使用,而其中的典范显然是函数式编程。因此在状态管理的问题上,选择由“所有东西都是对象”所构成的OOP是个错误的解决方案,但OOP至少(本来)尝试解决的问题方向是正确的。

面向对象的编程通过封装移动的部分来令代码可被理解,而函数式编程通过将移动的部分最小化来令代码可被理解。——Michael Feathers @mfeathers

封装=数据的相关行为

下面是封装的另一个问题:

在OOP从业者中,有一定程度的思想流派之争:程序的行为应当表示为类方法还是独立功能?例如很多JS代码使用很少使用方法,而另一方面很多Java代码广泛使用方法。然而,前者的风格仅仅是毫无意义,后者却成了一场灾难。

就如历史所述,在我们的问题领域面向对象设计首先识别出数据实体,然后识别出每个实体的相关行为。举个例子,确定需要一个消息类型,定位其字段,然后应当想到该如何利用消息,并通过该类中的方法来实现。

不过在考虑到代码所需要的功能时,许多行为本质上是横切关注点的,因此不真正属于某个具体的数据类型。但这些行为必须存在于某处,因此我们最终用一堆混杂的无意义Doer类来存放它们。通过封装功能到更多对象中,不仅导致结构很不直观,还增加了需要管理的状态,从而产生负担。(而这些无意义的实体习惯于产生更多无意义的实体:在有无数个Manager对象时,就需要一个管理Manager的管理器。)

思考这个基础问题:一个Message应当发送自身么?“发送”是我们希望Message执行的关键任务,因此Message对象肯定有一个“发送”方法,没错吧?如果Message不发送自身,那么必须由其他对象来完成发送任务,比如某些尚未创建的Sender对象。且慢,每个发送的Message需要一个Recipient,因此Recipient对象应当包含“接收”方法。

这是对象分解的核心难题。每个行为都可以围绕主语、动作和对象来重新语境化。Sender可以发送消息给Recipient;Message可以发送自身给Recipient;而Recipient可以接收消息。

现在你可能还认为其中一个方案是最自然的。也许如此。但消息发送是一个相对具体的动作。我们关于代码所做的大多事情都是高度精炼的,几乎没有任何“自然”责任分工,这就解释了为什么针对同一个重大问题,从来没有两个程序员给出两个相同的对象设计。

当然在功能分解上,也没有两个程序员能以相同的方式将工作划分为功能。然而:

  • 与对象不同,普通函数不需要管理,也不需要安置。
  • 较之在类中移动方法所需要的,重组功能所需的数据重组更少。

面向对象编程应当协助我们管理熟悉对象的状态与模型问题,但的确很容易将行为放入错误的对象,从而产生Frankenstein实体这样没有自然意义的实体。实际上造成了状态扩散与杂乱的状态共用),结果很快就以过于复杂和混乱的代码而告终。

因为很容易错置责任,面向对象设计无法很好的兼容增量式设计。没错,理论上完全分解类在补充额外类上十分简单,但事实上类分解并不完美,每个新类和新方法会导致更多混乱,为稍后的重组带来更大工作量。

即便在功能的责任分工上只是次佳选择的面向过程代码(特别是纯功能代码),都会有效地增加新功能和数据类型,而不会导致现有的代码变得混乱。相比之下,在采用面向对象设计时,程序员更容易缺乏前瞻性思维而受到惩罚。

设计瘫痪

多年来,我一直将面向对象编程奉为金科玉律、正确的编程方式,尽管我一直觉得自己编写的每个类和方法都会给自己带来问题,每个可能的对象分解也很可能引起争议、需要重构。将每个问题与类型相匹配就像在玩没有答案的骗人游戏。

现在我已经不再盲目追逐“合适”的对象分解,结果却更开心也更有效率了。当然在面向过程编程中,也没有合适的分解方案。不过在面向过程编程中,我不再感觉自己只是为结构增加层次,结果却没什么回报,只是增加复杂度和代码混乱性了。

原文链接:Object-Oriented Programming: A Disaster Story(译者/Vera 责编/钱曙光)


(责编/钱曙光,关注架构和算法领域,寻求报道或者投稿请发邮件qianshg@csdn.net,交流探讨可加微信qshuguang2008,备注姓名+公司+职位)

「CSDN 高级架构师群」,内有诸多知名互联网公司的大牛架构师,欢迎架构师加微信qshuguang2008入群,备注姓名+公司+职位。

评论