如何设计优雅的类结构(clean code阅读笔记之九)
注:正文中的引用是直接引用作者的话,两条横线中间的段落的是我自己的观点,其他大约都可以算是笔记了。
「Clean Code」这本书从这一章开始文风有些变化,感觉比较乱,很多概念在之前的章节也提到过,因为这本书的某些章节是不同的人编写的,所以这种情况也难免,所以可能会有些小节我会几句话简单带过。
本章讲的是类的组织结构,其实很多这些概念我们在学校里学习OOP时可能都有学到过,有些人可能会觉得讲得比较虚,但文中确实有些细节还是解开了一些之前的疑惑,姑且当做复习面向对象的概念也好。
在前面的章节中详细讨论了命名、方法和数据结构等等这些概念,它们能够帮助我们更好地理解在代码行或者代码块的级别里如何写出简洁优雅。在此基础上,我们还是要在更高的层面上去探究代码简洁之道。在现代的高级语言编程世界里,类是系统的基本组成部分,这章就着重讨论一下如何写出好的类。
类的组织结构
对于类的代码结构,Java中有一套不成文的约定:
- 一个类应该以一系列的常量和变量定义作为开始
- 如果有公共静态常量,它们应该放在最前边
- 接下来是私有的静态常量
- 接下来是私有的实例变量
- 类中不应该有公共的变量
- 紧接着是公共的方法
- 一些私有的方法应该紧接着它被调用的共有方法后边
封装
在OOP中很多概念都是相通的,封装作为OOP的一个基本概念突出了「开闭原则」的重要性,它很好地解决了一些扩展性的问题,它使得接口的提供者可以屏蔽此接口的具体实现,从而可以在一个较自由的范围内去修改自己的实现。
按照封装的概念,一个类中的所有变量都应该是私有的,但同时也不要对此概念太过执着,前边的章节也提到过测试的重要性,有时候测试需要某些类的变量可访问,那么可以考虑给它们赋予protected
属性。但应该尽可能的保证封装的特性。
类应该尽可能的「小」
在函数的那一章我们提到过方法应该设计的尽可能的小,我们衡量函数使用代码行数,在这里我们衡量类使用「职责」。
一个类的职责应该是唯一的,这才符合OOP对现实世界的模拟的概念。职责往往是和代码的行数正相关,但它们并不是完全正相关的,如代码9-1中所示:
代码9-1
public class SuperDashboard extends JFrame implements MetaDataUser{
public Component getLastFocusedComponent()
public void setLastFocused(Component lastFocused)
public int getMajorVersionNumber()
public int getMinorVersionNumber()
public int getBuildNumber()
}
这个类只包含了5个函数,那么它是不是已经足够小了呢?非也,它包含了两个不同的职责——它同时管理「版本号」与「某个JFrame组件」。
单职责原则(SRP)
SRP的意思是说一个类(或者一个模块)应该有且只有一个要修改它的原因(职责)。
比如代码9-1中所示那样,我们可能有两个原因要去修改类SuperDashboard
,一个是版本号改变了,另一个是获取组件的方法变了。诚然,当我们修改了获取lastFocus
组件的方法时,往往是要修改版本号的,但是反过来就不一定了。
SRP是OOP中最重要的设计理念之一,但同时也是最常被违反的理念之一。「使软件可以工作」和「使软件简洁优雅」是两个截然不同的的工作,我们常常没有时间也没有精力同时关注这两者,然后就只关注前者了。
在中国当下的现实环境是,很多代码的需求方(往往是老板)根本不在乎代码的可维护性、可扩展性甚至健壮性,他们的要求常常是软件快速上线,而不是简洁优雅但要延期上线的软件。此外,还有有很多代码写完之后可能永远也不会被维护和修改,所以「使软件简洁优雅」慢慢地块要变成一种个人追求,变成了「大家都说这样做更好,但真正这么做的却很少」。
我倒是觉得尽管在实际的编程工作中不得不不断地进行妥协,但是只要把clean code的理念放在心中,并用它来审视自己的代码,我们总是会写出越来越好的代码。编程是如此,人生何尝不是如此。
问题是很多人认为软件「可以工作」的那一刻,我们的工作就结束了。我们接下来要做的是解决下一个问题而不是回过头来把这些超级类分解成解耦的小单元。与此同时,还有很多人很害怕看到「大量的小而职责单一的类」,觉得那样会使他们很难去从大方向上理解整个系统。事实则恰恰相反,大量的小的职责单一的解耦的类往往带来更多的好处。
我们的目标是这样的:我们的系统由大量的小的职责单一的类组成,而不是少数几个超级大类。每一个类都只与少数几个其他类进行交互(这点有点像迪米特法则)。
内聚
内聚的概念是这样的:一个类应该只有少数的几个实例变量,这个类的每个方法都应该操作这个类中的一个或多个实例变量。通常一个方法操作的实例变量越多,那么这个方法对于这个类来说聚合性就越高。一个类中的所有方法都操作了这个类中的所有实例变量,那么这个类就是聚合型最高的。
但是,通常来说这样的超级内聚的类不太可能出现,也不建议去建立这样的类。但我们还是想要一个类的内聚性是高的,这表明这个类中的几个组成部分是互相依赖不可分割的。如代码9-2中所示的一个堆栈的简单实现,就是一个内聚性很高的例子:
代码9-2
public class Stack {
private int topOfStack = 0;
List<Integer> elements = new LinkedList<Integer>();
public int size() {
return topOfStack;
}
public void push(int element) {
topOfStack++;
elements.add(element);
}
public int pop() throws PoppedWhenEmpty {
if (topOfStack == 0)
throw new PoppedWhenEmpty();
int element = elements.get(--topOfStack);
elements.remove(topOfStack);
return element;
}
}
在这个类中出了方法size()
之外,其他几个方法都同时使用到了这个类中的两个变量。所以写出高内聚的类的诀窍就是,保持类中的变量个数很少,方法很小。如果一个你代码中某个类的内聚性很低,那么你就要考虑一下,是否要把它拆分成几个更小的类了。
维护类的高内聚往往会带来更小的类
只要你不断的将大的方法拆分成小的方法,最直接的结果就是你会看到越来越多的类。
举例来说,我们经常会碰到一个场景,我们想把一个超级方法中的某一个逻辑(可能是几行代码)抽出来重构成为一个新的方法,然后抽取之后的新方法需要传入4个在这个超级方法中定义的变量,这种情形下,最好就是把这4个变量编程类级别的变量,这样我们抽取的这个新方法就不需要传入任何的参数了。
但是,这样做之后这个类的内聚性就降低了——这4个变量只在两个方法中被调用——但是这种「有几个方法想要分享几个变量」的行为不正是类定义的由来吗。所以,一旦你的类的内聚性降低时,就去着手把它拆分为更小的类吧。
所以,拆分类可以从拆分超级方法开始,这样往往能给我们带来一个更清晰的类的组织结构。
为了变化而设计
对于大多数的系统,变化是持续发生的。每次发生改变,都可能对我们的现有系统造成威胁,那么我们设计系统中「类的组织结构」时就要尽可能降低这种风险。
然后在这个小节作者举了个使用abstract类来解决对类的修改的问题。「对扩展开放,对修改关闭」最好的一个实现就是使用抽象类,因为对于此抽象概念增加时只需要多写一个此抽象类的实现类,而不是去修改现有的实现类。
隔离变化
需求会不断地变化,所以我们的代码实现也会不断地变化。使用抽象类可以很大程度上地隔离这种变化。
这一小节的宗旨就是说要使用面向接口编程,使得此接口的调用者对接口依赖而不是对实现依赖,这样就实现了「隔离变化」。