掌握Java核心技巧:从入门到精通

发表时间: 2020-01-10 14:49

日复一日,我们编写的大多数Java只使用了该语言全套功能的一小部分。我们实例化的每个流以及我们在实例变量前面加上的每个@Autowired注解都足以完成我们的大部分目标。然而,有些时候,我们必须求助于语言中那些很少使用的部分:语言中为特定目的而隐藏的部分。

本文探索了四种技术,它们可以在绑定时使用,并将其引入到代码库中,以提高开发的易用性和可读性。并非所有这些技术都适用于所有情况,甚至大多数情况。例如,可能只是有一些方法,只会让自己协变返回类型或一些泛型类适合使用区间的泛型类型的模式,而其他人,如最终方法和类和try-with-resources块,将提高可读性和清洁度的大多数种代码基底的意图。无论哪种情况,重要的是不仅要知道这些技术的存在,还要知道何时明智地应用它们。

1. 协变返回类型

即使是最介绍性的Java操作手册也会包含关于继承、接口、抽象类和方法重写的内容,但是即使是高级的文本也很少会在重写方法时探索更复杂的可能性。例如,下面的代码片段即使是最初级的Java开发人员也不会感到惊讶:

public interface Animal {    public String makeNoise();}public class Dog implements Animal {    @Override    public String makeNoise() {        return "Woof";    }}public class Cat implements Animal {    @Override    public String makeNoise() {        return "Meow";    }}

这是多态性的基本概念:可以根据对象的接口(Animal::makeNoise)调用方法,但是方法调用的实际行为取决于实现类型(Dog::makeNoise)。例如,下面方法的输出会根据是否将Dog对象或Cat对象传递给该方法而改变:

public class Talker {    public static void talk(Animal animal) {        System.out.println(animal.makeNoise());    }}Talker.talk(new Dog()); //输出:低音Talker.talk(new Cat()); //输出:喵

虽然这是许多Java应用程序中常用的一种技术,但是在重写方法时可以采取一个不太为人所知的操作:更改返回类型。虽然这看起来是一种覆盖方法的开放方式,但是对于被覆盖方法的返回类型有一些严格的限制。根据Java 8 SE语言规范(第248页):

如果方法声明d 1返回类型为R 1覆盖或隐藏另一个方法d的声明 2返回类型为R 2,那么d 1是否可以用返回类型替换d 2,或者出现编译时错误。

返回类型替换表(同上,第240页)定义为

  1. 如果R1是空的,那么R2无效
  2. 如果R1那么原始类型是R吗2等于R1
  3. 如果R1是一种引用类型,则下列其中之一为真:R1适用于d的类型参数2是R的子类型吗2.R1可以转换为R的子类型吗2通过无节制的转换d1没有与d相同的签名吗2和R1R = |2|

可以说最有趣的案例是规则3.a。和3. b。:重写方法时,可以将返回类型的子类型声明为被重写的返回类型。例如:

public interface CustomCloneable {    public Object customClone();}public class Vehicle implements CustomCloneable {    private final String model;    public Vehicle(String model) {        this.model = model;    }    @Override    public Vehicle customClone() {        return new Vehicle(this.model);    }    public String getModel() {        return this.model;    }}Vehicle originalVehicle = new Vehicle("Corvette");Vehicle clonedVehicle = originalVehicle.customClone();System.out.println(clonedVehicle.getModel());

虽然clone()的原始返回类型是Object,但是我们能够在克隆的车辆上调用getModel()(不需要显式的强制转换),因为我们已经将Vehicle::clone的返回类型重写为Vehicle。这消除了对混乱类型强制转换的需要,我们知道我们要寻找的返回类型是一个载体,即使它被声明为一个对象(这相当于基于先验信息的安全类型强制转换,但严格来说是不安全的):

Vehicle clonedVehicle = (Vehicle) originalVehicle.customClone();

注意,我们仍然可以将车辆类型声明为对象,而返回类型将恢复为对象的原始类型:

Object clonedVehicle = originalVehicle.customClone();System.out.println(clonedVehicle.getModel()); //错误:getModel不是一个对象方法

注意,对于泛型参数不能重载返回类型,但是对于泛型类可以重载。例如,如果基类或接口方法返回一个列表 ,则可以将子类的返回类型重写为ArrayList ,但不能将其重写为List 。

2. 区间的泛型类型

创建泛型类是创建一组以类似方式与组合对象交互的类的最佳方法。例如,一个列表 只是存储和检索类型为T的对象,而不了解它所包含元素的性质。在某些情况下,我们希望约束泛型类型参数(T)使其具有特定的特征。例如,给定以下接口

public interface Writer {    public void write(); }

我们可能想创建一个特定的作家集合如下与复合模式:

public class WriterComposite<T extends Writer> implements Writer {    private final List<T> writers;    public WriterComposite(List<T> writers) {        this.writers = writer;    }    @Override    public void write() {        for (Writer writer: this.writers) {            writer.write();         }    }}

