[譯] Transducers: JavaScript 中高效的資料處理 Pipeline(第 18 部分)

0x7e2發表於2019-01-07

Transducers:JavaScript 中高效的資料處理 Pipeline

Smoke Art Cubes to Smoke

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0)

注意:這是從頭開始學 JavaScript ES6+ 中的函數語言程式設計和組合軟體技術中 “撰寫軟體” 系列的一部分。敬請關注,我們會講述大量關於這方面的知識! < 上一篇 | << 從第一篇開始

在使用 transducer 之前,你首先要完全搞懂複合函式(function composition)reducers 是什麼。

Transduce:源於 17 世紀的科學術語(latin name 一般指學名)“transductionem”,意為“改變、轉換”。它更早衍生自“transducere/traducere”,意思是“引導或者跨越、轉移”。

一個 transducer 是一個可組合的高階 reducer。以一個 reducer 作為輸入,返回另外一個 reducer。

Transducers 是:

  • 可組合使用的簡單功能集合
  • 對大型集合或者無限流有效:不管 pipeline 中的運算元量有多少,都只對單一元素進行一次列舉。
  • 能夠轉換任何可列舉的源(例如,陣列、樹、流、圖等...)
  • 無需更換 transducer pipeline,即可用於惰性或熱切求值(譯者注:求值策略)。

Reducer 將多個輸入 摺疊(fold) 成單個輸出,其中“摺疊”可以用幾乎任何產生單個輸出的二進位制操作替換,例如:

// 求和: (1, 2) = 3  
const add = (a, c) => a + c;

// 求乘積: (2, 4) = 8  
const multiply = (a, c) => a * c;

// 字串拼接: ('abc', '123') = 'abc123'  
const concatString = (a, c) => a + c;

// 陣列拼接: ([1,2], [3,4]) = [1, 2, 3, 4]  
const concatArray = (a, c) => [...a, ...c];
複製程式碼

Transducer 做了很多相同的事情,但是和普通的 reducer 不同,transducer 可以使用正常地組合函式組合。換句話說,你可以組合任意數量的 tranducer,組成一個將每個 transducer 元件串聯在一起的新 transducer。

普通的 reducer 不能這樣(組合)。因為它需要兩個引數,只返回一個輸出值。所以你不能簡單地將輸出連線到串聯中下一個 reducer 的輸入。這樣會出現型別不符合的情況:

f: (a, c) => a
g:          (a, c) => a
h: ???
複製程式碼

Transducers 有著不同的簽名:

f: reducer => reducer
g:            reducer => reducer
h: reducer    =>         reducer
複製程式碼

為什麼選擇 Transducer?

通常,處理資料時,將處理分解成多個獨立的可組合階段很有用。例如,從較大的集合中選擇一些資料然後處理該資料非常常見。你可能會這麼做:

const friends = [
  { id: 1, name: 'Sting', nearMe: true },
  { id: 2, name: 'Radiohead', nearMe: true },
  { id: 3, name: 'NIN', nearMe: false },
  { id: 4, name: 'Echo', nearMe: true },
  { id: 5, name: 'Zeppelin', nearMe: false }
];

const isNearMe = ({ nearMe }) => nearMe;

const getName = ({ name }) => name;

const results = friends
  .filter(isNearMe)
  .map(getName);

console.log(results);
// => ["Sting", "Radiohead", "Echo"]
複製程式碼

這對於像這樣的小型列表來說很好,但是存在一些潛在的問題:

  1. 這僅僅只適用於陣列。對於那些來自網路訂閱的潛在無限資料流,或者朋友的朋友的社交圖如何處理呢?

  2. 每次在陣列上使用點鏈語法(dot chaining syntax)時,JavaScript 都會構建一個全新的中間陣列,然後再轉到鏈中的下一個操作。如果你有一個 2,000,000 名“朋友”的名單,這可能會使資料處理減慢一兩個數量級。使用 transducer,你可以通過完整的 pipeline 流式傳輸每個朋友,而無需在它們之間建立中間集合,從而節省大量時間和記憶體。

  3. 使用點鏈,你必須構建標準操作的不同實現。如 .filter().map().reduce().concat() 等。陣列方法內建在 JavaScript 中,但是如果你想構建自定義資料型別並支援一堆標準操作而且還不需要重頭進行編寫,改怎麼辦?Transducer 可以使用任何傳輸資料型別:編寫一次操作符,在支援 transducer 的任何地方使用它。

