重複性管理——從泛值到泛型以及泛函(中)

在前面,我們探討了泛型範式在解決重複性問題上的應用,在這裏,將繼續探討泛函範式在解決重複性問題上的作用。

注:關於“泛函(functional)”這一名稱,前面說了,泛型的本質是“參數化類型”,那麼,按照這一思路,泛函的意思也可以理解爲“函數的參數化”或者現在時髦的所謂“函數式編程(functional programming)”吧!

當然,你可以有自己的看法,這裏用這種比較概括性的說法可以使得標題等比較簡短,我也承認,很多時候,想取一個簡短又準確的名字是不容易的。

從高斯的求和故事說起

據說高斯(Gauss,德國數學家)同學小時候,有一次老師讓大家求從 1 加到 100 的和,當其它小朋友還在埋頭苦算時,我們的小高斯同學卻很快給出了結果:5050!

image

老師和其它小夥伴都驚呆了:

原來聰明的高斯同學注意到了一個事實,那就是:1+100=101,2+99=101,... 50+51=101,總共有 50 組,所以 50 * 101 = 5050,Done!

現在我們用程序來解決這一問題,我們就不用那些奇淫技巧了,簡單粗暴一個 for 循環求和,以計算機速度之飛快,妥妥秒殺我們的高斯同學:

/** 求普通和 */
public static int sum() {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += i;
  }
  return sum;
}

更多的求和

現在,讓我們來看更多的求和問題,除了普通的求和,我們還可能想求比如平方和,那麼可以這樣寫:

/** 求平方和 */
public static int sum() {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += i * i;
  }
  return sum;
}

如果想求立方和,可以這樣寫:

/** 求立方和 */
public static int sum() {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += i * i * i;
  }
  return sum;
}

自然,我們的計算機在做起這些反覆的類似的工作來是毫無怨言而且是又快又好的,可是一再類似的重複工作卻會讓我們人類心生厭倦。

一再重複的模式

讓我們具體看看,重複的 bad smell 壞味道很容易就能嗅到,請看下面的對比:

不難注意到,除了 += 右邊存在差異外,代碼的其它地方都是一樣的!

從字面看,也不難發現重複:

從求普通和,到求平方和,再到求立方和,自然,我們是不能忍受這種一再重複的。我們的語言能否表達出“求和”本身這一抽象概念,而不是限於求具體的某種和?如何去消除這種模式的重複呢?

去重的初步設想

按照我們之前在泛型範式中的討論,很容易就能想到:能否把這些差異參數化,外部化呢?比如這樣:

public static void main(String[] args) {
  sum(i);
  sum(i * i);
  sum(i * i * i);
}
 
public static int sum(Object exp) {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += exp;
  }
  return sum;
}

當然,以上代碼在 Java 下是不能編譯通過的,但它的確清晰的表達出了我們的意圖。再仔細想想,我們想要的效果大概是這樣:

public static int sum(Function f) {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += f(i);
  }
  return sum;
}
 
public static int identity(int i) {
  return i;
}
 
public static int square(int i) {
  return i * i;
}
 
public static int cube(int i) {
  return i * i * i;
}

我們想要的是傳遞一個函數(或者說方法)進來,然後在我們的求和函數中調用它。

public static void main(String[] args) {
  sum(identity);
  sum(square);
  sum(cube);
}

很遺憾,以上代碼在 Java 中依然是不能編譯通過的。

如果是使用 javascript 這樣的語言,這樣寫已經差不多了。不過這裏不打算列舉具體的代碼實現。

不過,再做些調整,就能達到我們的意圖了。

傳統的解決方案

自然,我們也可以一下子跳到函數式的解決方案上去,這在 Java 1.8 支持了 lambda 方式之後也並不是什麼問題了;或者你直接使用一個原生就支持函數式的語言那也 OK,比如 javascript。

不過,這裏還是打算一步一步的來,這樣有助於我們理清事情的來龍去脈,更加清晰的體會到函數式的好處。

如果你沒有耐心,可以直接直接跳過此章節。我也承認,有時這種技術文章不好寫,寫得詳細,基礎好的同學可能覺得囉嗦;寫得簡略,讀者可能又覺得跳躍性太大,不好理解。這裏做個折中,寫得是儘量詳細,但也分成了不同的章節,你可以根據需要取捨。

if-else, naive 的方式

最簡單也最容易想到的方式就是用 if-else 來判斷不同情況,這種方式的代碼如下:

public static void main(String[] args) {
  int idSum = sum("identity");
  int sqSum = sum("square");
  int cbSum = sum("cube");
   
  System.out.print(idSum + " " + sqSum + " " + cbSum);
}
 
public static int sum(String type) {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    int temp = 0;
    if ("identity".equals(type)) {
      temp = i;
    } else if ("square".equals(type)) {
      temp = i * i;
    } else if ("cube".equals(type)){
      temp = i * i * i;
    } else {
      // TODO error
    }
    sum += temp;
  }
  return sum;
}

