用懒加载等函数式思想重构Java的初始化


假设有一个简单的程序来管理存储在本地文件系统上的某些文件的元数据,用户可从磁盘读取这些文件并以某种方式处理它们。
管理文件元数据的类:

@Setter
@Getter
public class DataFileMetadata {

    private long customerId;
    private String type;
    private File f;
    private String contents;

    public void loadContents(){
        try {
            contents = loadFromFile();
        }catch(IOException e){
            throw new DataFileUnavailableException(e);
        }
    }
    private String loadFromFile() throws IOException {
        return new String(Files.readAllBytes(f.toPath()));
    }


在这个类中,一切都是可变的,contents内容可能是空,或可能是读取的文件字节内容。loadContents委托loadFromFile从文件中不断读取变化的内容。

惊人的复杂性
代码只有20行,却复杂得多。getContents方法和loadContents方法之间存在相互依赖关系。在访问内容之前需要调用loadContents
我们可以创建一个FileManager类,它接受DataFileMeta来处理并通过customerId将它们的内容存储在HashMap中。

public class FileManager {
    
    private Map<Long,String> dataTable = new HashMap<>();
    
    public void process(DataFileMetadata metadata){
        
        dataTable.put(metadata.getCustomerId(),metadata.getContents());
        
    }
    
}

如果您要提交此代码以进行代码审查,您可能会收到一些信息性反馈,即对FileManager :: process的调用将导致将空值存储在HashMap中,并且您需要在process方法中调用metadata.loadContents (

metadata是
前面的代码的类)。你加一段代码检查一下是否为空:
 if(metadata.getContents()==null)
      metadata.loadContents();

如果你曾经在一个庞大而复杂的应用程序中工作,其中代码审查一般可能是功能交付的瓶颈,应用代码中只有少数深度专家可以充分评估代码更改的影响 - 这种类型的逻辑和编码风格往往是核心原因:我们也未能正确地封装两种方法之间的核心关系,造成抽象泄漏。

重构

DataFileMetadata类

    private String contents = loadContents;

    private void loadContents(){
        try {
            contents = loadFromFile();
        }catch(IOException e){
            throw new DataFileUnavailableException(e);
        }
    }

将loadContents方法从public变成private,文件内容加载到内存中,直接赋予contents = loadContents,这样杜绝了getContents方法。它也会造成性能损失,因为我们现在在创建元数据对象时将每个文件加载到内存中。


函数概念:Laziness 
可以重载get方法来解决在创建对象时将每个文件加载到内存中的性能问题:

    private String contents = loadContents;

    public String getContents(){
      if (contents == null)
        loadContents();
      return contents;
    }
    private void loadContents(){
        try {
            contents = loadFromFile();
        }catch(IOException e){
            throw new DataFileUnavailableException(e);
        }
    }

再进一步,每次调用getContents时我们也不需要从磁盘加载内容,我们可以引入空检查以仅在第一次调用时加载内容。

现在我们已经成功封装了DataFileMetadata类的文件管理方面,并通过引入懒惰来获得可接受的性能。我们以强制性的方式实现了所有这些,因此代码相当冗长。我们可以通过引入一些函数类型来清理这些冗长代码 

Supplier :laziness
 JDK的Supplier接口是一个SAM函数接口,,代表了可以在未来懒加载内容,我们可以定义一个指向从File加载数据的方法的Supplier实例:

Suppilier <String> contents = this :: loadFromFile;

也就是说,我们可以创建一个指向用于从文件加载数据的方法的Supplier类型,loadContents方法的返回类型需要重构为返回一个String,并直接返回文件的内容。

  private String loadContents(){
        try {
            return loadFromFile();
        }catch(IOException e){
            throw new DataFileUnavailableException(e);
        }
    }

前面调用的代码重构为:
   

 private Suppilier <String> contents = this::loadFromFile;
  
   public String getContents(){
      return contents.get();
    }

但是,现在我们已经失去了缓存!

函数:缓存
Memoization与函数式编程中的懒惰密切相关,并且指的是缓存和重新使用延迟计算值的能力。实现Memoization非常简单,在Java中使用ConcurrentHashMap,我们可以为Supplier 定义一个Memoization函数:

public static <T> Supplier<T> memoizeSupplier(final Supplier<T> s) {
   final Map<Long,T> lazy = new ConcurrentHashMap<>();
   return () -> lazy.computeIfAbsent(1l, i-> s.get());
}

通过MemoizeSupplier方法传递的Supplier都会自动缓存它的结果

int called = 0;
Supplier<Integer> lazyCaching = memoizeSupplier(()->called++);

一旦至少一次调用lazyCaching.get(),called结果将始终保持为1。但是lazyCaching.get()一直是0;

Cyclops库包
用于Java函数编程的cyclops库为我们提供了一个实现,我们可以将它添加到我们的Maven Gradle类路径中

<dependency>
 <groupId>com.oath.cyclops</groupId>
 <artifactId>cyclops</artifactId>
 <version>10.0.1</version>
</dependency>

调用代码:

 private Suppilier <String> contents = Memoize.memoizeSupplier(this::loadFromFile);


cyclops 有一个数据类型在缓存和非缓存情况统一处理懒加载:Eval:

private Suppilier <String> contents = Eval.later(this::loadFromFile);​​​​​​​


在我们的初始实现中,contents字段必须是可变的,以支持它的懒惰加载(通过getContents方法)。现在,contents可以(应该)变成不可变的了,只有在第一次调用getContents方法时,contents才会从磁盘上延迟加载。contents一旦加载就会被缓存,所以我们只从磁盘加载一次。