讓我們看看 transducer。這段程式碼還不能工作,但是還請繼續,你將能夠自己構建這個 transducer pipeline 的每一部分:

const friends = [  
  { id: 1, name: 'Sting', nearMe: true },  
  { id: 2, name: 'Radiohead', nearMe: true },  
  { id: 3, name: 'NIN', nearMe: false },  
  { id: 4, name: 'Echo', nearMe: true },  
  { id: 5, name: 'Zeppelin', nearMe: false }  
];

const isNearMe = ({ nearMe }) => nearMe;

const getName = ({ name }) => name;

const getFriendsNearMe = compose(  
  filter(isNearMe),  
  map(getName)  
);

const results2 = toArray(getFriendsNearMe, friends);
複製程式碼

在你告訴他們開始並向他們提供一些資料進行處理之前,transducer 不會做任何事情。這就是我們為什麼需要使用 toArray()。他提供傳導過程並告訴 transducer 將結果轉換成新陣列。你可以告訴它轉換一個流、一個 observable,或者任何你喜歡的東西,而不僅僅只是呼叫 toArray()

Transducer 可以將數字對映(mapping)成字串,或者將物件對映到陣列,或者將陣列對映成更小的陣列,或者根本不做任何改變,對映 { x, y, z } -> { x, y, z }。Transducer 可以過濾流中的部分訊號 { x, y, z } -> { x, y },甚至可以生成新值插入到輸出流中,{ x, y, z } -> { x, xx, y, yy, z, zz }

我將在本節中使用“訊號(signal)”和“流(stream)”等詞語。請記住,當我說“流”時,我並不是指任何特定的資料型別:只是一個有零個或者多個值的序列,或者隨時間表達的值列表。

背景和詞源

在硬體訊號處理系統中,transducer(換能器)是將一種形式的能量轉換成另一種形式的裝置。例如,麥克風換能器將音訊波轉換為電能。換句話說,它將一種訊號轉換成為另一種訊號。同樣,程式碼中的 transducer 將一個訊號轉換成另一個訊號。

軟體找那個使用 “transducer” 一詞和資料轉換的可組合 pipeline 的通用概念至少可以追溯到 20 世紀 60 年代,但是我們對於他們應該如何工作的想法已經從一種語言和上下文轉變為下一種語言。在電腦科學的早期,許多軟體工程師也是電氣工程師。當時對電腦科學的一般研究經常涉及到硬體和軟體設計。因此,將計算過程視為 “transducer” 並不是特別新穎。在早期的電腦科學文獻中可能會遇到這個術語 —— 特別是在數字訊號處理(DSP)和資料流程式設計的背景下。

在 20 世紀 60 年代,麻省理工學院林肯實驗室的圖形計算開始使用 TX-2 計算機系統,這是美國空軍 SAGE 防禦系統的前身。Ivan Sutherland 著名的 Sketchpad,於 1961 年至 1962 年開發,是使用光筆進行物件原型委派和圖形程式設計的早期例子。

Ivan 的兄弟 William Robert “Bert” Sutherland 是資料流程式設計的幾個先驅之一。他在 Sketchpad 上構建了一個資料流程式設計環境。它將軟體“過程”描述為操作員節點的有向圖,其輸出連線到其他節點的輸入。他在 1966 年的論文 “The On-Line Graphical Specification of Computer Procedures” 中寫下了這段經歷。在連續執行的互動式程式迴圈中,所有內容都表示為值的流,而不是陣列和處理中的陣列。每個節點在到達引數輸入時處理每個值。你現在可以在虛擬藍圖引擎 Visual Scripting EnvironmentNative Instruments’ Reaktor 找到類似的系統,這是一種音樂家用來構建自定義音訊合成器的視覺化程式設計環境。

Bert Sutherland 撰寫的運營商組成圖

Bert Sutherland 撰寫的運營商組成圖

據我所知,第一本在基於通用軟體的流處理環境中推廣 “transducer” 一詞的書是 1985 年 MIT 電腦科學課程 “Structure and Interpretation of Computer Programs” 的教科書(SICP)。該書由 Harold Abelson、Gerald Jay Sussman、Julie Sussman 和撰寫。然而在數字訊號處理中使用術語 “transducer” 早於 SICP。

:從函數語言程式設計的角度來看,SICP 仍然是對電腦科學出色的介紹。它仍然是這個主題中我最喜歡的書。

