JavaScript 中的函數語言程式設計:函式,組合和柯里化

前端小智發表於2022-06-16

作者:Fernando Doglio
譯者:前端小智
來源:medium

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

經常有讀者問我,智哥,你 VSCode 主題是啥,好好看哦,能不能分享一下。剛好,今天(周天)看到 10 個好看的 VSCdoe 主題,其中第一個就是我使用的,這裡分享給大家。

物件導向程式設計函數語言程式設計是兩種非常不同的程式設計正規化,它們有自己的規則和優缺點。

但是,JavaScript 並沒有一直遵循一個規則,而是正好處於這兩個規則的中間,它提供了普通OOP語言的一些方面,比如類、物件、繼承等等。但與此同時,它還為你提供了函式程式設計的一些概念,比如高階函式以及組合它們的能力。

高階函式

我們行人人三個概念中最重要的一個開始:高階函式。

高階函式意味著函式不僅僅是一個可以從程式碼中定義和呼叫,實際上,你可以將它們用作可分配的實體。如果你使用過一些JavaScript,那麼這並不奇怪。將匿名函式分配給常量,這樣的事情非常常見。

const adder = (a, b) => {
  return a + b
}

上述邏輯在許多其他語言中是無效的,能夠像分配整數一樣分配函式是一個非常有用的工具,實際上,本文涵蓋的大多數主題都是該函式的副產品。

高階函式的好處:封裝行為

有了高階函式,我們不僅可以像上面那樣分配函式,還可以在函式呼叫時將它們作為引數傳遞。這為建立一常動態的程式碼基開啟了大門,在這個程式碼基礎上,可以直接將複雜行為作為引數傳遞來重用它。

想象一下,在純物件導向的環境中工作,你想擴充套件類的功能,以完成任務。 在這種情況下,你可能會使用繼承,方法是將該實現邏輯封裝在一個抽象類中,然後將其擴充套件為一組實現類。 這是一種完美的 OOP 行為,並且行之有效,我們:

  • 建立了一個抽象結構來封裝我們的可重用邏輯
  • 建立了二級構造
  • 我們重用的原有的類,並擴充套件了它

現在,我們想要的是重用邏輯,我們可以簡單地將可重用邏輯提取到函式中,然後將該函式作為引數傳遞給任何其他函式,這種方法,可以少省去一些建立“樣板”過程,因為,我們只是在建立函式。

下面的程式碼顯示瞭如何在 OOP 中重用程式邏輯。


//Encapsulated behavior封裝行為stract class LogFormatter {
  
  format(msg) {
    return Date.now() + "::" + msg
  } 
}

//重用行為
class ConsoleLogger extends LogFormatter {
  
  log(msg) {
    console.log(this.format(msg))
  }  
}

class FileLogger extends LogFormatter {

  log(msg) {
    writeToFileSync(this.logFile, this.format(msg))
  }
}

第二個示是將邏輯提取到函式中,我們可以混合匹配輕鬆建立所需的內容。 你可以繼續新增更多格式和編寫功能,然後只需將它們與一行程式碼混合在一起即可:

// 泛型行為抽象
function format(msg) {
  return Date.now() + "::" + msg
}

function consoleWriter(msg) {
  console.log(msg)
}

function fileWriter(msg) {
  let logFile = "logfile.log"
  writeToFileSync(logFile, msg)
}

function logger(output, format) {
  return msg => {
    output(format(msg))
  }
}
// 通過組合函式來使用它
const consoleLogger = logger(consoleWriter, format)
const fileLogger = logger(fileWriter, format)

這兩種方法都有優點,而且都非常有效,沒有誰最優。這裡只是展示這種方法的靈活性,我們有能力通過 行為(即函式)作為引數,就好像它們是基本型別(如整數或字串)一樣。

高階函式的好處:簡潔程式碼

對於這個好處,一個很好的例子就是Array方法,例如forEachmapreduce等等。 在非函數語言程式設計語言(例如C)中,對陣列元素進行迭代並對其進行轉換需要使用for迴圈或某些其他迴圈結構。 這就要求我們以指定方式編寫程式碼,就是需求描述迴圈發生的過程。

let myArray = [1,2,3,4]
let transformedArray = []

for(let i = 0; i < myArray.length; i++) {
  transformedArray.push(myArray[i] * 2) 
}

上面的程式碼主要做了:

  • 宣告一個新變數i,該變數將用作myArray的索引,其值的範圍為0myArray的長度
  • 對於i的每個值,將myArray的值在i的位置相乘,並將其新增到transformedArray陣列中。