很簡單,就是通過一個 String 的類別參數,然後用 if-else 的方式來判斷,它在一定程度上解決了重複,比如循環的代碼只出現了一遍,但其弊端也是很明顯的。

首先,儘管參數傳遞進來後就不會再變了,可是循環中還是每次都會去判斷,影響了性能,某種程度上看也是一種重複。

如果我們把判斷放在 for 循環外面,那又不得不重複 for 循環那些代碼,跟之前差不多。

其次是一旦有新的求和方式要添加,又不得不修改這些代碼。

它違反了所謂的開閉原則(OCP:Open Closed Principle),軟件中的對象(類,模塊,函數等等)應該對於擴展是開放的,但是對於修改是封閉的。(open for extension, but closed for modification)

通常會建議使用多態來代替這些條件判斷,參見 Martin Fowler 的這篇文章:https://refactoring.com/catalog/replaceConditionalWithPolymorphism.html

多態策略(Polymorphism)

if-else 的方式很容易想到,但弊端也很明顯,我們需要更好的解決方案。

實際上前面的初步設想已經很接近滿足需求了,只不過傳統的 Java 語言堅持“一切都是對象”,對象在 Java 中是第一級(first-class)的,可以做參數,可以放在變量中,可以作爲返回值等等。

關於第一級(first-class)的概念,後面還會具體介紹。

但它不能支持或者說不直接支持傳遞函數或方法的引用。爲此,我們不得不引入一個叫 MyFunction 的接口,裏面有一個簡單的 apply 方法,接受 int 參數,返回一個 int 結果:

public interface MyFunction {
  public int apply(int i);
}
 
public static int sum(MyFunction f) {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += f.apply(i);
  }
  return sum;
}

然後,弄幾個類實現這一接口:

class Identity implements MyFunction {
  @Override
  public int apply(int i) {
    return i;
  }
}
 
class Square implements MyFunction {
  @Override
  public int apply(int i) {
    return i * i;
  }
}
 
class Cube implements MyFunction {
  @Override
  public int apply(int i) {
    return i * i * i;
  }
}

這樣,想進行不同的求和時,new 出具體的類即可:

public static void main(String[] args) {
  int idSum = sum(new Identity());
  int sqSum = sum(new Square());
  int cbSum = sum(new Cube());
   
  System.out.println(idSum + " " + sqSum + " " + cbSum);
}

同時,它也具有良好的可擴展性,想進行新的求和,可以創建出新的類並實現接口即可。

泛型是參數化多態,接口和繼承則是子類型多態,不過這裏不打算去探討它們的細節。

這種方式大概是 GoF 說的“策略模式”(strategy)。

GoF: gang of four,就是寫《設計模式》一書的四個傢伙(四人幫)

不過,由於不少模式有些相似,我也記不清了這到底是策略模式還是模板方法,還是其他,亦或都不是,如果你比較清楚,歡迎留言。

不過,它的缺陷在這種簡單需求中也體現得很明顯,有許多的類要定義,大量重複的腳手架(scaffold)的代碼。

應該說,藉助於現代的 IDE,書寫這些代碼也不是很難了,不過有些人可能還是會覺得不爽。

畢竟,反覆地寫那些樣板代碼某種程度也是一種重複性的問題。

匿名內部類(Anonymous Inner Class)

如果對於簡單的需求不想定義太多的類,可以使用匿名類的方式:

public static void main(String[] args) {
  // 匿名類方式
  int idSum = sum(new MyFunction() {
    @Override
    public int apply(int i) {
      return i;
    }
  });
   
  int sqSum = sum(new MyFunction() {
    @Override
    public int apply(int i) {
      return i * i;
    }
  });
   
  int cbSum = sum(new MyFunction() {
    @Override
    public int apply(int i) {
      return i * i * i;
    }
  });
   
  System.out.println(idSum + " " + sqSum + " " + cbSum);
}

這種方式一定程度上減輕了某些重複繁瑣的工作,但依舊還是有不少的樣板代碼,不夠簡潔,重點也不突出。

反射方式(Reflection)

假如我們的代碼中已經存在諸如求平方,求立方等工具類的代碼,

public class MathUtil {
  public static int identity(int i) {
    return i;
  }
 
  public static int square(int i) {
    return i * i;
  }
 
  public static int cube(int i) {
    return i * i * i;
  }
}

而且我們也不想再定義什麼接口及子類型,儘管這在一定程度也解決了我們的問題,但回到我們最初的意圖,我們就想傳入一個方法,然後調用一下它而已。

這大概類似於 C++ 等語言中的函數指針。

Java 並不直接支持傳遞函數引用,但通過反射的方式,也還是能夠間接得做到這一點的。我們來看下:

public static void main(String[] args) throws Exception {
  // int.class 表示方法參數的類型
  int idSum = sum(MathUtil.class.getMethod("identity", int.class));
  int sqSum = sum(MathUtil.class.getMethod("square", int.class));
  int cbSum = sum(MathUtil.class.getMethod("cube", int.class));
   
  System.out.print(idSum + " " + sqSum + " " + cbSum);
}
 
