面试官:小伙子写一个 shuffle?(JavaScript)
shuffle:顾名思义,将数组随机排序,常在开发中用作实现随机功能。
我们来看看一个 shuffle 可以体现出什么代码品味。
地址
P.S. 我不会告诉你原文地址的显示效果有多么感人
原文地址: 作者:更多信息,check:
错误举例
function shuffle(arr) { return arr.sort(function () { return Math.random() - 0.5; });}复制代码
ES6
const shuffle = arr => arr.sort(() => Math.random() - 0.5);复制代码
测试代码
// testshuffle([1, 2, 3, 4, 5]);复制代码
请老铁千万不要这样写,这体现了两个错误:
- 你的这段代码一定是从网上抄/背下来的,面试官不想考这种能力
- 很遗憾,这是错误的,并不能真正地随机打乱数组
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 里有没有变量提升?没有,不仅
const
和let
都没有,连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
有两个不同:
- 块作用域,只存在于
{}
中,不像var
只有函数才能锁住它的作用域 var
有变量提升,let
没有
上代码:
for (var i = 0; i < 10; i++){ setTimeout(() => { console.log(i) }, 100)} // 输出全为 10for (let i = 0; i < 10; i++){ setTimeout(() => { console.log(i) }, 100)}// 输出 0 1 2 3 4 5 6 7 8 9复制代码
Why?
- 每次循环都创建一个块级作用域
- 所以每次循环改变的就是对局部变量赋值
进阶
光说不练假把式,我们来试用一下第四版的 shuffle
把:
// testshuffle([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
声明的变量没有变量提升,在调用 shuffle
和 swap
的时候他们还木有出生呢~!
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]];};// testshuffle([1, 2, 3, 4, 5]);复制代码
老铁没毛病。但主要逻辑运行代码放在后,次要逻辑函数定义放在前有没有不妥?
这里只有 shuffle
和 swap
两个函数,或许你会觉得区别不明显,那如果代码更长呢? 没错,或许你可以进行模块拆分,但如果像 underscore 那样的代码呢。如果像博主一样写一个 呢?(不是硬广:-D)
有时候我们需要一次自我审问:每次调用函数时都要确认函数声明在调用之前的工作是必须的吗?
最终解答
// testshuffle([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
是因为:
我想利用它的变量提升实现主逻辑前置,而不用去关心函数的定义位置。
进而从上到下,层层逻辑递进。再一次出现这两个词:逻辑简洁、好维护。
总结
-
你问:有没有高水平的代码来让面试官眼前一亮?
-
我答:只有好读又简洁,稳定易维护的代码,没有高水平的代码一说。
-
你问:说好的代码品味呢?
-
我答:都藏在每一个细节的处理上:)