用懶載入等函式式思想重構Java的初始化

banq發表於2018-11-10

假設有一個簡單的程式來管理儲存在本地檔案系統上的某些檔案的後設資料,使用者可從磁碟讀取這些檔案並以某種方式處理它們。
管理檔案後設資料的類:

@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一旦載入就會被快取,所以我們只從磁碟載入一次。

 

相關文章