模块分离
我们的第一个需求是清晰地划分模块,这样一个模块中的类就不会具有我们无法控制的功能:使用或覆盖另一个模块中的类。在传统的 Java 中有一个“classpath”(类路径),这是一个巨大的类列表,当多个类碰巧使用相同的名称时,总是使用第一个类,而第二个和其他所有的同名类将被忽略。看起来这种事情不会经常发生,当事实并非如此。当存在许多库而这些库又依靠其他库时,这个问题就变得常见了。这个覆盖问题绝对是致命的,因为它会导致一些奇怪的错误,比如 LinkageError、IncompatibleClassChangeError 等。事实上能够看到这些错误,那还是比较幸运的。倒霉的是这些错误没有提示,而系统一声不响地错误地运行,哪怕在部署之前我们做了许多先行测试。
对于类的覆盖和不可能空的可见性,预防方法是为每一个模块创建一个类加载器(class loader)。类加载器能够做到仅加载它能够直接识别的类,在我们的这个系统中,就是某个模块的内容(不过,它也可以根据类对类的方式,请求其他类加载器提供类,这种方式称为委派,即 delegation)。使用类加载器之后,每个模块包括他需要处理的代码和类,而且能够保证获得按照计划应该使用的类,即使系统中的其他模块包含同名的类。
从整体上恢复可见性等功能
完成以上步骤之后,我们到达这样一个点:所有模块完全隔离,无法互相通信。为了让这个系统变得实用些,我们需要恢复一些功能,以便能够看到其他模块中的类,不过这样做时必须非常谨慎,而且必须使用严格控制的方式。这里我们又多了一个需求:模块需要能够隐藏某些部署细节。
在 Java 中,protected/默认和 public 类型之间缺少访问修饰符。假设我写了一个库,希望这个库中其他包能够使用我的一个类,我必须让这个类设置为 public。但这样这个类将对所有人是可见的,包括这个库外部的客户,这些客户将能够直接使用我的内部类。我们想要的是一个“模块”级的访问级别,但现在的问题是 javac 编译器无法区分模块边界在哪里,因此对于这样的访问修饰符它无法执行任何检测。事实上,现有的“默认”访问修饰符也是有问题的,因为它应该只对同一个“运行时包(runtime package,即由某个特定类加载器加载的包)”提供访问权。但同样 javac 无法确定运行时存在哪些加载器。对于这种情况,javac 会采取冒险的方式:即使之后会导致 IllegalAccessErrors 错误,它也会提供访问权。
在我们这个模块系统中,我们选择的解决方式是允许模块仅“导出”其内容的一部分。如果模块中某些部分是非导出的,那么对于其他模块就是不可见的。但默认导出哪些内容?除了某些明显需要隐藏的部分,我们应该导出所有内容吗?或者除了那些明显需导出的部分,我们应该隐藏所有其他内容?选择后者看起来能够到来更好的透明度:我们可以很方便查看导出列表,确定那些可见的部分,即模块的“表面部分”。
请注意,我目前还没指定具体导出什么内容,这是一个需要仔细考虑的问题。
导出的反面是什么?当然是导入。一个模块想要使用其他模块中代码可以从后者进行导入。现在我们有了另一个选择……我们应该导入另一个模块导出的所有内容吗?或者只导入我们所需的那部分?同样我们还是选择后者,因为它会带来更好的透明度:重要的是我们导入了什么而不是从哪里导入。
与购物行为进行类比
关于导入的话题非常重要,所以这里次岔开一下话题,让我们看一个有点搞笑又有点夸张的购物行为。
我妻子和我的购物方式是不同的。我认为购物是一件麻烦的琐事。每当不得不去买东西时,我就找到一家商店(或者一组商店),那里有我需要的东西,我只买我需要的商品,买到之后回家。只要能买到我需要的东西,我不关心是从哪家商店买到的。
而我妻子去了一家商店,那家商店买什么她就买什么。
很明显我觉得我的购物方式更好,因为我妻子无法控制她能买到什么东西。如果她常去的一家商店更换了货柜上的商品,那她买回来的将是另外一些东西。当然很多东西并不是她需要的,而且她真正需要的又没有买到。
更糟糕的是,有时她买回来的东西并不能独自使用,因为还需要其他东西,比如电池。所以她不得不再次去商店里买电池,同样这次她会买下电池商店里出售的各种电池。再进一步假设,从电池商店里买到的某样东西还依靠其他东西才能使用,所以她又跑去另一家商店,仅仅是为了让某些商品能够正常工作,而这些商品从最初就不是我们所需要的。这个问题被称为“扇出”(fan-out)。
通过这个购物类比,相信你对模块系统将有一个更清晰的概念。这种非理智的购物行为等同于这样一个系统:我们申明了对某个模块的依靠性,而这个系统强制我们从该模块导入所有内容。当进行导入时,应导入所有我们实际需要的内容,而不管它来自哪里,同时忽略其他所有内容,可能内容只是碰巧位于它的包内。使用 Maven 构建工具时我们遇到这个尖锐的“扇出”问题,这个工具仅提供整体模块的依赖性(即“买下整个商店”方式)。其结果是,在编译 200 个字节的源文件之前,必须下载整个互联网的内容。