最近,transducer 已經重新被獨立發掘。並且 Rich Hickey(大約 2014 年)為 Clojure 開發了一個不同的協議,他以精心選擇基於詞源的概念詞而聞名。這時,我就會說他說的太棒了,因為 Clojure 的 transducer 的內在基本和 SICP 中的相同,並且他們也具有了很多共性。但是,他們並非嚴格相同。

Transducer 作為一般概念(不是 Hickey 的協議規範)來講,對電腦科學的重要分支產生了相當大的影響,包括資料流程式設計、科學和媒體應用的訊號處理、網路、人工智慧等等。隨著我們開發更好的工具和技術在我們打應用程式碼中闡釋 transducer,它們開始幫助我們更好的理解各種軟體組合,包括 Web 和 易用應用程式中的使用者介面行為,並且在將來,還可以很好地幫助我們管理複雜的 AR(augmented reality),自主裝置和車輛等。

為了討論起見,當我說 “transducer” 時,我並不是指 SICP transducer,儘管如果你已經熟悉了 SICP transducer,可能聽起來像是在講述它們。我也沒有具體提到 Clojure 的 transducer,或者已經成為 JavaScript 事實標準的 transducer 協議(由 Ramda、Transducer-JS、RxJS等支援...)。我指的是高階 reducer的一般概念 —— 變幻的轉換。

在我看來,transducer 協議的特定細節比 transducer 的一般原理和基本數學特性重要的多,但是如果你想在生產中使用 transducer,為了滿足互操作性,我目前的建議是使用現有的庫來實現 transducer 協議。

我將在這裡描述的 transducer 應該是用虛擬碼來演示概念。它們與 transducer 協議不相容,不應該在生產中使用。如果你想要學習如何使用特定庫的 transducer,請參閱庫文件。我這樣寫他們是為了引你入門,讓你看看它們是如何工作的,而不是強迫你同時學習協議。

當我們完成後,你應該更好的理解 transducer,以及如何在任意的上下文中、與任意的庫一起、在任何支援閉包和高階函式的語言中使用它。

Transducer 的音樂類比

如果你是眾多既是音樂家又是軟體的開發者的那群人中的一個,用音樂類比可能會很有用:你可以想到訊號處理裝置等感測器(如吉他失真踏板,均衡器,音量旋鈕,回聲,混響和音訊混頻器)。

要使用樂器錄製歌曲,我們需要某種物理感測器(即麥克風)來講空氣中的聲波轉換為電線上的電流。然後我們需要將該線路連線到我們想要使用的訊號處理單元。例如,為電吉他加失真,或者對音軌進行混響。最終,這些不同聲音的集合必須聚合在一起,混合來想成最終記錄的單個訊號(或者通道集合)。

換句話說,訊號流看起來可能是這樣。把箭頭想像成感測器之間的導線:

[ Source ] -> [ Mic ] -> [ Filter ] -> [ Mixer ] -> [ Recording ]
複製程式碼

更一般地說,你可以這麼表達:

[ Enumerator ]->[ Transducer ]->[ Transducer ]->[ Accumulator ]
複製程式碼

如果你曾經使用過音樂製作軟體,這可能會讓您想起一系列的音訊效果。當你考慮 transducer 時,這是一個很好的直覺。但他們還可以更廣泛的應用於數字、物件、動畫幀、3D 模型或者任何你可以在軟體中表示的其他內容。

[譯] Transducers: JavaScript 中高效的資料處理 Pipeline(第 18 部分)

螢幕截圖:Renoise 音訊效果通道。

如果你曾在陣列上使用 map 方法,你可能會對某些行為有點像 transducer 的東西熟悉。例如,要將一系列數字加倍:

const double = x => x * 2;  
const arr = [1, 2, 3];

const result = arr.map(double);
複製程式碼

在這個示例中,陣列是可列舉物件。map 方法列舉原始陣列,並將其元素傳遞給處理階段 double,它將每個元素乘以 2,然後將結果累積到一個新陣列中。

你甚至可以像這樣構成效果:

const double = x => x * 2;  
const isEven = x => x % 2 === 0;

const arr = [1, 2, 3, 4, 5, 6];

const result = arr  
  .filter(isEven)  
  .map(double)  
;

console.log(result);  
// [4, 8, 12]
複製程式碼

