函數語言程式設計瞭解一下(上)

Neal_yang發表於2018-04-15

一直以來沒有對函數語言程式設計有一個全面的學習和使用,或者說沒有一個深刻的思考。最近看到一些部落格文章,突然覺得函數語言程式設計還是蠻有意思的。看了些書和文章。這裡記載下感悟和收穫。 歡迎團隊姜某人多多指點@姜少。 由於部落格秉持著簡短且全面原則。遂分為上下兩篇

原文地址 Nealyang

部分簡介

函數語言程式設計瞭解一下(上)

  • 入門簡介
  • HOC簡介
  • 函式柯里化與偏應用

函數語言程式設計瞭解一下(下)

  • 組合與管道
  • 函子和Monad
  • 再回首Generator

入門簡介

函式的第一原則是要小,函式的第二原則是要更小

什麼是函數語言程式設計?為什麼他重要

在理解什麼是函數語言程式設計的開始,我們先了解下什麼數學中,函式具有的特性

  • 函式必須總是接受一個引數
  • 函式必須總是返回一個值
  • 函式應該依據接受到的引數,而不是外部的環境執行
  • 對於一個指定的x,必須返回一個確定的y

所以我們說,函數語言程式設計是一種正規化,我們能夠以此建立僅依賴輸入就可以完成自身邏輯的函式。這保證了當函式多次呼叫時,依然可以返回相同的結果。因此可以產生可快取的、可測試的程式碼庫

引用透明

所有的函式對於相同的輸入都返回相同的結構,這一特性,我們稱之為引用透明。 比如:

let identity = (i) => {return i};
複製程式碼

這麼簡單?對,其實就是這樣,也就是說他沒有依賴任何外部變數、外部環境,只要你給我東西,我經過一頓鼓搗,總是給你返回你所能預測的結果。

這也為我們後面的併發程式碼、快取成為可能。

命令式、宣告式和抽象

函數語言程式設計主張宣告式程式設計和編寫抽象程式碼。其實這個比較有意思,感覺更像是物件導向的程式設計。

光說不練都是扯淡。舉個例子

  var array = [1,2,3,4,5,6];
  for(let i = 0;i<array.length;i++){
    console.log(array[i])
  }
複製程式碼

這段程式碼的作用簡單明瞭,就是遍歷!但是你有沒有感覺這個程式碼呆呆的。沒有一丁點的靈氣?都是我告訴你該怎麼該怎麼做的。我們告訴編譯器,你先去獲取下陣列的長度的,然後挨個log出來。這種編碼方式,我們通常稱之為“命令式”解決方案。

而在函數語言程式設計中,我們其實更加主張用“宣告式”解決方案

let array = [1,2,3,4,5,6];
array.forEach(item=>{console.log(item)})
複製程式碼

簡單體會下,是不是有那麼一丟丟的靈感來了?等等,你這個forEach函式哪來的嘛!對,也是自己寫的,但是不是我們通過編寫這種抽象邏輯程式碼,而讓整體的業務程式碼更加的清晰明瞭了呢?開發者是需要關心手頭上的問題就好了,只需要告訴編譯器去幹嘛而不是怎麼幹了。是不是輕鬆了?

其實函數語言程式設計主張的就是以抽象的方式建立函式。這些函式可以在程式碼的其他部分被重用。

函數語言程式設計的好處

好處個人不喜歡扯太多,不是因為他沒有好處,而是對於剛剛接觸函數語言程式設計的哥們,上來就說好處其實是沒什麼概念的,所以這裡我簡單提一提,後面文章會細細說明。

純函式 => 可快取

熟悉redux的同學應該對這個詞語都不陌生,所謂的純函式,其實也就是我們說的引用透明,穩定輸出!好處呢?可預測嘛,容易編寫測試程式碼哇,可快取嘛。什麼是可快取?可以看我之前發的文章哈,這裡簡單舉個例子

let longRunningFunction = (input)=>{
  //進行了非常麻煩的計算,然後返回出來結果
  return output;
}
複製程式碼

如果longRunningFunction是一個純函式,引用透明。我們就可以說對於同樣的輸出,總是返回同樣的結果,所以我們為什麼不能夠運用一個物件將我們每一次的運算結果存起來呢?

let longRunningFunctionResult = {1:2,2:3,3:4};
//檢查key是否存在,存在直接用,不存在再計算
longRunningFunctionResult.hasOwnProperty(input)?longRunningFunctionResult[input]:longRunningFunctionResult[input] = longRunningFunction(input)
複製程式碼

比較直觀。不多說了哈。其實好處還有之前說到的併發。不說的這麼冠冕堂皇了,啥併不併發呀,我不依賴別人的任何因素,只依據你的輸出我產出。你說我支援什麼就是什麼咯,只要你給我對的引數傳進來就可以了。