我们现在可以遍历一个Writer树,不知道我们遇到的特定Writer是一个独立的Writer(一个叶子)还是一个Writer集合(一个组合)。如果我们还想让我们的组合作为读者和作者的组合呢?例如,如果我们有以下接口

public interface Reader {    public void read(); }

如何将WriterComposite修改为ReaderWriterComposite?一种技术是创建一个新的接口ReaderWriter,将Reader和Writer接口融合在一起:

public interface ReaderWriter extends Reader, Writer {}

然后我们可以修改现有的WriterComposite如下:

public class ReaderWriterComposite<T extends ReaderWriter> implements ReaderWriter {    private final List<T> readerWriters;    public WriterComposite(List<T> readerWriters) {        this.readerWriters = readerWriters;    }    @Override    public void write() {        for (Writer writer: this.readerWriters) {            writer.write();         }    }    @Override    public void read() {        for (Reader reader: this.readerWriters) {            reader.read();         }    }}

虽然这确实实现了我们的目标,但是我们在代码中创建了膨胀:我们创建了一个接口,其惟一目的是将两个现有接口合并在一起。随着接口越来越多,我们可以开始看到膨胀的组合爆炸。例如,如果我们创建一个新的修饰符接口,我们现在需要创建ReaderModifier、WriterModifier和ReaderWriter接口。注意,这些接口没有添加任何功能:它们只是合并现有的接口。

为了消除这种膨胀,我们需要能够指定ReaderWriterComposite接受泛型类型参数(当且仅当它们既是读写器又是写器时)。交叉的泛型类型允许我们这样做。为了指定泛型类型参数必须实现读写接口,我们在泛型类型约束之间使用&操作符:

public class ReaderWriterComposite<T extends Reader & Writer> implements Reader, Writer {    private final List<T> readerWriters;    public WriterComposite(List<T> readerWriters) {        this.readerWriters = readerWriters;    }    @Override    public void write() {        for (Writer writer: this.readerWriters) {            writer.write();         }    }    @Override    public void read() {        for (Reader reader: this.readerWriters) {            reader.read();         }    }}

在不扩展继承树的情况下,我们现在可以约束泛型类型参数来实现多个接口。注意,如果其中一个接口是抽象类或具体类,则可以指定相同的约束。例如,如果我们将Writer接口更改为类似下面的抽象类。

public abstract class Writer {    public abstract void write();}

我们仍然可以约束我们的泛型类型参数是读者和作家,但是作者(因为它是一个抽象类,而不是一个接口)必须首先指定(也请注意,我们现在ReaderWriterComposite扩展了写信人抽象类并实现了接口,而不是实现两个):

