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

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

引言

本文就是我在學習函數式編程的過程當中自己體悟到的一些東西,這裡將用go,JavaScript以及Haskell三種語言來分析函數式編程的一些奧秘。JavaScript由於具有的一些優勢能夠讓我們可以實現函數式編程,而go作為一種強型別語言,雖然靈活性又稍有欠缺,但是也能夠完成一些高階函數的實現,Haskell語言作為正統的函數式程式設計語言,為瞭解釋說明問題,作為對比參照。

本文

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

  1. 不包括指派陳述式(assignment statement),一個變數一旦初始化,就無法被修改(immutable)
  2. 無副作用,函數除了計算結果,將不會產生任何副作用
  3. 因為無副作用,所以任何錶達式在任何時候都能夠evaluate

雖然上面的優勢看看上去好像很厲害的樣子,但是,到底厲害在哪裡呢?我們可以通過下面的例子進行說明:

求和函數

Haskell

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 restfoldr 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函數的呢?

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]-- 輸出: 2foldl (-) 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 -> cflip' 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 ->ccount 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 : ydoubleall 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):ymap' :: (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]] -> asummatrix 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

結語

在學習函數式編程的過程中,我感受到了一種新的思維模式的衝擊,彷彿開啟了一種全新的世界,沒有迴圈,甚至沒有分支,文法簡潔優雅。我認為作為一名電腦從業人員都應該去接觸一下函數式編程,能夠讓你的視野更加開闊,能夠從另一個角度去思考。

原文發佈於本人個人部落格,保留文章所有權利,未經允許不得轉載。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.