結束語

匆匆收尾!僅作為拋磚引玉。後面我們們在系統性的學習下函數語言程式設計。

高階函式(HOC)簡介

概念

JavaScript作為一門語言,將函式視為資料。允許函式代替資料傳遞是一個非常強大的概念。接受一個函式作為引數的函式成為高階函式(Higher-Order Function)

從資料入門HOC

JavaScript支援如下幾種資料型別:

  • Number
  • String
  • Boolean
  • Object
  • null
  • undefined

這裡面想強調的是JavaScript將函式也同樣是為一種資料型別。當一門語言允許將函式作為資料那樣傳遞和使用的時候,我們就稱函式為一等公民。

所以說這個就是為了強調說明,在JavaScript中,函式可以被賦值,作為引數傳遞,也可以被其他函式返回。

//傳遞函式
let tellType = (arg)=>{
  if(typeof arg === 'function'){
    arg();
  }else{
    console.log(`this data is ${arg}`)
  }
}

let dataFn = ()=> {
  console.log('this is a Function');
}

tellType(dataFn);
複製程式碼
//返回函式
let returnStr = ()=> String;

returnStr()('Nealyang')

//let fn = returnStr();
//fn('Nealyang');
複製程式碼

從上我們可以看到函式可以接受另一個函式作為引數,同樣,函式也可以將兩一個函式作為返回值返回。

所以高階函式就是接受函式作為引數並且/或者返回函式作為輸出的函式

HOC 到底你是幹嘛的

當我們瞭解到如何去建立並執行一個高階函式的時候,同行我們都想去了解,他到底是幹嘛的?OK,簡單的說,高階函式常用於抽象通用的問題。換句話說,高階函式就是定義抽象。簡單的說,其實就類似於命令式的程式設計方式,將具體的實現細節封裝、抽象起來,讓開發者更加的關心業務。抽象讓我們專注於預定的目標而不是去關心底層的系統概念。

理解這個概念非常重要,所以下面我們將通過大量的栗子來說明

舉斤栗子

const every = (arr,fn)=>{
  let result = true;
  for(const value of arr){
    result  = result && fn(value);
  }
  return result;
}

every([NaN,NaN,4],isNaN);

const some = (arr,fn)=>{
  let result = true;
  for(const value of arr){
    result  = result || fn(value);
  }
  return result;
}
some([3,1,2],isNaN);
//這裡都是低效的實現。這裡主要是理解高階函式的概念
複製程式碼
let sortObj = [
  {firstName:'aYang',lastName:'dNeal'},
  {firstName:'bYang',lastName:'cNeal'},
  {firstName:'cYang',lastName:'bNeal'},
  {firstName:'dYang',lastName:'aNeal'},
];

const sortBy = (property)=>{
  return (a,b) => {
    return (a[property]<b[property])?-1:(a[property]>b[property])?1:0
  }
}

sortObj.sort(sortBy('lastName'));
//sort函式接受了被sortBy函式返回的比較函式,我們再次抽象出compareFunction的邏輯,讓使用者更加關注比較,而不用去在乎怎麼比較的。
複製程式碼

HOC必然離不開閉包

上面的sortBy其實大家都應該看到了閉包的蹤影。關於閉包的產生、概念這裡就不囉嗦了。總之我們知道,閉包非常強大的原因就是它對作用域的訪問。

簡單說下閉包的三個可訪問的作用域:

  • 在它自身宣告之內的變數
  • 對全域性變數的訪問
  • 對外部函式變數的訪問(*)

接著舉栗子

const forEach = (arr,fn)=>{
  for(const item of arr){
    fn(item);
  }
}
//tap接受一個value,返回一個帶有value的閉包函式
const tap = (value)=>(fn)=>{
  typeof fn === 'function'?fn(value):console.log(value);
}

forEach([1,2,3,4,5],(a)=>{
  tap(a)(()=>{
    console.log(`Nealyang:${a}`)
  })
});

複製程式碼

函式柯里化與偏應用

函式柯里化

概念

直接看概念,柯里化是把一個多參函式轉換為一個巢狀的一元函式的過程

不理解,莫方!舉個例子就明白了。

假設我們有一個函式,add:

const add = (x,y)=>x+y;
複製程式碼

我們呼叫的時候當然就是add(1,2),沒有什麼特別的。當我們柯里化了以後呢,就是如下版本:

const addCurried = x => y => x + y;
複製程式碼

呼叫的時候呢,就是這個樣子的:

addCurried(4)(4)//8
複製程式碼

是不是非常的簡單?

說到這,我們在來回顧下,柯里化的概念:把一個多參函式轉換成一個巢狀的一元函式的過程。

如何實現多參函式轉為一元

