混沌之初
在进行程序开发的过程中,我们有时会看到这样的Java类:
- 有上百个公共方法
- 单个方法好几百行
- 整个Java文件几千行
先下结论,这样的类显然是不好的。尽管他勉强能维持当前功能的运行。但实际上它已经无法在进行功能上的扩展了。我们对他能做的只有保守治疗,在危楼上再添砖加瓦。
尽管大家都不愿意承认自己是一片混沌的制造者,但实际上每一个巨型类的代码都是由你我亲手或间接缔造的。
但是当有一天我们意识到,这个类已经太过巨大,需要进行重构的时候,我们需要一些方法论与准则来帮助我们进行判断。
好的类是什么样的
在实际的开发过程中,我们一眼就能判断出来哪些类写得好,哪些类写的坏。我们可能不能明确地说出来个所以然,但是就是能感觉出来。这种原因是:
优秀的类可能精准地展现出它所具备的能力。
在我们进行代码编写的时候,无时无刻的会和类打交道。而类与类之间所表达出来的逻辑之间的依赖和交互则是我们要关注的重点。所以,当我们看到一个类时无法轻松的判断出来它是用于什么功能的,或者一不小心的误判了它实际的功能的时候,那么这个类就是有问题的。
以下我们针对哪些角度是可以帮助我们判断一个类是否优秀。
统一顺序
我们在进行代码编写的时候总是有各种各样的规范,规范的目的并不是和实际的编码人员对着干,主要目的在于减少团队中的沟通成本。所以对于编写类文件来说,需要做的第一件事就是要统一所有类中内容的排列顺序。这样做有两个好处:
- 减少在编写文件时候的位置考虑成本。
- 减少在阅读代码时的理解成本。
显然我们更加关注的是第二点。具体来说我们要保证类的属性在一起、类的方法在一起,以便在我们进行代码阅读的时候不会错过关键信息(我们更多的时候是简单浏览一下类的全貌,然后就径直的去找我们关注的内容)。同时一般来说,我们按照以下顺序来编写类:
- 公共静态常量
- 私有静态常量
- 私有静态变量(不太应该有共有变量)
- 公有方法
- 公有方法所用到的私有方法
总的来说,我们应该把属性放到类的上面,而把方法放到类的下面,并将私有方法放到所调用的公有方法下面(可以参考《如何写好一个方法》)。这样便于在阅读类中内容的时候可以从上到下逐步地了解类中的细节,符合我们自顶向下的阅读习惯。
单一职责
我们有时候会觉得类可能太长了。有很多的原因会让我们有这样的想法,而其中比较重要的一个原因是:这个类同时承担了复数个功能。
我们在进行面向对象编程的时候的主要方式是将拥有一些能力的对象用类的形式定义出来,这些能力就是类的方法。举个例子:可以播放音乐的音箱,那么我们就可以创建一个音箱的类,然后其中有一个方法播放音乐。目前这个类是很好理解的,我们有一个音箱类,然后通过音箱类来实例化对象就可以得到一个音箱,通过调用音箱中的播放方法就可以播放音乐。但随着功能的扩充,或许我们的音箱变成了移动音箱(注意,我们只有一个音箱),而且增加了一个充电功能,于是我们为这个类增加了一个充电方法。
随着功能不断地开发,我们可能会为音箱类中增加许多的与其有关联的事物,我们可能在音箱类中添加:充电、显示时间、定时关闭、随机播放等等工呢功能。在不断地迭代之后这个类会变得非常的臃肿,但是判断臃肿与否的条件,便是是否单一职责。
尽管单一职责的这个概念比较容易理解,但是在实际操作的时候却没有一个明确的边界,也就是说要凭感觉。就比如上文中的音箱的例子,如果在只有充电和播放音乐的这种情况下,我们也可以将其写入到一个类中。但是如果功能变多,比如增加了电量展示、涓流充电等功能,那显然我们更应该把这些方法放到一个电池的类中,并将电量的属性也放进去。
所以从可实施的角度上来说的话,我们可以通过两种方法来帮我判断这个方法是否满足单一职责:
- 能否为方法起一个合适的名字
- 能否通过句简短的话来描述其功能。
解释来说,如果无法用一个对象名来描述这个类的话,而只能通过一些通用概念(如处理器、执行器、管理器)来对他进行描述的话,就说明这个类的功能并不单一(当然如果本身代码规范就是这么定义的就另当别论)。而如果无法用一两句话来描述类的功能,或者必须用大量的“与”、“或”等词来对描述做串联,就说明其承担了太多的功能了,我们应该将其拆分一下。
内聚
我们在进行面对对象编程的时候经常说的就是类需要“高内聚、低耦合”。我们把类中的方法操作类中的属性称做方法与属性的关联的话,那么关联越多的类就是越内聚的。极端一点的话,如果类内所有的方法都使用所有的类属性的话,那么这个类是最为内聚的。实际情况并不总是如此理想,所以我们可以根据这种关联关系来判断类中的内容是否足够内聚。
所以,如果你发现在在写完类了之后部分方法只与部分属性产生关联,而其他方法则与另外的属性产生关联,就说明这两部分之间是没有内聚性的。那么我们就可以将其拆分为两个类,而这两个类之间将更加的内聚:方法与属性互相依赖称为了一个整体。
当我们通过内聚性来分析类后,可能会将一个大类,拆分为多个小类。这样会增加类的数量从而增加类的复杂性,同时也会让整体的代码变多。但是类本身可以通过包路径来进行分类,所以这种拆分我认为是比较合理的。
可扩展
对于一些有扩展需求的类,尽管他们可能满足单一职责以及内聚属性。但是由于这种类本身的扩展性,导致我们会在新的业务需求的时候频繁地对这种类进行修改与新方法的增加。这种修改导致的问题是在每一次修改代码或者新增方法的时候都无法保证不会对原有的功能造成影响。虽然我们可以通过单元测试或者集成测试来验证我们的修改,但是这都会增加我们的工作量。
所以,对于可能会频繁修改、并进行业务追加的方法类,我们需要特别的为其保留扩展性。我们可以通过实现统一接口或者继承抽象类、父类的方法,来获得多个不同扩展能力的子类,而这些子类我们也可以通过策略模式或者责任链模式来组织。
对于类来说,对其进行扩展总是好与对其直接进行修改。
可测试
关于这个角度,其实是源于我的另一个问题,就是在传统的三层框架下,中间的service层我们是否需要编写一层接口(interface)。我原来的认知是,实际上在绝大多数的情况,这个service我们是不会再进行其他实现的,所以单独写一个接口然后再增加对应的Impl只会让我们在扩展方法的时候更加的繁琐。
但是现在我发现了直接的用处,那就是:支持了单元测试的进行。
如果我们直接依赖具体的细节,就会对我们的测试带来挑战。具体来说,如果我们依赖于一个具体的类的实现的话,那么当我们希望针对其中的细节进行测试的话我们就要对细节之外的内容进行调整,可能包括:系统时间、数据库字段等内容。但如果我们是使用接口对servce进行调用,那么在我们测试的时候就可以通过直接编写测试用的service来实现预期的返回内容。从而大大简化进行测试的难度。
我们通过接口降低了系统之间的耦合度,让类之间的关系更好理解,也便于测试的进行。而这也是我们的依赖倒置原则(DIP),我们针对抽象编程,让其不依赖与具体细节,这样当我们需要调整细节的时候也不会影响上层内容。
最后
我们在coding的过程中总是在进行类的编写,本篇文章对类的一些编写注意事项进行了描述,在编写过程中主要需要注意类的:内部属性方法顺序、类的职责是否单一、是否足够内聚、是否支持扩展、是否可以进行单元测试。尽管这并非全部需要注意的内容,但如果可以根据这些引发思考便足够了。