但是,如果你想過濾和加倍的可能是無限數字流,比如無人機的遙測資料呢?

陣列不能是無限的,並且陣列處理過程中的每個階段都要求你在單個值可以流經 pipeline 的下一個階段之前處理整個陣列。同樣的問題意味著使用陣列方法的合成會降低效能,因為需要建立一個新陣列,並且合成中的每個階段迭代一個新的集合。

想象一下,你有兩段管道,每段都代表一個應用於資料流的轉換,以及一個表示流的字串。第一個轉換表示 isEven 過濾器,下一個轉換表示 double 對映。為了從陣列中生成單個完全變換的值,你必須首先通過第一個管道執行整個字串,從而產生一個全新的過濾陣列,然後才能通過 double 管處理單個值。當你最終將第一個值 double,必須等待整個陣列加倍才能讀取單個結果。

所以,上面的程式碼相當於:

const double = x => x * 2;  
const isEven = x => x % 2 === 0;

const arr = [1, 2, 3, 4, 5, 6];

const tempResult = arr.filter(isEven);  
const result = tempResult.map(double);

console.log(result);  
// [4, 8, 12]
複製程式碼

另一種方法是將值直接從過濾後的輸出流式傳輸到對映轉換,而無需在其間建立和迭代臨時陣列。將值一次一個地流過,無需在轉換過程中對每個階段迭代相同的集合,並且 transducer 可以隨時發出停止訊號,這意味著你不需要在集合中更深入地計算每個階段。需要產生所需的值。

有兩種方法可以做到這一點:

  • Pull:惰性求值,或者
  • Push:及早求值

Pull API 等待 consumer 請求下一個值。JavaScript 中一個很好的例子是 Iterable。例如生成器函式生成的物件。在通過它在返回的迭代器物件上呼叫 .next() 來請求下一個值之前,生成器函式什麼事情都不做。

Push API 列舉源值並儘可能快地將它們推送到管中。對於 array.reduce() 呼叫是 push API 的一個很好的例子。array.reduce() 從陣列中一次獲取一個值並將其推送到 reducer,從而在另一端產生一個新值。對於像 array reduce 這樣的熱切程式,會立即對陣列中的每個元素重複該過程,直到處理完整個陣列。在此期間,阻止進一步的程式執行。

Transducers 不關心你是 pull 還是 push。Transducers 不瞭解他們所採取的資料結構。他們只需呼叫你傳遞給它們的 reducer 來積累新值。

Transducers 是高階 reducer: Reducer 函式採用 reducer 返回新的 reducer。Rich Hickey 將 transducer 描述為過程變換,這意味著 transducer 沒有簡單地改變流經的值,而是改變了作用這些值的過程。

簽名應該是這樣的:

reducer = (accumulator, current) => accumulator

transducer = reducer => reducer
複製程式碼

或者,拼出來:

transducer = ((accumulator, current) => accumulator) => ((accumulator, current) => accumulator)
複製程式碼

一般來說,大多數 transducer 需要部分應用於某些引數來專門化它們。例如,map transducer 可能如下所示:

map = transform => reducer => reducer
複製程式碼

或者更具體地說:

map = (a => b) => step => reducer
複製程式碼

換句話說,map transducer 採用對映函式(稱為變換)和 reducer(稱為 step 函式 ),返回新的 reducer。Step 函式是一個 reducer,當我們生成一個新值以下一步中新增到累加器時呼叫。

讓我們看一些不成熟的例子:

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);

const map = f => step =>  
  (a, c) => step(a, f(c));

const filter = predicate => step =>  
  (a, c) => predicate(c) ? step(a, c) : a;

const isEven = n => n % 2 === 0;  
const double = n => n * 2;

const doubleEvens = compose(  
  filter(isEven),  
  map(double)  
);

const arrayConcat = (a, c) => a.concat([c]);

const xform = doubleEvens(arrayConcat);

const result = [1,2,3,4,5,6].reduce(xform, []); // [4, 8, 12]

console.log(result);
複製程式碼

這包含了很多內容。讓我們分解一下。map 將函式應用於某些上下文的值。在這種情況下,上下文是 transducer pipeline。看起來大致如下:

const map = f => step =>  
  (a, c) => step(a, f(c));
複製程式碼

你可以像這樣使用它:

const double = x => x * 2;

const doubleMap = map(double);

const step = (a, c) => console.log(c);

