UML软件工程组织

面向对象软件开发和过程(五) 优化代码的组织
林星 ( iamlinx@21cn.com )
相信任何一位程序员都曾经见过面条状的代码,这种代码给人留下的只是噩梦。面向对象能够支持较好的代码组织方式,基本的处理思路是先将问题于分而治之,然后再把分开的代码整合起来。分而治之和整合,成为组织代码的关键思路。

1. 抽象是重要的思路

抽象是面向对象中优化代码组织的一个重要的思路。抽象的目标是消除细节,让开发人员的注意力集中在重要的目标之上。抽象的思路在计算机世界中无处不在。TCP协议栈的不同层次都提供一种抽象,对上层隐藏了下层的细节;业务模型也提供了一种抽象,对领域分析人员隐藏了具体技术细节;设计模式同样提供了抽象,它隐藏了具体的实现细节;甚至汇编语言也是一种抽象,它隐藏了机器指令;而我们这里的讨论也是一种抽象,因为我们忽略了应用类型、设计语言这些细节。对于开发人员来说,最重要的是识别出本层次的抽象,应该表现什么,应该隐藏什么。按照抽象的原则进行组织的设计将会是清晰的设计。

抽象的另一个好处是能够便于交流,在设计模式未被提出之前,我们不得不花上大量的时间来澄清设计的思路,但是设计模型的抽象使得我们用一个简单的名词来提到一个设计思路,这就大大提高了沟通的效率。而UML语言的设计目标也是如此。

下面我们提到的一些具体的技术,包括委托、包、分层,他们都在不同的角度体现出了抽象的思路。

1.1 委托

委托是面向对象中最为常用的一种技巧,它是那么的基本,我们在整篇文章中到处可见它的踪影。在一些重要的例子中,我们会指出它的用法,一般的我们就不说了,大家注意些就能够观察到。有人说,用C语言编写代码,代码量超过10000行就会失控,用C++,这个限制可以放宽到100000行。这种说法是否正确我不敢肯定,但有一点是很明确的,面向对象语言要比非面向对象语言更加的结构化,能够支持更大规模的软件。那么,为什么呢?这个问题的答案并不是在面向对象语言本身,而是在其它非软件的学科中。我们知道,在一个小企业中,他没有什么管理成本,管理人员也很少。但是一个大企业,他就有很多的管理人员,因为企业规模增大之后,需要付出管理成本。这些管理人员的主要职责,就是管理那些能够直接产生利益的工作人员。我们还知道,现代社会存在的基础是分工,分工明确,每个人做的事情少而精,这样整个社会就会有效率。而面向对象正体现了这两种思路。首先,一个面向对象的软件,其中一定会有很多什么都不做的代码,只是简单的把对它的调用转给另一个对象的方法,有的有做一些判断,或是类型处理,有的干脆什么都不做。这些代码看起来并不产生效益,甚至有些浪费。但是它们就好像是企业的管理者一样,虽然消耗了一些资源(所幸的是,面向对象语言设计的高效性让这种资源消耗是可接受的),但是它们能够让其它产生效益的代码工作的更加的有效。在后面的例子中,我们会看到很多这样的代码。其次,面向对象非常注重类的职责,每个类处理的事情尽可能的少,每个方法的目标都十分的单一。但是一件事情一定是几个专业的类共同完成的,而不是象传统方法那样,一个函数一包到底。

委托使用的多寡和面向对象语言本身有一定的关系。委托从某个角度来说也可以算是一种重用的技术,在不支持多重继承的语言中,委托将会更为重要一些。而支持多重继承的语言中,从不同的父类继承能够替代一部分的委托使用。如果继承和委托同样都能够实现目标,那么继承要比委托更加简单和直观一些,但是多重继承引入了额外的复杂性。事实上,以Java语言为代表的单根继承的语言,向来都认为委托是比继承更好重用方式。

在软件过程中,通过委托来将代码组织起来是非常普遍的做法。但要注意两点问题。委托给谁,和如何委托。前者说的是调用哪一个方法,后者说的是方法的参数、返回值和异常。两个都是基本问题,但却是最重要也最容易忽略的问题。在异常的案例中,我们知道需要约定异常的处理方式,以及为异常提供文档。委托的使用也是这样,给出类的说明,这样客户端就知道如何使用这个类,统一类接口的设计方法,形成一种约定,即便没有足够的文档也可以保证客户端正确的使用类。相应的,如何委托需要注意的也是类似的问题。这里我们只是简单的提到处理思路,因为后文中还会详细的讨论。

1.2 使用接口来组织代码