上面的程式碼中,我們實現了二元函式轉為一元函式的過程。那麼對於多參我們該如何做呢?

這個是比較重要的部分,我們一步一步來實現

我們先來新增一個規則,最一層函式檢查,如果傳入的不是一個函式來呼叫curry函式則丟擲錯誤。當如果提供了柯里化函式的所有引數,則通過使用這些傳入的引數呼叫真正的函式。

let curry = (fn) => {
if(typeof fn !== 'function'){
  throw Error('not a function');
}
return function curriedFn (...args){
  return fn.apply(null,args);
}
}
複製程式碼

所以如上,我們就可以這麼玩了

const multiply = (x,y,z) => x * y * z;
curry(multiply)(1,2,3);//6
複製程式碼

革命還未成功,我們繼續哈~下面我們的目的就是把多參函式轉為巢狀的一元函式(重回概念)

const multiply = (x,y,z) => x * y * z;
let curry = (fn) => {
  if(typeof fn !== 'function'){
    throw Error('not a function');
  }
  return function curriedFn (...args){
    if(args.length < fn.length){
      return function(){
        return curriedFn.apply(null,args.concat([].slice.call(arguments)));
      }
    }
   return fn.apply(null,args);
  }
}
curry(multiply)(1)(2)(3)
複製程式碼

如果是初次看到,可能會有些疑惑。我們一行行來瞅瞅。

args.length < fn.length
複製程式碼

這段程式碼比價直接,就是判斷,你傳入的引數是否小於函式引數長度。

args.concat([].slice.call(arguments))
複製程式碼

我們使用cancat函式連結一次傳入的一個引數,並遞迴呼叫curriedFn。由於我們將所有的引數傳入組合並遞迴呼叫,最終if判斷會失效,就返回結果了。

####小小實操一下 我們寫一個函式在陣列內容中查詢到包含數字的項

let curry = (fn) => {
  if(typeof fn !== 'function'){
    throw Error('not a function');
  }
  return function curriedFn (...args){
    if(args.length < fn.length){
      return function(){
        return curriedFn.apply(null,args.concat([].slice.call(arguments)));
      }
    }
   return fn.apply(null,args);
  }
}
let match = curry(function(expr,str){return str.match(expr)});

let hasNumber = match(/[0-9]+/);

let filter = curry(function(f,ary){
  return ary.filter(f)
});

filter(hasNumber)(['js','number1']);
複製程式碼

通過如上的例子,我想我們也應該看出來,為什麼我們需要函式的柯里化:

  • 程式片段越小越容易被配置
  • 儘可能的函式化

偏應用

假設我們需要10ms後執行某一個特定操作,我們一般的做法是

setTimeout(() => console.log('do something'),10);
setTimeout(() => console.log('do other thing'),10);
複製程式碼

如上,我們呼叫函式都傳入了10,能使用curry函式把他在程式碼中隱藏嗎?我擦,我們curry多牛逼!肯定不行的嘛~

因為curry函式應用引數列表是從最左到最右的。由於我們是根據需要傳遞函式,並將10儲存在常量中,所以不能以這種方式使用curry。我們可以這麼做:

const setTimeoutFunction = (time , fn) => {
  setTimeout(fn,time);
}
複製程式碼

但是如果這樣的話,我們是不是太過於麻煩了呢?為了減少了10的傳遞,還需要多造一個包裝函式?

這時候,偏應用就出來了!!!

簡單看下程式碼實現:

const partial = function (fn,...partialArgs){
  let args = partialArgs;
  return function(...fullArgs){
    let arg = 0;
    for(let i = 0; i<args.length && fullArgs.length;i++){
      if(arg[i] === undefined){
        args[i] = fullArgs[arg++];
      }
    }
    return fn.apply(null,args)
  }
}

let delayTenMs = partial(setTimeout , undefined , 10);

delayTenMs(() => console.log('this is Nealyang'));
複製程式碼

如上大家應該都能夠理解。這裡不做過多廢話解釋了。

簡單總結的說:

所以,像map,filter我們可以輕鬆的使用curry函式解決問題,但是對於setTimeout這類,最合適的選擇當然就是偏函式了。總之,我們使用curry或者partial是為了讓函式引數或者函式設定變得更加的簡單強大。

下節預告

上一部分說的比較淺顯基礎,希望大家也能夠從中感受到函數語言程式設計的精妙和靈活之處。大神請直接略過~求指正求指導~

下一節中,將主要介紹下,函數語言程式設計中的組合、管道、函子以及Monad。最後我們在介紹下es6的Generator,或許我們能從最後的Generator中豁然開朗獲得到很多啟發哦~~

技術交流

nodejs 技術交流 群號:698239345

React技術棧群號:398240621

前端技術雜談群號:604953717

相關文章