從Haskell、JavaScript、Go看函數式編程

引言

本文就是我在學習函數式編程的過程中本身體悟到的一些東西,這裏將用go,JavaScript以及Haskell三種語言來分析函數式編程的一些奧祕。JavaScript因爲具備的一些優點可以讓咱們能夠實現函數式編程,而go做爲一種強類型語言,雖然靈活性又稍有欠缺,可是也可以完成一些高階函數的實現,Haskell語言做爲正統的函數式編程語言,爲了解釋說明問題,做爲對比參照。javascript

正文

函數式編程也算是常常看到了,它的一些優點包括:java

  1. 不包括賦值語句(assignment statement),一個變量一旦初始化,就沒法被修改(immutable)
  2. 無反作用,函數除了計算結果,將不會產生任何反作用
  3. 由於無反作用,因此任何表達式在任什麼時候候都可以evaluate

雖然上面的優點看看上去好像很厲害的樣子,可是,到底厲害在哪裏呢?咱們能夠經過下面的例子進行說明:編程

求和函數數組

Haskellapp

sum [1,2,3]
-- 6
-- sum 的實現實際上是
foldr (+) 0 [1,2,3]

在Haskell中flodr的函數定義是:編程語言

foldr :: Foldable t => (a -> b -> b) -> b -> t a -> b

函數實現是:函數式編程

-- if the list is empty, the result is the initial value z; else
-- apply f to the first element and the result of folding the rest
foldr f z []     = z 
foldr f z (x:xs) = f x (foldr f z xs)

這是一個遞歸實現,在函數式編程中,遞歸定義是十分常見的。函數

foldr函數其實作了這樣的事情:foldr接受三個參數,第一個參數是函數f,第二個參數是初始值z,第三個參數是一個列表。若是列表爲空則返回初始化值z,不然遞歸調用 foldr,須要說明的是函數f的類型是接受兩個參數,返回一個值,兩個參數類型都應該和z相同(強類型語言中)。學習

在Haskell中咱們可以看到一個列表可以這樣被求和,那麼在JavaScript中,咱們是如何實現sum函數的呢?ui

JavaScript

首先咱們實現js版本的foldr

function foldr(f,z,list){
  //爲了簡潔起見,把類型判斷省略了
  // Object.prototype,toString.call(list) === '[object Array]' 
  if(list === null || list.length == 0){
    return z;
  }
  //這裏的shift會改變參數的狀態,會形成反作用
  //return f(list.shift(),foldr(f,z,list));
  //改用以下寫法
  return f(list[0],foldr(f,z,list.slice(1)));
}

而後咱們再實現js版本的(+):

function add(a,b){
  return a+b;
}

那麼咱們的sum就變成:

function sum(list){
  return foldr(add,0,list);
}

最後咱們的js版的sum也能夠這樣用了:

let a = [1,2,3];
sum(a); // 6

像js這樣的弱類型的語言較爲靈活,函數f能夠任意實現,對於foldr函數也可以在多種數據類型之間複用,那麼對於像go這樣的強類型語言,結果又是怎麼樣的呢?

go

一樣地,咱們實現如下go版本的foldr:

func foldr(f func(a,b int) int,z int,list []int)int{
    if len(list) == 0{
        return z
    }
    return f(list[0],foldr(f,z,list[1:]))
}

go由於有數組切片,因此使用起來較爲簡單,可是go又是強類型的語言,因此在聲明函數的時候必需要把類型聲明清楚。

再實現一下f函數:

func add(a,b int) int{
    return a+b;
}

依葫蘆畫瓢咱們能夠獲得go版本的sum函數:

func sum(list []int) int{
    return foldr(add,0,list)
}

能夠看出來好像套路都差很少,真正在調用的時候是這樣的:

func main(){
    a := []int{1,2,3}
    sum(a) // 6
}

在Haskell中是沒有循環的,由於循環能夠經過遞歸實現,在上文咱們實現的sum函數中,也沒有用到任何循環語句,這和咱們原來的編程思惟有所不一樣,剛開始我學寫求和函數的時候,都是從for,while開始的,可是函數式給我打開了新世界的大門。

有了上面的基礎,咱們發如今函數式編程中,代碼的重用很是便利:

求積函數

javaScript

function muti(a,b){
  return a*b;
}

function product(list){
  return foldr(muti,1,list);
}

go

func muti(a,b int) int{
    return a*b;
}

func product(list []int) int{
    return foldr(muti,1,list)
}

Haskell

foldr (*) 1 [1,2,3,4] 
-- 24
-- or 
-- product 是Haskell預約義的函數
myproduct xs = foldr (*) 1 xs
-- myproduct [1,2,3,4]

還有不少例如 anyTrueallTrue的例子,如下僅給出js實現:

anyTure

JavaScript

function or(a,b){
  return a || b;
}

function anyTrue(list){
  return foldr(or,false,list);
}

調用:

let b = [true,false,true];
console.log(anyTrue(b)); // true

allTure

JavaScript

function and(a,b){
  return a && b;
}

