運用工廠、回呼與鞣製來自訂語意


iThome 網站首載:運用工廠、回呼與鞣製來自訂語意

基於相容性、程式典範、面對的問題與可能引入的複雜度等考量,程式語言的語法演化不易,相較之下程式庫進化則較為靈活,可根據既有的程式語法與某些模式進行組合,並考慮特定領域需求來為API命名,如此一來就可運用程式庫來構造特定領域語意。

用工廠模式隱藏物件建立細節

設計模式中的工廠(Factory)模式可用來隱藏物件建立細節。舉例來說,JDK6使用群集(Collection)的泛型(Generics)功能建立物件時,必須使用List<Integer> scores = new ArrayList<Integer>()之類的語法,等號兩邊重複指定了Integer型態資訊,因而JDK7使用List<Integer> scores = new ArrayList<>()語法來簡化型態指定,但在JDK6中要解決此問題,可以如下設計emptyList方法,如此一來,就可使用List<Integer> scores = emptyList()來建立實例
public static <T> List<T> emptyList() {
    return new ArrayList<T>();
}

JDK API本身亦有類似案例,像是Arrays的asList方法,可以使用asList(1, 2, 3)的方式構造List<Integer>實例。類似地,亦可設計如下設計set方法,之後就可以用set(1, 2, 3)的語法來建立Set<Integer>物件
@SafeVarargs
public static <T> Set<T> set(T... elems) {
    return new HashSet<>(Arrays.asList(elems));   
}

有時候程式語言本身語法雖不支援,但可透過工廠模式隱藏細節,以建立高階抽象語意。例如有些程式語言有建立字典(Dictionary)物件的實字(Literal)語法,像是Python可用{'Justin' : 123456, 'Monica' : 933933}語法來建立;Java的字典物件是Map實例,必須建立後逐一新增鍵(Key)值(Value),若想擁有類似的建立Map實字語法,可如下設計emptyMap與map方法,如此之來,就可使用map("Justin", 123456, "Monica", 933933)來建立Map<String, Integer>實例
public static <K, V> Map<K, V> emptyMap() {
    return new HashMap<>();
}

@SuppressWarnings("unchecked")
public static <K, V> Map<K, V> map(Object... kvs) {
    Map<K, V> map = emptyMap();
    for(int i = 0; i < kvs.length; i += 2) {
        map.put((K) kvs[i], (V) kvs[i + 1]);
    }
    return map;
}
   
使用樣版回呼重用共通流程

我之前專欄 實現共用程式碼樣版的模式 中談過樣版回呼(Template callback)模式,可將共用流程予以封裝,特定演算委託回呼物件執行,只要透過適當名稱,就可以賦予共用流程高階語意。例如說,Java中有條件成立時予以執行的if語法,但沒有條件不成立時予以執行的unless語法,若可如下設計Block方面與unless方法:
/* Block.java */
public interface Block {

    void apply();
}

/* 某類別中 */
public static void unless(boolean cond, Block block) {
    if(!cond) {
        block.apply();
    }
}


若結合JDK8的Lambda語法,則可以如下運用unless
int number = Integer.parseInt(args[0]);
unless(number == 2, () -> {
    out.println("猜錯囉!");
});

使用API創造語意時,必須配合語言原有語法支援,才不會有冗餘資訊並呈現出適切語意。例如上述unless的例子受限於Java語法,多了不必要的括號、箭頭符號與分號資訊,因此創造出來的unless與原語言語法有格格不入的感覺;同樣是unless的例子,Scala由於具備以名呼叫參數(By-name parameter)、鞣製(Curry)等語法特性,因而定義的unless方法,可以如以下方式使用,看來就像程式語言內建語法:
unless(number == 2) {
    println("猜錯囉!")
}

Ruby可指定區塊(Block)給方法,因而可構造出如下的語法:
butThat(number == 1) {
    print "猜錯囉!"
}

