本文比较了过程式设计和面向对象设计,主要论述了过程式设计在程序开发中的一些问题及面向对象设计是如何解决这些问题的。 过程式设计 总的来说,过程式的程序设计是一种自上而下的设计方法,设计者用一个main函数概括出整个应用程序需要做的事,而main函数由对一系列子函数的调用组成。对于main中的每一个子函数,都又可以再被精炼成更小的函数。重复这个过程,就可以完成一个过程式的设计。其特征是以函数为中心,用函数来作为划分程序的基本单位,数据在过程式设计中往往处于从属的位置。 过程式设计的优点是易于理解和掌握,这种逐步细化问题的设计方法和大多数人的思维方式比较接近。 然而,过程式设计对于比较复杂的问题,或是在开发中需求变化比较多的时候,往往显得力不从心。这是因为过程式的设计是自上而下的,这要求设计者在一开始就要对需要解决的问题有一定的了解。在问题比较复杂的时候,要做到这一点会比较困难,而当开发中需求变化的时候,以前对问题的理解也许会变得不再适用。事实上,开发一个系统的过程往往也是一个对系统不断了解和学习的过程,而过程式的设计方法忽略了这一点。 在过程式设计的语言中,一般都既有定义数据的元素,如C语言中的结构,也有定义操作的元素,如C语言中的函数。这样做的结果是数据和操作被分离开,容易导致对一种数据的操作分布在整个程序的各个角落,而一个操作也可能会用到很多种数据,在这种情况下,对数据和操作的任何一部分进行修改都会变得很困难。 在过程式设计中,main()函数处于一个非常重要的地位。设计者正是在main()函数中,对整个系统进行一个概括的描述,再以此为起点,逐步细化出整个应用程序。然而,这样做的一个后果,是容易将一些较外延和易变化的逻辑(比如用户交互)同程序的核心逻辑混淆在一起。假设我们编写一个图形界面的计算器程序和一个命令行界面的计算器程序,可以想象这两个版本的main()函数会有很大的差异,由此衍生出来的程序很有可能也会迥然不同,而这两个版本的程序本应该有很大部分可以共用才对。 过程式设计还有一个问题就是其程序架构的依赖关系问题。一个典型的过程式程序往往如Figure 1所示: Figure 1 图中的箭头代表了函数间的调用关系,也是函数间的依赖关系。如图所示,main()函数依赖于其子函数,这些子函数又依赖于更小的子函数,而在程序中,越小的函数处理的往往是细节实现,这些具体的实现,又常常变化。这样的结果,就是程序的核心逻辑依赖于外延的细节,程序中本来应该是比较稳定的核心逻辑,也因为依赖于易变化的部分,而变得不稳定起来,一个细节上的小小改动,也有可能在依赖关系上引发一系列变动。可以说这种依赖关系也是过程式设计不能很好处理变化的原因之一,而一个合理的依赖关系,应该是倒过来,由细节实现依赖于核心逻辑才对。 面向对象设计 面向对象是一种自下而上的程序设计方法。不像过程式设计那样一开始就要用main概括出整个程序,面向对象设计往往从问题的一部分着手,一点一点地构建出整个程序。面向对象设计以数据为中心,类作为表现数据的工具,是划分程序的基本单位。而函数在面向对象设计中成为了类的接口。 面向对象设计自下而上的特性,允许开发者从问题的局部开始,在开发过程中逐步加深对系统的理解。这些新的理解以及开发中遇到的需求变化,都会再作用到系统开发本身,形成一种螺旋式的开发方式。(在这种开发方式中,对于已有的代码,常需要运用Refactoring技术来做代码重构以体现系统的变化。) 和函数相比,数据应该是程序中更稳定的部分,比如,一个网上购物程序,无论怎么变化,大概都会处理货物、客户这些数据对象。不过在这里,只有从抽象的角度来看,数据才是稳定的,如果考虑这些数据对象的具体实现,它们甚至比函数还要不稳定,因为在一个数据对象中增减字段在程序开发中是常事。因此,在以数据为中心构建程序的同时,我们需要一种手段来抽象地描述数据,这种手段就是使用函数。在面向对象设计中,类封装了数据,而类的成员函数作为其对外的接口,抽象地描述了类。用类将数据和操作这些数据的函数放在一起,这可以说就是面向对象设计方法的本质。 在面向对象设计中类之间的关系有两种:客户(Client)关系和继承(Inheritance)关系。客户关系如Figure 2所示,表示一个类(Client)会使用到另一个类(Server)。一般将这种关系中的Client类称为客户端,Server类称为服务器。 Figure 2 继承关系如Figure 3所示,表示一个类(Child)对另一个类(Parent)的继承。一般将这种关系中的Parent类称为父类,Child类称为子类。 Figure 3 面向对象设计的过程就是将各个类按以上的两种关系组合在一起,这两种关系都非常简单,不过组合在一起却能提供强大的设计能力。下面介绍如何利用这两种关系来划分程序的不同模块。 在很多应用中,我们都需要将数据存储到数据库中。一个常见的解决手段是使用分层的方法,将程序分为应用层和存储层,Figure 4中是这种方法一个不太成熟的设计: Figure 4 Application代表应用层的逻辑,DB代表了数据库访问的逻辑,在这个设计中,Application直接调用了DB的服务来将数据存储到数据库中。这样做的缺点是Application对DB有了依赖关系,一旦DB有了任何变化,Application都有可能会受其影响需要改动。当然,如果只是两个类的话,这种依赖关系根本算不上什么问题,然而,我们有理由相信,应用层和存储层都会由不只一个类组成,并都有一定的复杂度,这时它们之间的这种依赖关系就会让人头痛了。当程序的模块越来越多,如果不限制它们之间的联系,那模块间的依赖、程序的复杂度和开发维护的难度都会成指数上升。 所幸的是引入面向对象中的继承关系,我们可以解决这一问题,如图Figure 5所示: Figure 5 Persistence是一个接口,其包含了Application存储所需用到的服务。DB实现了这个接口,Application则调用Persistence中的服务来存储数据,两者都只依赖于Persistence。这样,Application到DB的依赖关系被Persistence这个接口切断了。因为接口中只包含了服务,也就是成员函数的声明,而不包括任何数据和函数的实现,所以Persistence接口的内容会很简单。在Persistence不变的情况下,Application和DB这两个模块都可以自由地进行修改而不影响到对方。就这样,通过在中间插入接口的方法,程序的模块被划分开,依赖关系也变得容易管理的多。 如果从另一个角度来看Figure 4中的设计,应用层是程序的核心部分,而存储层则可以看成是实现的细节,这个设计犯了和过程式设计同样的错误,即核心逻辑依赖于具体的实现细节,这样,当细节变化时,核心逻辑也会受到影响:假如,当我们需要将数据改存到文件或目录服务中时,按照Figure 4中的设计Application模块就难免受到影响。在Figure 5的设计中则没有这一问题:这里我们可以把Application和Persistence看成是程序的核心逻辑,而Persistence接口的实现(DB)可以看成是程序的细节实现,其依赖关系是细节依赖于核心,当细节变化时核心不会受其影响,这才是一个稳定的依赖关系。遵从这一设计,我们可以在不影响程序核心的情况下,为程序添加或更改存储类型,如Figure 6所示: Figure 6 这里值得注意的是Persistence和Application被划分到了一起,这是因为Persistence中的服务都是根据Application中的需求制定出来的,Persistence和Application的关系比起它和其子类的关系更加紧密。将接口和其调用者放入一个模块是一个常见的做法,当然,也可以将接口单独划分为一个模块,这一般是在有多个不同的调用模块依赖于该接口,或不同模块间需要更清晰的分离时才这样做。 例子 接下来我们将通过一个小例子来看看这两种设计方法的不同:假设我们有一个图形处理的程序,这个程序需要一个计算各种图形面积的功能。 如果用过程式的方法来设计这个功能,可以得到下面的这些代码: public class Circle { public double radius; } public class Rectangle { public double width; public double height; } public class Square { public double side; } public class AreaCalculator { public static double area(Object shape) { double area = 0.0; if (shape instanceof Circle) { Circle c = (Circle) shape; area = c.radius * PI * PI; } else if (shape instanceof Square) { Square s = (Square) shape; area = s.side * s.side; } else if (shape instanceof Rectangle) { Rectangle r = (Rectangle) shape; area = r.width * r.height; } return area; } private static final double PI = 3.14; |