這種方法很有效,而且相對容易理解,然而,這種邏輯的複雜性會隨著專案的複雜程度上升而上升,認知負荷也會隨之增加。但是,像下面這種方式就更容易閱讀:

const double = x => x * 2;

let myArray = [1,2,3,4];
let transformedArray = myArray.map(double);

與第一種方式相比,這種方式更容易閱讀,而且由於邏輯隱藏在兩個函式(mapdouble)中,因此你不必擔心瞭解它們的工作原理。 你也可以在第一個示例中將乘法邏輯隱藏在函式內部,但是遍歷邏輯必須存在,這就增加了一些不必要的閱讀阻礙。

柯里化

函式柯里化是把接受多個引數的函式變換成接受一個單一引數(最初函式的第一個引數)的函式,並且返回接受餘下的引數而且返回結果的新函式的技術。我們來看個例子:

function adder(a, b) {
  return a + b
}

// 變成
const add10 = x => adder(a, 10)

現在,如果你要做的就是將10新增到一系列值中,則可以呼叫add10而不是每次都使用相同的第二個引數呼叫adder。 這個事例看起來比較蠢,但它是體現了 柯里化 的理想。

你可以將柯里化視為函數語言程式設計的繼承,然後按照這種思路再回到logger的示例,可以得到以下內容:

function log(msg, msgPrefix, output) {
  output(msgPrefix + msg)
} 

function consoleOutput(msg) {
  console.log(msg)
}

function fileOutput(msg) {
  let filename = "mylogs.log"
  writeFileSync(msg, filename)
}

const logger = msg => log(msg, ">>", consoleOutput);
const fileLogger = msg => log(msg, "::", fileOutput);

log的函式需要三個引數,而我們將其引入僅需要一個引數的專用版本中,因為其他兩個引數已由我們選擇。

注意,這裡將log函式視為抽象類,只是因為在我的示例中,不想直接使用它,但是這樣做是沒有限制的,因為這只是一個普通的函式。 如果我們使用的是類,則將無法直接例項化它。

組合函式

函式組合就是組合兩到多個函式來生成一個新函式的過程。將函式組合在一起,就像將一連串管道扣合在一起,讓資料流過一樣。

在電腦科學中,函式組合是將簡單函式組合成更復雜函式的一種行為或機制。就像數學中通常的函式組成一樣,每個函式的結果作為下一個函式的引數傳遞,而最後一個函式的結果是整個函式的結果

這是來自維基百科的函式組合的定義,粗體部分是比較關鍵的部分。使用柯里化時,就沒有該限制,我們可以輕鬆使用預設的函式引數。

程式碼重用聽起來很棒,但是實現起來很難。如果程式碼業務性過於具體,就很難重用它。如時程式碼太過通用簡單,又很少人使用。所以我們需要平衡兩者,一種製作更小的、可重用的部件的方法,我們可以將其作為構建塊來構建更復雜的功能。

在函數語言程式設計中,函式是我們的構建塊。每個函式都有各自的功能,然後我們把需要的功能(函式)組合起來完成我們的需求,這種方式有點像樂高的積木,在程式設計中我們稱為 組合函式。

看下以下兩個函式:

var add10 = function(value) {
    return value + 10;
};
var mult5 = function(value) {
    return value * 5;
};

上面寫法有點冗長了,我們用箭頭函式改寫一下:

var add10 = value => value + 10;
var mult5 = value => value * 5;

現在我們需要有個函式將傳入的引數先加上 10 ,然後在乘以 5, 如下:

現在我們需要有個函式將傳入的引數先加上 10 ,然後在乘以 5, 如下:

var mult5AfterAdd10 = value => 5 * (value + 10)

儘管這是一個非常簡單的例子,但仍然不想從頭編寫這個函式。首先,這裡可能會犯一個錯誤,比如忘記括號。第二,我們已經有了一個加 10 的函式 add10 和一個乘以 5 的函式 mult5 ,所以這裡我們就在寫已經重複的程式碼了。

使用函式 add10mult5 來重構 mult5AfterAdd10

var mult5AfterAdd10 = value => mult5(add10(value));

我們只是使用現有的函式來建立 mult5AfterAdd10,但是還有更好的方法。

在數學中, f ∘ g 是函式組合,叫作“f 由 g 組合”,或者更常見的是 “f after g”。 因此 (f ∘ g)(x) 等效於f(g(x)) 表示呼叫 g 之後呼叫 f

在我們的例子中,我們有 mult5 ∘ add10 或 “add10 after mult5”,因此我們的函式的名稱叫做 mult5AfterAdd10。由於Javascript本身不做函式組合,看看 Elm 是怎麼寫的:

add10 value =
    value + 10
mult5 value =
    value * 5
mult5AfterAdd10 value =
    (mult5 << add10) value

Elm 中 << 表示使用組合函式,在上例中 value 傳給函式 add10 然後將其結果傳遞給 mult5。還可以這樣組合任意多個函式:

f x =
   (g << h << s << r << t) x

這裡 x 傳遞給函式 t,函式 t 的結果傳遞給 r,函式 t 的結果傳遞給 s,以此類推。在Javascript中做類似的事情,它看起來會像 g(h(s(r(t(x))))),一個括號噩夢。

大家都說簡歷沒專案寫,我就幫大家找了一個專案,還附贈【搭建教程】

常見的函式式函式(Functional Function)

函式式語言中3個常見的函式:Map,Filter,Reduce

如下JavaScript程式碼:

 for (var i = 0; i < something.length; ++i) {
    // do stuff
 }

這段程式碼存在一個很大的問題,但不是bug。問題在於它有很多重複程式碼(boilerplate code)。如果你用命令式語言來程式設計,比如Java,C#,JavaScript,PHP,Python等等,你會發現這樣的程式碼你寫地最多。這就是問題所在

現在讓我們一步一步的解決問題,最後封裝成一個看不見 for 語法函式:

先用名為 things 的陣列來修改上述程式碼:

var things = [1, 2, 3, 4];
for (var i = 0; i < things.length; ++i) {
    things[i] = things[i] * 10; // 警告:值被改變!
}
console.log(things); // [10, 20, 30, 40]

這樣做法很不對,數值被改變了!

在重新修改一次:

var things = [1, 2, 3, 4];
var newThings = [];
for (var i = 0; i < things.length; ++i) {
    newThings[i] = things[i] * 10;
}
console.log(newThings); // [10, 20, 30, 40]

這裡沒有修改things數值,但卻卻修改了newThings。暫時先不管這個,畢竟我們現在用的是 JavaScript。一旦使用函式式語言,任何東西都是不可變的。

現在將程式碼封裝成一個函式,我們將其命名為 map,因為這個函式的功能就是將一個陣列的每個值對映(map)到新陣列的一個新值。

var map = (f, array) => {
    var newArray = [];
    for (var i = 0; i < array.length; ++i) {
        newArray[i] = f(array[i]);
    }
    return newArray;
};

函式 f 作為引數傳入,那麼函式 map 可以對 array 陣列的每項進行任意的操作。

現在使用 map 重寫之前的程式碼:

var things = [1, 2, 3, 4];
var newThings = map(v => v * 10, things);

這裡沒有 for 迴圈!而且程式碼更具可讀性,也更易分析。

現在讓我們寫另一個常見的函式來過濾陣列中的元素:

var filter = (pred, array) => {
    var newArray = [];
for (var i = 0; i < array.length; ++i) {
        if (pred(array[i]))
            newArray[newArray.length] = array[i];
    }
    return newArray;
};

當某些項需要被保留的時候,斷言函式 pred 返回TRUE,否則返回FALSE。

使用過濾器過濾奇數:

var isOdd = x => x % 2 !== 0;
var numbers = [1, 2, 3, 4, 5];
var oddNumbers = filter(isOdd, numbers);
console.log(oddNumbers); // [1, 3, 5]

比起用 for 迴圈的手動程式設計,filter 函式簡單多了。最後一個常見函式叫reduce。通常這個函式用來將一個數列歸約(reduce)成一個數值,但事實上它能做很多事情。

在函式式語言中,這個函式稱為 fold

var reduce = (f, start, array) => {
    var acc = start;
    for (var i = 0; i < array.length; ++i)
        acc = f(array[i], acc); // f() 有2個引數
    return acc;
});

reduce函式接受一個歸約函式 f,一個初始值 start,以及一個陣列 array

這三個函式,map,filter,reduce能讓我們繞過for迴圈這種重複的方式,對陣列做一些常見的操作。但在函式式語言中只有遞迴沒有迴圈,這三個函式就更有用了。附帶提一句,在函式式語言中,遞迴函式不僅非常有用,還必不可少。


程式碼部署後可能存在的BUG沒法實時知道,事後為了解決這些BUG,花了大量的時間進行log 除錯,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug

原文:https://blog.bitsrc.io/functi...

交流

有夢想,有乾貨,微信搜尋 【大遷世界】 關注這個在凌晨還在刷碗的刷碗智。

本文 GitHub https://github.com/qq44924588... 已收錄,有一線大廠面試完整考點、資料以及我的系列文章。

相關文章