Hello C# (一)

 

编程范式探讨 之 面向对象编程

面向对象编程(Object Oriented Programming, OOP)的威力大家已经有目共睹,以类和对象为核心的抽象方式,可以把生活中绝大部分实体抽象成一个类,能很好的化简实际生活中的各种问题。然而,这只是一种思维范式,程序完全可以在脱离面向对象的情况下被写出来。

本文将从 OOP 的一些机制着手,反推设计者的动机,最终目的是探求并阐述现代的面向对象为何被设计成这种形式。以及何为 OOD ,何为符合 OOD 规范的 OOP 代码。 这将是一个系列文章,而此篇就作为该系列的开头。

前言

面向对象编程范式的三大支柱:继承、封装和多态。

借着这三个概念,我们几乎可以将现实世界的一切映射到一个类中去。

但是现代 OOP 编程语言,在围绕着三大支柱发展的同时,加入了大量的修饰。这是因为面向对象并不是那么完美,现实实体的复杂性也不仅仅是一个类能够描述的。

下面,将先从面向对象的一些缺陷谈起,逐渐引入委托和接口的设计动机。

继承

作为 OOP 最大优势的——继承,可以十分方便的实现代码复用,以及子类对父类功能的扩充。借助继承,我们可以很大程度上避免重复劳动。

这对新手程序员来说相当的重要,因为他们很容易写出只能使用一次的代码。等到遇到相似情况,就会从头再来一遍,但是却只修改了很小的一部分。而继承机制将会引导着他们走向代码复用的道路。

15-inherit

但是,过度的继承与滥用继承机制,将会导致代码复用的灾难。为代码复用而生的继承为何会导致代码复用的灾难呢?

香蕉猴子丛林问题

当有一个新的项目,它让你想起了另一个项目里你很喜欢的那个类。

没问题,复用拯救一切。我只需要把那个类拿过来用就好了。

嗯……其实……不仅是那一个类。还得把父类也拿过来。但……应该就可以了吧。

额……不对,似乎还需要父类的父类……还有……嗯,我们需要所有的祖先类。好吧好吧……搞定了。没问题。

不错。但编译不过,怎么回事?哦我知道了……这个对象还需要另一个对象。所以那个也得拿过来。没问题……

等等……我不仅需要那个对象,还需要那个对象的父类,和父类的父类,和……包含的所有对象的所有祖先……

唉…… 😭

Erlang 的创建者 JoeArmstrong 有句名言:

面向对象语言的问题在于,它们依赖于特定的环境。你想要个香蕉,但拿到的却是拿着香蕉的猩猩,乃至最后你拥有了整片丛林。

🙉🙉🙉

香蕉猴子丛林的解决方法

这个问题的解决方法是,不要把类层次建得那么深。但如果继承是重用的关键,那么给继承机制添加的任何限制都会限制重用。对吧?

没错。

那面向对象程序员该怎么办?

答案就是引入包含和委托(Contain and Delegate)。一会儿会详细解释。

菱形继承问题

考虑如下类继承关系:

15-2

请看伪代码:

Class PoweredDevice {
}
Class Scanner inherits from PoweredDevice {
  function start() {
  }
}
Class Printer inherits from PoweredDevice {
  function start() {
  }
}
Class Copier inherits from Scanner, Printer {
}

ScannerPrinter 同时实现了 start() 方法,那么 Copier 到底继承哪一个 start() 方法呢?

菱形继承的解决

没错。大多数面向对象都不让你这么干。

但是,但是……要是必须这样建模该怎么办?我需要重用!

那就必须使用包含和委托。

Class PoweredDevice {
}
Class Scanner inherits from PoweredDevice {
  function start() {
  }
}
Class Printer inherits from PoweredDevice {
  function start() {
  }
}
Class Copier {
  Scanner scanner
  Printer printer
  function start() {
    printer.start()
  }
}

注意现在 Copier 类包含一个 Printer 实例和一个 Scanner 实例。然后将 start 函数委托给 Printer 类的实现。要委托给 Scanner 也很简单。

