关于面向对象的基本事实
如果把我已经粗略接触到的编程范式分类的话,大概可以分为面向过程、面向对象。当然,还有仅仅听说过的函数式编程和很多没听说的。
面向过程编程
- 命令式编程语言就相当于冯诺依曼语言,也基本等同于通常所说的面向过程编程。
- 所有把修改变量的值当作最基本计算方式的语言都可以称作冯诺依曼语言,包括我们熟悉的C,Fortran等待。这类语言是建立冯诺依曼体系结构之上的。由于冯诺依曼体系结构,这类语言的核心有:模拟存储单元的变量,基于传输操作的赋值语句,以及迭代形式的循环运算。因此从某种程序上,这类语言是基于计算机的另一种数学模型(图灵机)的,实现了对计算机硬件结构的抽象。函数式语言的基础是具有值的表达式,而冯诺依曼语言的基础是语句(特别是赋值),他们通过修改存储器里面的值而产生副作用(side effect)的方法去影响后续计算。
简而言之,冯诺依曼语言核心:模拟存储单元的变量,基于传输操作的赋值语句,以及迭代形式的循环运算。是对图灵机(数学模型)的实现。把修改变量的值当作最基本计算方式。
面向过程编程的产生针对的问题
- 对于现在流行的面向对象编程,最开始的目的是“模拟”现实中的结构;其次是__降低GUI程序设计的门槛__,但这更多是一个实现问题,而不是设计问题。
- 面向对象始于__模拟__应用,后来被视为面向过程编程无法__向巨型项目扩展__绝症的解药。再以后被『发挥』到极致,不管适不适合都要用面向对象的方式去解决,应了那句老话『锤子眼里全是钉子』。
“状态”与面向对象编程
- 这个问题的根本在于 OOP 是基于状态的。每个对象都维护着自己的状态,暴露给外界的是一些可以改变对象状态的方法。一个对象的状态里可以有对其他对象的引用,一个对象的方法也可以调用其他对象的方法来改变其他对象的状态,所以这些状态还是关联的。
- 面向对象的核心是封装状态和相应的过程。通常面向对象是通过改变内部状态实现最终目的。调用对象过程的主要目的是产生改变其内部状态这个副作用(side effect)。这样封装的初衷是避免多个主体访问、修改同一状态造成混乱。在不少场合这样的封装确实也达到了目的,所以面向对象的方式才这么普及。
面向对象原本要解决什么
封装:封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项,或者叫接口。
有了封装,就可以明确区分__内外__,使得类实现者可以修改封装内的东西而不影响外部调用者;而外部调用者也可以知道自己不可以碰哪里。这就提供一个良好的合作基础——或者说,只要接口这个基础约定不变,则代码改变不足为虑。
继承+多态:继承和多态必须一起说。一旦割裂,就说明理解上已经误入歧途了。
先说继承:继承同时具有两种含义:
其一是继承基类的方法,并做出自己的扩展——号称解决了代码重用问题;
其二是声明某个子类兼容于某基类(或者说,接口上完全兼容于基类),外部调用者可无需关注其差别(内部机制会自动把请求派发[dispatch]到合适的逻辑)。
再说多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。
实践中,继承的第一种含义(实现继承)意义并不很大,甚至常常是有害的。因为它使得子类与基类出现强耦合。
继承的第二种含义非常重要。它又叫“接口继承”。
接口继承实质上是要求“做出一个良好的抽象,这个抽象规定了一个兼容接口,使得外部调用者无需关心具体细节,可一视同仁的处理实现了特定接口的所有对象”——这在程序设计上,叫做__归一化__。
归一化使得外部使用者可以不加区分的处理所有接口兼容的对象集合——就好象linux的泛文件概念一样,所有东西都可以当文件处理,不必关心它是内存、磁盘、网络还是屏幕(当然,如果你需要,当然也可以区分出“字符设备”和“块设备”,然后做出针对性的设计:细致到什么程度,视需求而定)。
归一化的实例:
a、一切对象都可以序列化/toString
b、一切UI对象都是个window,都可以响应窗口事件。——必须注意,是一切(符合xx条件的)对象皆可以做什么,而不是“一切皆对象”。后者毫无意义。
显然,归一化可以大大简化使用者的处理逻辑:这和带兵打仗是类似的,班长需要知道每个战士的姓名/性格/特长,否则就不知道该派谁去对付对面山坡上的狙击手;而连长呢,只需知道自己手下哪个班/排擅长什么就行了,然后安排他们各自去守一段战线;到了师长/军长那里,他更关注战场形势的转变及预期……没有这种层层简化、而是必须直接指挥到每个人的话,累死军长都没法指挥哪怕只是一场形势明朗的冲突——光一个个打完电话就能把他累成哑巴。
软件设计同样。比如说,消息循环在派发消息时,只需知道所有UI对象都是个window,都可以响应窗口消息就足够了;它没必要知道每个UI对象究竟是什么——该对象自己知道收到消息该怎么做。
合理划分功能层级、适时砍掉不必要的繁杂信息,一层层向上提供简洁却又完备的信息/接口,高层模块才不会被累死——KISS是最难也是最优的软件设计方法,没有之一。
什么是真正的封装?
——回答我,封装是不是等于“把不想让别人看到、以后可能修改的东西用private隐藏起来”?
显然不是。
如果功能得不到满足、或者未曾预料到真正发生的需求变更,那么你怎么把一个成员变量/函数放到private里面的,将来就必须怎么把它挪出来。
你越瞎搞,越去搞某些华而不实的“灵活性”——比如某种设计模式——真正的需求来临时,你要动的地方就越多。
真正的封装是,经过深入的思考,做出良好的抽象,给出“完整且最小”的接口,并使得内部细节可以对外透明(注意:对外透明的意思是,外部调用者可以顺利的得到自己想要的任何功能,完全意识不到内部细节的存在;而不是外部调用者为了完成某个功能、却被碍手碍脚的private声明弄得火冒三丈;最终只能通过怪异、复杂甚至奇葩的机制,才能更改他必须关注的细节——而且这种访问往往被实现的如此复杂,以至于稍不注意就会酿成大祸)。
一个设计,只有达到了这个高度,才能真正做到所谓的“封装性”,才能真正杜绝对内部细节的访问。
关于接口继承
接口继承真正的好处是什么?是用了继承就显得比较高大上吗?
显然不是。
接口继承没有任何好处。它只是声明某些对象在某些场景下,可以用归一化的方式处理而已。
换句话说,如果不存在“需要不加区分的处理类似的一系列对象”的场合,那么继承不过是在装X罢了。
封装可应付需求变更、归一化可简化(类的使用者的)设计:以上,就是面向对象最最基本的好处。
——其它一切,都不过是在这两个基础上的衍生而已。
关于设计
类似的,我遇到过写游戏的却去纠结“武器装备该不该从游戏角色继承”的神人。你觉得呢?
事实上,游戏界真正的抽象方法之一是:一切都是个有位置能感受时间流逝的精灵;而某个“感受到时间流逝显示不同图片的对象”,其实就是游戏主角;而“当收到碰撞事件时,改变主角下一轮显示的图片组的”,就是游戏逻辑。
看看它和“武器装备该不该从游戏角色继承”能差多远。想想到得后来,以游戏角色为基类的方案会变成什么样子?为什么会这样?
最具重量级的炸弹则是:正方形是不是一个矩形?它该不该从矩形继承?如果可以从矩形继承,那么什么是正方形的长和宽?在这个设计里,如果我修改了正方形的长,那么这个正方形类还能不能叫正方形?它不应该自然转换成长方形吗?什么语言能提供这种机制?
造成这颗炸弹的根本原因是,面向对象中的“类”,和我们日常语言乃至数学语言中的“类”根本就不是一码事。
面向对象中的“类”,意思是“接口上兼容的一系列对象”,关注的只不过是接口的兼容性而已(可搜索 里氏代换);关键放在“可一视同仁的处理”上(学术上叫is-a)。
显然,这个定义完全是且只是为了应付归一化的需要。
这个定义经常和我们日常对话中提到的类概念上重合;但,如前所述,根本上却彻彻底底是八杆子打不着的两码事。
就着生活经验滥用“类”这个术语,甚至依靠这种粗浅认识去做设计,必然会导致出现各种各样的偏差。这种设计实质上就是在胡说八道。