從鏈式呼叫到管道組合

serialcoder發表於2019-03-29

寫在前面的

在開始正文之前我想談些與此文相關性很低的話題。對這部分不感興趣的讀者可直接跳過。

在我發表上一篇文章或許我們在 JavaScript 中不需要 this 和 class之後,看到一種評論比較有代表性。此評論認為我們應該以 MDN 文件為指南。MDN 推薦的寫法,應當是無需質疑的寫法。

我不這麼看。

MDN 文件不是用來學習的,它是你在不確定某個具體語法時的參考指南。它是 JavaScript 使用說明書。說明書一般都不帶主觀思辨,它無法提供指引。就比如你光是把市面上的所有調味料買來,看它們的說明書,你還是學不會怎麼做菜的……

很碰巧我看的比較多的一些 JS 教程,都比較主觀,與 MDN 有很多偏差。比如 Kyle Simpson 認為 JS 裡面根本沒有繼承,提供 new 操作符以及 class 語法糖是在誤導開發者。JS 原型鏈的正確用法應該是代理而不是繼承。(我同意他)

更明顯的例子是 Douglas Crockford,他認為 JS 中處理非同步程式設計的主流方案—— callback hell, promise, async/await 全都錯了。你在看他的論述之前有十足把握斷定他在胡說嗎?他在 How JavaScript Works 裡面論述了他對事件程式設計(Eventual Programming)的看法,並寫了個完整的庫,提供他的解決方案。

批判和辯證地看問題,我們才能進步。

引言

我之前有兩篇文章寫過 JS 裡面惰性求值的實現,但都是淺嘗輒止,沒有過橫向擴充套件的打算。相關工作已經有人做了(如 lazy.js),我再做意義就不大了。這周在 GitHub 上看到有人寫了個類似的庫,用原生 Generator/Iterator 實現,人氣還很高。我一看還是有人在寫,我也試試吧。然後我就用 Douglas Crockford 倡導的一種程式設計風格去寫這個庫,想驗證下這種寫法是否可行。

Crockford 倡導的寫法是,不用 this 和原型鏈,不用 ES6 Generator/Iterator,不用箭頭函式…… 資料封裝則用工廠函式來實現。

Douglas Functions

首先,如果不用 ES6 Generator 的話,我們得自己實現一個 Generator,這個比較簡單:

function getGeneratorFromList(list) {
  let index = 0;
  return function next() {
    if (index < list.length) {
      const result = list[index];
      index += 1;
      return result;
    }
  };
}

// 例子:
const next = getGeneratorFromList([1, 2, 3]);
next(); // 1
next(); // 2
next(); // 3
next(); // undefined
複製程式碼

ES6 給陣列提供了 [Symbol.Iterator] 屬性,給資料賦予了行為,很方便我們進行惰性求值操作。而拋棄了 ES6 提供的這個便利之後,我們就只有手動將資料轉換成行為了。來看看怎麼做:

function Sequence(list) {
  const iterable = Array.isArray(list)
    ? { next: getGeneratorFromList(list) }
    : list;
}
複製程式碼

如果給 Sequence 傳入原生陣列的話,它會將陣列傳給 getGeneratorFromList,生成一個 Generator,這樣就完成了資料到行為的轉換

最核心的這兩個功能寫完之後,我們來實現一個 map

function createMapIterable(mapping, { next }) {
  function map() {
    const value = next();
    if (value !== undefined) {
      return mapping(value);
    }
  }
  return { next: map };
}

function Sequence(list) {
  const iterable = Array.isArray(list)
    ? { next: getGeneratorFromList(list) }
    : list;

  function map(mapping) {
    return Sequence(createMapIterable(mapping, iterable));
  }

  return {
    map,
  };
}
複製程式碼

map 寫完後,我們還需要一個函式幫我們把行為轉換回資料

function toList(next) {
  const arr = [];
  let value = next();
  while (value !== undefined) {
    arr.push(value);
    value = next();
  }
  return arr;
}
複製程式碼

然後我們就有一個半完整的惰性求值的庫了,雖然現在它只能 map

function Sequence(list) {
  const iterable = Array.isArray(list)
    ? { next: getGeneratorFromList(list) }
    : list;

  function map(mapping) {
    return Sequence(createMapIterable(mapping, iterable));
  }

  return {
    map,
    toList: () => toList(iterable.next);
  };
}

// 例子:
const double = x => x * 2 // 箭頭函式這樣用是沒問題的啊啊啊,破個例吧
Sequence([1, 3, 6])
  .map(double)
  .toList() // [2,6,12]
複製程式碼

再給 Sequence 加個 filter 方法就差不多完整了,其它方法再擴充套件很簡單了。

function createFilterIterable(predicate, { next }) {
  function filter() {
    const value = next();
    if (value !== undefined) {
      if (predicate(value)) {
        return value;
      }
      return filter();
    }
  }
  return {next: filter};
}

