Overreacted

「Bug-O」 符號

2019 M01 25 • ☕️ 3 min read

Translated by readers into: EspañolFrançais日本語简体中文繁體中文한국어

Read the originalImprove this translationView all translated posts

當你寫對於效能敏感的程式碼時,隨時將它的演算法複雜度銘記在心是件好事。它通常被呈現為 Big-O 標記

Big-O 是個測量 你的程式碼在你的資料量變大的時候會變得多慢 的標準。舉例來說,如果一個排序的演算法有 O(n2) 的複雜度,排序 50 倍的東西大概會變成 502 = 2,500 倍慢。Big O 不會給你確切的數字,但他能夠幫助你了解演算法如何擴展 (scales)。

一些例子:O(n), O(n log n), O(n2), O(n!)。

然而,本篇文章並不是要講關於演算法或效能的事。本篇文章要討論的是 APIs 和除錯。看起來,API 的設計有非常類似的考量。


我們大部分的時間花在尋找和修理我們程式碼裡面的錯誤。大多數的開發者會想要快點找出錯誤的地方。儘管它最終可能是令人滿意的結果,但本來你可以開發你藍圖裡的某個東西,卻變成花一整天的時間尋找單一一個錯誤,這會令你感覺超爛。

除錯的經驗會影響我們對於抽象化、使用的套件和工具的抉擇。某些 API 和程式語言的設計讓一大部分的錯誤變得不可能發生。有的則造成了無盡的問題。但你要怎麼知道誰屬於哪一種呢?

很多線上關於 API 的討論主要都是關注於美感,但那不能呈現出多少關於這個 API 實際上用起來的感覺如何。

我有一個幫助我衡量這個東西的標準,我叫他 Bug-O 符號:

🐞(n)

Big-O 解釋了你的程式碼在你輸入的資料量變大的時候會變得多慢。 Bug-O 解釋了當你的程式碼增長的時候某個 API 會拖慢多少。


舉例來說,以下的程式碼隨著時間急切的利用 node.appendChild()node.removeChild() 手動更新 DOM ,並且沒有明確的結構:

function trySubmit() {
  // 第一部分
  let spinner = createSpinner();
  formStatus.appendChild(spinner);
  submitForm().then(() => {
  	// 第二部分
    formStatus.removeChild(spinner);
    let successMessage = createSuccessMessage();
    formStatus.appendChild(successMessage);
  }).catch(error => {
  	// 第三部分
    formStatus.removeChild(spinner);
    let errorMessage = createErrorMessage(error);
    let retryButton = createRetryButton();
    formStatus.appendChild(errorMessage);
    formStatus.appendChild(retryButton)
    retryButton.addEventListener('click', function() {
      // 第四部分
      formStatus.removeChild(errorMessage);
      formStatus.removeChild(retryButton);
      trySubmit();
    });
  })
}

這段程式碼的問題不在它有多「醜」。我們不是在討論美感。 它的問題在於如果裡面有錯誤,我不知道該從哪裡追起。

取決於哪個 callbacks 或事件所觸發的順序,這個程式碼有爆炸性多的可能執行的程式碼的順序。 在它們之中的某些,我可以看到正確的訊息,其他的,我會看到多個微調、失敗、錯誤訊息出現在一起,而且還可能會整個崩壞。

這個函式有四個部分而且並不能保證它們的發生順序。我非常不科學嚴謹的計算告訴我,這裡會有 4×3×2×1 = 24 種不同的可能的執行順序。如果我再增加四小段程式碼,它會變成 8×7×6×5×4×3×2×1 - 四萬種組合。祝你幫它除錯順利。

換句話說,這個方法的 Bug-O 是 🐞(n!)n 是碰到 DOM 的程式碼片段的數量。耶,他是個階層。當然,我不是非常科學嚴謹的。實際上並不是所有的程序都會發生。但另一方面,每個小片段可能跑超過一次。🐞(¯\_(ツ)_/¯)可能可以更精確,但它仍然滿糟的,我們可以做得更好。


為了改善這份程式碼的 Bug-O,我們可以限制可能的狀態以及結果的數量。我們不需要任何套件來做這件事,這只是個某種強迫程式碼結構的方法。以下是一種可行的方法:

let currentState = {
  step: 'initial', // 'initial' | 'pending' | 'success' | 'error'
};

function trySubmit() {
  if (currentState.step === 'pending') {
    // 不允許送出兩次
    return;
  }
  setState({ step: 'pending' });
  submitForm().then(() => {
    setState({ step: 'success' });
  }).catch(error => {
    setState({ step: 'error', error });
  });
}

function setState(nextState) {
  // 清掉所有已經存在的小孩
  formStatus.innerHTML = '';

  currentState = nextState;
  switch (nextState.step) {
    case 'initial':
      break;
    case 'pending':
      formStatus.appendChild(spinner);
      break;
    case 'success':
      let successMessage = createSuccessMessage();
      formStatus.appendChild(successMessage);
      break;
    case 'error':
      let errorMessage = createErrorMessage(nextState.error);
      let retryButton = createRetryButton();
      formStatus.appendChild(errorMessage);
      formStatus.appendChild(retryButton);
      retryButton.addEventListener('click', trySubmit);
      break;
  }
}

這段程式碼可能看起來沒什麼差異,他甚至變得有點冗長,但它戲劇性的因為這行程式碼讓除錯變得更簡單:

function setState(nextState) {
  // 清掉所有已經存在的小孩
  formStatus.innerHTML = '';
  // ... 增加東西到 formStatus 的程式碼 ...

藉由在做任何操作以前清掉表格的狀態,我們可以保證我們在操作 DOM 的時候永遠都是從零開始。這個就是我們如何對抗難以避免的亂度 - 藉由不讓錯誤累績。這個等同於程式碼的「關掉它再重新打開它」,它運作地令人驚訝的好。

如果輸出有任何錯誤,我們只需要往回想一步 - 到前一個呼叫 setState 的地方。 除錯一個渲染的結果的 Bug-O 是 🐞(n),n 是可能發生的渲染路徑的數量。在這裡,它就是四(因為我們在 switch 裡有四種情況)。

我們在設定狀態的時候可能還是有競爭條件 (race conditions),但因為每個中間的狀態都可以被記錄和檢查,除錯變得更容易。我們也可以明確地不允許任何不想要的過渡:

function trySubmit() {
  if (currentState.step === 'pending') {
    // 不允許送出兩次
    return;
  }

當然,永遠重設 DOM 會帶來一些代價。每次都移除和重新產生 DOM 會破壞它的內部狀態、失去焦點和導致大型應用程式裡糟糕的效能問題。

這就是為什麼像 React 這樣的套件可以很有幫助。它們讓你擁有總是從頭開始重新創建 UI 的範例中思考而不必真的這樣做:

function FormStatus() {
  let [state, setState] = useState({
    step: 'initial'
  });

  function handleSubmit(e) {
    e.preventDefault();
    if (state.step === 'pending') {
      // 不允許送出兩次
      return;
    }
    setState({ step: 'pending' });
    submitForm().then(() => {
      setState({ step: 'success' });
    }).catch(error => {
      setState({ step: 'error', error });
    });
  }

  let content;
  switch (state.step) {
    case 'pending':
      content = <Spinner />;
      break;
    case 'success':
      content = <SuccessMessage />;
      break;
    case 'error':
      content = (
        <>
          <ErrorMessage error={state.error} />
          <RetryButton onClick={handleSubmit} />
        </>
      );
      break;
  }

  return (
    <form onSubmit={handleSubmit}>
      {content}
    </form>
  );
}

這段程式碼可能看起來不太一樣,但原則是相同的。元件的抽象化強制了邊界,所以你知道在這頁沒有其他程式碼可以弄亂它的 DOM 或狀態。元件化幫助減少 Bug-O。

事實上,如果任何一個在 React 應用程式裡的 DOM 的值看起來有錯,你可以藉由一個一個觀察在 React 樹裡的元件的程式碼來追蹤到它在哪裡。無論應用程式的大小,追蹤一個渲染的值是 🐞(樹的高度)。

下一次當你看見關於 API 的討論,試著思考:它的一般除錯的 🐞(n) 是多少?那你熟悉的已經存在的 API 和原則的又是多少呢? Redux、CSS、繼承 - 它們都有自己的 Bug-O。