脆弱的基类问题

基类的改动,有可能造成继承类的错误。

如果一旦会发生这种现象,那么继承类的作者必须清楚基类是怎样实现的。而且,基类的每个改动必须要通知所有继承类的作者,因为这些改动可能会以不可预知的方式破坏继承类。

唉!这个巨大的裂隙威胁到了整个继承支柱的稳定。

脆弱的基类的解决办法

这个问题还得要包含和委托来解决。

使用包含和委托,可以从 白盒编程转到黑盒编程 。白盒编程的意思是说,写继承类时必须要了解基类的实现。

而黑盒编程可以完全无视基类的实现,因为不可能通过重载函数的方式向基类注入代码。只需要关注接口即可。

层次结构的问题

每到一个新公司时,我都要为在哪儿保存公司文档(即员工手册)而纠结。

是应该建一个 Documents 文件夹,然后在里面建个 Company 呢?

还是应该建个 Company 文件夹,然后在里面建个 Documents 呢?

两者都可以。但哪个是正确的?哪个更好?

层次分类的思想是因为基类(父类)更通用,继承类(子类)更专用。沿着继承链越往下走,概念就越专用(见上面的形状层次)。

但如果父节点和子节点能随意交换位置,那么显然这种模型是有问题的。

层次结构的解决

那层次分类应该用在哪里?

包含关系。

真实世界里有很多包含关系(或者叫做独占关系)的层次结构。

但你找不到层次分类。仔细想一下。面向对象范式是根据充满了各种对象的真实世界建立的。但它用错了模型——层次分类在真实世界中没有类比。

但真实世界里到处都是层次包含关系。层次包含关系的一个非常好的例子就是你的袜子。袜子放在装袜子的抽屉里,然后抽屉包含在衣柜里,衣柜包含在卧室里,卧室包含在房子里,等等。

硬盘上的目录也是层次包含关系的另一个例子——它们包含文件。

那我们该怎样分类呢?

仔细想一下公司文档,就会发现其实放在哪儿都无所谓。我可以放在 Documents 目录下或者放在 Stuff 目录下也可以。

我选择的分类法是标签。我给它加上不同的标签。

Document
Company
Handbook

标签是没有顺序或层次的(这同时解决了菱形继承问题)。

标签可以类比为接口,因为同一份文档可以有多种类型。

委托

上文中引出了面向对象编程中一些现有逻辑比较难以处理的问题,增加新的概念将暂时解决这些问题。

本文的主要目的是罗列 OOP 的差评,为了不使文章产生太过强烈的割裂感(因为下面的内容是我抄的),所以委托机制的着重讲解将放在该系列的第二篇文章

面向对象编程的一些思考

面向对象的弊端

OO的弊端就是:设计抽象和封装的时间远远超过你解决问题的时间。 对OO来说,最好只是把它看作一种语言工具,而不是模型工具。

另一弊端在于作为一种建模技术没有很好的 定义自己的适用范围。面向对象脱胎的环境有两个重要因素,一是基于 WIMP (Window, Icon, Menu, Pointer) 的图形化界面,二是早期提供图形界面接口的机器缺乏代码级别之外的组件管理方式 (比如 Unix 的进程和 IPC)。

面向对象在 WIMP 的环境中是很必要也是很成功的。原因是 WIMP 环境需要重量的实现继承提供的重用,WIMP 的对象种类能很好的被单继承模拟,WIMP 的属性和类别容易区分。而面向对象扩展到 WIMP 之外的环境中就失败了:

  1. 实际世界是多纬度的,属性和类别不好区分。红苹果是 color 属性为 red 的苹果,还是 Apple 的子类?

  2. 实际世界的工具是用来完成任务的。而不是象 WIMP 那样构建一个虚拟的空间化界面。
  3. 《人月神话》指出,编写 reusable code 比编写普通 code 至少要多花三倍的工作量。而面向对象的模糊了代码的重用和使用。使被重用的代码的依赖复杂化。导致很多不适合被重用的代码被重用。编写代码时要过分考虑重用的可能性。
  4. 其它管理复杂度的机制越来越流行。

