函數語言程式設計雜談

vivo網際網路技術發表於2019-09-19

本文首發於 vivo網際網路技術 微信公眾號 
連結: https://mp.weixin.qq.com/s/gqw57pBYB4VRGKmNlkAODg
作者:張文博

比起指令式程式設計,函數語言程式設計更加強調程式執行的結果而非執行的過程,倡導利用若干簡單的執行單元讓計算結果不斷演進,逐層推匯出複雜的運算。本文通過函數語言程式設計的一些趣味用法來闡述學習函數語言程式設計的奇妙之處。

一、程式設計正規化綜述

程式設計是為了解決問題,而解決問題可以有多種視角和思路,其中普適且行之有效的模式被歸結為“程式設計正規化”。程式語言日新月異,從彙編、Pascal、C、C++、Ruby、Python、JS,etc...其背後的程式設計正規化其實並沒有發生太多變化。拋開各語言繁紛複雜的表象去探究其背後抽象的程式設計正規化可以幫助我們更好地使用computer進行compute。

1.命令式

計算機本質上是執行一個個指令,因此程式設計人員只需要一步步寫下需要執行的指令,比如:先算什麼再算什麼,怎麼輸入怎麼計算怎麼輸出。所以程式語言大多都具備這四種型別的語句:

  1. 運算語句將結果存入儲存器中以便日後使用;

  2. 迴圈語句使得一些語句可以被反覆執行;

  3. 條件分支語句允許僅當某些條件成立時才執行某個指令集合;

  4. 以及存有爭議的類似goto這樣的無條件分支語句。

使得執行順序能夠轉移到其他指令之處。

無論使用匯編、C、Java、JS 都可以寫出這樣的指令集合,其主要思想是關注計算機執行的步驟,即一步一步告訴計算機先做什麼再做什麼。所以命令式語言特別適合解決線性的計算場景,它強調自上而下的設計方式。這種方式非常類似我們的工作、生活,因為我們的日常活動都是按部就班的順序進行的,甚至你可以認為是程式導向的。也比較貼合我們的思維方式,因此我們寫出的絕大多數程式碼都是這樣的。

2.宣告式

宣告式程式設計是以資料結構的形式來表達程式執行的邏輯,它的主要思想是告訴計算機應該做什麼,但不指定具體要怎麼做(當然在一些場景中,我們也還是要指定、探究其如何做)。SQL 語句就是最明顯的一種宣告式程式設計的例子,例如:“SELECT * FROM student WHERE age> 18”。因為我們歸納剝離了how,我們就可以專注於what,讓資料庫來幫我們執行、優化how。

有時候對於某個業務邏輯目前沒有任何可以歸納提取的通用實現,我們只能寫指令式程式設計程式碼。當我們寫成以後,如果進行思考歸納抽象、進一步優化,就為以後的宣告式做下鋪墊。

通過對比,指令式程式設計模擬電腦運算,是行動導向的,關鍵在於定義解法,即“怎麼做”,因而演算法是顯性而目標是隱性的;宣告式程式設計模擬人腦思維,是目標驅動的,關鍵在於描述問題,即“做什麼”,因而目標是顯性而演算法是隱性的。

3.函式式

函數語言程式設計將計算機運算視為函式運算,並且避免使用程式狀態以及易變物件。這裡的“函式”不是指計算機中的函式,而是指數學中的函式,即自變數的對映。也就是說一個函式的值僅決定於函式引數的值,不依賴其他狀態。比如f(x),只要x不變,不論什麼時候呼叫,呼叫幾次,值都是不變的。比起指令式程式設計,函數語言程式設計更加強調程式執行的結果而非執行的過程,倡導利用若干簡單的執行單元讓計算結果不斷演進,逐層推匯出複雜的運算,而不是設計一個複雜的執行過程。函式作為一等公民,可以出現在任何地方,比如你可以把函式作為引數傳遞給另一個函式、還可以將函式作為返回值。

函數語言程式設計的特點:

  1. 減少了可變數的宣告,程式更為安全;

  2. 相比指令式程式設計,少了非常多的狀態變數的宣告與維護,天然適合高併發多執行緒平行計算等任務,我想這也是函式是程式設計近年又大熱的重要原因之一;

  3. 程式碼更為簡潔,但是可讀性是高是低也依賴於不同場景、仁者見仁智者見智。