function allTrue(list){
  return foldr(and,true,list);
}

調用:

let b = [true,false,true];
console.log(allTrue(b)); // false

固然咱們能夠看出來這個flodr函數賊好用,可是好像仍是有點疑惑,它是怎麼工做的呢?看了一圈,flodr就是一個遞歸函數,但其實在編程世界,它還有一個更加出名的名字——reduce。咱們看看在js中是如何使用reduce實現sum函數的:

求和函數reduce版

const _ = require("lodash");
_.reduce([1,2,3],function(sum,n){
  return sum+n;
});

lodash官方文檔是這麼定義的:

_.reduce alias _.foldl
_.reduceRight alias _.foldr

好吧,我欺騙了大家,其實foldr應該對應reduceRight

那麼foldlfoldr到底有什麼不一樣呢?

其實這兩個函數的不一樣之處在於結合的方式不一樣,以求差爲例:

Haskell

foldr (-) 0 [1,2,3]
-- 輸出: 2
foldl (-) 0 [1,2,3]
-- 輸出: -6

爲何兩個輸出是不一樣的呢?這個和結合方向有關:

foldr (-) 0 [1,2,3]

至關於:

1-(2-(3-0)) = 2

foldl (-) 0 [1,2,3]

至關於:

((0-1)-2)-3) = -6

結合方向對於求和結果而言是沒有區別的,可是對於求差,就有影響了:

JavaScript

const _ = require("lodash");
//reduce 至關於 foldl
_.reduce([1,2,3],function(sum,n){
  return sum-n;
});
// 輸出 -4

這個和說好的-6好像又不同了,坑爹呢麼不是?!這是由於,在lodash的實現中,reduce的初始值爲數組的第一個元素,因此結果是1-2-3 = -4

那麼咱們看看reduceRight == foldr的結果:

JavaScript

const _ = require("lodash");
//reduceRight 至關於 foldr
_.reduceRight([1,2,3],function(sum,n){
  return sum-n;
});
// 輸出 0

咱們看到這個結果是0也算是預期結果,由於3-2-1=0

注:上文爲了易於理解和行文連貫,加入了一些我本身的理解。須要說明的是,在Haskell中,foldl1函數應該是和JavaScript的reduce(lodash)函數是一致的,foldl1函數將會把列表的第一個元素做爲初始值。

如今咱們總結一下foldrfoldl的一些思路:

若是對列表[3,4,5,6]應用函數f初始值爲z進行foldr的話,應該理解爲:

f 3 (f 4 (f 5 ( f 6 z)))
-- 當 f 爲 +, z = 0 上式就變爲:
3 + (4 + (5 + (6 + 0)))
-- 前綴(+)形式則爲:
(+)3 ((+)4 ((+)5 ((+)6 0)))

若是對列表[3,4,5,6]應用函數g初始值爲z進行foldl的話,應該理解爲:

g(g (g (g z 3) 4) 5) 6
-- 固然咱們也能夠相似地把 g 設爲 +, z = 0, 上式就變爲:
(((0 + 3) + 4) + 5) + 6
-- 改爲前綴形式
(+)((+)((+)((+)0 3) 4) 5) 6

從上面的例子能夠看出,左摺疊(foldl)和右摺疊(foldr)二者有一個很關鍵的區別,就是,左摺疊沒法處理無限列表,可是右摺疊能夠。

前面咱們說的都是用預約義的函數+,-,*…,(在函數式編程裏,這些運算符其實也都是函數)用這些函數是爲了可以讓咱們更加便於理解,如今咱們看看用咱們本身定義的函數呢?試試逆轉一個列表:

reverse

Haskell

flip' :: (a -> b -> c) -> b -> a -> c
flip' f x y= f y x

上面的flip'函數的做用就是傳入第一個參數是一個函數,而後將兩個參數的順序調換一下(flip是預約義函數)。

Hasekll

foldr flip' [] [1,2,3]

那麼JavaScript的實現呢?

JavaScript

function flip(f, a, b){
     return f(b,a);
}
//這個函數須要進行柯里化,不然沒法在foldr中做爲參數傳入
var flip_ = _.curry(flip);

function cons(a,b){
     return a.concat(b);
 }

function reverse(list){
  return foldr(flip_(cons),[],list);
}

調用結果又是怎麼樣的呢?

console.log(reverse([1,2,3]))
// [ 3, 2, 1 ]

好了,如今咱們好像又看到了一個新東西——curry,柯里化。簡單地說,柯里化就是一個函數能夠先接受一部分參數,而後返回一個接受剩下參數的函數。用上面的例子來講,flip函數在被柯里化以後獲得的函數flip_,能夠先接受第一個參數cons而後返回一個接受兩個參數a,b的函數,也就是咱們須要的鏈接函數。

在go語言裏面,實現curry是一個很麻煩的事情,所以go的函數式編程支持仍是比較有限的。

接着咱們試試如何取得一個列表的長度,實現一個length函數:

length

Haskell

