JS函數式編程【譯】5.3 單子 (Monad)


單子是幫助你組合函數的工具。

像原始類型一樣,單子是一種數據結構,它可以被當做裝載讓函子取東西的容器使用。 函子取出了數據,進行處理,然后放到一個新的單子中並將其返回。

我們將要關注三種單子:

  • Maybes
  • Promises
  • Lenses

除了用於數組的map和函數的compose以外,我們還有三種函子(maybe、promise和lens)。 這僅僅是另一些函子和單子。

Maybe

Maybe可以讓我們優雅地使用有可能為空並且有默認值的數據。maybe是一個可以有值也可以沒有值的變量,並且這對於調用者來說無所謂。

就他自己來說,這看起來不是什么大問題。所有人都知道空值檢查可以通過一個if-else語句很容易地實現。

if (getUsername() == null) {
  username = 'Anonymous'
} else {
  username = getUsername();
}

但是用函數式編程,我們要打破過程的、一行接一樣的做事方式,而應該用函數和數據的管道方式。 如果我們不得不從鏈的中間斷開來檢查值是否存在,我們就得創建臨時變量並寫更多的代碼。 maybe僅僅是幫助我們保持邏輯跟隨管道的工具。

要實現maybe,我們首先要創建一些構造器。

// Maybe單子構造器,目前是空的
var Maybe = function(){};

// None實例, 對一個沒有值的對象的包裝
var None = function(){};
None.prototype = Object.create(Maybe.prototype);
None.prototype.toString = function(){return 'None';};

// 現在可以寫`none`函數了
// 這讓我們不用總去寫`new None()`
var none = function(){return new None()};

// Just實例, 對一個有一個值的對象的包裝
var Just = function(x){return this.x = x;};
Just.prototype = Object.create(Maybe.prototype);
Just.prototype.toString = function(){return "Just "+this.x;};
var just = function(x) {return new Just(x)};

最后,我們可以寫maybe函數了。它返回一個新的函數,這個函數返回“沒東西”或者maybe。它是個函子。

var maybe = function(m) {
  if (m instanceof None) {
    return m;
  } else if (m instanceof Just) {
    return just(m.x);
  } else {
    throw new TypeError("Error: Just or None expected, " +
      m.toString() + " given.");
  }
}

我們還可以生成一個函子的生成器,就像數組的那樣。

var maybeOf = function(f) {
  return function(m) {
    if (m instanceof None) {
      return m;
    } else if (m instanceof Just) {
      return just(f(m.x));
    } else {
      throw new TypeError("Error: Just or None expected, " +
        m.toString() + " given.");
    }
  }
}

那么Maybe是單子,maybe是函子,maybeOf返回一個已經分配了態射的函子。

在我們繼續往下進行之前,我們還得做點事情。我們需要給Maybe這個單子對象添加一個方法讓它用起來更直觀。

Maybe.prototype.orElse = function(y) {
  if (this instanceof Just) {
    return this.x;
  } else {
    return y;
  }
}

在現在這樣的形式里,maybe可以直接被使用。

maybe(just(123)).x; // Returns 123
maybeOf(plusplus)(just(123)).x; // Returns 123
maybeOf(plusplus)(none()).orElse('none'); // returns 'none'

所有東西都返回一個方法然后再被調用,這太復雜了,簡直是在自找麻煩。我們可以通過我們的curry()函數讓它看起來稍微明白一點:

maybePlusPlus = maybeOf.curry()(plusplus);
maybePlusPlus(just(123)).x; // returns 123
maybePlusPlus(none()).orElse('none'); // returns none

不過當直接調用none()和just()這些臟業務被抽象起來的時候,maybe真正的力量就變得明顯了。我們用一個User對象的例子來試一下,這里username將使用maybe。

var User = function() {
  this.username = none(); // 初始設置為`none`
};
User.prototype.setUsername = function(name) {
  this.username = just(str(name)); // 這里用一個just
};
User.prototype.getUsernameMaybe = function() {
  var usernameMaybe = maybeOf.curry()(str);
  return usernameMaybe(this.username).orElse('anonymous');
};
var user = new User();
user.getUsernameMaybe(); // Returns 'anonymous'
user.setUsername('Laura');
user.getUsernameMaybe(); // Returns 'Laura'

現在我們有了強大且安全的方法來定義默認值。記住這個User對象,因為在后面的章節里要用到它。

Promise

承諾(Promise)的本質就是它不受形式變化的影響
- Frank Underwood, 紙牌屋

在函數式編程中,我們經常使用管道和數據流:也就是函數鏈,每個函數產生的數據類型被下一個函數消費。然而,有很多這些函數是異步的:文件、事件、Ajax等等。如果不用持續傳遞的風格和深層嵌套的回調,我們該如何修改這些函數的返回類型來說明結果?把它們封裝到promise里。

promise像是回調的函數式等價物。很明顯,回調不是那么函數式,如果不止一個函數要改變同樣的數據,就會出現競爭條件和bug。promise解決了這個問題。

不用promise的代碼是這樣:

fs.readFile("file.json", function(err, val) {
  if (err) {
    console.error("unable to read file");
  } else {
    try {
      val = JSON.parse(val);
      console.log(val.success);
    } catch (e) {
      console.error("invalid json in file");
    }
  }
});

用promise應該把代碼編程這樣:

fs.readFileAsync("file.json").then(JSON.parse)
  .then(function(val) {
    console.log(val.success);
  })
  .catch(SyntaxError, function(e) {
    console.error("invalid json in file");
  })
  .catch(function(e) {
    console.error("unable to read file")
  });