二、函數語言程式設計的一些趣味用法

1.Closure(閉包)

public class OutClass {
  private void helloWorld() {
    System.out.println("Hello World!");
  }
  public InnerClass getInnerClass() {
    return new InnerClass();
  }
  public class InnerClass {
    public void hello() {
      helloWorld();
    }
  }
  /**
   * @param args
   */
  public static void main(String[] args) {
    // 在外部使用OutClass的private方法
    new OutClass().getInnerClass().hello();
  }
}

在Java中有很多方式實現上述目的,因為我們的作用域和JS有著巨大差異。但是借鑑閉包的原理,我們來看一個場景。假設介面A有一個方法m;介面B也有一個同名的方法m,兩個方法的簽名完全一樣但是功能卻不一樣。類C想要同時實現介面A和介面B中的方法。因為兩個介面中的方法簽名完全一致,所以C只能有一個m方法,這種情況下應該怎麼實現需求呢?

public class C implements A {
  @Override
  public void m() {
    //...
  }
  private void o() {
    //...
  }
  public D getD() {
    return new D();
  }
  class D implements B {
    @Override
    public void m() {
      o();
    }
  }
  public static void main(String[] args) {
      C c = new C();
      c.m();
      c.getD().m();
  }
}


2.Currying(柯里化)

我對柯里化(Currying)的理解:柯里化函式可以接收一些引數,接收了這些引數之後,該函式並不是立即求值,而是繼續返回另一個函式,剛才傳入的引數在函式形成的閉包中被儲存起來,待到函式真正需要求值的時候,之前傳入的所有引數都能用於求值。

下面先通過JS(個人感覺通過JS比較好理解)對柯里化有一個直觀的認識。

var calculator = function(x, y, z){
    return(x + y)* z;
}


呼叫:calculator( 2, 7, 3);

柯里化寫法:

var calculator=function(x){
  return function(y){
    return function(z){
      return(x + y)* z;
    };
  };
};


呼叫:calculator(2)(7)(3);

通過對比,我們發現柯里化的數學描述應該類似這樣,calculator(2, 7, 3) ---> calculator(2)(7)(3)。

現在我們來回頭看看柯里化較為學術的定義,是把接受多個引數的函式變換成接受一個單一引數的函式,並且返回接受餘下的引數的新函式,這個新函式最後還能返回所有輸入的運算結果。

Java 中的柯里化實現

Function<Integer, Function<Integer, Function<Integer, Integer>>> currying =
    new Function<Integer, Function<Integer, Function<Integer, Integer>>>() {
    @Override
    public Function<Integer, Function<Integer, Integer>> apply(Integer x) {
        return new Function<Integer, Function<Integer, Integer>>() {
            @Override
            public Function<Integer, Integer> apply(Integer y) {
                return new Function<Integer, Integer>() {
                    @Override
                    public Integer apply(Integer z) {
                        return (x + y) * z;
                    }
                };
            }
        };
    }
};
//在這裡,我們可以發現,雖然依次輸入2、7,但是我們並不會計算結果,而是等到最後輸入結束時才會返回值。
Function function1 = curryingFun().apply(2);//返回的是函式
Function function2 = curryingFun().apply(2).apply(7);//返回的是函式
Integer value = curryingFun().apply(2).apply(7).apply(3);//引數全部輸入,返回最後的值


柯里化的爭論

(1)支援的觀點

  • 延遲計算,只有在最後的輸入結束才會進行計算;

  • 當你發現你要呼叫一個函式,並且呼叫引數都是一樣的情況下,這個引數就可以被柯里化,以便更好的完成任務;

  • 優雅的寫法,語義更有表達力;

(2)不過也有一些人持反對觀點,引數的不確定性、排查錯誤困難。

3.Promise

Promise 是非同步程式設計的一種解決方案,比傳統的諸如“回撥函式、事件”解決方案,更合理和更強大。ES6已經廣泛應用。我在這裡主要分析兩個最常見的用法。

  • then

Promise例項生成以後,可以用then方法分別指定resolved狀態和rejected狀態的回撥函式。then方法返回的是一個新的Promise例項(注意,不是原來那個Promise例項)。因此可以採用鏈式寫法,即then方法後面再呼叫另一個then方法。

promise.then(function(value) {
 // success
}, function(error) {
 // failure
}).then(...);


  • all