doubleMap(step)(0, 4);  // 8doubleMap(step)(0, 21); // 42
複製程式碼

函式呼叫末尾的零表示 reducer 的初始值。請注意,step 函式應該是 reducer,但出於演示目的,我們可以劫持它並開啟控制檯。如果需要對 step 函式的使用方式進行斷言,則可以在單元測試中使用相同的技巧。

當我們將它們組合在一起的時候,transducer 將會變得很有意思。讓我們實現一個簡化的 filter transducer:

const filter = predicate => step =>  
  (a, c) => predicate(c) ? step(a, c) : a;
複製程式碼

Filter 採用 predicate 函式,只傳遞與 predicate 匹配的值。否則,返回的 reducer 返回累加器,不變。

由於這兩個函式都使用 reducer 並且返回了 reducer,因此我們可以使用簡單的函式組合來組合它們:

const compose = (...fns) => x => fns.reduceRight((y, f) => f(y), x);

const isEven = n => n % 2 === 0;  
const double = n => n * 2;

const doubleEvens = compose(  
  filter(isEven),  
  map(double)  
);
複製程式碼

這也將返回一個 transducer,需要我們必須提供最後一個 step 函式,以告訴 transducer 如何累積結果:

const arrayConcat = (a, c) => a.concat([c]);

const xform = doubleEvens(arrayConcat);
複製程式碼

此呼叫結果是標準的 reducer,我們可以直接傳遞給任何相容的 reduce API。第二個參數列示 reduction 的初始值。這種情況下是一個空陣列:

const result = [1,2,3,4,5,6].reduce(xform, []); // [4, 8, 12]
複製程式碼

如果這看起來像是做了很多,請記住,已經有函式程式設計庫提供常見的 transducer 以及諸如 compose 工具程式,他們處理函式組合,並將值轉換為給定的空值。例如:

const xform = compose(
  map(inc),
  filter(isEven)
);

into([], xform, [1, 2, 3, 4]); // [2, 4]
複製程式碼

由於工具帶中已經有了大多數所需的工具,因此使用 transducer 進行程式設計非常直觀。

一些支援 transducer 的流行庫包括 Ramda、RxJS 和 Mori。

由上至下組合 transducers

標準函式組成下的 transducer 從上到下/從左到右而非從下到上/從右到左應用。也就是說,使用正常函式組合,compose(f, g) 表示“在 g 之後複合 f”。Transducer 在組成下糾纏其他 transducer。換言之,transducer 說“我要做我的事情,然後呼叫管道中下一個 transducer”,這會將執行堆疊內部轉出。

想象一下,你有一沓紙,頂部的一個標有 f,下一個是 g,再下面是 h。對於每張紙,將紙張從紙沓的頂部取出,然後將其放到相鄰的新的一沓紙的頂部。當你這樣做之後,你將獲得一個棧,其內容標記為 h,然後是 g,然後是 f

Transducer 規則

上面的例子不太成熟,因為他們忽略了 transducer 必須遵循的互操作性(interoperability)規則

和軟體中的大部分內容一樣,transducer 和轉換過程需要遵循一些規則:

  1. 初始化:如果沒有初始的累加器值,transducer 必須呼叫 step 函式來產生有效的初始值進行操作。該值應該表示空狀態。例如,累積陣列的累加器應該在沒有引數的情況下呼叫其 step 函式時提供空陣列。

  2. 提前終止:使用 transducer 的程式必須在收到 reduce 過的累加器值時檢查並停止。此外,對於巢狀 reduce 的 transducer,使用其 step 函式時必須在遇到時檢查並傳遞 reduce 過的值。

  3. 完成(可選):某些轉換過程永遠不會完成,但那些轉換過程應呼叫完成函式(completion function)來產生最終值/或重新整理(flush)狀態,並且狀態 transducer 應提供完成的操作以清除任何積累的資源和可能產生最終的資源值。

初始化

讓我們回到 map 操作並確保它遵守初始化(空)法則。當然,我們不需要做任何特殊的事情,只需要使用 step 函式在 pipeline 中傳遞請求來建立預設值:

const map = f => step => (a = step(), c) => (
  step(a, f(c))
);
複製程式碼

我們關心的部分是函式簽名中的 a = step()。如果 a(累加器)沒有值,我們將通過鏈中的下一個 reducer 來生成它。最終,它將到達 pipeline 的末端,並(但願)為我們建立有效的初始值。