-- 先定義實現一個count 函數
count :: a -> b ->c
count a n = n + 1
-- 再實現一個length函數
length' = foldr (count) 0
-- 再調用
length' [1,2,3,4]
-- 4

JavaScript

//先定義一個count函數
function count(a,n){
  return n + 1;
}
//再實現length函數
function length(list){
  return foldr(count,0,list);
}
//調用
console.log(length([1,2,3,4]));
// 4

就是這麼簡單,好了,reduce咱們講完了,而後咱們看看map,要知道map函數是怎麼來的,咱們要從一個比較簡單的函數先入手,這個函數的功能是把整個列表的全部元素乘以2:

doubleall

haskell

-- 定義一個乘以2,並鏈接的函數
doubleandcons :: a -> [a] -> [a]
doubleandcons x y  = 2 * x : y

doubleall x = foldr doubleandcons []

-- 調用
doubleall [1,2,3]
-- 輸出
-- [2,4,6]

JavaScript

function doubleandcons(a,list){
  return [a * 2].concat(list)
}

function doubleall(list){
  return foldr(doubleandcons,[],list)
}

//調用
console.log(doubleall([1,2,3]));
// [2,4,6]

再來看看go怎麼寫:

go

go 的尷尬之處在於,須要很是明確的函數定義,因此咱們要從新寫一個foldr函數,來接受第二個參數爲列表的f

func foldr2(f func(a int,b []int) []int,z []int,list []int)[]int{
    if len(list) == 0{
        return z
    }
    return f(list[0],foldr2(f,z,list[1:]))
}

而後咱們再實現同上面相同的邏輯:

func doubleandcons(n int,list []int) []int{
    return append([]int{n * 2},list...)
}

func doubleall(list []int) []int{
    return foldr2(doubleandcons,make([]int,0),list)
}
// doubleall([]int{1,2,3,4})
//[2 4 6 8]

go這門強類型編譯語言雖然支持必定的函數式編程,可是使用起來仍是有必定侷限性的,起碼代碼複用上仍是不如js的。

接下來咱們關注一下其中的doubleandcons函數,這個函數其實能夠轉換爲這樣的一個函數:

fandcons f el [a]= (f el) : [a]
double el = el * 2
-- 只傳入部分參數,柯里化
doubleandcons = fandcons double

如今咱們關注一下這裏的fandcons,其實這裏能夠通用表述爲Cons·f,這裏的·稱爲函數組合。而函數組合有這樣的操做:

$$
(f. g) (h) = f (g(h))
$$

那麼上面的咱們的函數就能夠表述爲:

$$
fandcons(f(el)) = (Cons.f)(el)= Cons (f(el))
$$

因此:

$$
fandcons(f(el),list) = (Cons.f) ( el , list) = Cons ((f(el)) ,list)
$$

最終版本就是:

$$
doubleall = foldr((Cons . double),Nil)
$$

這裏的foldr(Cons.double) 其實就是咱們要的map double,那麼咱們的map的原本面目就是:

$$
map = foldr((Cons.f), Nil)
$$

這裏的Nilfoldr函數的初始值。

好了map已經現身了,讓咱們再仔細看看一個map函數應該怎麼實現:

map

Haskell

fandcons :: (a->b) ->a -> [b] -> [b]
fandcons f x y= (f x):y

map' :: (a->b) -> [a] -> [b]
map' f x = foldr (fandcons f) [] x

-- 調用 
map' (\x -> 2 * x)  [1,2,3]
-- 輸出 [2,4,6]

這裏用了Haskell的lambda表達式,其實就是fdouble實現。

咱們也看看js版本的實現:

JavaScript

function fandcons(f, el, list){
  return [f(el)].concat(list);
}
//須要柯里化
var fandcons_ = _.curry(fandcons);

function map(f, list){
  return foldr(fandcons_(f),[],list);
}
//調用
console.log(map(function(x){return 2*x},[1,2,3,4]));
// 輸出[ 2, 4, 6, 8 ]

這些須要柯里化的go我都不實現了,由於go實現柯里化比較複雜。

最後咱們再看看map的一些神奇的操做:

矩陣求和

summatrix

Haskell

summatrix :: Num a => [[a]] -> a
summatrix x = sum (map sum x)

-- 調用
summatrix [[1,2,3],[4,5,6]]
-- 21

這裏必定要顯式聲明 參數a的類型,由於sum函數要求Num類型的參數

JavaScript

function sum(list){
  return foldr(add,0,list);
}
function summatrix(matrix){
  return sum(map(sum,matrix));
}
//調用
 mat = [[1,2,3],[4,5,6]];
 console.log(summatrix(mat));
//輸出 21

結語

在學習函數式編程的過程當中,我感覺到了一種新的思惟模式的衝擊,彷彿打開了一種全新的世界,沒有循環,甚至沒有分支,語法簡潔優雅。我認爲做爲一名計算機從業人員都應該去接觸一下函數式編程,可以讓你的視野更加開闊,可以從另外一個角度去思考。

原文發佈於本人我的博客,保留文章全部權利,未經容許不得轉載。

相關文章
相關標籤/搜索