Promise.all方法用於將多個 Promise 例項,包裝成一個新的 Promise 例項。

const p = Promise.all([p1, p2, p3]);


上面程式碼中,Promise.all方法接受一個陣列作為引數,p1、p2、p3都是 Promise 例項,p的狀態由p1、p2、p3決定,分成兩種情況。

  • 只有p1、p2、p3的狀態都變成fulfilled,p的狀態才會變成fulfilled,此時p1、p2、p3的返回值組成一個陣列,傳遞給p的回撥函式。

  • 只要p1、p2、p3之中有一個被rejected,p的狀態就變成rejected,此時第一個被reject的例項的返回值,會傳遞給p的回撥函式。

下面是一個具體的例子:

// 生成一個Promise物件的陣列
const promises = [1,2,3.....].map(function (id) {
  return getJSON('/post/' + id + ".json");
});
Promise.all(promises).then(function (posts) {
  // ...
}).catch(function(reason){
   // ...
});


Java的實現

Java中的使用方法目前確實不如js方便,可以看看CompletableFuture,給我們提供了一些方法。

4.Partial Function

其定義如下:當函式的引數個數太多,可以建立一個新的函式,這個新函式可以固定住原函式的部分引數,從而在呼叫時更簡單。下面是基於Python的實現。個人覺得,最大的便利就是避免我們再去寫一些過載的方法。不過暫時沒有看到partial的Java版本。看到這裡,大家肯定認為“偏函式”這個翻譯實在是不準確,如果直譯過來叫“部分函式”好像也不怎麼清晰,我們姑且還是稱其為Partial Function。

# !/usr/bin/python
# -*- coding: UTF-8 -*-
from functools import partial
def multiply(x, y):
  return x * y
print(multiply(3,4))# 輸出12
multiply4 = partial(multiply, y =4)# 不需要定義過載函式
print(multiply4(3))# 輸出12


5.map/reduce

Java現在對map、reduce也做了支援,特別是map已經是大家日常編碼的利器,相信大家也都不陌生了。map(flatMap)按照規則轉換輸入內容,而reduce則是通過某個連線動作將所有元素彙總的操作。但是在這裡我還是使用Python的例子來進行闡述,因為我覺得Python看起來更簡潔明瞭。

# !/usr/bin/python
# -*- coding: UTF-8 -*-
from functools import reduce
def addTen(x):
    return x + 10
def add(x, y):
    return x + y
r = map(addTen, [1, 2, 3, 4, 5, 6, 7, 8, 9])
print r  #[11, 12, 13, 14, 15, 16, 17, 18, 19]
total = reduce(add, r)
print total #[11, 12, 13, 14, 15, 16, 17, 18, 19]加和等於135

6.divmod

divmod是Python的函式,我之所以專門來講述,是因為它所代表的思想確實新穎。函式會把除數和餘數運算結果結合起來返回,如下。不過Java肯定不支援。

//把秒數轉換成時分秒結構顯示
def parseDuration( seconds ):
    m, s = divmod(int(seconds), 60)
    h, m = divmod(m, 60)
    return  ("%02d:%02d:%02d" % (h, m, s))

三、關於Scala

上述很多特性,Scala都提供了支援,它整合了物件導向程式設計和函數語言程式設計的一些特性,感興趣的同學可以瞭解一下。之前看過介紹,Twitter對於Scala的應用比較多,推薦閱讀  Twitter Effective Scala 。

四、結語:我們為什麼要學習函數語言程式設計

在很多時候,無可否認指令式程式設計很好用。當我們寫業務邏輯時會書寫大量的命令式程式碼,甚至在很多時候並沒有可以歸納抽離的實現。但是,如果我們花時間去學習、發現可以歸納抽離的部分使其朝著宣告式邁進,結合函式式的思維來思考,能為我們的程式設計帶來巨大的便捷。

通過其他語言來觸類旁通函數語言程式設計的奇技淫巧,確實能帶給我們新的視野。我相信隨著機器運算能力不斷提升、底層能力更加完善,我們也需要跳出如何做的思維限制,更多地站在更高的抽象層去思考做什麼,方能進入一個充滿想象、神奇的computable world。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69912579/viewspace-2656068/,如需轉載,請註明出處,否則將追究法律責任。

相關文章