面向对象的编程是思想和理论吗?

在陈昊的《对象已死》一文中,作者认为:

在这个年代,大家有一种神圣化面向对象技术的倾向,很多人都把对象技术奉为高深的思想和理论。但实际上,面向对象技术仅仅一种 工程实践而已,它是依托于其他技术而存在的一种实践,本身并不是一种完备的计算模型。 对于可计算性问题的研究和发展,大抵确立了几种的计算模型:递归函数类、图灵机、Lambda演算、Horn子句、Post系统等等。但是面向对象始终没有一个自己的计算模型。 作者认为有两种不同的“面向对象”技术。其中一种是用来解决如何构造更好的类型系统的,它是以抽象数据类型( ADT,Abstract Data Type)为源起。另一种是用来对函数和副作用进行有效模块化和局部化的数据抽象。静态类型的函数语言未来可能成为主流。

对OO来说,最好只是把它看作一种语言工具,而不是模型工具。

那么,以对象驱动的代替方案比较可靠的是那些呢?其实,Unix 早就给出了答案——数据驱动编程(详见:《Unix 编程艺术》的第 9.1 章),更好的架构应该是数据驱动式的。

面向对象要解决的问题

面向对象始于模拟应用,后来被视为面向过程编程无法向巨型项目扩展绝症的解药。再以后被『发挥』到极致,不管适不适合都要用面向对象的方式去解决,应了那 句老话『 锤子眼里全是钉子』。基本上代码里面出现诸如 Executor.execute() 类似表达时,它在面向对象这条歧途上就已经走得太远了。

设计模式和面向对象的关系

那 23 个经典的设计模式和 OO 半毛钱关系没有,只不过人家用 OO 来实现罢了。设计模式就三个准则:1)中意于组合而不是继承,2)依赖于接口而不是实现,3)高内聚,低耦合。你看,这完全就是 Unix 的设计准则。

即使是在面向对象热潮达到顶峰的时候,我就坚称,并不是任何东西都可以成为对象,各种模式的混合使用在过去,在将来,都是正确之道。即使现在函数式编程正 趋于流行、使用量增加的情况下仍然会是这种情况。那些古老的编程模式会完全的消失吗?我不这么认为。以后大家都不用对象了吗?不可能的。我想,那些大学, 特别是声称 “提供教育而培训” 的大学其实是对学生的一种伤害,他们没有看到当今这个领域的真实情况。不祥之兆不断显现,函数式编程日显重要。但忽略面向对象编程不会给任何人带来好处。只是我的个人意见。

总结

面向对象的编程只是一种编程范式 [paradigm describes distinct concepts or thought patterns.],也是一种程序开发方式。编程范式在维基百科中如下解释 – 编程范型或编程范式(范即模范之意,范式即模式、方法),是一类典型的编程风格,是指从事软件工程的一类典型的风格(可以对照方法学)。如:函数式编程、程序编程、面向对象编程、指令式编程等等为不同的编程范型。当我们提到面向对象的时候,它不仅指一种程序设计方法。它更多意义上是一种程序开发方式。在这一方面,我们必须了解更多关于面向对象系统分析和面向对象设计(Object Oriented Design,简称 OOD)方面的知识。

面向对象的编程仅仅是一个工程实践,而不是一种原理(principle),和 Model 也不是同样的概念。Model 是为了对复杂的事物做出抽象而建立的原型,面向对象的分析和设计中可能需要建模。而建模在各种泛型的编程中都存在。

编程的目的,是为了解决人们的问题,或者实现一个功能,解决问题的实体叫做软件。对于软件的构建方式,面向对象的编程范型和其他编程范型一样,只是一种方法而已。解决复杂问题靠的是抽象和建模,这对任何编程范型都是需要的。没有必要把面向对象编程神化,作为解决所有问题的办法,并不是任何东西都可以成为对象,各种模式的混合使用在过去,在将来,都是正确之道。