從 shuffle 看程式碼品味(面試題)

RayJune發表於2018-03-14

面試官:小夥子寫一個 shuffle?(JavaScript)

shuffle:顧名思義,將陣列隨機排序,常在開發中用作實現隨機功能。

我們來看看一個 shuffle 可以體現出什麼程式碼品味

地址

P.S. 我不會告訴你原文地址的顯示效果有多麼感人
原文地址:
rayjune.me/2018/03/13/…
作者:rayjune

更多資訊,check: rayjune.me/about

錯誤舉例

function shuffle(arr) {
  return arr.sort(function () {
    return Math.random() - 0.5;
  });
}
複製程式碼

ES6

const shuffle = arr =>
  arr.sort(() => Math.random() - 0.5);
複製程式碼

測試程式碼

// test
shuffle([1, 2, 3, 4, 5]);
複製程式碼

請老鐵千萬不要這樣寫,這體現了兩個錯誤:

  1. 你的這段程式碼一定是從網上抄/背下來的,面試官不想考這種能力
  2. 很遺憾,這是錯誤的,並不能真正地隨機打亂陣列

Why? Check: https://blog.oldj.net/2017/01/23/shuffle-an-array-in-javascript/

思考

下面來到了第一反應:思考問題。

陣列隨機化 -> 要用到 Math.random -> 看來每個元素都要 random 一下 -> 處理 arr.length 的取整要用到 Math.floor -> 交換陣列元素位置需要用到 swap

一切正常的話你的方向應該轉向經典的 Fisher–Yates shuffle 演算法思路了 :》

第一版

由此有了第一版程式碼:

function shuffle(arr) {
  var i;
  var randomIndex;
 
  for (i = arr.length - 1; i > 0; i--) {
    randomIndex = Math.floor(Math.random() * (i + 1));
    swap(arr, i, randomIndex);
  }

  return arr;
}
複製程式碼
  • 為什麼用 randomIndex 不用 j? -> 更有意義的變數命名
  • 為什麼要把 i 和 randomIndex 的宣告放在最前方? -> ES5 裡的變數提升(ES6 裡有沒有變數提升?沒有,不僅 constlet 都沒有,連 class 也沒有。但是 import 命令具有提升效果,會提升到整個模組的頭部,首先執行
  • 為什麼第 3 行和第 5 行中留一個空行 & 為什麼第 8 行和第 10 行之間留一個空行?將宣告的變數、函式體、return 分開。三段式結構,一目瞭然的邏輯使程式碼更加清晰易維護

需要注意的是這裡的 randomIndex 處理是 Math.floor(Math.random() * (index + 1))index + 1,看起來好彆扭,可以用 Math.ceil 來替換嗎:

function shuffle(arr) {
  var i;
  var randomIndex;
 
  for (i = arr.length - 1; i > 0; i--) {
    randomIndex = Math.ceil(Math.random() * i);
    swap(arr, i, randomIndex);
  }

  return arr;
}
複製程式碼

多謝評論區的 @旅行者2號 大神提醒,這是不行的。因為:

  • Math.random() 產生的隨機數範圍是 [0,1),Math.ceil 會將 (0, 1) 範圍的數字都化為 1,只有 0 才化為 0。這樣會導致 index 為 0 的元素很難被 randomIndex 隨機到。

什麼,JavaScript 中木有這麼基礎的 swap 函式?

寫一個,使邏輯更加清晰 & 重複利用:

function swap(arr, indexA, indexB) {
  var temp;
 
  temp = arr[indexA];
  arr[indexA] = arr[indexB];
  arr[indexB] = temp;
}
複製程式碼

第二版

一點點小的改動:

function shuffle(arr) {
  arr.forEach(function (curValue, index) {
    var randomIndex = Math.floor(Math.random() * (index + 1));

    swap(arr, index, randomIndex);
  });

  return arr;
}
複製程式碼

arr.forEach 替代原本的 for 迴圈。(我會告訴你 array.forEach 的返回值是 undefined 這一點容易出錯嘛)

此外不希望有人質疑:JS 由於函式呼叫棧空間有限,用 for 迴圈不是比 forEach 效率更高嗎?

拿出這段話壓壓驚:

”We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil.” -- Donald Knuth

JavaScript 天生支援函數語言程式設計(functional programing),放下腦海中的 CPP-OOP,請好好珍惜它。

有了 High-order function & First-class function 的存在,編寫程式碼的邏輯愈發清晰簡潔好維護

第三版

且慢,同學不寫一個 ES6 版本的嗎?

const shuffle = (arr) => {
  arr.forEach((element, index) => {
    const randomIndex = Math.floor(Math.random() * (index + 1);
 
    swap(arr, index, randomIndex);
  });

  return arr;
};
複製程式碼

使用 ES6 的箭頭函式(arrow function),邏輯的表達更為簡潔清晰好維護。(我會告訴你箭頭函式還因為本身繫結的是外部的 this,解決了一部分 this 繫結的問題嘛。注意我沒有說全部)。

順便也用 ES6 重寫一下 swap 函式把。簡介的語法,更強大的表現力,誰用誰喜歡:

const swap = (arr, indexA, indexB) => {
  [arr[indexA], arr[indexB]] = [arr[indexB], arr[indexA]];
};
複製程式碼

怎麼樣,ES6 的物件解構賦值(Destructuring)燃不燃?好用不好用?

第四版

其實程式碼到第三版已然 OK 了,但還是有一處紕漏:

arr.forEach((curValue, index) => { ... })index = 0 時,randomIndex 的值也只能為 0,顯然這個時候 swap(arr, 0, 0) 的操作是沒必要的:

const shuffle = (arr) => {
  arr.forEach((element, index) => {
    if (index !== 0) {
      const randomIndex = Math.floor(Math.random() * (index + 1));
  
      swap(arr, index, randomIndex);
    }
  });

  return arr;
};
複製程式碼

那麼我們原先的 for 迴圈語句有木有這種問題呢?ES6 重寫一下:

const shuffle = (arr) => {
  for (let i = arr.length - 1; i > 0; i--) {
    const randomIndex = Math.floor(Math.random() * (i + 1));
    
    swap(arr, i, randomIndex);
  }

  return arr;
}
複製程式碼

其中的迴圈終止條件是 i > 0,自動排除掉 i = 0 的情況了,所以在 for 迴圈中是沒有問題的。


既然在迴圈中用 let 代替了 var,我們來回顧一下兩者的區別吧:

let 相比較 var 有兩個不同:

  1. 塊作用域,只存在於 {} 中,不像 var 只有函式才能鎖住它的作用域
  2. var 有變數提升,let 沒有

上程式碼:

for (var i = 0; i < 10; i++){
  setTimeout(() => {
    console.log(i)
  }, 100)
}  
// 輸出全為 10

for (let i = 0; i < 10; i++){
  setTimeout(() => {
    console.log(i)
  }, 100)
}
// 輸出 0 1 2 3 4 5 6 7 8 9
複製程式碼

Why?

  • 每次迴圈都建立一個塊級作用域
  • 所以每次迴圈改變的就是對區域性變數賦值

進階

光說不練假把式,我們來試用一下第四版的 shuffle 把:

// test
shuffle([1, 2, 3, 4, 5]);

const shuffle = (arr) => {
  arr.forEach((element, index) => {
    if (index !== 0) {
      const randomIndex = Math.floor(Math.random() * (index + 1));
  
      swap(arr, index, randomIndex);
    }
  });

  return arr;
};

const swap = (arr, indexA, indexB) => {
  [arr[indexA], arr[indexB]] = [arr[indexB], arr[indexA]];
};
複製程式碼

出現呼叫錯誤,const 宣告的變數沒有變數提升,在呼叫 shuffleswap 的時候他們還木有出生呢~!

So 這樣?

const shuffle = (arr) => {
  arr.forEach((element, index) => {
    if (index !== 0) {
      const randomIndex = Math.floor(Math.random() * (index + 1));
  
      swap(arr, index, randomIndex);
    }
  });

  return arr;
};

const swap = (arr, indexA, indexB) => {
  [arr[indexA], arr[indexB]] = [arr[indexB], arr[indexA]];
};

// test
shuffle([1, 2, 3, 4, 5]);
複製程式碼

老鐵沒毛病。但主要邏輯執行程式碼放在後,次要邏輯函式定義放在前有沒有不妥?

這裡只有 shuffleswap 兩個函式,或許你會覺得區別不明顯,那如果程式碼更長呢? 沒錯,或許你可以進行模組拆分,但如果像 underscore 那樣的程式碼呢。如果像博主一樣寫一個 indexeddb-crud 呢?(不是硬廣:-D)

有時候我們需要一次自我審問:每次呼叫函式時都要確認函式宣告在呼叫之前的工作是必須的嗎

最終解答

// test
shuffle([1, 2, 3, 4, 5]);

function shuffle(arr) {
  arr.forEach((element, index) => {
    if (index !== 0) {
      const randomIndex = Math.floor(Math.random() * (index + 1));
  
      swap(arr, index, randomIndex);
    }
  });

  return arr;
}

function swap(arr, indexA, indexB) {
  [arr[indexA], arr[indexB]] = [arr[indexB], arr[indexA]];
}
複製程式碼

為啥用 ES5 的方式來寫 function,Airbnb 的 ES6 規範建議不是用 const + 箭頭函式來替代傳統的 ES5 function 宣告式嗎?

我們來看 const + 箭頭式函式宣告帶來了什麼,失去了什麼:

  • 帶來了更加規範簡介的函式定義,向外的一層 this 繫結
  • 失去了更加自由的邏輯展現(呼叫不能放在宣告之前)

子曰:

  • 程式設計規範是人定的,而你是有選擇的
  • 軟體開發不是遵循教條,程式碼世界本沒有標準答案

在這裡用傳統 ES5 function 是因為:

我想利用它的變數提升實現主邏輯前置而不用去關心函式的定義位置

進而從上到下,層層邏輯遞進。再一次出現這兩個詞:邏輯簡潔好維護

總結

  • 你問:有沒有高水平的程式碼來讓面試官眼前一亮?

  • 我答:只有好讀又簡潔,穩定易維護的程式碼,沒有高水平的程式碼一說。

  • 你問:說好的程式碼品味呢?

  • 我答:都藏在每一個細節的處理上:)

相關文章