继承除了重用之外,另一个用处是定义规范。角色和演员模式中,两个对象职责分离,演员负责实现人在多个环境中恒定不变的特征和行为。而角色用于表现人在不同环境中的不同行为。但是很多时候,为了方便起见,角色也应当能够扮演演员的一部分职责。因此,我们就需要用到委托机制,角色本身并不真正回答问题,而是把问题转交给演员。但是,这样一来,演员和角色之间就缺少了一个规范,那些是演员负责的,那些是角色负责的,没有一个清晰的界定。对客户端也是一件困惑的事情。所以我们最好是为它们定一个规范:


public interface PersonProfile {
String getName();
Address getAddress();
PhoneNumber getPhoneNumber();
}

public class Employee implements PersonProfile {
protected Person parent;
public String getName() {
return this.parent.getName();
}
public Address getAddress() {
return this.parent.getAddress();
}
public PhoneNumber getPhoneNumber() {
return this.parent.getPhoneNumber();
}
}

PersonProfile是一个接口,这里之所以采用接口,是因为我们的目标在于定义一个规范,而不是进行代码重用。比起实现继承,接口继承具有更佳的灵活性。定义了这个接口之后,两者的职责就已经非常的明确了。这里使用到委托机制的这些代码都可以算是管理代码,本身并没有太大的价值,它们的管理价值是在客户端体现的。

看到了吗,在实际运用中,一定是多个技法同时使用的。可能有人问,我的设计思路和这不同。这并不是问题的关键,问题的关键在于,我们如何使用这些技法来描述问题、解决问题。在软件开发的过程中,象这种代码级别的规范是必须勤于制定的,因为这种规范往往是客户调用方和代码提供方的一种约定、一种契约。针对这些规范进行工作的衔接、文档的撰写、测试的设计都能够达成事半功倍的效果。久而久之,在软件设计中就能够形成很明显的"块状"的设计和代码,这对软件的质量是很有帮助的。

1.3 包

当早期编写程序的时候,缺少一种对代码的组织技术,因此在大学时代写项目,靠的就是目录结构。在开始设计软件之前,会先在磁盘上建立需要的目录,例如有的目录用于存放全局的函数,有的目录用于存放数据库的DDL文件,有的目录用于存放文档。短短几年后,我们已经不需要使用这么老土的技术了。在现代的语言中,有很多新的机制能够帮助我们更好的组织代码。

我们这里讨论的包主要是对源代码的组织,同时,各种平台中还存在对执行码的组织形式,例如Java平台中的Jar、.Net平台中的程序集。在C++和C#语言中,代码组织形式为命名空间(namespace),在Java语言中,组织形式为包(Package),在Eiffel语言中,组织形式为群集(cluster)。它们产生理由各不相同,命名空间主要解决的是名字冲突的问题,而包提供了一种对类和子包的打包技术,并能够和文件系统优雅的结合,此外,包还提供了额外的可见性(包内可见),群集技术就相对比较松散,它不是Eiffel语言的组织部分,仅仅是对源代码进行打包而已。

包在软件过程中是非常重要的,它是建立统一愿景的重要工具。软件开发的初期,类的设计还不明确,这时候首先确定的就应该是包图。包图不但表现了对业务领域的初步划分,还表现了开发团队以往的经验。

此外,我们知道,类和类之间最重要的就是它们相互作用,形成一个网络。但是当这个网络过于复杂的时候,我们就需要一些办法来组织这张网,使它更加的清晰。包的作用就是对网络进行分块,并降低块和块之间的交互。记得设计模式中的fa?ade模式吗,这个思路是一样的。例如Java中包的成员能够看见彼此之间的保护和私有成员,这种做法就是加强了包内的类之间的耦合度,限制了包间的耦合度。

2.规范

在前一章中,我们很难为重用来定义规范,因为重用的设计需要对症下药,很难定义一个标准的设计。但是代码组织则不同,可以想出很多代码组织的规范。例如,命名规则、自文档的代码、业务实体的设计规范。代码组织的规范应该从整体开始考虑,这样的效果最为明显。

所以,最佳的规范应该是从分层设计上和包的设计上入手。软件按照什么样的原则进行分层;每一个包的含义是什么,在分层设计中起到什么样的作用;如何保存数据,如何处理数据,如何传递数据,如何表现数据。这些是非常值得,也是非常必须建立规范的。

设计上的规范能够从整体上规范代码,而代码规范能够在细节上规范代码。代码级别的规范有很多,例如sun的网站上就提供了很多的规范。

而像接口设计这样的技巧就很难定义一个规范,所以它的规范和上一章制定重用规范的思路类似。