記住這條規則:當沒有引數呼叫時,reducer 的操作應該總是為 reducer 返回一個有效的初始(空)值。對於任何 reducer 函式,包括 React 或 Redux 的 Reducer,遵守此規則通常是個好主意。

提前終止

可以向 pipeline 中的其他 transducer 發出訊號,表明我們已經完成了 reduce,並且他們不應該期望再處理任何值。在看到 reduced 值時,其他 transducer 可以決定停止新增到集合,並且轉換過程(由最終 step() 函式控制)可以決定停止列舉值。由於接收到 reduced 值,轉換過程可以再呼叫一次:完成上述呼叫。我們可以通過特殊的 reduce 過的累加器來表示這個意圖。

什麼是 reduced 值?它可能像將累加器值包裝在一個名為 reduced 的特殊型別中一樣簡單。可以把它想象包裝盒子並用 "Express" 或 "Fragile" 這樣的訊息標記盒子。像這樣的後設資料包裝器(metadata wrapper)在計算中很常見。例如:http 訊息包含在名為 “request” 或 “response” 的容器中,這些容器型別提供了狀態碼、預期訊息長度、授權引數等資訊的表頭...

基本上,它是一種傳送多條資訊的方式,其中只需要一個值。reduced() 型別提升的最小(非標準)示例可能如下所示:

const reduced = v => ({
  get isReduced () {
    return true;
  },
  valueOf: () => v,
  toString: () => `Reduced(${ JSON.stringify(v) })`
});
複製程式碼

