1. 程式人生 > >map的實現和柯里化(Currying)

map的實現和柯里化(Currying)

  版權申明:本文為博主窗戶(Colin Cai)原創,歡迎轉帖。如要轉貼,必須註明原文網址

  http://www.cnblogs.com/Colin-Cai/p/11329874.html 

  作者:窗戶

  QQ/微信:6679072

  E-mail:[email protected]

  對於函數語言程式設計來說,map/reduce/filter這幾個運算元非常重要,其中有的語言不是reduce而是fold,但功能基本一樣,不過reduce的迭代一般只有一個方向,fold可能會分兩個方向,這是題外話。

  這篇文章就是來理解map的語義和實現,使用Sheme、Python、JS三種語言來解釋一下這個概念。

 

map的語義

 

  所謂運算元,或者說高階函式,是指輸入或輸出中帶有函式的一種函式。一般情況下運算元可能指輸入中帶有函式的情況,而對於輸出中帶有函式並帶有輸入引數資訊的,我們很多情況下習慣叫閉包。

  map運算元(高階函式)是想同時處理n個長度相同的array或list等,它的輸入引數中存在一個引數是函式。

  

  如圖以一個簡單的例子來演示map的作用,4個引數,一個引數是一個帶有三個引數的函式f,另外三個引數是長度一樣的list a、b、c。所有list依次按位置給出一個值,作為f的引數,依次得到的值組成的list就是map的返回值。

  給個實際的例子:

  map帶上的引數中,函式是f:x,y->x-y,也就是的得到兩個引數的差,帶上兩個list,分別是[10,9,8]和[1,2,3],則依次將(10,1)、(9,2)、(8,3)傳給f,得到9,7,5,從而map返回的值是[9,7,5]。

  很多時候,map函式的處理是針對一個array/list的轉換,從而看重面向物件程式設計的JS,其Array物件就有一個map方法。

 

 map的一種實現

 

  理解了map函式的語義之後,我們自然從過程式的思路明白瞭如何一個個的構造結果list的每個元素。但既然是函數語言程式設計,一般來說,我們需要的不是過程式的思路,而是函式式的思路,最基本的思路是要去構造遞迴。

  所謂遞迴,說白了就是尋找函式整體與部分的相似性。

  我們還是用剛才的例子,用函式f:x,y->x-y,兩個list為[10,9,8]和[1,2,3],我們構造結果第一個數,需要先從[10,9,8]取出第一個元素10,從[1,2,3]中取出第一個元素1,用f:x,y->x-y作用得到9,此處[10,9,8]和[1,2,3]還剩下[9,8]、[2,3]尚未處理。而[9,8]、[2,3]的處理依然是map做的事情。於是這裡就構造了一個遞迴:

  1.處理每個list的第一個元素,得到結果list的第一個元素

  2.map遞迴所有list的剩餘部分,得到結果list的其他元素

  3.拼接在一起,得到結果list

 

  過程中,需要兩個動作,一個對所有list取第一個元素,另一個是對所有list取剩餘元素。單看這兩個動作,共同點都是對所有list做的,不同點在於對每個list做的不同,一個是提取第一個元素,一個是提取剩餘元素,於是我們這裡就可以提取共性,也就是抽象。

  我們先來做這個抽象,我們希望這樣用,(scan s f),帶兩個引數,一個是s是一個list,另一個是f,結果是一個和s等長度的list,它的元素和s的元素一一對應,由函式f轉換而來。

  和之前的map類似,這個也一樣可以分為三部分:

  1.處理s的第一個元素,為(f (car s))

  2.scan遞迴s的剩餘部分,為(scan (cdr s) f)

  3.把兩者用cons拼接在一起,為(cons (f (car s)) (scan (cdr s) f))

 

  其實,這裡少了一個邊界條件,就是還得考慮s為空列的時候,返回也是空列。

  於是scan的實現應該是

  (define (scan  s f) (if (null? s) '() (cons (f (car s)) (scan (cdr s) f))))

 

  同理,map也一樣有邊界條件,我們要考慮map所跟的那一組list都為空列的情況,這種情況返回也是空列。

  於是map的實現應該是

  (define (map f . s) (if (null? (car s)) '()

   (cons

   ;處理每個list最開頭的元素

    (apply op (scan s car))

    ;遞迴處理剩餘部分

    (apply map2 op (scan s cdr)))))

  apply是函數語言程式設計支援語言裡常用的功能,在於展開其最後一個為list的引數,比如apply(f, (1,2,3))也就是f(1,2,3)。

 

  然後,我們考慮Python的實現,因為序偶(pair)並非是Python的底層,我們需要用list拼接來實現,JS也一樣。Python下用list的加號來實現拼接,為了簡單起見,我們並不用生成器實現。

  我們來模仿之前的Scheme,先實現scan函式。

  scan = lambda s,f : [] if len(s)==0 else [f(s[0])] + ([] if len(s)==1 else scan(s[1:],f))

  Python的apply在早期版本里曾經存在過,後來都用*來取代了apply。比如f(*(1,2,3))在Python裡就等同於f(1,2,3)

  拋開這個不同,取代了之後,我們實現map如下

  map = lambda f,*s : [] if len(s[0])==0 else [f(*scan(s, lambda x:x[0]))] + map(f, *scan(lst, lambda x:x[1:]))

 

  JS似乎比Python更看重面向物件,它的Array拼接用的是Array的concat方法,同時,它並沒有Python那樣的語法糖,不能像Python那樣切片而只能用Array的slice方法,甚至於apply也是函式的方法的樣子。另外,JS對可變引數的支援是使用arguments,需要轉換成Array才可以切片。這些讓我覺得似乎還是Python用起來更加順手,不過這些特性讓人看起來更加像函數語言程式設計。另外,JS有很多框架,很多時候程式設計甚至看起來脫離了原始的JS。

  所以以下map的實現雖然本質上和之前是一回事情,但寫法看上去差別比較大了。

  function map()
  {
    op = arguments[0];
    scan = (s,f) => s.length==0?[]:[f(s[0])].concat(scan(s.slice(1),f));
    s = [].slice.call(arguments).slice(1);//先取得所有的list
    return s[0].length==0 ? [] : [op.apply(this,scan(s, x=>x[0]))].concat(map.apply(this,[op].concat(list_do(s, x=>x.slice(1)))));
  }

 

柯里化

 

  函數語言程式設計裡,有一個概念叫柯里化,它將一個多引數的函式變成巢狀著的每層只有一個引數的函式。

  我們以Python為例子,我們先定義一個普通的函式add

  def  add(a,b,c):

    return a+b+c

  然後再定義另一個看起來有些詭異的函式

  def  g(a):

    def g2(b):

      def g3(c):

        return f(a,b,c)

      return g3

    return g2

  這個函式g怎麼用呢?

  我們測試發現,g(1)(2)(3)得到6,也就是add(1,2,3)的結果,而g(1)、g(1)(2)都是函式,這種層層閉包方式就是柯里化了。

 

  在此,我們希望設計一個函式來實現柯里化,curry(n ,f),其中f為希望柯里化的函式,而n為f的引數個數。

  比如之前g則為curry(3, add)。

 

  curry一樣可以通過遞迴實現,比如之前g是curr(3, add),如果我們構造一個函式

  h = lambda a,b : lambda c : add(a, b, c)

  那麼 g = curry(2, h)

  為了對於所有的curry都可以如此遞迴,要考慮之前討論的不定引數,Python下也就是用*實現,而Scheme用apply,重寫h函式如下:

  h = lambda  *s : lambda c : add(*(s+(c,)))

  於是,得到curry的Python實現:

  def  curry(n, f):

    return f if n==1 else curry(n-1, lambda *s : lambda c : f(*(s+(c,))))

  

  從而,我們對於之前的g(1)(2)(3)也就是curry(3,add)(1)(2)(3),

  再者,curry函式本身一樣可以柯里化,

  於是,還可以寫成

  curry(2, curry)(3)(add)(1)(2)(3)

  不斷對curry柯里化,以下結果都是一樣的,

  curry(2, curry)(2)(curry)(3)(add)(1)(2)(3)

  curry(2, curry)(2)(curry)(2)(curry)(3)(add)(1)(2)(3)

  ...

 

  Scheme的版本也就很容易根據上述Python的實現來改寫,

  (define  (curry n f)  (if (= n 1) f (curry (- n 1) (lambda s (lambda  (a) (apply f (append s (list a))))))))

  

  JS的版本中,也需要用到函式的方法apply來實現不定引數,以及陣列的concat方法來實現陣列拼接。

  function curry(n, f)
  {
      return n==1 ? f : curry(n-1, function () {return a => f.apply(this, [].slice.apply(arguments).concat([a]))});
  }

 

 

基於柯里化的map實現

 

  這裡引入柯里化的原因,自然也是為了實現map。

  我們這樣去想,我們先把map的引數f柯里化,然後依次一步步的每次傳一個引數,巧妙的利用閉包傳遞資訊,直到最終算出結果。

 

  

 

  之前實現的scan對於每個元都採用相同的函式處理,這裡要有所區別,每個資料都有自己獨立的函式來處理,所以處理的函式也組成一個相同長的list。

  與之前幾乎相同,只是f成了一個list。

  (define  (scan s f) (if  (null?  s) '() (cons ((car  f) (car  s)) (scan (cdr  s) (cdr  f)))))

  而對於(map op . s)的定義,我們首先要把op柯里化了,(curry (length  s) op),因為op會有(length s)個引數。

  同時,最終的結果是(length (car s))個元素的list,所以是(length (car  s))個值按s來迭代,所以迭代初始值是(make-list (length (car  s)) (curry (length  s) op))。

  最後,我們順著s從左到右的方向按照scan迭代一圈即可,我們用R6RS的fold-left來做這事。

  (define (map  op . s) (fold-left  scan (make-list (length (car s)) (curry (length  s) op)) s))

 

  Python下,scan也很容易修改:

  scan = lambda s,f : [] if  len(s)==0  else [f[0](s[0])] + ([] if  len(s)==1  else scan(s[1:],f[1:]))

  Python下的reduce和Scheme的fold-left語義基本一致,再者Scheme下的make-list在Python下用個乘號就簡單實現了。

  map = lambda f,*s : reduce(scan, s, [curry(len(s), f)] * len(s[0]))

  Python3下reduce在functools裡,需要事先import

  from functools import reduce

 

  JS下的scan倒是修改起來沒有什麼難度,JS下的reduce是Array的一個方法,make-list是用一個分配好長度的Array用fill方法實現,JS的確太面向物件了。

  function map()
  {
    scan = (f,s) => s.length==0 ? [] : [(f[0])(s[0])].concat(scan(f.slice(1),s.slice(1)));
    return [].slice.call(arguments).slice(1).reduce(scan ,(new Array(arguments[1].length)).fill(mycurry(arguments.length-1, arguments[0])));
  }

 

 

另一種藉助柯里化的實現

 

  我們可以考慮map的柯里化,如果我們可以先得到map的柯里化,那麼就很容易得到最終的結果。

  說白了,也就是我希望這樣:

  (define (map op . s)

   (foldl (lambda (n r) (r n)) map-currying-op s)

  )

 

  (curry (+ 1 (length s)) map) 是對map的柯里化,map-currying-op也就是要實現((curry (+ 1 (length s)) map) op)

   最開始的時候,是意識到構造這個柯里化與之前scan有一定的相似性,需要利用其資料的list形成閉包,從而抽象出curry-map這個高階函式。再者閉包所封裝的資料中不僅僅有各層運算中的list,還需要帶有計算層次的資訊,因為最終的一次scan的結果得到的並不是函式,而是map的結果了,將計算層次和list形成pair,計算層次每往後算一個list,則減1,直到變成1了,下一步得到的就不再是閉包。

 

  (define (map op . s)

   (define scan

    (lambda (s f)

     (if (null? s)

      '()

      (cons ((car f) (car s)) (scan (cdr s) (cdr f))))))

   (define curry-map

     (lambda (x)

      (if (= (car x) 1)

       (lambda (s) (scan s (cdr x)))

       (lambda (s)

        (curry-map

         (cons

          (- (car x) 1)

          (scan s (cdr x))))))))

   (define map-currying-op

    (curry-map

     (cons

      (length s)

      (make-list (length (car s)) (curry (length s) op)))))

 

   (fold-left (lambda (n r) (r n)) map-currying-op s)

  )

   上述實現就是通過map的柯里化來實現map,可能比較複雜而拗口,我在構造實現的時候也一度卡了殼,這個很正常,形式化的世界裡的確有晦澀的時候。

  另外,實際上這裡curry-map並不是對map的柯里化,只是這樣寫更加整齊一些,其實也可以改變一下,真正得到map的柯里化,這個只是一個小小的改動。

  (define curry-map
    (lambda (x)
     (if (pair? x)
      (if (= (car x) 1)
       (lambda (s) (scan s (cdr x)))
       (lambda (s)
        (curry-map
         (cons
          (- (car x) 1)
          (scan s (cdr x))))))
      (curry-map
       (cons
        (length s)
        (make-list (length (car s)) (curry (length s) x)))))))
   (define map-currying-op
    (curry-map op))

  有興趣的朋友可以分析一下這一節的所有程式碼,在此我並不給出Python和JS的實現,有興趣的可在明白了之後可以自己來實現。

 

結束語

 

  以上的實現可以幫助我們大家去從所使用語言的內部去理解這些高階函式。但實際上,這些作為該語言基本介面的map/reduce/filter等,一般是用實現這些語言的更低階語言來實現,如此實現有助於提升語言的效率。比如對於Lisp,我們在學習Lisp的過程能中,可能會自己去實現各種最基本的函式,甚至包括cons/car/cdr,但是要認識到現實,在我們自己去實現Lisp的直譯器或者編譯器的時候,還是會為了加速,把這些介面放在語言級別實現