public static int sum(Method m) throws Exception {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    // 第一個參數爲 null 表示爲靜態方法,沒有對象與之關聯
    // 返回值爲 Object 類型,所以需要強制類型轉換
    sum += (int)m.invoke(null, i);
  }
  return sum;
}

可以看到,通過反射,方法也能被參數化了,這樣直接就解決了我們的問題。

當然,弊端也不少,比如很多異常要處理:

爲求簡潔,示例代碼中直接拋出了所有異常,但真實應用中,這樣做是很草率的。

其次,直接使用字符串參數,也沒有編譯期的檢查,寫錯了不到運行時也發現不了。

再次,大量反射的運用也有潛在的性能開銷。

總體而言,至少在這個問題上,反射方案還是不夠簡潔優雅,雖然已經很接近我們最終的意圖了。從根源上講,問題出在 Java 不能直接支持所謂的“函數第一級(first-class function)”上。

JCP 社區的大佬們似乎也聽到了羣衆的呼聲,推出的 JDK 8.0 總算是在這個問題上有了交待。

在進一步講解之前,我們先簡單瞭解下“函數第一級”的概念。

函數第一級(First-class Function)

一般而言,程序設計語言總會對計算元素的可能使用方式強加上某些限制。帶有最少限制的元素被稱爲具有第一級(first-class)的狀態。第一級元素的某些“權利或者特權”包括:

  • 可以用變量命名;
  • 可以提供給過程作爲參數;
  • 可以由過程作爲結果返回;
  • 可以包含在數據結構中。

注:以上說法直接來自《SICP》一書中,這裏所謂的“過程”,可以認爲就是“方法”或者“函數”。

程序設計語言元素的第一級狀態的概念應歸功於英國計算機科學家 Christopher Strachey。

簡單地講,函數第一級就是函數可以做參數,可以作爲返回值等等。

高階函數(Higher Order Function)

有了函數第一級,一些函數就可以接受函數作爲參數,也可以把函數作爲返回值返回,這樣的函數,我們稱之爲“高階函數(higher order function)”,高階函數可以爲我們提供強大的抽象能力,從而消除一些我們用普通方式不能或者很難消除的重複。

簡單講,可以認爲它們是函數的函數,用我們前面的話講,它們是代碼的代碼,抽象之抽象,模板的模板,等等。

泛函的解決方案(lambda 式)

有了 JDK 1.8,有了函數第一級,我們就可以把 sum 函數定義爲一個高階函數,它接受一個函數作爲參數,這裏用 java.uitl 包下的 Function 類型表示這樣一個泛函參數:

public static int sum(Function<Integer, Integer> f) {
  int sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += f.apply(i);
  }
  return sum;
}

它有個 apply 方法,但並不需要我們去實現,傳遞給它的方法就是它的實現,所以直接傳遞一個方法引用給它即可:

public static void main(String[] args) {
  int idSum = sum(MathUtil::identity);
  int sqSum = sum(MathUtil::square);
  int cbSum = sum(MathUtil::cube);
}

注意這裏的寫法,類後面跟着兩個冒號(::),然後是方法名。

這裏並沒有在調用這個方法,沒有括號,也沒有參數,實際上它就是我們一開始所設想的那種意圖,僅僅是傳遞一個方法引用而已。

跟反射的方式比較的話,它不是一個 String,而更像是一個符號類型(Symbol ),支持編譯器檢查,也支持 IDE 的代碼提示,如果你寫錯了,IDE 會提示你出錯了,不用像反射那樣到運行期才能知道。

這裏甚至可以使用所謂的 lambda 表達式,進行所謂“函數式編程”:

int idSum = sum(i -> i);// 求普通和
int sqSum = sum(i -> i * i);// 求平方和
int cbSum = sum(i -> i * i * i);// 求立方和
 
int dbSum = sum(i -> 2 * i);// 求兩倍和
int qrSum = sum(i -> i * i * i * i);// 求四次方和

這裏的箭頭表達式就是所謂的 lambda 表達式了,可以看到,我們可以很輕鬆地寫出求普通和,平方和,立方和,乃至四次方和等等,幾乎消除了所有的腳手架式的代碼,非常簡潔優雅。

也可以直接複用 Math 類中的方法:

double sinSum = sumf(Math::sin);

因爲 sin 需要 double 的參數,這裏需要調整 sum 的參數爲 double:

public static double sumf(Function<Double, Double> f) {
  double sum = 0;
  for (int i = 1; i <= 100; i++) {
    sum += f.apply((double) i);
  }
  return sum;
}

然後還可以直接複用 Math 裏的 pow 方法來做平方和立方等等:

double sqSum2 = sumf(i -> Math.pow(i, 2));
double cbSum2 = sumf(i -> Math.pow(i, 3));

總結

可以看出,引入了函數式編程後,代碼顯得直接,簡潔,優雅。利用高階函數的抽象,我們去除了重複,消除了耦合。

由於篇幅的關係,關於泛型與泛函的一個綜合總結,留待下篇再分析。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章