前面的代碼來自於bluebird的README,bluebird是一個對Promises/A+的全面實現,並有非常好的性能。Promises/A+是一個JavaScript中promise的實現規范。這里只給出JavaScript社區當前的成果,把實現留給Promises/A+團隊吧,因為它比maybe復雜得多。

不過這里給出部分實現:

// Promise單子
var Promise = require('bluebird');
// promise函子
var promise = function(fn, receiver) {
  return function() {
    var slice = Array.prototype.slice,
      args = slice.call(arguments, 0, fn.length - 1),
      promise = new Promise();
    args.push(function() {
      var results = slice.call(arguments),
        error = results.shift();
      if (error) promise.reject(error);
      else promise.resolve.apply(promise, results);
    });
    fn.apply(receiver, args);
    return promise;
  };
};

現在我們可以利用函子promise()把需要傳入回調的函數改為返回promise的函數。

var files = ['a.json', 'b.json', 'c.json'];
readFileAsync = promise(fs.readFile);
var data = files
  .map(function(f) {
    readFileAsync(f).then(JSON.parse)
  })
  .reduce(function(a, b) {
    return $.extend({}, a, b)
  });

lens

程序員真正喜歡單子的另一個原因是它們使編寫庫非常簡單。為了一探究竟,我們來給User對象擴展出更多的函數,這些函數用於設置和獲取值,不過我們不用getter和setter,而使用lens。

lens是頭等getter和setter,它讓我們不僅可以設置和獲取值,還可以直接運行函數。不過它不會改變數據,而是克隆一份數據,函數對克隆出來的數據進行修改后返回。它強制數據不可變,這對很多庫所需要的安全性和一致性都很有好處。它有益於寫出優雅的代碼,無論什么樣的應用,只要拷貝數組帶來的增加量造成的性能沖擊不是什么大問題。

在我們寫lens()函數前,先來看看它是如何工作的:

var first = lens(
  function(a) { return arr(a)[0]; }, // get
  function(a, b) { return [b].concat(arr(a).slice(1)); } // set
);
first([1, 2, 3]); // 輸出 1
first.set([1, 2, 3], 5); // 輸出 [5, 2, 3]
function tenTimes(x) { return x * 10 }
first.modify(tenTimes, [1, 2, 3]); // 輸出 [10,2,3]

下面展示了lens()是函數如何工作的。它返回了一個定義了set、get和mod的函數。lens()函數本身是個函子。

var lens = fuction(get, set) {
  var f = function (a) {return get(a)};
  f.get = function (a) {return get(a)};
  f.set = set;
  f.mod = function (f, a) {return set(a, f(get(a)))};
  return f;
};

來試個例子,我們要擴展前面例子中的User對象。

// userName :: User -> str
var userName = lens(
  function(u) {
    return u.getUsernameMaybe()
  }, // get
  function(u, v) { // set
    u.setUsername(v);
    return u.getUsernameMaybe();
  }
);
var bob = new User();
bob.setUsername('Bob');
userName.get(bob); // 返回'Bob'
userName.set(bob, 'Bobby'); //return 'Bobby'
userName.get(bob); // 返回'Bobby'
userName.mod(strToUpper, bob); // 返回'BOBBY'
strToUpper.compose(userName.set)(bob, 'robert'); // 返回'ROBERT'
userName.get(bob); // 返回'robert'

jQuery是一個單子

如果你覺得所有這些關於范疇、函子和單子的抽象的玩意兒並沒有在真實世界的應用,那你再想想。jQuery,這個最流行的JavaScript庫,它提供了操作HTML的增強接口,實際上,它是一個單子化的庫。

jQuery對象是一個單子,它的方法是函子。實際上它們是一種特殊類型的函子,叫做endofunctor。endofunctor是返回與輸入相同范疇的函子,也就是F :: X -> X。每個jQuery方法都取一個jQuery對象並返回一個jQuery對象,這就使得方法可以鏈式調用。它們的類型簽名是jFunc :: jquery-obj -> jquery-obj。

$('li').add('p.me-too').css('color', 'red').attr({id:'foo'});

這也用於jQuery的插件框架。如果一個插件以jQuery對象為輸入,並返回一個jQuery對象為輸出,那它就可以被添加到方法鏈中。

來看下jQuery是如何實現這個的。

單子是函子“伸手進去”取數據的容器。通過這種方式,數據就可以被保護起來並由庫來控制。jQuery通過一些方法提供了訪問里面數據(一系列封裝起來的HTML元素)的方式。

jQuery自身是寫成一個匿名函數調用的結果。

var jQuery = (function() {
  var j = function(selector, context) {
    var jq - obj = new j.fn.init(selector, context);
    return jq - obj;
  };
  j.fn = j.prototype = {
    init: function(selector, context) {
      if (!selector) {
        return this;
      }
    }
  };
  j.fn.init.prototype = j.fn;
  return j;
})();

在這個高度簡化的jQuery版本里,它返回了一個定義了j對象的函數。j函數實際上是一個增強了的init構造器。

var $ = jQuery(); // 從這個函數得到了返回值並賦值給`$`
var x = $('#select-me'); // 返回了jQuery對象

與函子從容器中取用值的方式相同,jQuery把HTML元素封裝了起來,並提供了訪問他們的方法,而沒有直接去修改HTML元素。

盡管jQuery不經常宣揚它,但jQuery有自己的map方法用於從封裝中取出HTML元素對象。就像fmap()方法一樣,這些元素被取出,對它們做一些事情,然后放回到容器中。這就是許多jQuery命令后期的使用方式。

$('li').map(function(index, element) {
  // do something to the element
  return element
});

另一個用於操作HTML元素的庫Prototype就不是這樣工作的。它通過一些輔助方式直接修改HTML元素。因此它在JavaScript社區里就沒有那樣的地位。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM