合理的使用純函數式編程

本文是篇譯文,原文連接An Introduction to Reasonably Pure Functional Programming,不當之處還請指正。javascript

一個好的程序員應該有能力掌控你寫的代碼,可以以最簡單的方法使你的代碼正確而且可讀。做爲一名優秀的程序員,你會編寫儘可能短小的函數,使代碼更好的被複用;你會編寫測試代碼,使本身有足夠的信心相信代碼會按本來的意圖正確運行。沒有人喜歡解bug,因此一名優秀的程序員也要會避免一些錯誤,這些要靠經驗得到,也能夠遵循一些最佳實踐,好比Douglas Crockford 最著名的JavaScript:The good partshtml

函數式編程可以下降程序的複雜程度:函數看起來就像是一個數學公式。學習函數編程可以幫助你編寫簡單而且更少bug的代碼。java

純函數

純函數能夠理解爲一種 相同的輸入一定有相同的輸出的函數,沒有任何能夠觀察到反作用node

//pure
function add(a + b) {
  return a + b;
}

上面是一個純函數,它不依賴也不改變任何函數之外的變量狀態,對於相同的輸入總能返回相同的輸出。react

//impure
var minimum = 21;
var checkAge = function(age) {
  return age >= minimum; // 若是minimum改變,函數結果也會改變
}

這個函數不是純函數,由於它依賴外部可變的狀態jquery

若是咱們將變量移到函數內部,那麼它就變成了純函數,這樣咱們就可以保證函數每次都能正確的比較年齡。git

var checkAge = function(age) {
  var minimum = 21;
  return age >= minimum;
};

純函數沒有反作用,一些你要記住的是,它不會:程序員

  • 訪問函數之外的系統狀態es6

  • 修改以參數形式傳遞過來的對象github

  • 發起http請求

  • 保留用戶輸入

  • 查詢DOM

控制增變(controlled mutation)

你須要留意一些會改變數組和對象的增變方法,舉例來講你要知道splice和slice之間的差別。

//impure, splice 改變了原數組
var firstThree = function(arr) {
  return arr.splice(0,3);
}

//pure, slice 返回了一個新數組
var firstThree = function(arr) {
  return arr.slice(0,3);
}

若是咱們避免使用傳入函數的對象的增變方法,咱們的程序將更容易理解,咱們也有理由指望咱們的函數不會改變任何函數以外的東西。

let items = ['a', 'b', 'c'];
let newItems = pure(items);
//對於純函數items始終應該是['a', 'b', 'c']

純函數的優勢

相比於不純的函數,純函數有以下優勢:

  • 更加容易被測試,由於它們惟一的職責就是根據輸入計算輸出

  • 結果能夠被緩存,由於相同的輸入總會得到相同的輸出

  • 自我文檔化,由於函數的依賴關係很清晰

  • 更容易被調用,由於你不用擔憂函數會有什麼反作用

由於純函數的結果能夠被緩存,咱們能夠記住他們,這樣以來複雜昂貴的操做只須要在被調用時執行一次。例如,緩存一個大的查詢索引的結果能夠極大的改善程序的性能。

不合理的純函數編程

使用純函數可以極大的下降程序的複雜度。可是,若是咱們使用過多的函數式編程的抽象概念,咱們的函數式編程也會很是難以理解。

import _ from 'ramda';
import $ from 'jquery';

var Impure = {
  getJSON: _.curry(function(callback, url) {
    $.getJSON(url, callback);
  }),

  setHtml: _.curry(function(sel, html) {
    $(sel).html(html);
  })
};

var img = function (url) {
  return $('<img />', { src: url });
};

var url = function (t) {
  return 'http://api.flickr.com/services/feeds/photos_public.gne?tags=' +
    t + '&format=json&jsoncallback=?';
};

var mediaUrl = _.compose(_.prop('m'), _.prop('media'));
var mediaToImg = _.compose(img, mediaUrl);
var images = _.compose(_.map(mediaToImg), _.prop('items'));
var renderImages = _.compose(Impure.setHtml("body"), images);
var app = _.compose(Impure.getJSON(renderImages), url);
app("cats");

花一分鐘理解上面的代碼。

除非你接觸過函數式編程的這些概念(柯里化,組合和prop),不然很難理解上述代碼。相比於純函數式的方法,下面的代碼則更加容易理解和修改,它更加清晰的描述程序而且更少的代碼。

  • app函數的參數是一個標籤字符串

  • Flickr獲取JSON數據

  • 從返回的數據裏抽出urls

  • 建立<img>節點數組

  • 將他們插入文檔

var app = (tags) => {
  let url = `http://api.flickr.com/services/feeds/photos_public.gne?tags=${tags}&format=json&jsoncallback=?`;
  $.getJSON(url, (data) => {
    let urls = data.items.map((item) => item.media.m)
    let images = urls.map(url) => $('<img />', {src:url}) );
    
    $(document.body).html(images);
  })
}
app("cats");

或者可使用fetchPromise來更好的進行異步操做。

let flickr = (tags)=> {
  let url = `http://api.flickr.com/services/feeds/photos_public.gne?tags=${tags}&format=json&jsoncallback=?`
  
  return fetch(url)
    .then((resp)=> resp.json())
    .then((data)=> {
      let urls = data.items.map((item)=> item.media.m )
      let images = urls.map((url)=> $('<img />', { src: url }) )

      return images
  })
}
flickr("cats").then((images)=> {
  $(document.body).html(images)
})

Ajax請求和DOM操做都不是純的,可是咱們能夠將餘下的操做組成純函數,將返回的JSON數據轉換成圖片節點數組。

let responseToImages = (resp) => {
  let urls = resp.items.map((item) => item.media.m)
  let images = urls.map((url) => $('<img />', {src:url}))
  
  return images
}

咱們的函數作了2件事情:

  • 將返回的數據轉換成urls

  • 將urls轉換成圖片節點

函數式的方法是將上述2個任務拆開,而後使用compose將一個函數的結果做爲參數傳給另外一個參數。

let urls = (data) => {
  return data.items.map((item) => item.media.m)
}
let images = (urls) => {
  return urls.map((url) => $('<img />', {src: url}))
}
let responseToImages = _.compose(images, urls)

compose 返回一系列函數的組合,每一個函數都會將後一個函數的結果做爲本身的入參

這裏compose作的事情,就是將urls的結果傳入images函數

let responseToImages = (data) => {
  return images(urls(data))
}

經過將代碼變成純函數,讓咱們在之後有機會複用他們,他們更加容易被測試和自文檔化。很差的是當咱們過分的使用這些函數抽象(像第一個例子那樣), 就會使事情變得複雜,這不是咱們想要的。當咱們重構代碼的時候最重要的是要問一下本身:

這是否讓代碼更加容易閱讀和理解?

基本功能函數

我並非要詆譭函數式編程。每一個程序員都應該齊心合力去學習基礎函數,這些函數讓你在編程過程當中使用一些抽象出的通常模式,寫出更加簡潔明瞭的代碼,或者像Marijn Haverbeke說的

一個程序員可以用常規的基礎函數武裝本身,更重要的是知道如何使用它們,要比那些苦思冥想的人高效的多。-- Eloquent JavaScript, Marijn Haverbeke

這裏列出了一些JavaScript開發者應該掌握的基礎函數
Arrays
-forEach
-map
-filter
-reduce

Functions
-debounce
-compose
-partial
-curry

Less is More

讓咱們來經過實踐看一下函數式編程能如何改善下面的代碼

let items = ['a', 'b', 'c'];
let upperCaseItems = () => {
  let arr = [];
  for (let i=0, ii= items.length; i<ii; i++) {
    let item = items[i];
    arr.push(item.toUpperCase());
  }
  items = arr;
}

共享狀態來簡化函數

這看起來很明顯且微不足道,可是我仍是讓函數訪問和修改了外部的狀態,這讓函數難以測試且容易出錯。

//pure
let upperCaseItems = (items) => {
  let arr = [];
  for (let i =0, ii= items.length; i< ii; i++) {
    let item = items[i];
    arr.push(item.toUpperCase());
  }
  return arr;
}

使用更加可讀的語言抽象forEach來迭代

let upperCaseItems = (items) => {
  let arr = [];
  items.forEach((item) => {
    arr.push(item.toUpperCase());
  })
  return arr;
}

使用map進一步簡化代碼

let upperCaseItems = (items) => {
  return items.map((item) => item.toUpperCase())
}

進一步簡化代碼

let upperCase = (item) => item.toUpperCase()
let upperCaseItems = (item) => items.map(upperCase)

刪除代碼直到它不能工做

咱們不須要爲這種簡單的任務編寫函數,語言自己就提供了足夠的抽象來完成功能

let items = ['a', 'b', 'c']
let upperCaseItems = item.map((item) => item.toUpperCase())

測試

純函數的一個關鍵優勢是易於測試,因此在這一節我會爲咱們以前的Flicker模塊編寫測試。

咱們會使用Mocha來運行測試,使用Babel來編譯ES6代碼。

mkdir test-harness
cd test-harness
npm init -y
npm install mocha babel-register babel-preset-es2015 --save-dev
echo '{ "presets": ["es2015"] }' > .babelrc
mkdir test
touch test/example.js

Mocha提供了一些好用的函數如describeit來拆分測試和鉤子(例如before和after這種用來組裝和拆分任務的鉤子)。assert是用來進行相等測試的斷言庫,assertassert.deepEqual是頗有用且值得注意的函數。

讓咱們來編寫第一個測試test/example.js

import assert from 'assert';