有時可試著結合工廠與回呼,利用工廠隱藏物件建立細節的特性,將繁瑣語法封裝起來。舉例來說,某個斷言為assertTrue(number < 2),若可試著如下設計Matcher介面、assertThat與lessThan方法,如此就可改用assertThat(1, lessThan(2))的方式進行斷言
/* Matcher.java */
public interface Matcher<T> {
    boolean matches(T item);
}

/* 某類別中 */
public static <T> void assertThat(T elem, Matcher<T> matcher) {
    if(!matcher.matches(elem)) {
        throw new RuntimeException("Assertion fails");
    }
}

@SuppressWarnings("unchecked")
public static <T extends Comparable> Matcher<T> lessThan(Object that) {
    return elem -> elem.compareTo(that) < 0;
}

類似地,像assertTrue(numbers.contains(1) && numbers.contains(3) && numbers.contains(4))不易閱讀的語法,可設計如下的hasItems()方法,之後就可以使用assertThat(numbers, hasItems(1, 3, 4))來進行斷言
public static <T extends Collection> Matcher<T> hasItems(Object... elems) {
    return (T c) -> {
        for(Object elem : elems) if(!c.contains(elem)) {
            return false;
        }

        return true;
    };
}

使用鞣製概念建立管線操作

透過設計,還可進一步地自由組合API形成更複雜語意。例如想達成assertThat(numbers, hasItem(lessThan(5)))效果,可如下設計hasItem()方法,就可取代不易閱讀的assertTrue(numbers.get(0) < 5 || numbers.get(1) < 5 || numbers.get(2) < 5)
@SuppressWarnings("unchecked")
public static <T extends Collection> Matcher<T> hasItem(Matcher matcher) {

    return (T c) -> {
        for(Object o : c) if(matcher.matches(o)) {
            return true;
        }

        return false;
    };
}

我之前專欄 函式的鞣製化 中談過,使用傳回物件保有前次操作成果,以便開發者進一步作後續方法呼叫,因而可形成方法鏈(Method chain),這種形式可說是鞣製概念的延伸。JavaScript程式庫jQuery的鏈狀風格,Java的ORM框架Hibernate的Criteria API,或者是JDK8中為Lambda語法增加的Collection API,都運用了相同概念。

實際上,像assertThat(numbers, everyItem(lessThan(5)))的組合方式,也是鞣製概念的延伸。相較於session.createCriteria(User.class).setFirstResult(51).setMaxResults(50).list()風格是由左而右逐一生成具可執行能力的物件,assertThat(numbers, everyItem(lessThan(5)))則是由右而左逐一生成具可執行能力的物件,lessThan會生成Matcher實例作為hasItem的回呼物件,而hasItem再生成Matcher實例作為assertThat的回呼物件,無論是由左而右或是由右而左,形成的管線(Piped)操作只要方法命名適當,都可為程式增加不少語意上的可讀性。

思考程式庫於特定領域的命名與組合方式

今日的程式庫不再僅是共用功能的集中地,特定領域的程式庫,某種程度上就是在建立特定領域語言,只不過有時程式庫本身的命名過於空泛而不易看出,與其如此,不如思考基於程式既有語法,採用適當模式建立特定領域的相關語意,並重視API命名,讓程式庫不僅是程式庫,而是作為語言的延伸。

舉例來說,JDK8的Collection配合Lambda語法增加了一些高階操作,實際上前述的hasItems是在進行一種allMatch操作,然而使用allMatch的高階語意來撰寫會是assertTrue(asList(1, 2, 3).allMatch(elem -> numbers.contains(elem))),雖然重用了allMatch定義的流程,但可讀性並不好,不如針對測試領域採用assertThat(numbers, hasItems(1, 2, 3))來得清楚;類似地,前述的hasItem實際上是在進行一種anyMatch操作,然而與其採用assertTrue(numbers.anyMatch(elem -> elem < 5))的寫法,不如針對測試領域在命名上給予巧思,改採可讀性更好的assertThat(numbers, hasItem(lessThan(5)))寫法