function Sequence(list) {
  const iterable = Array.isArray(list)
    ? { next: getGeneratorFromList(list) }
    : list;

  function map(mapping) {
    return Sequence(createMapIterable(mapping, iterable));
  }

  function filter(predicate) {
    return Sequence(createFilterIterable(predicate, iterable));
  }

  return {
    map,
    filter,
    toList: () => toList(iterable.next);
  };
}

// 例子:
Sequence([1, 2, 3])
  .map(triple)
  .filter(isEven)
  .toList() // [6]
複製程式碼

看樣子接著上面的例子繼續擴充套件就沒問題了。

問題

我繼續寫了十幾個函式,如 take, takeWhile, concat, zip 等。直到寫到我不知道接著寫哪些了,然後我去參考了下 lazy.js 的 API,一看倒吸一口涼氣。lazy.js 快 200 個 API 吧(沒數過,目測),寫完程式碼還要寫文件。我實在不想這麼折騰了。更嚴重的問題不在於工作量,而是這麼龐大的 API 數量讓我意識到我這種寫法的問題。

在使用工廠函式實現鏈式呼叫的時候,每次呼叫都返回了一個新的物件,這個新物件包含了所有的 API。假設有 200 個 API,每次呼叫都是隻取了其中一個,剩下 199 個全扔掉了…… 記憶體再夠用也不能這麼玩吧。我有強迫症,受不了這種浪費。

結論就是,如果想實現鏈式呼叫,還是用原型鏈實現比較好。

然而鏈式呼叫本身就沒問題了嗎?雖然用原型鏈實現的鏈式呼叫能省去後續呼叫的物件建立,但是在初始化的時候也無可避免浪費記憶體。比如,原型鏈上有 200 個方法,我只呼叫其中 10 個,剩下的那 190 個都不需要,但它們還是會在初始化時建立。

我想到了 Rx.js 在版本 5 升級到版本 6 的 API 變動。

// rx.js 5 的寫法:
Source.startWith(0)
  .filter(predicate)
  .takeWhile(predicate2)
  .subscribe(() => {});

// rx.js 6 的寫法:
import { startWith, filter, takeWhile } from 'rxjs/operators';

Source.pipe(
  startWith(0),
  filter(predicate),
  takeWhile(predicate2)
).subscribe(() => {});
複製程式碼

RxJS 6 裡面採用了管道組合替代了鏈式呼叫。這樣子改動之後,想用什麼操作符就引用什麼,沒有多餘的操作符初始化,也利於 tree shaking。那麼我們就模仿 Rxjs 6 的 API 改寫上面的 Sequence 庫吧。

用管道組合實現惰性求值

操作符的實現和上面沒太大區別,主要區別在操作符的組合方式變了:

function getGeneratorFromList(list) {
  let index = 0;
  return function generate() {
    if (index < list.length) {
      const result = list[index];
      index += 1;
      return result;
    }
  };
}

function toList(sequence) {
  const arr = [];
  let value = sequence();
  while (value !== undefined) {
    arr.push(value);
    value = sequence();
  }
  return arr;
}

// Sequence 函式本身非常輕量,操作符按需引入
function Sequence(list) {
  const initSequence = getGeneratorFromList(list);
  
  function pipe(...args) {
    return args.reduce((prev, current) => current(prev), initSequence);
  }
  return { pipe };
}

function filter(predicate) {
  return function(sequence) {
    return function filteredSequence() {
      const value = sequence();
      if (value !== undefined) {
        if (predicate(value)) {
          return value;
        }
        return filteredSequence();
      }
    };
  };
}

function map(mapping) {
  return function(sequence) {
    return function mappedSequence() {
      const value = sequence();
      if (value !== undefined) {
        return mapping(value);
      }
    };
  };
}

function take(n) {
  return function(sequence) {
    let count = 0;
    return function() {
      if (count < n) {
        count += 1;
        return sequence();
      }
    };
  };
}

function skipWhile(predicate) {
  return function(sequence) {
    let startTaking = false;
    return function skippedSequence() {
      const value = sequence();
      if (value !== undefined) {
        if (startTaking) {
          return value;
        } else if (!predicate(value)) {
          startTaking = true;
          return value;
        }
        return skippedSequence();
      }
    };
  };
}

function takeUntil(predicate) {
  return function(sequence) {
    return function() {
      const value = sequence();
      if (value !== undefined) {
        if (predicate(value)) {
          return value;
        }
      }
    };
  };
}

Sequence([2, 4, 6, 7, 9, 11, 13]).pipe(
  filter(x => x % 2 === 1),
  skipWhile(y => y < 10),
  toList
); // [11,13]
複製程式碼

【重點】

螞蟻金服保險體驗與社群技術組招高階前端開發工程師/專家。我所在的團隊,隊友們個個都是獨當一面。學霸很多,我天天跟著他們學習。(坐在我右手邊的同學是清華醫學博士。可能是因為玩過手術刀,這位大神擼程式碼行雲流水,全 Vim 擼到底)我們開發了很有社會公益價值的相互寶,接下來會有更多激動人心的產品。有興趣的同學聯絡我 ray.hl@alipay.com

參考:

Let’s experiment with functional generators and the pipeline operator in JavaScript

相關文章