从本节开始,我们将考虑一个
Container
的实现,这个实现可以由一个接触过C语言等结构化语言后刚刚接触Java的、没什么编程经验的程序员来编写。这个类是整本书中你会遇到的众多版本中的第一个。我给每个版本起了一个名字,以帮助浏览和比较它们。这个版本的名字是Novice,它在代码库中的全称是
eis.chapter1.novice.Container
。
即使是经验丰富的专业人士,在某个时刻也曾是初学者,在新语言的语法中摸爬滚打,对隐藏在角落里的众多API并不了解。起初,可以选择数组这种数据结构,但解决语法错误的要求太高,以至于不能考虑什么编码风格的问题。经过一番试错后,初学编程的人拼凑出了一个类,可以编译通过,而且似乎还能满足需求。也许开始时候的代码有点儿像代码清单1-1所示的那样。
代码清单1-1 Novice:字段和构造函数
public class Container {
Container[] g; ❶ 一组相连的容器
int n; ❷ 容器组的实际大小
double x; ❸ 该容器中的水量
public Container() {
g = new Container[1000]; ❹ 注意:这是一个魔法数
g[0] = this; ❺ 将该容器放入容器组中
n = 1;
x = 0;
}
这几行代码包含大量的轻微和不太轻微的缺陷。让我们把重点放在那些容易修复的表面缺陷上,因为其他的缺陷在随后章的版本中会逐渐浮现出来。
这三个实例字段的用途如下。
g
是一个数组,用于保存连接到这个容器的所有容器,包括当前容器(在构造函数中可以看出)。
n
为
g
中的容器数量。
x
是该容器中的水量。
唯一明显让这段代码显得业余的地方是选择的变量名:非常短,而且完全没有表达出应有的信息。即使一个专家被犯罪分子要挟用60 s的时间“黑”进一个超级安全的水容器系统,他也不会给一个组起名为
g
的。玩笑归玩笑,有意义的命名是代码可读性的首要原则,第7章会讨论可读性。
然后就是可见性问题。字段应该是私有的(
private
),而不是默认的(
default
)。回想一下,默认可见性比私有性更开放;它允许同一包中的其他类访问。信息隐藏(又名封装)是一个基本的OO原则,它使类可以不用关心其他类的内部实现,并通过一个定义良好的公有接口(一种分离关注点的形式)与它们交互。这进而使得类可以修改其内部实现,而不影响已有的客户端。
关注点分离的原则也为本书提供了基础。以下各章介绍的许多实现都符合同样的公有API,因此,客户端原则上可以互换使用各个版本的实现。使用这种方法,API的每一个实现细节对外部都是不可见的,这要归功于可见性标识符。从更深的层面来看,单独优化不同方面的软件质量本身就是一种极端的关注点分离。它过于极端了,事实上只是一种说教的工具,而不应该是在实践中追求的方法。
继续往下看,如代码清单1-1中的第六行代码所示,数组的大小由一个所谓的
魔法数
(magic number)定义,即一个没有被赋予任何名称的常数。最佳实践要求你把所有的常量分配给某个
final
变量,一来变量的名字可以表示这个常量的含义,二来把这个常量的赋值集中在单个点上,如果多次使用这个常量,那么这一点特别有用。
这里选择使用普通数组并不是很合适,因为它对连接的容器的最大数量有一个预先确定的边界:如果边界太小,那么程序必然会失败;太大的边界又会浪费空间。此外,使用数组迫使我们不得不手动跟踪组中实际的容器数量(此处为字段
n
)。在Java API中还有更好的选择,将在第2章中讨论。尽管如此,普通数组也将在第5章中派上用场,那里的主要目标是节省空间。
getAmount
和
addWater
方法
接下来看看前两个方法的源代码,如代码清单1-2所示。
代码清单1-2
Novice:
getAmount
和
addWater
方法
public double getAmount() { return x; }
public void addWater(double x){
double y = x / n;
for (int i=0; i<n; i++)
g[i].x = g[i].x + y;
}
getAmount
只是一个简单的getter,
addWater
则显示了变量
x
和
y
的常见命名问题,而
i
作为数组索引的传统名称是可以接受的。如果代码清单的最后一行使用
+=
运算符,就不会重复
g[i].x
两次,也就不必来回查看,以确保语句实际上是在递增同一个变量。
注意,
addWater
方法没有检查它的参数是否为负值。在这种情况下,表示并没有考虑容器组是否有足够的水量。像这样的稳健性问题,将在第6章中专门讨论。
connectTo
方法
最后,我们的新手程序员实现了
connectTo
方法,它的任务是用一个新的连接合并两组容器。在这个操作之后,两组中的所有容器都会持有相同的水量,因为它们都成了连通器。首先,该方法将计算出两组中的总水量和两组中容器的总数。合并之后,每个容器的水量,就是简单地用前者除以后者。
还需要更新两个组中所有容器的数组。一种朴素的方法是将第二组中的所有容器附加到属于第一组的所有数组,反之亦然。代码清单1-3就是这样做的,使用了两个嵌套循环。最后,该方法更新了所有受影响的容器的大小字段
n
和水量字段
x
。
代码清单1-3
Novice:
connectTo
方法
public void connectTo(Container c) {
double z = (x*n + c.x*c.n) / (n + c.n); ❶ 合并后,每个容器的水量
for (int i=0; i<n; i++) ❷ 遍历第一组中的每个容器
for (int j=0; j<c.n; j++) { ❸ 遍历第二组中的每个容器
g[i].g[n+j] = c.g[j]; ❹ 将c.g[j]添加到g[i]组中
c.g[j].g[c.n+i] = g[i]; ❺ 将g[i]添加到c.g[j]组中
}
n += c.n;
for (int i=0; i<n; i++) { ❻ 更新大小和水量
g[i].n = n;
g[i].x = z;
}
}
如你所见,
connectTo
方法是命名问题最严重的地方。所有这些单字母的名字很难让人理解。为了进行明显的比较,你可能会想先跳过,去看一下第7章中的可读性优化的版本。
如果用增强型
for
循环(C#中的
foreach
语句)替换掉三个
for
循环,可读性也会有所改善,但基于固定大小数组的表示方式使其有点儿麻烦。确实如此,想象一下,用下面的语句替换代码清单1-3中的最后一个循环。
for (Container c: g){
c.n = n;
c.x = z;
}
这个新的循环当然可读性更强,但是一旦
c
变量超出了实际存储容器引用的数组单元格(cell)
[1]
,就会出现
NullPointerException
。补救方法很简单,只要检测到一个
null
引用,就立即退出循环。
[1] 本书中数组的cell统一翻译为“单元格”,从而在某些上下文中和“元素”(element)等进行区分。——译者注
for (Container c: g){
if (c==null) break;
c.n = n;
c.x = z;
}
尽管完全不可读,但代码清单1-3中的
connectTo
方法在逻辑上是正确的,只是有一些限制。事实上,思考一下
this
和
c
在方法调用之前就已经相连的情况。更具体地说,假设下面的用例,涉及两个全新的容器。
a.connectTo(b);
a.connectTo(b);
你能看出会发生什么吗?方法能容忍调用者的这种轻微失误吗?请在继续阅读之前仔细思考一下。我会等着你。
答案是,连接两个已经连接的容器会破坏它们的状态。容器
a
的容器组数组中最后会有两个指向自己的引用和两个指向
b
的引用,并且大小字段
n
是4而不是2。类似的事情也会发生在
b
上。更糟糕的是,即使
this
(当前容器)和
c
只是间接连接,也会出现这种缺陷,这不能被认为是调用者的使用不当。我说的情况如下所示(再强调一次,
a
、
b
和
c
是三个全新的容器)。
a.connectTo(b);
b.connectTo(c);
c.connectTo(a);
在最后一行代码之前,容器
a
和
c
已经连接起来了,尽管是间接的(见图1-5右图)。最后一行代码增加了它们之间的直接连接,根据需求规范,这是有效的。这导致了图1-5左图所示的情况。但是代码清单1-3中的
connectTo
方法却给所有容器组数组添加了所有三个容器的第二个副本,同时错误地将所有组的大小设置为6而不是3。
此实现的另一个明显缺陷是,如果合并后的组中包含超过1000个成员(那个魔法数),则代码清单1-3这两行的其中之一:
g[i].g[n+j] = c.g[j];
c.g[j].g[c.n+i] = g[i];
会抛出一个
ArrayIndexOutOfBoundsException
异常,并导致程序崩溃。
下一章将介绍一个参考的实现,它解决了这里指出的大部分表面问题,同时在不同方面的代码质量之间取得了平衡。