Another RayJune

从 shuffle 看代码品味

面试官:小伙子写一个 shuffle?(JavaScript)

shuffle:顾名思义,将数组随机排序,常在开发中用作实现随机功能,又称洗牌算法。

我们来看看一个 shuffle 可以体现出什么代码品味

错误举例

1
2
3
4
5
function shuffle(arr) {
return arr.sort(function () {
return Math.random() - 0.5;
});
}

ES6

1
2
const shuffle = arr =>
arr.sort(() => Math.random() - 0.5);

测试代码

1
2
// 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 算法思路了 :》

第一版

由此有了第一版代码:

1
2
3
4
5
6
7
8
9
10
11
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 来替换吗:

1
2
3
4
5
6
7
8
9
10
11
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 函数?

写一个,使逻辑更加清晰 & 重复利用:

1
2
3
4
5
6
7
function swap(arr, indexA, indexB) {
var temp;

temp = arr[indexA];
arr[indexA] = arr[indexB];
arr[indexB] = temp;
}

第二版

一点点小的改动:

1
2
3
4
5
6
7
8
9
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 版本的吗?

1
2
3
4
5
6
7
8
9
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 函数把。简介的语法,更强大的表现力,谁用谁喜欢:

1
2
3
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) 的操作是没必要的:

1
2
3
4
5
6
7
8
9
10
11
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 重写一下:

1
2
3
4
5
6
7
8
9
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 没有

上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 把:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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 这样?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)

有时候我们需要一次自我审问:每次调用函数时都要确认函数声明在调用之前的工作是必须的吗

最终解答

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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 是因为:

我想利用它的变量提升实现主逻辑前置而不用去关心函数的定义位置

进而从上到下,层层逻辑递进。再一次出现这两个词:逻辑简洁好维护

总结

  • 你问:有没有高水平的代码来让面试官眼前一亮?
  • 我答:只有好读又简洁,稳定易维护的代码,没有高水平的代码一说。

  • 你问:说好的代码品味呢?

  • 我答:都藏在每一个细节的处理上:)

号外:博主为 18 届应届生,目前状态是前端开发补招进行时。如有内推机会,欢迎一波带走 :)

简历:https://www.rayjune.me/resume.pdf

文章标题:从 shuffle 看代码品味

文章作者:RayJune

时间地点:晚 7:37,于又玄图书馆

原始链接:https://www.rayjune.me/2018/03/13/see-code-taste-from-shuffle/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。