面向对象编程(OOP),是一种设计思想或者架构风格。OO 语言之父 Alan Kay,Smalltalk 的发明人,在谈到 OOP 时是这样说的:
I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning – it took a while to see how to do messaging in a programming language efficiently enough to be useful).
…
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.
简单解释一下上面的这几句话的大概意思:OOP 应该体现一种网状结构,这个结构上的每个节点“Object”只能通过“消息”和其他节点通讯。每个节点会有内部隐藏的状态,状态不可以被直接修改,而应该通过消息传递的方式来间接的修改。
这个编程思想被设计能够编写庞大复杂的系统。
那么为什么 OOP 能够支撑庞大复杂的系统呢?用开公司举个例子。如果公司就只有几个人,那么大家总是一起干活,工作可以通过“上帝视角“完全搞清楚每一个细节,于是可以制定非常清晰的、明确的流程来完成这个任务。这个思想接近于传统的面向过程编程。而如果公司人数变多,达到几百上千,这种“上帝视角”是完全不可行的。在这样复杂的公司里,没有一个人能搞清楚一个工作的所有细节。为此,公司要分很多个部门,每个部门相对的独立,有自己的章程,办事方法和规则等。独立性就意味着“隐藏内部状态”。比如你只能说申请让某部门按照章程办一件事,却不能说命令部门里的谁谁谁,在什么时候之前一定要办成。这些内部的细节你管不着。类似的,更高一层,公司之间也存在大量的协作关系。一个汽车供应链可能包括几千个企业,组成了一个商业网络。通过这种松散的协作关系维系的系统可以无限扩展下去,形成庞大的,复杂的系统。这就是 OOP 想表达的思想。
第一门 OOP 语言是 Ole-Johan Dahland 和 Kristen Nygaard 发明的 Simula(比 smalltalk 还要早)。从名字就可以看出来,是用来支撑“模拟系统”的。模拟这个场景非常适合体现 OOP 的这个思想。这个语言引入了 object、class、subclass、inheritance、动态绑定虚拟进程等概念,甚至还有 GC。Java 很大程度上受了 Simula 的影响。我们在现在教书上讲解 OOP 类、实例和继承关系时,总会给出比如动物-猫-狗,或者形状-圆-矩形的例子,都源自于此。
还有一些带有 OO 特征的语言或者研究成果在 Simula 之前就出现,这里就不往前追溯了。
但随后在施乐 Palo Alto 研究中心(Xerox PARC),Alan Kay、Dan Ingalls、Adele Goldberg 在 1970 年开发了 smalltalk,主要用于当时最前沿计算模型研究。在 Simula 的基础之上,smalltak 特别强调 messaging 的重要性,成为了当时最有影响力的 OOP 语言。与 smalltalk 同期进行的还有比如 GUI、超文本等项目。smalltalk 也最早的实现了在 GUI 使用 MVC 模型来编程。
但是,并不是说 OOP 程序一定要用 OOP 语言来写。再强调一下,OOP 首先是一种设计思想,非仅仅是编码方式。从这个角度推演,其实 OOP 最成功的例子其实是互联网。(Alan Kay 也是互联网前身ARPNET 的设计者之一)。另外一个 OOP 典型的例子是 Linux 内核,它充分体现了多个相对独立的组件(进程调度器、内存管理器、文件系统……)之间相互协作的思想。尽管 Linux 内核是用 C 写的,但是他比很多用所谓 OOP 语言写的程序更加 OOP。
现在很多初学者会把使用 C++,Java 等语言的“OOP”语法特性后的程序称为 OOP。比如封装、继承、多态等特性以及 class、interface、private 等管家你在会被大量提及和讨论。OOP 语言不能代替人类做软件设计。既然做不了设计,就只能把一些轮子和语法糖造出来,供想编写 OOP 程序的人使用。但是,特别强调,是 OOP 设计思想在前,OOP 编码在后。简单用 OOP 语言写代码,程序也不会自动变成 OOP,也不一定能得到 OOP 的各种好处。
我们在以为我们在 OOP 时,其实很多时候都是在处理编码的细节工作,而非 OOP 提倡的“独立”,“通讯”。以“class”为例,实际上我们对它的用法有:
- 表达一个类型(和父子类关系),以对应真实世界的概念,一个类型可以起到一个“模版”的作用。这个类型形成的对象会严格维护内部的状态(或者叫不变量)
- 表达一个 Object(即单例),比如 XXXService 这种“Bean”
- 表达一个名字空间,这样就可以把一组相关的代码写到一起而不是散播的到处都是,其实这是一个“module”
- 表达一个数据结构,比如 DTO 这种
- 因为代码复用,硬造出来的,无法与现实概念对应,但又不得不存在的类
- 提供便利,让 foo(a) 这种代码可以写成 a.foo() 形式
其中前两种和 OOP 的设计思想有关,而其他都是编写具体代码的工具,有的是为了代码得到更好的组织,有的就是为了方便。
很多地方提及 OOP = 封装+继承+多态。我非常反对这个提法,因为这几个术语把原本很容易理解的,直观的做事方法变的图腾化。初学者往往会觉得他们听上去很牛逼,但是使用起来又经常和现实相冲突以至于落不了地。
“封装”,是想把一段逻辑/概念抽象出来做到“相对独立”。这并不是 OOP 发明的,而是长久以来一直被广泛采用的方法。比如电视机就是个“封装”的好例子,几个简单的操作按钮(接口)暴露出来供使用者操作,复杂的内部电路和元器件在机器里面隐藏。再比如,Linux 的文件系统接口也是非常好的“封装”的例子,它提供了 open,close,read,write 和 seek 这几个简单的接口,却封装了大量的磁盘驱动,文件系统,buffer 和 cache,进程的阻塞和唤醒等复杂的细节。然而它是用函数做的“封装”。好的封装设计意味着简洁的接口和复杂的被隐藏的内部细节。这并非是一个 private 关键字就可以表达的。一个典型的反面的例子是从数据库里读取出来的数据,几乎所有的字段都是要被处理和使用的,还有新的字段可能在处理过程中被添加进来。这时用 ORM 搞出一个个实体 class,弄一堆 private 成员再加一堆 getter 和 setter 是非常愚蠢的做法。这里的数据并非是具有相对独立性的,可以进行通讯的“Object”,而仅仅是“Data Structure”。因此我非常喜欢有些语言提供“data object”的支持。
当然,好的 ORM 会体现“Active Record”这种设计模式,非常有趣,本文不展开
再说说“继承”,是希望通过类型的 is-a 关系来实现代码的复用。绝大部分 OOP 语言会把 is-a 和代码复用这两件事情合作一件事。但是我们经常会发现这二者之间并不一定总能对上。有时我们觉得 A is a B,但是 A 并不想要 B 的任何代码,仅仅想表达 is-a 关系而已;而有时,仅仅是想把 A 的一段代码给B用,但是 A 和 B 之间并没有什么语义关系。这个分歧会导致严重的设计问题。比如,做类的设计时往往会希望每个类能与现实当中的实体/概念对应上;但如果从代码复用角度出发设计类,就可能会得到很多现实并不存在,但不得不存在的类。一般这种类都会有奇怪的名字和非常玄幻的意思。如果开发者换了个人,可能很难把握原来设计的微妙的思路,但又不得不改,再稳妥保守一点就绕开重新设计,造成玄幻的类越来越多……继承造成的问题相当多。现在人们谈论“继承”,一般都会说“Composite Over Inheritance”。
多态和 OOP 也不是必然的关系。所谓多态,是指让一组 Object 表达同一概念,并展现不同的行为。入门级的 OOP 的书一般会这么举例子,比如有一个基类 Animal,定义了 run 方法。然后其子类Cat,Dog,Cow 等都可以 override 掉 run,实现自己的逻辑,因为 Cat,Dog,Cow 等都是 Animal。例子说得挺有道理。但现实的复杂性往往会要求实现一个不是 Animal 的子类也能“run”,比如汽车可以 run,一个程序也可以“run”等。总之只要是 run 就可以,并不太在意其类型表达出的包含关系。这里想表达的意思是,如果想进行极致的“多态”,is-a 与否就不那么重要了。在动态语言里,一般采用 duck typing 来实现这种“多态”——不关是什么东西,只要觉得他可以 run,就给他写个叫“run”的函数即可;而对于静态语言,一般会设计一个“IRun”的接口,然后 mixin 到期望得到 run 能力的类上。简单来说,要实现多态可以不用继承、甚至不用 class。
OOP 一定好吗?显然是否定的。回到 OOP 的本心是要处理大型复杂系统的设计和实现。OOP 的优势一定要到了根本就不可能有一个“上帝视角”的存在,不得不把系统拆成很多 Object 时才会体现出来。
举个例子,smalltalk 中,1 + 2 的理解方式是:向“1”这个 Object 发送一给消息“+”,消息的参数是“2”。的确是非常存粹的 OOP 思想。但是放在工程上,1 + 2 理解为一般人常见的表达式可能更容易理解。对于 1 + 2 这样简单的逻辑,人很容易从上帝视角出发得到最直接的理解,也就有了最简单直接的代码而无用考虑“Object”。
如果是那种“第一步”、“第二步“……的程序,面向数据的程序,极致为性能做优化的程序,是不应该用 OOP 去实现的。但很无奈如果某些“纯 OOP 语言”,就不得不造一些本来就不需要的 class,再绕回到这个领域适合的编码模式上。比如普通的 Web 系统就是典型的“面向”数据库这个中心进行数据处理(处理完了展示给用户,或者响应用户的操作)。这个用 FP 的思路去理解更加简单,直观。也有 MVC,MVVM 这样的模式被广泛应用。
还有一些领域尽管用 OOP 最为基础很适合,但是根据场景,已经诞生出了“领域化的 OOP”,比如 GUI 是一个典型的例子。GUI 里用 OOP 也是比较适合的,但是 GUI 里有很多细节 OOP 不管或者处理不好,因此好的 GUI 库会在 OOP 基础之上扩展很多。早期的 MFC,.Net GUI Framework, React 等都是这样。另外一个领域是游戏,用 OOP 也很合适,但也是有些性能和领域细节需要特殊处理,因此ECS 会得到广泛的采用。
总结一下,OOP 是众多设计思想中的一种。很多 OOP 语言把这种思想的不重要的细节工具化,但直接无脑应用这些工具不会直接得到 OOP 的设计。即便是 OOP 思想本身也有其适合的场景和不适合的场景。即便是适合的场景,也可能针对这个场景在 OOP 之上做更针对这个场景需求的定制的架构/框架。如果简单把 OOP 作为某种教条就大大的违反了这个思想的初衷,也只能得到拧巴的代码。
面向对象编程是一种处理复杂问题的设计工具,本身没有什么好坏之分,只有用的好坏之分。但面向对象的问题在于长期以来的技术环境、编程语言、一些工具的推广、培训和教育都大大的过分乐观的强调了面向对象编程本身可以带来的好处。以至于很多学习编程的人都深深的相信“只要用了面向对象编程(以及基于其基础之上的的一系列设计模式、规范、工具、框架),就能得到非常容易维护、可以复用、明晰可理解的代码“。
但, 这并不是真的 。
如果你经历过很多,就会发现“只要如何如何,就一定能如何如何”这个提法一旦出现,基本上就不靠谱,不管是编程还是别的什么事情。
在大量的场景中,可以偏执的认为“万物皆对象”(或者万物皆别的什么),但是哲学上的单纯并不一定能让现实中的工程变得更“好”。如果说非得有个“万物皆 XX”,那么这个 XX 八成就是根据众多需求综合到一起的 “折衷” 。
简单从工程讲的话,如果程序(或者说工作)是一次性的,那么怎么写得快,能 work 就怎么来。这个相对好理解。但是,如果程序是要长期维护的,那么 如何管理其复杂性 是核心的问题。而管理复杂性的要点在于
- 让事情本身变得简单。这说白了就是砍需求,研发和 PM 之间要经常沟通去避免 nice to have 的需求变动带来的程序复杂性的剧烈变化(比如一个 1 对 1 的实体关系,需求变动一点就变成了麻烦的多的“有时 1 对 1,有时 1 对多”的混合关系)。
- 运用隔离的手段将复杂性拆解为互相影响很小的单元。一个单元对外只暴露一个简单的“接口”,隐藏内部复杂性。这就是“抽象”或者“封装“的力量。但是问题在于,这个抽象本身是否做的合适是由于问题决定的,而不是代码本身决定的。
即便是抽象,也有很多种做法。可以定义一组接口,这个接口是一组函数、一组服务的 RPC 还是一个 class 的 public method 都可以根据实际情况商讨。面向对象只是这里面其中一种做法而已。一个想要把程序编好的人,需要注重的是理解问题,然后尝试做出几种不同的抽象,评估各自优缺点后得到一个当时可行解的能力。而现有的大环境、教育体系,没有那么多真实的、复杂的案例,只能用一些简单的 sample code 来教授。并且在说明问题本身时,简化问题本身,而突出代码设计的“模式”。这就好像是在用视频教人游泳一样。学习者自己需要认识到这些培训只是个参考,玩真的还是要到项目里去体会。
即便是用面向对象做抽象也会有问题。很多时候,面向对象编程并不是一种好的“抽象”。如果抽象做得好,透过抽象出来的“接口”就可以轻易的使用这个系统。这时“大量的复杂性”被隐藏到接口后的实现里。这就像是你看电视从来都不需要拆开壳子看里面液晶屏幕和视频信号的转换,只需要知道【电源】、【调台】、【调音量】就能用。一个抽象做得好,往往要“deep”,隐藏足够的复杂度。而面向对象的文化/教育往往会鼓励程序员做很多无意义的,无性价比的抽象。看看有些代码里完全不知所云的 adaptor,factory,builder 等就是这种做法的产物。
此外,在大量使用继承作为设计方法时,也没有起到任何实质的隔离作用。如果你尝试扩展一个继承体系,往往需要了解整个继承体系才能写对代码——这时,复杂性并没有被隐藏起来。你也许只是代码写的少了而已。对于这种 复杂度没有降低,编写代码只是写的少,但是要看懂还是得结合整个体系才能做到的方式,不是抽象,是“压缩”。 压缩只能少写代码,却会让系统更难以理解了。
也许不太容易理解压缩在这里意思。比如在一段被压缩的数据中有 3 个 bytes 是“A”,“1”, “8”。但是他们的意思可能是 A 连续出现 18 次,也许是 A1 连续出现 8 次。至于到底是哪个意思,必须从头读所有的数据才能弄明白。编码也是这个道理。
再说说类型本身。一些面向对象编码对类型的定义要求的比较严格。其本质假设是“如果一个 Object 的类型是 XXXX”,则其行为模式必然是“YYYY”。但现实当中,一个 Object 的行为模式不光与他的类型有关,还与这个 Object “如何被使用”有关。比方说,一个 User 的 Object,如果是用户自己看自己,就可以登陆、登出,修改昵称;如果是其他普通用户看,就只能看看看昵称和头像;如果是管理员来操作,可以 reset 密码、注销或者踢出登陆。这时就得界定一个 Scope,来说明现在的 User 到底是哪个 scope 的 User。DDD 的一些理念就源自于此——找到某个上下文的某个实体概念,不能有歧义。但是即便不用 DDD,也必须用各种变通的手段,把“如何用”的信息与类型信息结合到一起来实现逻辑。很郁闷的是,这个“如何用”完全没有章法,可能是“iOS App登陆“,也可能是“第一次下单时”,或者是“系统处于降级状态”时。你永远也猜不到下一次可能会有个什么条件是要纳入到上下文的。大家都知道大量用 if 不好,容易让代码变成麻花,无法维护。但面向对象编程本身没解决这个问题。很多文章提出面向对象某个模式可以少写 if,让代码容易维护。但是这其实是建立在那个问题的上下文已经明确的基础之上。上下文易变的问题没有解决,换一个上下文,招数便不灵了,到时还得处理一坨“模式代码”,非常恶心。
最后,面向对象会倾向于将不同的代码抽象为不同相互作用的 Object,但是有一些现实因素会让这么面向对象得到非常不理想的效果:
- 安全 - 如果你的代码要求非常安全,那么所有的 Object 都要耦合安全控制的代码;要不就是在一层对外的接口之前拦截一道处理安全问题,内部 Object 都无视安全问题。这也就相当于放弃了一部分的安全性。
- 性能 - 如果强调性能的话,是要尽量减少隔离的层次的。无论抽象如何做,只要隔离发生,就要经历一次转换以及相应的性能损耗。比如早期的 Hibernate 不支持“bulk insert”和“bulk update”,只能逼着程序员做 for loop IO;而 native 的 sql 却可以轻易办到。在每多一次IO都很伤的场景下,这种隔离只能把事情做的更糟。
- 数据为中心 - 很多业务场景都是以数据为中心。也就是说DB里的那坨数据是唯一的 truth。在代码层面做的只是为处理数据更加方便。这时做的很多抽象意义不大。比如你可以在 ORM 层强制声明读取出来的一个数据少了某个字段是 invalid 的。但是你没法阻止你的第三方数据提供商源给你 invalid 的数据。对 Invalid 数据的处理远不是一个 Annotation 就能搞定的,必须引入复杂的业务流程。
- 灵活性和成本 - 每次做某种抽象都意味着对一个系统“要做某种变化的能力做出优化”,但是同时,也就意味着或多或少对其他种变化适应性做“劣化“。如果系统变化的方向和预期的不一致,那么浪费掉的工作不说,为了再次调整设计方向的代价也会相当的大。这种情况比比皆是。
总结下,我希望所有的程序员都要理解自己的工作的最终目的是干什么的,并且活用自己所能用到的一切工具来达成自己的目标。不要在各种编程范式里迷了路。如果是初学编程的人,我衷心的希望你的编程课程讲授的是解决一些实际的问题,多了解业务,多尝试对业务的变动作出合理和准确的预。不要过早的接触高层的思想和哲学层面的问题——一个小孩看《红楼梦》又能真的看懂多少呢。
P.S. 回到面向对象编程的本身,我这里有一篇回答比较详细的解释了一下