唯一嚴格要求的部分是:

  • 型別提升:獲取型別內部值的方法(例如,這種情況下的 reduced 函式)
  • 型別識別:一種測試值以檢視它是否為 reduced 值的方法(例如,isReduced getter)
  • 值提取:一種從值中取出值的方法(例如,valueOf()

此處包含 toString() 以便於除錯。它允許您在 console 中同時內省型別和值。

完成

“在完成步驟中,具有重新整理狀態(flush state)的 transducer 應該在呼叫巢狀 transducer 的完成函式之前重新整理狀態,除非之前已經看到巢狀步驟中的 reduced 值,在這種情況下應該丟棄 pending 狀態。” ~ Clojure transducer 文件

換句話說,如果在前一個函式表示已完成 reduce 後,有更多狀態需要重新整理,則完成函式是處理它的時間。在此階段,你可以選擇:

  • 再傳送一個值(重新整理待處理狀態)
  • 丟棄 pending 狀態
  • 執行任何所需的狀態清理

Transducing

可以轉換大量不同型別的資料,但是這個過程可以推廣:

// 匯入標準 curry,或者使用這個魔術:
const curry = (
  f, arr = []
) => (...args) => (
  a => a.length === f.length ?
    f(...a) :
    curry(f, a)
)([...arr, ...args]);

const transduce = curry((step, initial, xform, foldable) =>
  foldable.reduce(xform(step), initial)
);
複製程式碼

transduce() 函式採用 step 函式(transducer pipeline 的最後一步),累加器的初始值,transducer 並且可摺疊。可摺疊是提供 .reduce() 方法的任何物件。

通過定義 transduce(),我們可以輕鬆建立一個轉換為陣列的函式。首先,我們需要一個 reduce 陣列的 reducer:

const concatArray = (a, c) => a.concat([c]);
複製程式碼

現在我們可以使用柯里化過的 transduce() 建立一個轉換為陣列的部分應用程式:

const toArray = transduce(concatArray, []);
複製程式碼

使用 toArray() 我們可以用一行替代兩行程式碼,並在很多其他情況下複用它,除此之外:

// 手動 transduce:
const xform = doubleEvens(arrayConcat);
const result = [1,2,3,4,5,6].reduce(xform, []);
// => [4, 8, 12]

// 自動 transduce:
const result2 = toArray(doubleEvens, [1,2,3,4,5,6]);
console.log(result2); // [4, 8, 12]
複製程式碼

Transducer 協議

到目前為止,我們一直在隱藏幕後一些細節,但現在是時候看看它們了。Transducer 並非真正的單一函式。他們由 3 種不同的函式組成。Clojure 使用函式的 arity 上的模式匹配並在它們之間切換。

在電腦科學中,函式的 arity 是函式所採用引數的數量。在 transducer 的情況下,reducer 函式有兩個引數,累加器和當前值。在 Clojure 中,兩者都是可選的,並且函式的行為會根據引數是否通過而更改。如果沒有傳遞引數,則函式中該引數的型別是 undefined

JavaScript transducer 協議處理的方式略有不同。JavaScript transducer 不是使用函式 arity,而是採用 transducer 並返回 transducer 的函式。Transducer 是一個有三種方法的物件:

  • init 返回累加器的有效初始值(通常,只需要呼叫下一步 step())。
  • step 應用變換,例如,對於 map(f)step(accumulator, f(current))
  • result 如果在沒有新值的情況下呼叫 transducer,它應該處理其完成步驟(通常是 step(a),除非 transducer 是有狀態的)。

注意: JavaScript 中的 transducer 協議分別使用 @@transducer/init@@transducer/step@@transducer/result

有些庫提供一個 tranducer() 工具程式,可以自動為你包裝 transducer。

這是一個不那麼不成熟的 transducer 實現:

const map = f => next => transducer({
  init: () => next.init(),
  result: a => next.result(a),
  step: (a, c) => next.step(a, f(c))
});
複製程式碼

預設情況下,大多數 transducer 應該將 init() 呼叫傳遞給 pipeline 中的下一個 transducer,因為我們不知道傳輸資料型別,因此我們無法為它生成有效的初始值。

此外,特殊的 reduced 物件使用這些屬性(在 transducer 協議中也命名為 @@transducer/<name>):

  • reduced 一個布林值,對於 reduced 的值,該值始終為 true
  • value reduced 的值。

結論

Transducers 是可組合的高階 reducer,可以 reduce 任何基礎資料型別。

Transducers 產生的程式碼比使用陣列進行點連結的效率高几個數量級,並且可以處理潛在的無需資料集而無需建立中間聚合。

注意:Transducers 並不是總是比內建陣列方法更快。當資料集非常大(數十萬個專案)或 pipeline 非常大(顯著增加使用方法鏈所需的迭代次數)時,效能優勢往往會有所提升。如果你追求效能優勢,請記住簡介。

再看看介紹中的例子。你應該能使用示例程式碼作為參考構建 filter()map()toArray(),並使此程式碼工作:

const friends = [  
  { id: 1, name: 'Sting', nearMe: true },  
  { id: 2, name: 'Radiohead', nearMe: true },  
  { id: 3, name: 'NIN', nearMe: false },  
  { id: 4, name: 'Echo', nearMe: true },  
  { id: 5, name: 'Zeppelin', nearMe: false }  
];

const isNearMe = ({ nearMe }) => nearMe;

const getName = ({ name }) => name;

const getFriendsNearMe = compose(  
  filter(isNearMe),  
  map(getName)  
);

const results2 = toArray(getFriendsNearMe, friends);
複製程式碼

在生產中,你可以使用 RamdaRxJStransducers-js 或者 Mori

所有上面的這些都與這裡的示例程式碼略有不同,但遵循所有相同的基本原則。

一下是 Ramda 的一個例子:

import {  
  compose,  
  filter,  
  map,  
  into  
} from 'ramda';

const isEven = n => n % 2 === 0;  
const double = n => n * 2;

const doubleEvens = compose(  
  filter(isEven),  
  map(double)  
);

const arr = [1, 2, 3, 4, 5, 6];

// into = (structure, transducer, data) => result  
// into transduces the data using the supplied  
// transducer into the structure passed as the  
// first argument.  
const result = into([], doubleEvens, arr);

console.log(result); // [4, 8, 12]
複製程式碼

每當我們需要組個一些操作時,例如 mapfilterchunktake 等,我會深入 transducer 以優化處理過程並保持程式碼的可讀性和清爽。來試試吧。

EricElliottJS.com 上可以瞭解到更多

視訊課程和函數語言程式設計已經為 EricElliottJS.com 的網站成員準備好了。如果你還不是當中的一員,現在就註冊吧

[譯] Transducers: JavaScript 中高效的資料處理 Pipeline(第 18 部分)


Eric Elliott“編寫 JavaScript 應用”(O’Reilly)以及“跟著 Eric Elliott 學 Javascript” 兩書的作者。他為許多公司和組織作過貢獻,例如 Adobe SystemsZumba FitnessThe Wall Street JournalESPNBBC 等,也是很多機構的頂級藝術家,包括但不限於 UsherFrank Ocean 以及 Metallica

大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一起。

感謝 JS_Cheerleader

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章