describe('Math', () => {
  describe('.floor', () => {
    it('rounds down to the nearest whole number', () => {
      let value = Math.floor(4.24)
      assert(value === 4)
    })
  })
})

打開package.json文件,將"test"腳本修改以下

mocha --compilers js:babel-register --recursive

而後你就能夠在命令行運行npm test

Math
  .floor
    ✓ rounds down to the nearest whole number
1 passing (32ms)

Note:若是你想讓mocha監視改變,而且自動運行測試,能夠在上述命令後面加上-w選項。

mocha --compilers js:babel-register --recursive -w

測試咱們的Flicker模塊

咱們的模塊文件是lib/flickr.js

import $ from 'jquery';
import { compose } from 'underscore';

let urls = (data) => {
  return data.items.map((item) => item.media.m)
}

let images = (urls) => {
  return urls.map((url) => $('<img />', {src: url})[0] )
}

let responseToImages = compose(images, urls)

let flickr = (tags) => {
  let url = `http://api.flickr.com/services/feeds/photos_public.gne?tags=${tags}&format=json&jsoncallback=?`
  
  return fetch(url)
    .then((response) => reponse.json())
    .then(responseToImages)
}

export default {
  _responseToImages: responseToImages,
  flickr: flickr
}

咱們的模塊暴露了2個方法:一個公有flickr和一個私有函數_responseToImages,這樣就能夠獨立的測試他們。

咱們使用了一組依賴:jquery,underscore和polyfill函數fetchPromise。爲了測試他們,咱們使用jsdom來模擬DOM對象windowdocument,使用sinon包來測試fetch api。

npm install jquery underscore whatwg-fetch es6-promise jsdom sinon --save-dev
touch test/_setup.js

打開test/_setup.js,使用全局對象來配置jsdom

global.document = require('jsdom').jsdom('<html></html>');
global.window = document.defaultView;
global.$ = require('jquery')(window);
global.fetch = require('whatwg-fetch').fetch;

咱們的測試代碼在test/flickr.js,咱們將爲函數的輸出設置斷言。咱們"stub"或者覆蓋全局的fetch方法,來阻斷和模擬HTTP請求,這樣咱們就能夠在不直接訪問Flickr api的狀況下運行咱們的測試。

import assert from 'assert';
import Flickr from '../lib/flickr';
import sinon from 'sinon';
import { Promise } from 'es6-promise';
import { Response } from 'whatwg-fetch';

let sampleResponse = {
  items: [{
    media: { m: 'lolcat.jpg' }
  }, {
    media: {m: 'dancing_pug.gif'}
  }]
}

//實際項目中咱們會將這個test helper移到一個模塊裏
let jsonResponse = (obj) => {
  let json = JSON.stringify(obj);
  var response = new Response(json, {
    status: 200,
    headers: {'Content-type': 'application/json'}
  });
  return Promise.resolve(response);
}


describe('Flickr', () => {
  describe('._responseToImages', () => {
    it("maps response JSON to a NodeList of <img>", () => {
      let images = Flickr._responseToImages(sampleResponse);
      
      assert(images.length === 2);
      assert(images[0].nodeName === 'IMG');
      assert(images[0].src === 'lolcat.jpg');
    })
  })
  
  describe('.flickr', () => {
    //截斷fetch 請求,返回一個Promise對象
    before(() => {
      sinon.stub(global, 'fetch', (url) => {
        return jsonResponse(sampleResponse)
      })
    })
    
    after(() => {
      global.fetch.restore();
    })
    
    it("returns a Promise that resolve with a NodeList of <img>", (done) => {
      Flickr.flickr('cats').then((images) => {
        assert(images.length === 2);
        assert(images[1].nodeName === 'IMG');
        assert(images[1].src === 'dancing_pug.gif');
        done();
      })
    })
  })  
  
})

運行npm test,會獲得以下結果:

Math
  .floor
    ✓ rounds down to the nearest whole number

Flickr
  ._responseToImages
    ✓ maps response JSON to a NodeList of <img>
  .flickr
    ✓ returns a Promise that resolves with a NodeList of <img>

3 passing (67ms)

到這裏,咱們已經成功的測試了咱們的模塊以及組成它的函數,學習到了純函數以及如何使用函數組合。咱們知道了純函數與不純函數的區別,知道純函數更可讀,由小函數組成,更容易測試。相比於不太合理的純函數式編程,咱們的代碼更加可讀、理解和修改,這也是咱們重構代碼的目的。

Links

以上就是本文的所有!很是感謝閱讀,我但願這篇文章很好的向你介紹了函數式編程,重構以及測試你的JavaScript。因爲目前特別火熱的庫如React,Redux,Elm,CycleReactiveX都在鼓勵和使用這種模式,因此這個時候寫這樣一篇有趣的範例也算是推波助流吧。

相關文章
相關標籤/搜索