public class ReaderWriterComposite<T extends Writer & Reader> extends Writer implements Reader {  //与前面一样的类}

还需要注意的是,这种交互的泛型类型可以用于两个以上的接口(或一个抽象类和多个接口)。例如,如果我们想要我们的组合也包括修饰符接口,我们可以写我们的类定义如下:

public class ReaderWriterComposite<T extends Reader & Writer & Modifier> implements Reader, Writer, Modifier {    private final List<T> things;    public ReaderWriterComposite(List<T> things) {        this.things = things;    }    @Override    public void write() {        for (Writer writer: this.things) {            writer.write();        }    }    @Override    public void read() {        for (Reader reader: this.things) {            reader.read();        }    }    @Override    public void modify() {        for (Modifier modifier: this.things) {            modifier.modify();        }    }}

尽管执行上述操作是合法的,但这可能是代码气味的一种标志(作为读取器、写入器和修饰符的对象可能是更具体的东西,比如文件)。

有关交互式泛型类型的更多信息,请参见Java 8语言规范。

3.Auto-Closeable类

创建资源类是一种常见的实践,但是维护资源的完整性可能是一个具有挑战性的前景,特别是在涉及异常处理时。例如,假设我们创建了一个资源类resource,并希望对该资源执行一个可能抛出异常的操作(实例化过程也可能抛出异常):

public class Resource {    public Resource() throws Exception {        System.out.println("Created resource");    }    public void someAction() throws Exception {        System.out.println("Performed some action");    }    public void close() {        System.out.println("Closed resource");    }}

无论是哪种情况(如果抛出或不抛出异常),我们都希望关闭资源以确保没有资源泄漏。正常的过程是将我们的close()方法封装在finally块中,确保无论发生什么,我们的资源在封闭的执行范围完成之前是关闭的:

Resource resource = null;try {    resource = new Resource();    resource.someAction();} catch (Exception e) {    System.out.println("Exception caught");}finally {    resource.close();}

通过简单的检查,有很多样板代码会降低对资源对象执行someAction()的可读性。为了纠正这种情况,Java 7引入了try-with-resources语句,通过该语句可以在try语句中创建资源,并在保留try执行范围之前自动关闭资源。要使类能够使用try-with-resources,它必须实现AutoCloseable接口:

public class Resource implements AutoCloseable {    public Resource() throws Exception {        System.out.println("Created resource");    }    public void someAction() throws Exception {        System.out.println("Performed some action");    }    @Override    public void close() {        System.out.println("Closed resource");    }}

我们的资源类现在实现了AutoCloseable接口,我们可以清理我们的代码,以确保我们的资源是关闭之前离开的尝试执行范围:

try (Resource resource = new Resource()) {    resource.someAction();} catch (Exception e) {    System.out.println("Exception caught");}

与不使用资源进行尝试的技术相比,此过程要少得多,并且维护了相同的安全性(在完成try执行范围后,资源总是关闭的)。如果执行上述try-with-resources语句,则得到以下输出:

Created resourcePerformed some actionClosed resource

为了演示这种使用资源的尝试技术的安全性,我们可以更改someAction()方法来抛出一个异常:

public class Resource implements AutoCloseable {    public Resource() throws Exception {        System.out.println("Created resource");    }    public void someAction() throws Exception {        System.out.println("Performed some action");        throw new Exception();    }    @Override    public void close() {        System.out.println("Closed resource");    }}

如果我们再次运行try-with-resources语句,我们将得到以下输出:

Created resourcePerformed some actionClosed resourceException caught

注意,即使在执行someAction()方法时抛出了一个异常,我们的资源还是关闭了,然后捕获了异常。这确保在离开try执行范围之前,我们的资源被保证是关闭的。同样重要的是,资源可以实现close - able接口,并且仍然使用try-with-resources语句。实现AutoCloseable接口和Closeable接口之间的区别在于close()方法签名抛出的异常类型:exception和IOException。在我们的例子中,我们只是更改了close()方法的签名,以避免抛出异常。

4. 最后的类和方法

在几乎所有的情况下,我们创建的类都可以由另一个开发人员扩展并定制以满足该开发人员的需求(我们可以扩展自己的类),即使我们并没有打算要扩展我们的类。虽然这对于大多数情况已经足够了,但是有时我们可能不希望覆盖某个方法,或者更一般地说,扩展某个类。例如,如果我们创建一个文件类,封装了文件系统上的文件的阅读和写作,我们可能不希望任何子类覆盖读(int字节)和写(字符串数据)方法(这些方法中的逻辑是否改变,它可能导致文件系统会损坏)。在这种情况下,我们将不可扩展的方法标记为final:

public class File {    public final String read(int bytes) {       //对文件系统执行读操作        return "Some read data";    }    public final void write(String data) {      //执行对文件系统的写操作    }}

现在,如果另一个类希望覆盖读或写方法,则会抛出编译错误:无法覆盖文件中的最终方法。我们不仅记录了不应该重写我们的方法,而且编译器还确保了在编译时强制执行这个意图。

将这个想法扩展到整个类,有时我们可能不希望我们创建的类被扩展。这不仅使类的每个方法都不可扩展,而且还确保了类的任何子类型都不会被创建。例如,如果我们正在创建一个使用密钥生成器的安全框架,我们可能不希望任何外部开发人员扩展我们的密钥生成器并覆盖生成算法(自定义功能可能在密码方面较差并危及系统):

public final class KeyGenerator {    private final String seed;    public KeyGenerator(String seed) {        this.seed = seed;    }    public CryptographicKey generate() {      //…做一些加密工作来生成密钥…    }}

通过使我们的KeyGenerator类成为final,编译器将确保没有类可以扩展我们的类并将自己作为有效的密钥生成器传递给我们的框架。虽然简单地将generate()方法标记为final似乎就足够了,但这并不能阻止开发人员创建自定义密钥生成器并将其作为有效的生成器传递。由于我们的系统是面向安全的,所以最好尽可能地不信任外部世界(如果我们提供了KeyGenerator类中的其他方法,聪明的开发人员可能会通过更改它们的功能来更改生成算法)。

尽管这看起来是对开放/封闭原则的公然漠视(事实的确如此),但这样做是有充分理由的。正如我们在上面的安全性示例中所看到的,很多时候,我们无法允许外部世界对我们的应用程序做它想做的事情,我们必须在关于继承的决策中非常慎重。像Josh Bolch这样的作者甚至说,一个类应该被有意地设计成可扩展的,或者应该显式地对扩展关闭(有效的Java)。尽管他故意夸大了这个想法(参见记录继承或不允许继承),但他提出了一个很好的观点:我们应该仔细考虑哪些类应该扩展,哪些方法可以重写。

结论

虽然我们编写的大多数代码只利用了Java的一小部分功能,但它足以解决我们遇到的大多数问题。有时候,我们需要更深入地研究语言,重新拾起那些被遗忘或未知的部分来解决特定的问题。其中一些技术,如协变返回类型和交互式泛型类型,可以在一次性的情况下使用,而其他技术,如自动关闭的资源和最终方法和类,可以而且应该更频繁地使用,以生成更具可读性和更精确的代码。将这些技术与日常编程实践相结合不仅有助于更好地理解我们的意图,而且有助于更好地编写更好的Java。

本文译自:Catalogic软件公司软件工程师Justin Albano的博客。