不过,一如之前的案例,制定规范只是第一步,更重要的是在后面。

3.组织

之所以要组织代码,其中的一个原因就是为了满足组织的需要。在单人的开发环境下,代码要不要组织都是次要的,只要开发人员本人没有问题,那就没有问题。但多人的开发环境下就不同了。不同的开发人员需要对软件和软件开发有着一致的了解。在组织环境中,每个开发人员都在一定的抽象层次上为其它开发人员提供服务。开发人员之间通过这种提供服务和使用服务的方式耦合起来,如果服务效果不好,那么开发就会打折扣。

一个人写出的类或框架,别人用起来是否方便,理解起来是否容易,这些都很重要。在上面讨论接口的例子中,我们为什么一边要分离两者的职责,一边又要让角色对象具有演员对象的特征呢?既然是这样,用一个单独的类解决它们不是更好吗,何必要多此一举呢?注意到,这是两个层面的问题。一个是分工问题,一个是便利问题。回想以前我们缴费的时候,需要去电业局缴电费,去自来水公司缴水费,很不方便,后来发明了银行代扣,这样,原先要去好几个地方的,现在只需要去一个地方。角色和演员就好比电业局和自来水公司,它们的职责不同。但是这对客户端来说是不方便的,为了获取演员的姓名,我们必须这样:


// Employee的处理代码
String name = (Person)e.getPerson().getName;

为了不需要在演员和角色之间切换来切换去,我们希望客户端的代码能够简单一些:


String name = e.getName();

所以呢,这时候角色就变成了银行(当然也可以引入第三个对象来扮演银行,有的例子中确实需要这样,但这里没有必要)。客户端的代码就简化了,至于怎么获得姓名,那是你角色的事情,和客户端没有什么关系。那么,PersonProfile接口是一个什么东西呢?其实就是一种约定,是我们知道在银行可以代缴水电费这样一种知识。如果缺乏这种知识,我们就会向银行询问,你这里能代缴些什么费用啊?银行就会回答说,可以代缴水电费。这就使得接口变成了一种动态接口了。

如果我们把类本身看作是服务端,对类的调用看作是客户端。那么这里表达了一种面向对象的设计思路,就是宁可服务端复杂,也要使客户端简单。这其实也就是抽象的思路。

面向对象设计的这种特点决定了面向对象软件开发过程中的一些特色。首先,软件设计的工作量分配应当基于类进行。每一个开发人员都是为其它开发人员服务,任何一个人都需要用到其他人的代码。这种关系是分工的关系,要比非面向对象开发的分工化程度高许多。当然,这种分工是有着一定的原则的,是按照软件的分层体系来划分呢?还是按照业务需求来划分。只要能够划分清楚,任何一种方式都是可接受的。

其次,每个人保证代码质量,并维护自己的代码。在活用XP一文中,我们曾经讨论,是否采用代码集体所有制。我比较倾向于个人代码所有制,因为在这种分工的情况下,代码的质量和接口的稳定是非常重要的。所以,采用面向对象设计的团队,往往能够同时进步。正是因为客户端的要求非常的严格,迫使服务端不断的改进。

再次,对使用程度不同的类进行不同的资源分派。虽然每个类都会被其它人所使用,但有些类是很重要的,例如基础的类,或是重要的业务实体。这些类被频繁使用,保证它们的质量。

所以,代码组织的优劣对于组织来说是非常重要的,但是它还需要有过程的保证。

4.过程

执行规范需要有相应的过程保证。而规范的执行结果也同样需要有过程的保证。可能很多的软件组织都拥有自己的软件过程,但是过程是否涉及代码组织的问题?可能有,也可能没有。代码组织的上游是设计的组织,而设计组织的上游是需求的组织。上游水源的清澈与否直接影响到下游。所以在开发过程中,有两件事情是非常重要的,一是尽可能保证需求组织的正确性,而是保证代码的组织能够正确的反映需求。要做好这两点并不容易,更多的情况是,你以为自己已经做好了,其实未必。更好的做法是要求团队具备快速变化的能力,代码的组织是一个演进的过程,或者说是一个重构的过程。不断的改进,才有可能带来优秀的组织方式,不去改进,原本优秀的组织方式也会变得丑陋。

关于作者

林星,辰讯软件工作室项目管理组资深项目经理,有多年项目实施经验。辰讯软件工作室致力于先进软件思想、软件技术的应用,主要的研究方向在于软件过程思想、Linux集群技术、OO技术和软件工厂模式。您可以通过电子邮件 iamlinx@21cn.com 和他联系。
 
 

版权所有:UML软件工程组织